From 66c2accc24d01fda900d088bd00ffaeb8a106d46 Mon Sep 17 00:00:00 2001 From: yawkat Date: Thu, 16 Nov 2023 09:49:18 +0100 Subject: [PATCH 01/16] Move ByteBody creation to PipeliningServerHandler --- .../netty/HttpToHttpsRedirectHandler.java | 6 ++- .../http/server/netty/NettyHttpRequest.java | 13 +++--- .../server/netty/RoutingInBoundHandler.java | 18 ++++---- .../http/server/netty/body/ByteBody.java | 41 ++++++++++++++----- .../handler/PipeliningServerHandler.java | 38 ++++++++--------- .../server/netty/handler/RequestHandler.java | 6 ++- .../NettyServerWebSocketUpgradeHandler.java | 9 ++-- .../server/netty/binding/HttpRequestTest.java | 3 ++ .../netty/binding/NettyHttpRequestSpec.groovy | 13 +++--- .../PipeliningServerHandlerSpec.groovy | 25 ++++++----- .../JsonContentProcessorBenchmark.java | 2 + 11 files changed, 101 insertions(+), 73 deletions(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpToHttpsRedirectHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpToHttpsRedirectHandler.java index 4b0783bdd55..d403ac25156 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpToHttpsRedirectHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpToHttpsRedirectHandler.java @@ -20,6 +20,7 @@ import io.micronaut.http.HttpResponse; import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.netty.NettyHttpResponseBuilder; +import io.micronaut.http.server.netty.body.ByteBody; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.handler.PipeliningServerHandler; import io.micronaut.http.server.netty.handler.RequestHandler; @@ -30,6 +31,7 @@ import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpRequest; /** * Handler to automatically redirect HTTP to HTTPS request when using dual protocol. @@ -50,8 +52,8 @@ record HttpToHttpsRedirectHandler( ) implements RequestHandler { @Override - public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { - NettyHttpRequest strippedRequest = NettyHttpRequest.createSafe(request, ctx, conversionService, serverConfiguration); + public void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + NettyHttpRequest strippedRequest = NettyHttpRequest.createSafe(request, body, ctx, conversionService, serverConfiguration); UriBuilder uriBuilder = UriBuilder.of(hostResolver.resolve(strippedRequest)); strippedRequest.release(); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/NettyHttpRequest.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/NettyHttpRequest.java index 54fc5725369..25d20d7d0ac 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/NettyHttpRequest.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/NettyHttpRequest.java @@ -185,6 +185,7 @@ public class NettyHttpRequest extends AbstractNettyHttpRequest implements */ @SuppressWarnings("MagicNumber") public NettyHttpRequest(io.netty.handler.codec.http.HttpRequest nettyRequest, + ByteBody body, ChannelHandlerContext ctx, ConversionService environment, HttpServerConfiguration serverConfiguration) throws IllegalArgumentException { @@ -199,30 +200,28 @@ public NettyHttpRequest(io.netty.handler.codec.http.HttpRequest nettyRequest, this.serverConfiguration = serverConfiguration; this.channelHandlerContext = ctx; this.headers = new NettyHttpHeaders(nettyRequest.headers(), conversionService); - this.body = ByteBody.of(nettyRequest); + this.body = body; this.contentLength = headers.contentLength().orElse(-1); this.contentType = headers.contentType().orElse(null); this.origin = headers.getOrigin().orElse(null); } - public static NettyHttpRequest createSafe(io.netty.handler.codec.http.HttpRequest request, ChannelHandlerContext ctx, ConversionService conversionService, NettyHttpServerConfiguration serverConfiguration) { + public static NettyHttpRequest createSafe(io.netty.handler.codec.http.HttpRequest request, ByteBody body, ChannelHandlerContext ctx, ConversionService conversionService, NettyHttpServerConfiguration serverConfiguration) { try { return new NettyHttpRequest<>( request, + body, ctx, conversionService, serverConfiguration ); } catch (IllegalArgumentException iae) { // invalid URI - if (request instanceof StreamedHttpRequest streamed) { - streamed.closeIfNoSubscriber(); - } else { - ((FullHttpRequest) request).release(); - } + body.release(); return new NettyHttpRequest<>( new DefaultFullHttpRequest(request.protocolVersion(), request.method(), "/", Unpooled.EMPTY_BUFFER), + ByteBody.empty(), ctx, conversionService, serverConfiguration diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 0f2bb91f13d..8054a591313 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -51,6 +51,7 @@ import io.micronaut.http.netty.stream.StreamedHttpResponse; import io.micronaut.http.server.RouteExecutor; import io.micronaut.http.server.binding.RequestArgumentSatisfier; +import io.micronaut.http.server.netty.body.ByteBody; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.handler.PipeliningServerHandler; import io.micronaut.http.server.netty.handler.RequestHandler; @@ -60,11 +61,10 @@ import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpHeaders; -import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.DefaultHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpResponseStatus; @@ -192,14 +192,17 @@ public void handleUnboundError(Throwable cause) { } @Override - public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { NettyHttpRequest mnRequest; try { - mnRequest = new NettyHttpRequest<>(request, ctx, conversionService, serverConfiguration); + mnRequest = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); } catch (IllegalArgumentException e) { + body.release(); + // invalid URI NettyHttpRequest errorRequest = new NettyHttpRequest<>( - new DefaultFullHttpRequest(request.protocolVersion(), request.method(), "/", Unpooled.EMPTY_BUFFER), + new DefaultHttpRequest(request.protocolVersion(), request.method(), "/"), + ByteBody.empty(), ctx, conversionService, serverConfiguration @@ -208,11 +211,6 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe try (PropagatedContext.Scope ignore = PropagatedContext.getOrEmpty().plus(new ServerHttpRequestContext(errorRequest)).propagate()) { new NettyRequestLifecycle(this, outboundAccess, errorRequest).handleException(e.getCause() == null ? e : e.getCause()); } - if (request instanceof StreamedHttpRequest streamed) { - streamed.closeIfNoSubscriber(); - } else { - ((FullHttpRequest) request).release(); - } return; } outboundAccess.attachment(mnRequest); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/body/ByteBody.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/body/ByteBody.java index d1256b61b4c..970fb21f246 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/body/ByteBody.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/body/ByteBody.java @@ -18,13 +18,16 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.execution.ExecutionFlow; import io.micronaut.http.exceptions.ContentLengthExceededException; +import io.micronaut.http.netty.reactive.HotObservable; import io.micronaut.http.netty.stream.StreamedHttpRequest; import io.micronaut.http.server.HttpServerConfiguration; import io.micronaut.http.server.netty.FormDataHttpContentProcessor; +import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpRequest; -import io.netty.handler.codec.http.HttpUtil; /** * Base class for a raw {@link HttpBody} with just bytes. @@ -77,17 +80,33 @@ public sealed interface ByteBody extends HttpBody permits ImmediateByteBody, Str HttpRequest claimForReuse(HttpRequest request); /** - * Create a byte body for the given request. The request must be either a - * {@link FullHttpRequest} or a {@link StreamedHttpRequest}. + * Create a new byte body containing the given immediate content. Release ownership is + * transferred to the returned body. * - * @param request The request - * @return The {@link ByteBody} for the body data + * @param content The content + * @return The ByteBody with the content */ - static ByteBody of(HttpRequest request) { - if (request instanceof FullHttpRequest full) { - return new ImmediateByteBody(full.content()); - } else { - return new StreamingByteBody((StreamedHttpRequest) request, HttpUtil.getContentLength(request, -1L)); - } + static ByteBody of(ByteBuf content) { + return new ImmediateByteBody(content); + } + + /** + * Create a new byte body containing the given streamed content. + * + * @param content The publisher of HttpContent + * @param contentLength The advertised content length, or -1 if unknown + * @return The streaming body + */ + static ByteBody of(HotObservable content, long contentLength) { + return new StreamingByteBody(content, contentLength); + } + + /** + * Create an empty byte body. + * + * @return An empty body + */ + static ByteBody empty() { + return of(Unpooled.EMPTY_BUFFER); } } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java index d2121ce377d..5af40901aca 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java @@ -21,10 +21,10 @@ import io.micronaut.core.util.SupplierUtil; import io.micronaut.http.exceptions.MessageBodyException; import io.micronaut.http.netty.body.NettyWriteContext; -import io.micronaut.http.netty.stream.DelegateStreamedHttpRequest; -import io.micronaut.http.netty.stream.EmptyHttpRequest; +import io.micronaut.http.netty.reactive.HotObservable; import io.micronaut.http.netty.stream.StreamedHttpResponse; import io.micronaut.http.server.netty.SmartHttpContentCompressor; +import io.micronaut.http.server.netty.body.ByteBody; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.CompositeByteBuf; @@ -35,7 +35,6 @@ import io.netty.channel.DefaultFileRegion; import io.netty.channel.EventLoop; import io.netty.channel.FileRegion; -import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; @@ -348,13 +347,13 @@ void read(Object message) { OutboundAccess outboundAccess = new OutboundAccess(); outboundQueue.add(outboundAccess); if (request instanceof FullHttpRequest full) { - requestHandler.accept(ctx, full, outboundAccess); + requestHandler.accept(ctx, full, ByteBody.of(full.content()), outboundAccess); } else if (!hasBody(request)) { inboundHandler = droppingInboundHandler; if (message instanceof HttpContent) { inboundHandler.read(message); } - requestHandler.accept(ctx, new EmptyHttpRequest(request), outboundAccess); + requestHandler.accept(ctx, request, ByteBody.empty(), outboundAccess); } else { optimisticBufferingInboundHandler.init(request, outboundAccess); inboundHandler = optimisticBufferingInboundHandler; @@ -407,19 +406,11 @@ void read(Object message) { fullBody = composite; } buffer.clear(); - FullHttpRequest fullRequest = new DefaultFullHttpRequest( - request.protocolVersion(), - request.method(), - request.uri(), - fullBody, - request.headers(), - last.trailingHeaders() - ); - fullRequest.setDecoderResult(request.decoderResult()); - request = null; + HttpRequest request = this.request; + this.request = null; OutboundAccess outboundAccess = this.outboundAccess; this.outboundAccess = null; - requestHandler.accept(ctx, fullRequest, outboundAccess); + requestHandler.accept(ctx, request, ByteBody.of(fullBody), outboundAccess); inboundHandler = baseInboundHandler; } @@ -449,16 +440,23 @@ private void devolveToStreaming() { this.outboundAccess = null; inboundHandler = streamingInboundHandler; - Flux flux = streamingInboundHandler.flux(); + Flux flux; if (HttpUtil.is100ContinueExpected(request)) { - flux = flux.doOnSubscribe(s -> outboundAccess.writeContinue()); + flux = streamingInboundHandler.flux().doOnSubscribe(s -> outboundAccess.writeContinue()); + } else { + flux = streamingInboundHandler.flux(); } - requestHandler.accept(ctx, new DelegateStreamedHttpRequest(request, flux) { + requestHandler.accept(ctx, request, ByteBody.of(new HotObservable<>() { @Override public void closeIfNoSubscriber() { streamingInboundHandler.closeIfNoSubscriber(); } - }, outboundAccess); + + @Override + public void subscribe(Subscriber s) { + flux.subscribe(s); + } + }, HttpUtil.getContentLength(request, -1L)), outboundAccess); } } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/RequestHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/RequestHandler.java index cad06e6aea6..26014110302 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/RequestHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/RequestHandler.java @@ -16,6 +16,7 @@ package io.micronaut.http.server.netty.handler; import io.micronaut.core.annotation.Internal; +import io.micronaut.http.server.netty.body.ByteBody; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.HttpRequest; @@ -32,9 +33,10 @@ public interface RequestHandler { * * @param ctx The context this request came in on * @param request The request, either a {@link io.netty.handler.codec.http.FullHttpRequest} or a {@link io.micronaut.http.netty.stream.StreamedHttpRequest} - * @param outboundAccess The {@link io.micronaut.http.server.netty.handler.PipeliningServerHandler.OutboundAccess} to use for writing the response + * @param body + * @param outboundAccess The {@link PipeliningServerHandler.OutboundAccess} to use for writing the response */ - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess); + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess); /** * Handle an error that is not bound to a request, i.e. happens outside a diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java index 75fbdccc5aa..1eb43eb91b3 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java @@ -40,6 +40,7 @@ import io.micronaut.http.server.netty.NettyEmbeddedServices; import io.micronaut.http.server.netty.NettyHttpRequest; import io.micronaut.http.server.netty.RoutingInBoundHandler; +import io.micronaut.http.server.netty.body.ByteBody; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.handler.PipeliningServerHandler; import io.micronaut.http.server.netty.handler.RequestHandler; @@ -133,9 +134,9 @@ static boolean isWebSocketUpgrade(@NonNull io.netty.handler.codec.http.HttpReque } @Override - public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { if (isWebSocketUpgrade(request)) { - NettyHttpRequest msg = new NettyHttpRequest<>(request, ctx, conversionService, serverConfiguration); + NettyHttpRequest msg = NettyHttpRequest.createSafe(request, body, ctx, conversionService, serverConfiguration); Optional> optionalRoute = router.find(HttpMethod.GET, msg.getPath(), msg) .filter(rm -> rm.isAnnotationPresent(OnMessage.class) || rm.isAnnotationPresent(OnOpen.class)) @@ -153,7 +154,7 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe } }); } else { - next.accept(ctx, request, outboundAccess); + next.accept(ctx, request, body, outboundAccess); } } @@ -255,7 +256,7 @@ protected ChannelFuture handleHandshake(ChannelHandlerContext ctx, NettyHttpRequ } else { return handshaker.handshake( channel, - req.getNativeRequest(), + req.toFullHttpRequest(), nettyHeaders, channel.newPromise() ); diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpRequestTest.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpRequestTest.java index 4d20e158f36..168d281b2b4 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpRequestTest.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpRequestTest.java @@ -21,6 +21,7 @@ import io.micronaut.http.MediaType; import io.micronaut.http.server.HttpServerConfiguration; import io.micronaut.http.server.netty.NettyHttpRequest; +import io.micronaut.http.server.netty.body.ByteBody; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.HttpVersion; @@ -36,6 +37,7 @@ public void testForEach() { nettyRequest.headers().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); HttpRequest request = new NettyHttpRequest( nettyRequest, + ByteBody.empty(), new DetachedMockFactory().Mock(ChannelHandlerContext.class), ConversionService.SHARED, new HttpServerConfiguration() @@ -57,6 +59,7 @@ public void testForEach2() { nettyRequest.headers().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_XML); HttpRequest request = new NettyHttpRequest( nettyRequest, + ByteBody.empty(), new DetachedMockFactory().Mock(ChannelHandlerContext.class), ConversionService.SHARED, new HttpServerConfiguration() diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/NettyHttpRequestSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/NettyHttpRequestSpec.groovy index ccaf8fc33a9..ec0f69b7c13 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/NettyHttpRequestSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/NettyHttpRequestSpec.groovy @@ -21,6 +21,7 @@ import io.micronaut.http.HttpMethod import io.micronaut.http.MutableHttpRequest import io.micronaut.http.server.HttpServerConfiguration import io.micronaut.http.server.netty.NettyHttpRequest +import io.micronaut.http.server.netty.body.ByteBody import io.netty.channel.ChannelHandlerContext import io.netty.handler.codec.http.DefaultFullHttpRequest import io.netty.handler.codec.http.HttpVersion @@ -37,7 +38,7 @@ class NettyHttpRequestSpec extends Specification { void "test mutate request"() { given: DefaultFullHttpRequest nettyRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, GET, "/foo/bar") - NettyHttpRequest request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + NettyHttpRequest request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) when: def altered = request.mutate() @@ -52,7 +53,7 @@ class NettyHttpRequestSpec extends Specification { void "test mutating a mutable request"() { given: DefaultFullHttpRequest nettyRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, GET, "/foo/bar") - def request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + def request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) when: request = request.mutate() @@ -70,7 +71,7 @@ class NettyHttpRequestSpec extends Specification { void "test netty http request parameters"() { given: DefaultFullHttpRequest nettyRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri) - NettyHttpRequest request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + NettyHttpRequest request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) String fullURI = request.uri.toString() String expectedPath = fullURI.indexOf('?') > -1 ? fullURI.substring(0, fullURI.indexOf('?')) : fullURI @@ -93,7 +94,7 @@ class NettyHttpRequestSpec extends Specification { nettyRequest.headers().add(header.key.toString(), header.value) } - NettyHttpRequest request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + NettyHttpRequest request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) String fullURI = request.uri.toString() String expectedPath = fullURI.indexOf('?') > -1 ? fullURI.substring(0, fullURI.indexOf('?')) : fullURI @@ -115,7 +116,7 @@ class NettyHttpRequestSpec extends Specification { nettyRequest.headers().add(header.key.toString(), header.value) } - NettyHttpRequest request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + NettyHttpRequest request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) String fullURI = request.uri.toString() String expectedPath = fullURI.indexOf('?') > -1 ? fullURI.substring(0, fullURI.indexOf('?')) : fullURI @@ -140,7 +141,7 @@ class NettyHttpRequestSpec extends Specification { nettyRequest.headers().add(header.key.toString(), header.value) } - NettyHttpRequest request = new NettyHttpRequest(nettyRequest, Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) + NettyHttpRequest request = new NettyHttpRequest(nettyRequest, ByteBody.empty(), Mock(ChannelHandlerContext), new DefaultMutableConversionService(), new HttpServerConfiguration()) String fullURI = request.uri.toString() String expectedPath = fullURI.indexOf('?') > -1 ? fullURI.substring(0, fullURI.indexOf('?')) : fullURI diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy index c67836a7be4..8374eb0c044 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy @@ -1,6 +1,9 @@ package io.micronaut.http.server.netty.handler -import io.micronaut.http.netty.stream.StreamedHttpRequest + +import io.micronaut.http.server.HttpServerConfiguration +import io.micronaut.http.server.netty.body.ByteBody +import io.micronaut.http.server.netty.body.ImmediateByteBody import io.netty.buffer.Unpooled import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelOutboundHandlerAdapter @@ -35,7 +38,7 @@ class PipeliningServerHandlerSpec extends Specification { def resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT) def ch = new EmbeddedChannel(mon, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { outboundAccess.writeFull(resp) } @@ -80,7 +83,7 @@ class PipeliningServerHandlerSpec extends Specification { def sink = Sinks.many().unicast().onBackpressureBuffer() def ch = new EmbeddedChannel(mon, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { outboundAccess.writeStreamed(resp, sink.asFlux()) } @@ -128,10 +131,10 @@ class PipeliningServerHandlerSpec extends Specification { def resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT) def ch = new EmbeddedChannel(mon, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { - assert request instanceof FullHttpRequest - assert request.content().toString(StandardCharsets.UTF_8) == "foobar" - request.release() + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + assert body instanceof ImmediateByteBody + assert body.contentUnclaimed().toString(StandardCharsets.UTF_8) == "foobar" + body.release() outboundAccess.writeFull(resp) } @@ -167,8 +170,8 @@ class PipeliningServerHandlerSpec extends Specification { def resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT) def ch = new EmbeddedChannel(mon, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { - Flux.from((StreamedHttpRequest) request).collectList().subscribe { outboundAccess.writeFull(resp) } + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + Flux.from(body.rawContent(new HttpServerConfiguration()).asPublisher()).collectList().subscribe { outboundAccess.writeFull(resp) } } @Override @@ -211,7 +214,7 @@ class PipeliningServerHandlerSpec extends Specification { } }, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { assert request instanceof FullHttpRequest request.release() outboundAccess.writeFull(resp) @@ -248,7 +251,7 @@ class PipeliningServerHandlerSpec extends Specification { def cleaned = 0 def ch = new EmbeddedChannel(mon, new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { assert request instanceof FullHttpRequest request.release() outboundAccess.writeStreamed(resp, sink.asFlux().doOnCancel { diff --git a/http-server-netty/src/test/java/io/micronaut/http/server/netty/jackson/JsonContentProcessorBenchmark.java b/http-server-netty/src/test/java/io/micronaut/http/server/netty/jackson/JsonContentProcessorBenchmark.java index 39130003f86..63b4a9696f9 100644 --- a/http-server-netty/src/test/java/io/micronaut/http/server/netty/jackson/JsonContentProcessorBenchmark.java +++ b/http-server-netty/src/test/java/io/micronaut/http/server/netty/jackson/JsonContentProcessorBenchmark.java @@ -6,6 +6,7 @@ import io.micronaut.core.type.Argument; import io.micronaut.http.netty.body.JsonCounter; import io.micronaut.http.server.netty.NettyHttpRequest; +import io.micronaut.http.server.netty.body.ByteBody; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.json.JsonMapper; import io.micronaut.json.JsonSyntaxException; @@ -110,6 +111,7 @@ public void setUp() throws IOException { }); request = new NettyHttpRequest<>( new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/"), + ByteBody.empty(), ch.pipeline().firstContext(), ConversionService.SHARED, configuration From 85e72d1ebef681913e6e32814df50d4d88986c4f Mon Sep 17 00:00:00 2001 From: yawkat Date: Fri, 17 Nov 2023 15:37:16 +0100 Subject: [PATCH 02/16] Add fast compiled route matcher This PR adds an alternate request processing path that mostly bypasses RoutingInBoundHandler. The goal is to speed up processing of simple routes significantly. The approach taken here is to accumulate all routes, prepare those that can be executed quickly (i.e. simple argument / return binding, exact path match, no difficult filters), and then compile the routing tree into an optimized MatchPlan. In principle this approach could be extended to all routes, we "just" need to adapt other framework features (e.g. path parameters) to be included in the compiled MatchPlan. This is my long-term vision for the future of routing in the Micronaut HTTP stack. --- .../server/stack/FullHttpStackBenchmark.java | 5 +- .../body/ByteBufRawMessageBodyHandler.java | 2 +- .../http/netty/body/NettyJsonHandler.java | 23 +- .../netty/body/NettyTextPlainHandler.java | 22 +- .../netty/body/NettyWritableBodyWriter.java | 6 + .../body/ShortCircuitNettyBodyWriter.java | 70 +++++ .../server/netty/RoutingInBoundHandler.java | 162 +++++++++++ .../binders/NettyBodyAnnotationBinder.java | 40 ++- .../NettyHttpServerConfiguration.java | 34 +++ .../shortcircuit/DiscriminatorStage.java | 144 ++++++++++ .../netty/shortcircuit/ExecutionLeaf.java | 58 ++++ .../server/netty/shortcircuit/MatchPlan.java | 40 +++ .../NettyShortCircuitRouterBuilder.java | 264 ++++++++++++++++++ .../ShortCircuitArgumentBinder.java | 62 ++++ .../netty/interceptor/ContextPathFilter.java | 2 + .../server/netty/interceptor/FirstFilter.java | 2 + .../netty/interceptor/SecondFilter.java | 2 + .../netty/interceptor/TestReactiveFilter.java | 2 + .../netty/interceptor/TestSecurityFilter.java | 2 + .../netty/shortcircuit/FastRoutingSpec.groovy | 55 ++++ .../NettyShortCircuitRouterBuilderSpec.groovy | 122 ++++++++ .../netty/stack/InvocationStackSpec.groovy | 3 + .../threading/ThreadSelectionSpec.groovy | 12 +- .../websocket/SimpleTextWebSocketSpec.groovy | 10 +- .../WebSocketContextValidationFilter.java | 7 +- ...ContextlessMessageBodyHandlerRegistry.java | 5 +- .../DefaultMessageBodyHandlerRegistry.java | 7 +- .../http/body/RawMessageBodyHandler.java | 27 ++ .../body/RawMessageBodyHandlerRegistry.java | 18 +- .../http/body/WritableBodyWriter.java | 21 ++ .../micronaut/http/filter/FilterRunner.java | 12 + .../micronaut/http/uri/UriMatchTemplate.java | 29 ++ .../web/router/DefaultRequestMatcher.java | 14 +- .../web/router/DefaultRouteBuilder.java | 3 +- .../web/router/DefaultRouteInfo.java | 29 +- .../micronaut/web/router/DefaultRouter.java | 101 ++++++- .../web/router/DefaultUrlRouteInfo.java | 8 + .../micronaut/web/router/RequestMatcher.java | 16 ++ .../io/micronaut/web/router/RouteInfo.java | 23 ++ .../java/io/micronaut/web/router/Router.java | 24 ++ .../io/micronaut/web/router/UriRouteInfo.java | 13 + .../web/router/shortcircuit/MatchRule.java | 221 +++++++++++++++ .../ShortCircuitRouterBuilder.java | 65 +++++ 43 files changed, 1730 insertions(+), 57 deletions(-) create mode 100644 http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java create mode 100644 http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy create mode 100644 http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy create mode 100644 router/src/main/java/io/micronaut/web/router/shortcircuit/MatchRule.java create mode 100644 router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java diff --git a/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java b/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java index 057651bbb7c..f2b58066deb 100644 --- a/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java +++ b/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java @@ -28,7 +28,6 @@ import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.TearDown; -import org.openjdk.jmh.profile.AsyncProfiler; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; @@ -129,7 +128,6 @@ public void setUp() { //System.out.println(response.content().toString(StandardCharsets.UTF_8)); Assertions.assertEquals(HttpResponseStatus.OK, response.status()); Assertions.assertEquals("application/json", response.headers().get(HttpHeaderNames.CONTENT_TYPE)); - Assertions.assertEquals("keep-alive", response.headers().get(HttpHeaderNames.CONNECTION)); String expectedResponseBody = "{\"listIndex\":4,\"stringIndex\":0}"; Assertions.assertEquals(expectedResponseBody, response.content().toString(StandardCharsets.UTF_8)); Assertions.assertEquals(expectedResponseBody.length(), response.headers().getInt(HttpHeaderNames.CONTENT_LENGTH)); @@ -165,7 +163,8 @@ Stack openChannel() { ApplicationContext ctx = ApplicationContext.run(Map.of( "spec.name", "FullHttpStackBenchmark", //"micronaut.server.netty.server-type", NettyHttpServerConfiguration.HttpServerType.FULL_CONTENT, - "micronaut.server.date-header", false // disabling this makes the response identical each time + "micronaut.server.date-header", false, // disabling this makes the response identical each time + "micronaut.server.netty.fast-routing", true )); EmbeddedServer server = ctx.getBean(EmbeddedServer.class); EmbeddedChannel channel = ((NettyHttpServer) server).buildEmbeddedChannel(false); diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java index 9dd0e5e08c2..0c9cc9a51a6 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java @@ -83,7 +83,7 @@ public void writeTo(Argument type, MediaType mediaType, ByteBuf object, } @Override - public ByteBuffer writeTo(Argument type, MediaType mediaType, ByteBuf object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { + public ByteBuffer writeTo(MediaType mediaType, ByteBuf object, ByteBufferFactory bufferFactory) throws CodecException { return NettyByteBufferFactory.DEFAULT.wrap(object); } diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java index 5973a67dade..79ac0fc8485 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java @@ -35,10 +35,17 @@ import io.micronaut.json.JsonMapper; import io.micronaut.json.body.JsonMessageHandler; import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.EmptyHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; import jakarta.inject.Singleton; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -47,7 +54,6 @@ * * @param The type */ -@SuppressWarnings("DefaultAnnotationParam") @Singleton @Internal @Replaces(JsonMessageHandler.class) @@ -71,7 +77,7 @@ }) @BootstrapContextCompatible @Requires(beans = JsonMapper.class) -public final class NettyJsonHandler implements MessageBodyHandler, ChunkedMessageBodyReader, CustomizableNettyJsonHandler { +public final class NettyJsonHandler implements MessageBodyHandler, ChunkedMessageBodyReader, CustomizableNettyJsonHandler, ShortCircuitNettyBodyWriter { private final JsonMessageHandler jsonMessageHandler; public NettyJsonHandler(JsonMapper jsonMapper) { @@ -135,6 +141,19 @@ public ByteBuffer writeTo(Argument type, MediaType mediaType, T object, Mu return jsonMessageHandler.writeTo(type, mediaType, object, outgoingHeaders, bufferFactory); } + @Override + public void writeTo(io.micronaut.http.HttpHeaders requestHeaders, HttpResponseStatus status, HttpHeaders responseHeaders, T object, NettyWriteContext nettyContext) { + ByteBuf buffer = nettyContext.alloc().buffer(); + JsonMapper jsonMapper = jsonMessageHandler.getJsonMapper(); + try { + jsonMapper.writeValue(new ByteBufOutputStream(buffer), object); + } catch (IOException e) { + buffer.release(); + throw new CodecException("Error encoding object [" + object + "] to JSON: " + e.getMessage(), e); + } + nettyContext.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buffer, responseHeaders, EmptyHttpHeaders.INSTANCE)); + } + @Override public MessageBodyWriter createSpecific(Argument type) { return new NettyJsonHandler<>((JsonMessageHandler) jsonMessageHandler.createSpecific(type)); diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java index fd4ccbecb66..31ea4a7be95 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java @@ -21,23 +21,18 @@ import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; -import io.micronaut.http.MutableHttpHeaders; -import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Consumes; import io.micronaut.http.annotation.Produces; import io.micronaut.http.body.MessageBodyHandler; import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.http.body.TextPlainHandler; import io.micronaut.http.codec.CodecException; -import io.micronaut.http.netty.NettyHttpHeaders; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.EmptyHttpHeaders; import io.netty.handler.codec.http.FullHttpResponse; -import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import jakarta.inject.Singleton; @@ -50,24 +45,17 @@ @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.TEXT_PLAIN) @Internal -final class NettyTextPlainHandler implements MessageBodyHandler, NettyBodyWriter { +final class NettyTextPlainHandler implements MessageBodyHandler, ShortCircuitNettyBodyWriter { private final TextPlainHandler defaultHandler = new TextPlainHandler(); @Override - public void writeTo(HttpRequest request, MutableHttpResponse outgoingResponse, Argument type, MediaType mediaType, CharSequence object, NettyWriteContext nettyContext) throws CodecException { - MutableHttpHeaders headers = outgoingResponse.getHeaders(); - ByteBuf byteBuf = Unpooled.wrappedBuffer(object.toString().getBytes(MessageBodyWriter.getCharset(headers))); - NettyHttpHeaders nettyHttpHeaders = (NettyHttpHeaders) headers; - io.netty.handler.codec.http.HttpHeaders nettyHeaders = nettyHttpHeaders.getNettyHeaders(); - if (!nettyHttpHeaders.contains(HttpHeaders.CONTENT_TYPE)) { - nettyHttpHeaders.set(HttpHeaderNames.CONTENT_TYPE, mediaType); - } - nettyHeaders.set(HttpHeaderNames.CONTENT_LENGTH, byteBuf.readableBytes()); + public void writeTo(HttpHeaders requestHeaders, HttpResponseStatus status, io.netty.handler.codec.http.HttpHeaders responseHeaders, CharSequence object, NettyWriteContext nettyContext) { + ByteBuf byteBuf = Unpooled.wrappedBuffer(object.toString().getBytes(MessageBodyWriter.getCharset(requestHeaders))); FullHttpResponse fullHttpResponse = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, - HttpResponseStatus.valueOf(outgoingResponse.code(), outgoingResponse.reason()), + status, byteBuf, - nettyHeaders, + responseHeaders, EmptyHttpHeaders.INSTANCE ); nettyContext.writeFull(fullHttpResponse); diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java index 7c56c31a8c6..c7c13d51f57 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java @@ -21,6 +21,7 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.io.Writable; import io.micronaut.core.io.buffer.ByteBuffer; +import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.type.Argument; import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; @@ -97,6 +98,11 @@ public void writeTo(Argument type, MediaType mediaType, Writable objec defaultWritable.writeTo(type, mediaType, object, outgoingHeaders, outputStream); } + @Override + public ByteBuffer writeTo(MediaType mediaType, Writable object, ByteBufferFactory bufferFactory) throws CodecException { + return defaultWritable.writeTo(mediaType, object, bufferFactory); + } + @Override public Publisher readChunked(Argument type, MediaType mediaType, Headers httpHeaders, Publisher> input) { return defaultWritable.readChunked(type, mediaType, httpHeaders, input); diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java b/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java new file mode 100644 index 00000000000..4a3831134dd --- /dev/null +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java @@ -0,0 +1,70 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.netty.body; + + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpHeaders; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.codec.CodecException; +import io.micronaut.http.netty.NettyHttpHeaders; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponseStatus; + +/** + * {@link NettyBodyWriter} extension that can do without a {@link HttpRequest}. + * + * @param The body type + * @since 4.3.0 + * @author Jonas Konrad + */ +@Internal +@Experimental +public interface ShortCircuitNettyBodyWriter extends NettyBodyWriter { + @Override + default void writeTo(HttpRequest request, MutableHttpResponse outgoingResponse, Argument type, MediaType mediaType, T object, NettyWriteContext writeContext) throws CodecException { + MutableHttpHeaders headers = outgoingResponse.getHeaders(); + NettyHttpHeaders nettyHttpHeaders = (NettyHttpHeaders) headers; + io.netty.handler.codec.http.HttpHeaders nettyHeaders = nettyHttpHeaders.getNettyHeaders(); + if (!nettyHttpHeaders.contains(HttpHeaders.CONTENT_TYPE)) { + nettyHttpHeaders.set(HttpHeaderNames.CONTENT_TYPE, mediaType); + } + writeTo(request.getHeaders(), HttpResponseStatus.valueOf(outgoingResponse.code(), outgoingResponse.reason()), nettyHeaders, object, writeContext); + } + + /** + * Write an object to the given context. + * + * @param requestHeaders The request headers + * @param status The response status + * @param responseHeaders The response headers + * @param object The object to write + * @param nettyContext The netty context + * @throws CodecException If an error occurs decoding + */ + void writeTo( + HttpHeaders requestHeaders, + HttpResponseStatus status, + io.netty.handler.codec.http.HttpHeaders responseHeaders, + T object, + NettyWriteContext nettyContext + ); +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 8054a591313..8d836f1b61c 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -27,36 +27,54 @@ import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.core.type.Argument; import io.micronaut.core.type.MutableHeaders; +import io.micronaut.core.util.ArrayUtils; import io.micronaut.http.HttpAttributes; import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; import io.micronaut.http.MediaType; import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.bind.binders.RequestArgumentBinder; import io.micronaut.http.body.DynamicMessageBodyWriter; import io.micronaut.http.body.MediaTypeProvider; import io.micronaut.http.body.MessageBodyHandlerRegistry; import io.micronaut.http.body.MessageBodyWriter; +import io.micronaut.http.body.RawMessageBodyHandler; import io.micronaut.http.codec.CodecException; import io.micronaut.http.context.ServerHttpRequestContext; import io.micronaut.http.context.ServerRequestContext; import io.micronaut.http.context.event.HttpRequestTerminatedEvent; import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.http.filter.FilterRunner; +import io.micronaut.http.filter.GenericHttpFilter; +import io.micronaut.http.netty.NettyHttpHeaders; import io.micronaut.http.netty.NettyHttpResponseBuilder; import io.micronaut.http.netty.NettyMutableHttpResponse; import io.micronaut.http.netty.body.NettyBodyWriter; import io.micronaut.http.netty.body.NettyWriteContext; +import io.micronaut.http.netty.body.ShortCircuitNettyBodyWriter; import io.micronaut.http.netty.stream.JsonSubscriber; import io.micronaut.http.netty.stream.StreamedHttpRequest; import io.micronaut.http.netty.stream.StreamedHttpResponse; import io.micronaut.http.server.RouteExecutor; import io.micronaut.http.server.binding.RequestArgumentSatisfier; +import io.micronaut.http.server.cors.CorsFilter; import io.micronaut.http.server.netty.body.ByteBody; +import io.micronaut.http.server.netty.body.ImmediateByteBody; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.handler.PipeliningServerHandler; import io.micronaut.http.server.netty.handler.RequestHandler; +import io.micronaut.http.server.netty.shortcircuit.ExecutionLeaf; +import io.micronaut.http.server.netty.shortcircuit.MatchPlan; +import io.micronaut.http.server.netty.shortcircuit.NettyShortCircuitRouterBuilder; +import io.micronaut.http.server.netty.shortcircuit.ShortCircuitArgumentBinder; +import io.micronaut.inject.MethodExecutionHandle; +import io.micronaut.inject.UnsafeExecutionHandle; import io.micronaut.web.router.RouteInfo; +import io.micronaut.web.router.UriRouteInfo; import io.micronaut.web.router.resource.StaticResourceResolver; +import io.micronaut.web.router.shortcircuit.MatchRule; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler.Sharable; @@ -65,8 +83,11 @@ import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.EmptyHttpHeaders; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import org.reactivestreams.Processor; @@ -83,6 +104,7 @@ import java.nio.channels.ClosedChannelException; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.function.BiConsumer; @@ -117,6 +139,7 @@ public final class RoutingInBoundHandler implements RequestHandler { final ApplicationEventPublisher terminateEventPublisher; final RouteExecutor routeExecutor; final ConversionService conversionService; + final MatchPlan shortCircuitMatchPlan; /** * @param serverConfiguration The Netty HTTP server configuration @@ -144,6 +167,14 @@ public final class RoutingInBoundHandler implements RequestHandler { this.multipartEnabled = isMultiPartEnabled.isEmpty() || isMultiPartEnabled.get(); this.routeExecutor = embeddedServerContext.getRouteExecutor(); this.conversionService = conversionService; + + if (serverConfiguration.isFastRouting()) { + NettyShortCircuitRouterBuilder> scrb = new NettyShortCircuitRouterBuilder<>(); + routeExecutor.getRouter().collectRoutes(scrb); + this.shortCircuitMatchPlan = scrb.transform(this::shortCircuitHandler).plan(); + } else { + this.shortCircuitMatchPlan = null; + } } private void cleanupRequest(NettyHttpRequest request) { @@ -193,6 +224,19 @@ public void handleUnboundError(Throwable cause) { @Override public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + if (shortCircuitMatchPlan != null && + request.decoderResult().isSuccess() && + // origin needs to be checked by CorsFilter + !request.headers().contains(HttpHeaderNames.ORIGIN) && + body instanceof ImmediateByteBody) { + + ExecutionLeaf instantResult = shortCircuitMatchPlan.execute(request); + if (instantResult instanceof ExecutionLeaf.Route route) { + route.routeMatch().accept(ctx, request, body, outboundAccess); + return; + } + } + NettyHttpRequest mnRequest; try { mnRequest = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); @@ -219,6 +263,124 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe } } + @Nullable + private static MatchRule.ContentType findFixedContentType(MatchRule matchRule) { + if (matchRule instanceof MatchRule.ContentType ct) { + return ct; + } else if (matchRule instanceof MatchRule.And and) { + return and.rules().stream() + .map(RoutingInBoundHandler::findFixedContentType) + .filter(Objects::nonNull) + .findFirst().orElse(null); + } else { + return null; + } + } + + private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRouteInfo routeInfo) { + if (routeInfo.isWebSocketRoute()) { + return ExecutionLeaf.indeterminate(); + } + List fixedFilters = routeExecutor.getRouter().getFixedFilters().orElse(null); + // CorsFilter is handled specially here. It's always present, so we can't bail, but it only does anything when the Origin header is set, which is checked in accept(). + if (fixedFilters == null || !fixedFilters.stream().allMatch(ghf -> FilterRunner.isCorsFilter(ghf, CorsFilter.class))) { + return ExecutionLeaf.indeterminate(); + } + MethodExecutionHandle executionHandle = routeInfo.getTargetMethod(); + if (executionHandle.getReturnType().isOptional() || + executionHandle.getReturnType().getType() == HttpStatus.class) { + return ExecutionLeaf.indeterminate(); + } + boolean unwrapResponse = HttpResponse.class.isAssignableFrom(executionHandle.getReturnType().getType()); + MatchRule.ContentType fixedContentType = findFixedContentType(rule); + MediaType responseMediaType; + if (fixedContentType != null) { + responseMediaType = fixedContentType.expectedType(); + } else { + List produces = routeInfo.getProduces(); + if (!produces.isEmpty()) { + responseMediaType = produces.get(0); + } else { + responseMediaType = MediaType.APPLICATION_JSON_TYPE; + } + } + RequestArgumentBinder[] argumentBinders = routeInfo.resolveArgumentBinders(requestArgumentSatisfier.getBinderRegistry()); + ShortCircuitArgumentBinder.Prepared[] shortCircuitBinders = new ShortCircuitArgumentBinder.Prepared[argumentBinders.length]; + for (int i = 0; i < argumentBinders.length; i++) { + if (!(argumentBinders[i] instanceof ShortCircuitArgumentBinder scb)) { + return ExecutionLeaf.indeterminate(); + } + //noinspection unchecked + Optional prep = scb.prepare(executionHandle.getArguments()[i], fixedContentType); + if (prep.isEmpty()) { + return ExecutionLeaf.indeterminate(); + } + shortCircuitBinders[i] = prep.get(); + } + if (routeInfo.getExecutor(serverConfiguration.getThreadSelection()) != null || + routeInfo.isSuspended() || + routeInfo.isAsyncOrReactive()) { + return ExecutionLeaf.indeterminate(); + } + if (!(executionHandle instanceof UnsafeExecutionHandle unsafeExecutionHandle)) { + return ExecutionLeaf.indeterminate(); + } + @SuppressWarnings("unchecked") + MessageBodyWriter messageBodyWriter = (MessageBodyWriter) routeInfo.getMessageBodyWriter(); + ShortCircuitNettyBodyWriter scWriter ; + RawMessageBodyHandler rawWriter ; + if (messageBodyWriter instanceof ShortCircuitNettyBodyWriter scw) { + rawWriter = null; + scWriter = scw; + } else if (messageBodyWriter instanceof RawMessageBodyHandler raw) { + rawWriter = raw; + scWriter = null; + } else { + return ExecutionLeaf.indeterminate(); + } + return new ExecutionLeaf.Route<>(new RequestHandler() { + @Override + public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + try { + NettyHttpHeaders requestHeaders = new NettyHttpHeaders(request.headers(), conversionService); + + Object[] arguments = shortCircuitBinders.length == 0 ? ArrayUtils.EMPTY_OBJECT_ARRAY : new Object[shortCircuitBinders.length]; + for (int i = 0; i < arguments.length; i++) { + arguments[i] = shortCircuitBinders[i].bind(request, requestHeaders, (ImmediateByteBody) body); + } + Object result = unsafeExecutionHandle.invokeUnsafe(arguments); + HttpResponseStatus status = HttpResponseStatus.OK; + HttpHeaders responseHeaders; + if (unwrapResponse) { + HttpResponse resp = (HttpResponse) result; + responseHeaders = ((NettyHttpHeaders) resp.getHeaders()).getNettyHeaders(); + if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { + responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + } + status = HttpResponseStatus.valueOf(resp.code(), resp.reason()); + result = resp.body(); + } else { + responseHeaders = new DefaultHttpHeaders(); + responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + } + if (scWriter != null) { + scWriter.writeTo(requestHeaders, status, responseHeaders, result, outboundAccess); + } else { + ByteBuf buf = (ByteBuf) rawWriter.writeTo(responseMediaType, result, NettyByteBufferFactory.DEFAULT).asNativeBuffer(); + outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); + } + } catch (Exception e) { + RoutingInBoundHandler.this.handleUnboundError(e); + } + } + + @Override + public void handleUnboundError(Throwable cause) { + throw new UnsupportedOperationException(); + } + }); + } + public void writeResponse(PipeliningServerHandler.OutboundAccess outboundAccess, NettyHttpRequest nettyHttpRequest, MutableHttpResponse response, diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index a96bceec901..b39c205754e 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -21,8 +21,10 @@ import io.micronaut.core.convert.ConversionService; import io.micronaut.core.convert.value.ConvertibleValues; import io.micronaut.core.execution.ExecutionFlow; +import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; import io.micronaut.http.bind.binders.DefaultBodyAnnotationBinder; import io.micronaut.http.bind.binders.PendingRequestBindingResult; import io.micronaut.http.body.MessageBodyHandlerRegistry; @@ -33,11 +35,13 @@ import io.micronaut.http.server.netty.FormDataHttpContentProcessor; import io.micronaut.http.server.netty.NettyHttpRequest; import io.micronaut.http.server.netty.body.ImmediateByteBody; +import io.micronaut.http.server.netty.shortcircuit.ShortCircuitArgumentBinder; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.util.List; import java.util.Optional; -final class NettyBodyAnnotationBinder extends DefaultBodyAnnotationBinder { +final class NettyBodyAnnotationBinder extends DefaultBodyAnnotationBinder implements ShortCircuitArgumentBinder { private static final CharSequence ATTR_CONVERTIBLE_BODY = "NettyBodyAnnotationBinder.convertibleBody"; final HttpServerConfiguration httpServerConfiguration; @@ -159,4 +163,38 @@ Optional transform(NettyHttpRequest nhr, ArgumentConversionContext cont .convert(conversionService, context) .map(o -> (T) o.claimForExternal()); } + + @Override + public Optional prepare(Argument argument, MatchRule.ContentType fixedContentType) { + boolean hasBodyAnnotation = argument.getAnnotationMetadata().hasAnnotation(Body.class); + Optional optionalBodyComponent = argument.getAnnotationMetadata().stringValue(Body.class); + if (!hasBodyAnnotation || optionalBodyComponent.isPresent()) { + // only full body binding implemented + return Optional.empty(); + } + boolean raw = DefaultHttpContentProcessorResolver.isRaw(argument); + MessageBodyReader reader; + if (raw) { + reader = null; + } else { + if (fixedContentType == null) { + return Optional.empty(); + } + Optional> opt = bodyHandlerRegistry.findReader(argument, fixedContentType.expectedType() == null ? null : List.of(fixedContentType.expectedType())); + if (opt.isEmpty()) { + return Optional.empty(); + } + reader = opt.get(); + } + return Optional.of((nettyRequest, mnHeaders, body) -> { + if (body.empty()) { + return null; + } + if (raw) { + return body.rawContent(httpServerConfiguration).convert(conversionService, ConversionContext.of(argument)).orElse(null); + } else { + return body.processSingle(httpServerConfiguration, reader, argument, fixedContentType.expectedType(), mnHeaders).claimForExternal(); + } + }); + } } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java index 89d2c0ce428..bdfaf96b978 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java @@ -163,6 +163,14 @@ public class NettyHttpServerConfiguration extends HttpServerConfiguration { @SuppressWarnings("WeakerAccess") public static final int DEFAULT_HTTP3_INITIAL_MAX_STREAMS_BIDIRECTIONAL = 100; + /** + * The default value for fast routing. + * + * @since 4.3.0 + */ + @SuppressWarnings("WeakerAccess") + public static final boolean DEFAULT_FAST_ROUTING = false; + private static final Logger LOG = LoggerFactory.getLogger(NettyHttpServerConfiguration.class); private final List pipelineCustomizers; @@ -196,6 +204,7 @@ public class NettyHttpServerConfiguration extends HttpServerConfiguration { private List listeners = null; private boolean eagerParsing = DEFAULT_EAGER_PARSING; private int jsonBufferMaxComponents = DEFAULT_JSON_BUFFER_MAX_COMPONENTS; + private boolean fastRouting = DEFAULT_FAST_ROUTING; /** * Default empty constructor. @@ -728,6 +737,30 @@ public void setJsonBufferMaxComponents(int jsonBufferMaxComponents) { this.jsonBufferMaxComponents = jsonBufferMaxComponents; } + /** + * Whether fast routing should be enabled for routes that are simple enough to be supported. + * During fast routing, not all framework features may be available, such as the thread-local + * request context or some error handling. Default {@value DEFAULT_FAST_ROUTING}. + * + * @return {@code true} if fast routing should be enabled + * @since 4.3.0 + */ + public boolean isFastRouting() { + return fastRouting; + } + + /** + * Whether fast routing should be enabled for routes that are simple enough to be supported. + * During fast routing, not all framework features may be available, such as the thread-local + * request context or some error handling. Default {@value DEFAULT_FAST_ROUTING}. + * + * @param fastRouting {@code true} if fast routing should be enabled + * @since 4.3.0 + */ + public void setFastRouting(boolean fastRouting) { + this.fastRouting = fastRouting; + } + /** * Http2 settings. */ @@ -1476,6 +1509,7 @@ public void setFd(Integer fd) { public boolean isBind() { return bind; } + /** * Whether the server should bind to the socket. {@code true} by default. If set to * {@code false}, the socket must already be bound and listening. diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java new file mode 100644 index 00000000000..0b520ec39c2 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java @@ -0,0 +1,144 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.MediaType; +import io.micronaut.web.router.shortcircuit.MatchRule; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.QueryStringDecoder; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * The different stages of route matching. These are matched in order. For example the + * {@link #PATH} stage matches the request path to the candidate routes.
+ * This class also handles transforming the {@link MatchRule}s corresponding to each stage to a + * {@link MatchPlan}. + * + * @author Jonas Konrad + * @since 4.3.0 + */ +@Internal +enum DiscriminatorStage { + PATH { + @Override + MatchPlan planDiscriminate(Map> nextPlans) { + // simplify already removed any overlaps between patterns and exact matches. only the exact matches are left + Map> byString = nextPlans.entrySet().stream() + .filter(e -> e.getKey() instanceof MatchRule.PathMatchExact) + .collect(Collectors.toMap(e -> ((MatchRule.PathMatchExact) e.getKey()).path(), Map.Entry::getValue)); + return request -> { + // this replicates AbstractNettyHttpRequest.getPath but not exactly :( + URI uri; + try { + uri = URI.create(request.uri()); + } catch (IllegalArgumentException iae) { + return ExecutionLeaf.indeterminate(); + } + String rawPath = new QueryStringDecoder(uri).rawPath(); + if (!rawPath.isEmpty() && rawPath.charAt(rawPath.length() - 1) == '/') { + rawPath = rawPath.substring(0, rawPath.length() - 1); + } + MatchPlan plan = byString.get(rawPath); + return plan == null ? ExecutionLeaf.indeterminate() : plan.execute(request); + }; + } + }, + METHOD { + @Override + MatchPlan planDiscriminate(Map> nextPlans) { + Map> byMethod = coerceRules(MatchRule.Method.class, nextPlans) + .entrySet().stream().collect(Collectors.toMap(e -> HttpMethod.valueOf(e.getKey().method().name()), Map.Entry::getValue)); + return request -> { + MatchPlan plan = byMethod.get(request.method()); + return plan == null ? ExecutionLeaf.indeterminate() : plan.execute(request); + }; + } + }, + CONTENT_TYPE { + @Override + MatchPlan planDiscriminate(Map> nextPlans) { + Map> byContentType = coerceRules(MatchRule.ContentType.class, nextPlans) + .entrySet().stream().collect(Collectors.toMap(e -> { + MediaType expectedType = e.getKey().expectedType(); + return expectedType == null ? null : expectedType.getName(); + }, Map.Entry::getValue)); + return request -> { + MatchPlan plan = byContentType.get(request.headers().get(HttpHeaderNames.CONTENT_TYPE)); + return plan == null ? ExecutionLeaf.indeterminate() : plan.execute(request); + }; + } + }, + ACCEPT { + @Override + MatchPlan planDiscriminate(Map> nextPlans) { + Map> rules = coerceRules(MatchRule.Accept.class, nextPlans); + return request -> { + List accept = MediaType.orderedOf(request.headers().getAll(HttpHeaderNames.ACCEPT)); + if (accept.isEmpty() || accept.contains(MediaType.ALL_TYPE)) { + if (rules.size() == 1) { + return rules.values().iterator().next().execute(request); + } else { + return ExecutionLeaf.indeterminate(); + } + } + MatchPlan match = null; + for (Map.Entry> e : rules.entrySet()) { + for (MediaType producedType : e.getKey().producedTypes()) { + if (accept.contains(producedType)) { + if (match != null) { + return ExecutionLeaf.indeterminate(); + } + match = e.getValue(); + } + } + } + return match == null ? ExecutionLeaf.indeterminate() : match.execute(request); + }; + } + }, + SERVER_PORT { + @Override + MatchPlan planDiscriminate(Map> nextPlans) { + // todo: not implemented yet + return request -> ExecutionLeaf.indeterminate(); + } + }; + + /** + * Create a {@link MatchPlan} that delegates to the next plan given in {@code nextPlans} + * depending on which {@link MatchRule} in matched. The {@link MatchRule}s must be appropriate + * for this stage. + * + * @param nextPlans The next match plans + * @param The route type + * @return The combined plan + */ + @NonNull + abstract MatchPlan planDiscriminate(@NonNull Map> nextPlans); + + private static Map> coerceRules(Class cl, Map> nextPlans) { + assert nextPlans.keySet().stream().allMatch(cl::isInstance); + //noinspection unchecked,rawtypes + return (Map) nextPlans; + } +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java new file mode 100644 index 00000000000..5171a8c3cd0 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; + +/** + * This class represents the final "leaf" result of the decision tree in a {@link MatchPlan}. + * + * @param The route type + * @since 4.3.0 + * @author Jonas Konrad + */ +@Internal +public sealed interface ExecutionLeaf { + /** + * Result that signifies that no match was found or the match was indeterminate. + * + * @param The route type + * @return The singleton + */ + @SuppressWarnings("unchecked") + static Indeterminate indeterminate() { + return Indeterminate.INSTANCE; + } + + /** + * Result that signifies that no match was found or the match was indeterminate. + * + * @param The route type + */ + record Indeterminate() implements ExecutionLeaf { + @SuppressWarnings("rawtypes") + private static final Indeterminate INSTANCE = new Indeterminate(); + } + + /** + * Result for a successful route match. + * + * @param The route type + * @param routeMatch The route match + */ + record Route(R routeMatch) implements ExecutionLeaf { + } +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java new file mode 100644 index 00000000000..c1124e4be87 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.netty.handler.codec.http.HttpRequest; + +/** + * This class matches a set of routes against their match rules and returns either a successful + * match or an indeterminate result. This is the hot path of fast routing. + * + * @param The route type + * @author Jonas Konrad + * @since 4.3.0 + */ +@Internal +public interface MatchPlan { + /** + * Attempt to match the given request using this match plan. + * + * @param request The request to match + * @return The match result + */ + @NonNull + ExecutionLeaf execute(@NonNull HttpRequest request); +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java new file mode 100644 index 00000000000..0902b2743fa --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java @@ -0,0 +1,264 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.web.router.shortcircuit.MatchRule; +import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +/** + * This is a netty implementation of {@link ShortCircuitRouterBuilder}. + *

+ * The principle of operation is that all routes are collected into a tree of {@link MatchNode}s. + * Every level of the tree stands for one {@link DiscriminatorStage}. When all routes have been + * collected, the {@link io.micronaut.web.router.UriRouteInfo}s are transformed to closures that + * actually implement the routes by {@link io.micronaut.http.server.netty.RoutingInBoundHandler}. + * Then a simplification algorithm is run to reduce the size of the decision tree. Finally, the + * decision tree is transformed into a {@link MatchPlan} using the {@link DiscriminatorStage}s. + * + * @param The route type. Initially {@link io.micronaut.web.router.UriRouteInfo} for the router + * to add routes. {@link io.micronaut.http.server.netty.RoutingInBoundHandler} then + * {@link #transform(BiFunction) transforms} the type to its own prepared route call + * object. + * @author Jonas Konrad + * @since 4.3.0 + */ +@Internal +public final class NettyShortCircuitRouterBuilder implements ShortCircuitRouterBuilder { + private final MatchNode topNode = new MatchNode(); + + @Override + public void addRoute(MatchRule rule, R match) { + addRoute(rule, new ExecutionLeaf.Route<>(match)); + } + + @Override + public void addLegacyRoute(MatchRule rule) { + addRoute(rule, ExecutionLeaf.indeterminate()); + } + + @Override + public void addLegacyFallbackRouting() { + } + + /** + * Eagerly apply a transformation function to every {@link ExecutionLeaf.Route} in this builder. + * + * @param transform The transformation function + * @return A builder with the transformed routes + * @param The transformed route type + */ + @SuppressWarnings("unchecked") + @NonNull + public NettyShortCircuitRouterBuilder transform(@NonNull BiFunction> transform) { + topNode.eachNode(List.of(), (path, n) -> { + if (n.leaf instanceof ExecutionLeaf.Route route) { + n.leaf = (ExecutionLeaf) transform.apply(MatchRule.and(path), route.routeMatch()); + } + }); + return (NettyShortCircuitRouterBuilder) this; + } + + public MatchPlan plan() { + topNode.simplify(0); + return topNode.plan(0); + } + + private void addRoute(MatchRule rule, ExecutionLeaf executionLeaf) { + // transform the input rule to DNF. Then add each part of the OR as a separate route. + MatchRule.Or dnf = toDnf(rule); + for (MatchRule conjunction : dnf.rules()) { + if (conjunction instanceof MatchRule.And and) { + addRoute(and.rules(), executionLeaf); + } else { + addRoute(List.of(conjunction), executionLeaf); + } + } + } + + private void addRoute(List leafRules, ExecutionLeaf executionLeaf) { + // split up rules by discriminator + Map rulesByDiscriminator = new EnumMap<>(DiscriminatorStage.class); + for (MatchRule rule : leafRules) { + DiscriminatorStage disc; + if (rule instanceof MatchRule.Method) { + disc = DiscriminatorStage.METHOD; + } else if (rule instanceof MatchRule.ContentType) { + disc = DiscriminatorStage.CONTENT_TYPE; + } else if (rule instanceof MatchRule.Accept) { + disc = DiscriminatorStage.ACCEPT; + } else if (rule instanceof MatchRule.PathMatchExact || rule instanceof MatchRule.PathMatchPattern) { + disc = DiscriminatorStage.PATH; + } else if (rule instanceof MatchRule.ServerPort) { + disc = DiscriminatorStage.SERVER_PORT; + } else { + executionLeaf = ExecutionLeaf.indeterminate(); + continue; + } + MatchRule existing = rulesByDiscriminator.put(disc, rule); + if (existing != null && !existing.equals(rule)) { + // different rules of the same type. this can (probably) never match, just ignore this route. + return; + } + } + // add to decision tree + MatchNode node = topNode; + for (DiscriminatorStage stage : DiscriminatorStage.values()) { + MatchRule rule = rulesByDiscriminator.get(stage); + node = node.next.computeIfAbsent(rule, r -> new MatchNode()); + } + node.leaf = merge(node.leaf, executionLeaf); + } + + private static MatchRule.Or toDnf(MatchRule rule) { + // https://en.wikipedia.org/wiki/Disjunctive_normal_form + if (rule instanceof MatchRule.Or or) { + return new MatchRule.Or( + or.rules().stream() + .flatMap(r -> toDnf(r).rules().stream()) + .toList() + ); + } else if (rule instanceof MatchRule.And and) { + List> combined = List.of(List.of()); + for (MatchRule right : and.rules()) { + List> newCombined = new ArrayList<>(); + for (MatchRule rightPart : toDnf(right).rules()) { + for (List left : combined) { + List linked = new ArrayList<>(left.size() + 1); + linked.addAll(left); + if (rightPart instanceof MatchRule.And ra) { + linked.addAll(ra.rules()); + } else { + linked.add(rightPart); + } + newCombined.add(linked); + } + } + combined = newCombined; + } + return new MatchRule.Or(combined.stream() + .map(MatchRule::and) + .toList()); + } else { + // "Leaf" rule + return new MatchRule.Or(List.of(rule)); + } + } + + private static ExecutionLeaf merge(@Nullable ExecutionLeaf a, @Nullable ExecutionLeaf b) { + if (a == null) { + return b; + } else if (b == null) { + return a; + } else if (a.equals(b)) { + return a; + } else { + return ExecutionLeaf.indeterminate(); + } + } + + private class MatchNode { + /** + * The next rules in the decision tree. A {@code null} key means that all possible requests + * are matched. + */ + final Map next = new HashMap<>(); + /** + * If this is not {@code null}, then the decision tree stops here and we have the final + * result. + */ + ExecutionLeaf leaf = null; + + void simplify(int level) { + // simplify children first + for (MatchNode n : next.values()) { + n.simplify(level + 1); + } + + MatchNode anyMatchNode = next.get(null); + if (anyMatchNode != null) { + if (next.size() == 1) { + this.leaf = merge(this.leaf, anyMatchNode.leaf); + } else { + // give up. we can't merge a wildcard match with the other branches properly + this.leaf = ExecutionLeaf.indeterminate(); + } + } else { + if (level == DiscriminatorStage.PATH.ordinal()) { + for (Map.Entry entry : List.copyOf(next.entrySet())) { + if (entry.getKey() instanceof MatchRule.PathMatchPattern pattern) { + // remove any exact path matches that overlap with a pattern + next.keySet().removeIf(mr -> mr instanceof MatchRule.PathMatchExact exact && pattern.pattern().matcher(exact.path()).matches()); + // refuse to match patterns. + entry.getValue().leaf = ExecutionLeaf.indeterminate(); + } + } + } + } + + // at this point, the next branches do not overlap. remove any nodes that lead to indeterminate decisions. + next.values().removeIf(n -> n.leaf instanceof ExecutionLeaf.Indeterminate); + // if there's no more available choices, indeterminate result. + if (next.isEmpty() && leaf == null) { + leaf = ExecutionLeaf.indeterminate(); + } + + if (this.leaf != null) { + next.clear(); + } + } + + void eachNode(List rulePath, BiConsumer, MatchNode> consumer) { + consumer.accept(rulePath, this); + for (Map.Entry entry : next.entrySet()) { + List newRulePath; + if (entry.getKey() == null) { + newRulePath = rulePath; + } else { + newRulePath = new ArrayList<>(rulePath.size() + 1); + newRulePath.addAll(rulePath); + newRulePath.add(entry.getKey()); + } + entry.getValue().eachNode(newRulePath, consumer); + } + } + + MatchPlan plan(int level) { + if (leaf != null) { + return request -> leaf; + } + if (next.containsKey(null)) { + assert next.size() == 1; + return next.get(null).plan(level + 1); + } else { + DiscriminatorStage ourStage = DiscriminatorStage.values()[level]; + return ourStage.planDiscriminate(next.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().plan(level + 1)))); + } + } + } +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java new file mode 100644 index 00000000000..4a0ccb55193 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.bind.binders.RequestArgumentBinder; +import io.micronaut.http.server.netty.body.ImmediateByteBody; +import io.micronaut.web.router.shortcircuit.MatchRule; +import io.netty.handler.codec.http.HttpRequest; + +import java.util.Optional; + +/** + * {@link RequestArgumentBinder} extension for fast routing. + * + * @param + * @author Jonas Konrad + * @since 4.3.0 + */ +@Internal +public interface ShortCircuitArgumentBinder extends RequestArgumentBinder { + /** + * Prepare this binder for a parameter. + * + * @param argument The parameter type with annotations + * @param fixedContentType The content type of the request, if known + * @return The prepared binder or {@link Optional#empty()} if this argument cannot use short-circuit binding + */ + Optional prepare(@NonNull Argument argument, @Nullable MatchRule.ContentType fixedContentType); + + /** + * Prepared argument binder. + */ + interface Prepared { + /** + * Bind the parameter. + * + * @param nettyRequest The netty request + * @param mnHeaders The request headers (micronaut-http class) + * @param body The request body + * @return The bound argument + */ + Object bind(@NonNull HttpRequest nettyRequest, HttpHeaders mnHeaders, @NonNull ImmediateByteBody body); + } +} diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/ContextPathFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/ContextPathFilter.java index f84efeef9d5..23863af1385 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/ContextPathFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/ContextPathFilter.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.netty.interceptor; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.async.publisher.Publishers; import io.micronaut.http.HttpRequest; import io.micronaut.http.MutableHttpResponse; @@ -27,6 +28,7 @@ * Tests filters with the context path already prepended still work */ @Filter("/context/path/**") +@Requires(property = "spec.name", pattern = "HttpFilterSpec|HttpFilterContextPathSpec") public class ContextPathFilter implements HttpServerFilter { @Override diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/FirstFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/FirstFilter.java index b4a65edd616..160d1a3dd29 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/FirstFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/FirstFilter.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.netty.interceptor; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.async.publisher.Publishers; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; @@ -29,6 +30,7 @@ * @since 1.0 */ @Filter("/secure**") +@Requires(property = "spec.name", pattern = "HttpFilterSpec|HttpFilterContextPathSpec") public class FirstFilter implements HttpFilter { @Override diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/SecondFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/SecondFilter.java index 76a12825f74..8b80a777c7c 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/SecondFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/SecondFilter.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.netty.interceptor; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.convert.value.MutableConvertibleValues; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; @@ -29,6 +30,7 @@ * @since 1.0 */ @Filter("/secure**") +@Requires(property = "spec.name", pattern = "HttpFilterSpec|HttpFilterContextPathSpec") public class SecondFilter implements HttpFilter { @Override public int getOrder() { diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestReactiveFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestReactiveFilter.java index 93ea6e6d885..5d49c6d1d8b 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestReactiveFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestReactiveFilter.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.netty.interceptor; +import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Filter; @@ -28,6 +29,7 @@ * @since 1.0 */ @Filter("/secure**") +@Requires(property = "spec.name", pattern = "HttpFilterSpec|HttpFilterContextPathSpec") public class TestReactiveFilter implements HttpServerFilter{ @Override diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestSecurityFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestSecurityFilter.java index 46c40037392..69d96d2aed8 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestSecurityFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/interceptor/TestSecurityFilter.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.netty.interceptor; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.async.publisher.Publishers; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; @@ -31,6 +32,7 @@ * @since 1.0 */ @Filter("/secure**") +@Requires(property = "spec.name", pattern = "HttpFilterSpec|HttpFilterContextPathSpec") public class TestSecurityFilter implements HttpServerFilter { public static final int POSITION = 0; diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy new file mode 100644 index 00000000000..815dcc7e11d --- /dev/null +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy @@ -0,0 +1,55 @@ +package io.micronaut.http.server.netty.shortcircuit + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Post +import io.micronaut.http.client.HttpClient +import io.micronaut.http.context.ServerRequestContext +import io.micronaut.runtime.server.EmbeddedServer +import spock.lang.Specification + +class FastRoutingSpec extends Specification { + def test(boolean fastRouting) { + given: + def ctx = ApplicationContext.run(['spec.name': 'FastRoutingSpec', 'micronaut.server.netty.fast-routing': fastRouting]) + def server = ctx.getBean(EmbeddedServer) + server.start() + def client = ctx.createBean(HttpClient, server.URI).toBlocking() + + expect: + // if this returns the wrong boolean value, then fast routing is broken for this route. maybe you added a filter that applies to this test? + client.retrieve("/simple") == "foo: ${!fastRouting}" + + client.retrieve(HttpRequest.POST("/json", '{"foo": "bar"}')) == '{"foo":"bar"}' + + cleanup: + server.stop() + client.close() + ctx.close() + + where: + fastRouting << [false, true] + } + + @Controller + @Requires(property = "spec.name", value = "FastRoutingSpec") + static class MyController { + @Get("/simple") + String simple() { + return "foo: " + ServerRequestContext.currentRequest().isPresent() + } + + @Post("/json") + MyRecord json(@Body MyRecord json) { + return json + } + } + + @Introspected + record MyRecord(String foo) {} +} diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy new file mode 100644 index 00000000000..13b16ac9c1b --- /dev/null +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy @@ -0,0 +1,122 @@ +package io.micronaut.http.server.netty.shortcircuit + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Nullable +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Consumes +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Produces +import io.micronaut.http.server.RouteExecutor +import io.micronaut.web.router.UriRouteInfo +import io.netty.handler.codec.http.DefaultHttpRequest +import io.netty.handler.codec.http.HttpMethod +import io.netty.handler.codec.http.HttpRequest +import io.netty.handler.codec.http.HttpVersion +import spock.lang.Specification + +class NettyShortCircuitRouterBuilderSpec extends Specification { + def 'route builder'(HttpRequest request, @Nullable String methodName) { + given: + def ctx = ApplicationContext.run(['spec.name': "NettyShortCircuitRouterBuilderSpec"]) + def scb = new NettyShortCircuitRouterBuilder>() + ctx.getBean(RouteExecutor).getRouter().collectRoutes(scb) + def plan = scb.plan() + + when: + def leaf = plan.execute(request) + def actualName + if (leaf instanceof ExecutionLeaf.Route) { + actualName = ((UriRouteInfo) leaf.routeMatch()).targetMethod.name + } else { + actualName = null + } + then: + actualName == methodName + + where: + request | methodName + get("/simple") | "simple" + get("/pattern-collision/exact") | null + get("/produces") | "produces" + get("/produces", ['accept': 'application/json']) | "produces" + get("/produces", ['accept': '*/*']) | "produces" + get("/produces", ['accept': 'text/plain, */*']) | "produces" + get("/produces", ['accept': 'text/plain']) | null + get("/produces-overlap", ['accept': 'text/plain']) | "producesOverlapText" + get("/produces-overlap", ['accept': 'application/json']) | "producesOverlapJson" + get("/produces-overlap", ['accept': '*/*']) | null + get("/produces-overlap") | null + get("/produces-overlap", ['accept': 'text/plain, application/json']) | null + post("/consumes", ['content-type': 'application/json']) | "consumes" + post("/consumes", ['content-type': 'text/plain']) | null + post("/consumes") | "consumes" + post("/consumes-overlap", ['content-type': 'application/json']) | "consumesOverlapJson" + post("/consumes-overlap", ['content-type': 'text/plain']) | "consumesOverlapText" + post("/consumes-overlap") | null + } + + private static HttpRequest get(String path, Map headers = [:]) { + def request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, path) + for (Map.Entry entry : headers.entrySet()) { + request.headers().add(entry.key, entry.value) + } + return request + } + + private static HttpRequest post(String path, Map headers = [:]) { + def request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, path) + for (Map.Entry entry : headers.entrySet()) { + request.headers().add(entry.key, entry.value) + } + return request + } + + @Controller + @Requires(property = "spec.name", value = "NettyShortCircuitRouterBuilderSpec") + static class TestBean { + @Get("/simple") + void simple() { + } + + @Get("/pattern-collision/{pattern}") + void patternCollisionPattern() { + } + + @Get("/pattern-collision/exact") + void patternCollisionExact() { + } + + @Get("/produces") + @Produces(MediaType.APPLICATION_JSON) + void produces() { + } + + @Get("/produces-overlap") + @Produces(MediaType.APPLICATION_JSON) + void producesOverlapJson() { + } + + @Get("/produces-overlap") + @Produces(MediaType.TEXT_PLAIN) + void producesOverlapText() { + } + + @Post("/consumes") + @Consumes(MediaType.APPLICATION_JSON) + void consumes() { + } + + @Post("/consumes-overlap") + @Consumes(MediaType.APPLICATION_JSON) + void consumesOverlapJson() { + } + + @Post("/consumes-overlap") + @Consumes(MediaType.TEXT_PLAIN) + void consumesOverlapText() { + } + } +} diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/stack/InvocationStackSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/stack/InvocationStackSpec.groovy index 2d1939b1cb5..2286b92b5e1 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/stack/InvocationStackSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/stack/InvocationStackSpec.groovy @@ -217,6 +217,7 @@ class InvocationStackSpec extends Specification { } + @Requires(property = "spec", value = "InvocationStackSpec") @Filter("/stack-check/with-one-reactive-filter*") static class MyOneFilter implements HttpServerFilter { @@ -229,6 +230,7 @@ class InvocationStackSpec extends Specification { } } + @Requires(property = "spec", value = "InvocationStackSpec") @Filter("/stack-check/with-two-reactive-filters*") static class MyTwoFilter1 implements HttpServerFilter { @@ -241,6 +243,7 @@ class InvocationStackSpec extends Specification { } } + @Requires(property = "spec", value = "InvocationStackSpec") @Filter("/stack-check/with-two-reactive-filters*") static class MyTwoFilter2 implements HttpServerFilter { diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/threading/ThreadSelectionSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/threading/ThreadSelectionSpec.groovy index 83a7b38eecd..9f39fce7ddc 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/threading/ThreadSelectionSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/threading/ThreadSelectionSpec.groovy @@ -1,6 +1,7 @@ package io.micronaut.http.server.netty.threading import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Blocking import io.micronaut.core.annotation.NonBlocking import io.micronaut.http.HttpRequest @@ -44,7 +45,7 @@ class ThreadSelectionSpec extends Specification { void "test thread selection strategy #strategy"() { given: - EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['micronaut.server.thread-selection': strategy]) + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['spec.name': 'ThreadSelectionSpec', 'micronaut.server.thread-selection': strategy]) ThreadSelectionClient client = embeddedServer.applicationContext.getBean(ThreadSelectionClient) expect: @@ -66,7 +67,7 @@ class ThreadSelectionSpec extends Specification { void "test thread selection strategy for reactive types #strategy"() { given: - EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['micronaut.server.thread-selection': strategy]) + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['spec.name': 'ThreadSelectionSpec', 'micronaut.server.thread-selection': strategy]) ThreadSelectionClient client = embeddedServer.applicationContext.getBean(ThreadSelectionClient) @@ -89,7 +90,7 @@ class ThreadSelectionSpec extends Specification { void "test thread selection for exception handlers #strategy"() { given: - EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['micronaut.server.thread-selection': strategy]) + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['spec.name': 'ThreadSelectionSpec', 'micronaut.server.thread-selection': strategy]) ThreadSelectionClient client = embeddedServer.applicationContext.getBean(ThreadSelectionClient) when: @@ -115,7 +116,7 @@ class ThreadSelectionSpec extends Specification { void "test thread selection for error route #strategy"() { given: - EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['micronaut.server.thread-selection': strategy]) + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ['spec.name': 'ThreadSelectionSpec', 'micronaut.server.thread-selection': strategy]) ThreadSelectionClient client = embeddedServer.applicationContext.getBean(ThreadSelectionClient) when: @@ -149,6 +150,7 @@ class ThreadSelectionSpec extends Specification { } @Client("/thread-selection") + @Requires(property = "spec.name", value = "ThreadSelectionSpec") static interface ThreadSelectionClient { @Get("/blocking") String blocking() @@ -188,6 +190,7 @@ class ThreadSelectionSpec extends Specification { } @Controller("/thread-selection") + @Requires(property = "spec.name", value = "ThreadSelectionSpec") static class ThreadSelectionController { @Get("/blocking") String blocking() { @@ -266,6 +269,7 @@ class ThreadSelectionSpec extends Specification { } @Filter("/thread-selection/alter**") + @Requires(property = "spec.name", value = "ThreadSelectionSpec") static class ThreadSelectionFilter implements HttpServerFilter { @Override diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/SimpleTextWebSocketSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/SimpleTextWebSocketSpec.groovy index 82044027506..206f6086790 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/SimpleTextWebSocketSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/SimpleTextWebSocketSpec.groovy @@ -16,7 +16,6 @@ package io.micronaut.http.server.netty.websocket import io.micronaut.context.ApplicationContext -import io.micronaut.core.util.StreamUtils import io.micronaut.http.client.annotation.Client import io.micronaut.runtime.server.EmbeddedServer import io.micronaut.websocket.WebSocketClient @@ -34,7 +33,7 @@ class SimpleTextWebSocketSpec extends Specification { @Retry void "test simple text websocket exchange"() { given: - EmbeddedServer embeddedServer = ApplicationContext.builder('micronaut.server.netty.log-level':'TRACE').run(EmbeddedServer) + EmbeddedServer embeddedServer = ApplicationContext.builder('micronaut.server.netty.log-level':'TRACE', 'spec.name':'SimpleTextWebSocketSpec').run(EmbeddedServer) PollingConditions conditions = new PollingConditions(timeout: 15 , delay: 0.5) def uri = embeddedServer.getURI() uri = new URI(scheme, uri.schemeSpecificPart, uri.fragment) // apply wss scheme @@ -118,6 +117,7 @@ class SimpleTextWebSocketSpec extends Specification { 'micronaut.server.ssl.port': -1, 'micronaut.server.ssl.build-self-signed':true, 'micronaut.http.client.ssl.insecure-trust-all-certificates': true, + 'spec.name':'SimpleTextWebSocketSpec', ]).run(EmbeddedServer) PollingConditions conditions = new PollingConditions(timeout: 15 , delay: 0.5) def uri = embeddedServer.getURI() @@ -198,7 +198,7 @@ class SimpleTextWebSocketSpec extends Specification { void "test simple text websocket connection with query"() { given: - EmbeddedServer embeddedServer = ApplicationContext.builder('micronaut.server.netty.log-level': 'TRACE').run(EmbeddedServer) + EmbeddedServer embeddedServer = ApplicationContext.builder('micronaut.server.netty.log-level': 'TRACE', 'spec.name':'SimpleTextWebSocketSpec').run(EmbeddedServer) PollingConditions conditions = new PollingConditions(timeout: 2, delay: 0.5) when: "a websocket connection is established" @@ -224,7 +224,7 @@ class SimpleTextWebSocketSpec extends Specification { void "test a filter responding to a websocket upgrade request"() { given: EmbeddedServer embeddedServer = ApplicationContext.builder( - 'websocket-filter-respond': true + 'websocket-filter-respond': true, 'spec.name':'SimpleTextWebSocketSpec' ).run(EmbeddedServer) when: @@ -238,7 +238,7 @@ class SimpleTextWebSocketSpec extends Specification { void "test filters are invoked for web socket requests that don't match any routes"() { given: - EmbeddedServer embeddedServer = ApplicationContext.builder().run(EmbeddedServer) + EmbeddedServer embeddedServer = ApplicationContext.builder('spec.name':'SimpleTextWebSocketSpec').run(EmbeddedServer) when: WebSocketClient wsClient = embeddedServer.applicationContext.createBean(WebSocketClient, embeddedServer.getURI()) diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/WebSocketContextValidationFilter.java b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/WebSocketContextValidationFilter.java index bf54e1d0369..4beaf4db082 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/WebSocketContextValidationFilter.java +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/websocket/WebSocketContextValidationFilter.java @@ -1,5 +1,6 @@ package io.micronaut.http.server.netty.websocket; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.async.publisher.Publishers; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; @@ -7,13 +8,13 @@ import io.micronaut.http.context.ServerRequestContext; import io.micronaut.http.filter.FilterChain; import io.micronaut.http.filter.HttpFilter; +import org.reactivestreams.Publisher; + import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -import org.reactivestreams.Publisher; @Filter({"/chat/**", "/abc/**"}) +@Requires(property = "spec.name", value = "SimpleTextWebSocketSpec") public class WebSocketContextValidationFilter implements HttpFilter { AtomicInteger executeCount = new AtomicInteger(); diff --git a/http/src/main/java/io/micronaut/http/body/ContextlessMessageBodyHandlerRegistry.java b/http/src/main/java/io/micronaut/http/body/ContextlessMessageBodyHandlerRegistry.java index dfc2d79525b..a438b731435 100644 --- a/http/src/main/java/io/micronaut/http/body/ContextlessMessageBodyHandlerRegistry.java +++ b/http/src/main/java/io/micronaut/http/body/ContextlessMessageBodyHandlerRegistry.java @@ -60,7 +60,10 @@ public void add(@NonNull MediaType mediaType, @NonNull MessageBodyHandler han @SuppressWarnings("unchecked") @Nullable - private MessageBodyHandler findHandler(List mediaTypes) { + private MessageBodyHandler findHandler(@Nullable List mediaTypes) { + if (mediaTypes == null) { + return null; + } for (MediaType mediaType : mediaTypes) { for (Entry entry : entries) { if (mediaType.matches(entry.mediaType)) { diff --git a/http/src/main/java/io/micronaut/http/body/DefaultMessageBodyHandlerRegistry.java b/http/src/main/java/io/micronaut/http/body/DefaultMessageBodyHandlerRegistry.java index ad5a121948b..11a9b81ea28 100644 --- a/http/src/main/java/io/micronaut/http/body/DefaultMessageBodyHandlerRegistry.java +++ b/http/src/main/java/io/micronaut/http/body/DefaultMessageBodyHandlerRegistry.java @@ -20,6 +20,7 @@ import io.micronaut.context.annotation.BootstrapContextCompatible; import io.micronaut.core.annotation.Experimental; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; import io.micronaut.core.order.OrderUtil; import io.micronaut.core.type.Argument; import io.micronaut.core.util.ArrayUtils; @@ -111,7 +112,7 @@ private MediaTypeQualifier newMediaTypeQualifier(Argument type, Lis @NonNull private List resolveMediaTypes(List mediaTypes) { - if (codecConfigurations.isEmpty()) { + if (codecConfigurations.isEmpty() || mediaTypes == null) { return mediaTypes; } List resolvedMediaTypes = new ArrayList<>(mediaTypes.size()); @@ -173,7 +174,7 @@ protected MessageBodyWriter findWriterImpl(Argument type, List(Argument type, - List mediaTypes, + @Nullable List mediaTypes, Class annotationType) implements Qualifier { @Override @@ -194,7 +195,7 @@ public > Stream reduce(Class beanType, Stream can } String[] applicableTypes = c.getAnnotationMetadata().stringValues(annotationType); return ((applicableTypes.length == 0) || Arrays.stream(applicableTypes) - .anyMatch(mt -> mediaTypes.contains(new MediaType(mt))) + .anyMatch(mt -> mediaTypes != null && mediaTypes.contains(new MediaType(mt))) ); }); } diff --git a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java index 2ddf07382a8..a46907a83a9 100644 --- a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java +++ b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java @@ -18,6 +18,13 @@ import io.micronaut.core.annotation.Experimental; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.io.buffer.ByteBuffer; +import io.micronaut.core.io.buffer.ByteBufferFactory; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.MutableHeaders; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.MediaType; +import io.micronaut.http.codec.CodecException; import java.util.Collection; @@ -42,4 +49,24 @@ public interface RawMessageBodyHandler extends MessageBodyHandler, Chunked */ @NonNull Collection> getTypes(); + + /** + * Same as {@link #writeTo(Argument, MediaType, Object, MutableHeaders, ByteBufferFactory)} but + * with fewer parameters. + * + * @param mediaType The media type + * @param object The object to write + * @param bufferFactory A byte buffer factory + * @throws CodecException If an error occurs decoding + * @return The encoded byte buffer + */ + ByteBuffer writeTo(MediaType mediaType, T object, ByteBufferFactory bufferFactory) throws CodecException; + + @Override + default ByteBuffer writeTo(Argument type, MediaType mediaType, T object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { + if (mediaType != null && !outgoingHeaders.contains(HttpHeaders.CONTENT_TYPE)) { + outgoingHeaders.set(HttpHeaders.CONTENT_TYPE, mediaType); + } + return writeTo(mediaType, object, bufferFactory); + } } diff --git a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java index eb4d54a5229..b060cc899c5 100644 --- a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java +++ b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java @@ -43,6 +43,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -83,11 +84,11 @@ private MessageBodyHandler rawHandler(Argument type, boolean covariant return null; } - protected abstract MessageBodyReader findReaderImpl(Argument type, List mediaTypes); + protected abstract MessageBodyReader findReaderImpl(Argument type, @Nullable List mediaTypes); @SuppressWarnings({"unchecked"}) @Override - public Optional> findReader(Argument type, List mediaTypes) { + public Optional> findReader(Argument type, @Nullable List mediaTypes) { HandlerKey key = new HandlerKey<>(type, mediaTypes); MessageBodyReader messageBodyReader = readers.get(key); if (messageBodyReader == null) { @@ -146,7 +147,7 @@ private static void addContentType(MutableHeaders outgoingHeaders, @Nullable Med private record RawEntry(Class type, MessageBodyHandler handler) { } - record HandlerKey(Argument type, List mediaTypes) { + record HandlerKey(Argument type, @Nullable List mediaTypes) { @Override public boolean equals(Object o) { if (this == o) { @@ -156,7 +157,7 @@ public boolean equals(Object o) { return false; } HandlerKey that = (HandlerKey) o; - return type.equalsType(that.type) && mediaTypes.equals(that.mediaTypes); + return type.equalsType(that.type) && Objects.equals(mediaTypes, that.mediaTypes); } @Override @@ -242,8 +243,7 @@ public void writeTo(Argument type, MediaType mediaType, Object object, M } @Override - public ByteBuffer writeTo(Argument type, MediaType mediaType, Object object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { - addContentType(outgoingHeaders, mediaType); + public ByteBuffer writeTo(MediaType mediaType, Object object, ByteBufferFactory bufferFactory) throws CodecException { return bufferFactory.wrap(object.toString().getBytes(getCharset(mediaType))); } @@ -294,8 +294,7 @@ public void writeTo(Argument type, MediaType mediaType, byte[] object, M } @Override - public ByteBuffer writeTo(Argument type, MediaType mediaType, byte[] object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { - addContentType(outgoingHeaders, mediaType); + public ByteBuffer writeTo(MediaType mediaType, byte[] object, ByteBufferFactory bufferFactory) throws CodecException { return bufferFactory.wrap(object); } @@ -353,8 +352,7 @@ public void writeTo(Argument> type, MediaType mediaType, ByteBuffe } @Override - public ByteBuffer writeTo(Argument> type, MediaType mediaType, ByteBuffer object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { - addContentType(outgoingHeaders, mediaType); + public ByteBuffer writeTo(MediaType mediaType, ByteBuffer object, ByteBufferFactory bufferFactory) throws CodecException { return object; } diff --git a/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java b/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java index 59823b471a1..7f8149ae54e 100644 --- a/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java +++ b/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java @@ -20,6 +20,7 @@ import io.micronaut.core.annotation.Experimental; import io.micronaut.core.io.Writable; import io.micronaut.core.io.buffer.ByteBuffer; +import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.io.buffer.ReferenceCounted; import io.micronaut.core.type.Argument; import io.micronaut.core.type.Headers; @@ -78,6 +79,26 @@ public void writeTo(Argument type, MediaType mediaType, Writable objec } } + @Override + public ByteBuffer writeTo(MediaType mediaType, Writable object, ByteBufferFactory bufferFactory) throws CodecException { + ByteBuffer buffer = bufferFactory.buffer(); + try { + try { + OutputStream outputStream = buffer.toOutputStream(); + object.writeTo(outputStream); + outputStream.flush(); + } catch (IOException e) { + throw new CodecException("Error writing body text: " + e.getMessage(), e); + } + } catch (Throwable t) { + if (buffer instanceof ReferenceCounted rc) { + rc.release(); + } + throw t; + } + return buffer; + } + private Writable read0(ByteBuffer byteBuffer) { String s = byteBuffer.toString(applicationConfiguration.getDefaultCharset()); if (byteBuffer instanceof ReferenceCounted rc) { diff --git a/http/src/main/java/io/micronaut/http/filter/FilterRunner.java b/http/src/main/java/io/micronaut/http/filter/FilterRunner.java index b30b3835450..f2fffbbbcca 100644 --- a/http/src/main/java/io/micronaut/http/filter/FilterRunner.java +++ b/http/src/main/java/io/micronaut/http/filter/FilterRunner.java @@ -92,6 +92,18 @@ public static void sortReverse(@NonNull List filters) { OrderUtil.reverseSort(filters); } + /** + * Check whether the given filter is a cors filter. This is an internal hook that is used to + * optimize cors filter processing. + * + * @param genericHttpFilter The filter to check + * @param corsFilterClass The cors filter class as it's in a different module + * @return {@code true} iff this is the cors filter + */ + public static boolean isCorsFilter(GenericHttpFilter genericHttpFilter, Class corsFilterClass) { + return genericHttpFilter instanceof MethodFilter mf && mf.method().getDeclaringType() == corsFilterClass; + } + /** * Transform a response, e.g. by replacing an error response with an exception. Called before * every filter. diff --git a/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java b/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java index 7b8bb15d451..42f8eb1f750 100644 --- a/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java +++ b/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.uri; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.util.CollectionUtils; @@ -217,6 +218,34 @@ public UriMatchInfo tryMatch(@NonNull String uri) { return null; } + /** + * Get the exact matched path. + * + * @return The path to match or {@link Optional#empty()} if this is not an exact match + */ + @Internal + public Optional getExactPath() { + if (!exactMatch) { + return Optional.empty(); + } + if (isRoot) { + return Optional.of("/"); + } else { + return Optional.of(templateString); + } + } + + /** + * Get the regex matched path. + * + * @return The pattern to match or {@link Optional#empty()} if this is not an exact match + */ + @Nullable + @Internal + public Optional getMatchPattern() { + return Optional.ofNullable(matchPattern); + } + @Override public UriMatchTemplate nest(CharSequence uriTemplate) { return (UriMatchTemplate) super.nest(uriTemplate); diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRequestMatcher.java b/router/src/main/java/io/micronaut/web/router/DefaultRequestMatcher.java index f5a351e7e89..d54b5e7f7c4 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRequestMatcher.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRequestMatcher.java @@ -21,8 +21,10 @@ import io.micronaut.http.MediaType; import io.micronaut.http.body.MessageBodyHandlerRegistry; import io.micronaut.inject.MethodExecutionHandle; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; /** @@ -53,7 +55,7 @@ public DefaultRequestMatcher(MethodExecutionHandle targetMethod, } @Override - public boolean matching(HttpRequest httpRequest) { + public final boolean matching(HttpRequest httpRequest) { if (predicates.isEmpty()) { return true; } @@ -64,4 +66,14 @@ public boolean matching(HttpRequest httpRequest) { } return true; } + + @Override + public Optional matchingRule() { + if (predicates.stream().allMatch(p -> p instanceof MatchRule)) { + //noinspection unchecked,rawtypes + return Optional.of(MatchRule.and((List) predicates)); + } else { + return Optional.empty(); + } + } } diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouteBuilder.java b/router/src/main/java/io/micronaut/web/router/DefaultRouteBuilder.java index 40ee9363e3f..a0fb55e5693 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouteBuilder.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouteBuilder.java @@ -44,6 +44,7 @@ import io.micronaut.scheduling.executor.ExecutorSelector; import io.micronaut.scheduling.executor.ThreadSelection; import io.micronaut.web.router.exceptions.RoutingException; +import io.micronaut.web.router.shortcircuit.MatchRule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -926,7 +927,7 @@ public UriRoute body(String argument) { @Override public UriRoute exposedPort(int port) { this.port = port; - where(httpRequest -> httpRequest.getServerAddress().getPort() == port); + where(new MatchRule.ServerPort(port)); DefaultRouteBuilder.this.exposedPorts.add(port); return this; } diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java b/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java index 27d107877f4..a1b450386b6 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java @@ -32,11 +32,13 @@ import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.http.sse.Event; import io.micronaut.scheduling.executor.ThreadSelection; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutorService; +import java.util.stream.Stream; /** * The default route info implementation. @@ -196,15 +198,36 @@ public List getConsumes() { } @Override - public boolean doesConsume(MediaType contentType) { + public final boolean doesConsume(MediaType contentType) { return contentType == null || consumesMediaTypesContainsAll || explicitlyConsumes(contentType); } @Override - public boolean doesProduce(@Nullable Collection acceptableTypes) { + public final Optional doesConsumeRule() { + if (consumesMediaTypesContainsAll) { + return Optional.of(MatchRule.pass()); + } else { + return Optional.of(MatchRule.or(Stream.concat( + Stream.of(new MatchRule.ContentType(null)), + consumesMediaTypes.stream().map(MatchRule.ContentType::new) + ).toList())); + } + } + + @Override + public final boolean doesProduce(@Nullable Collection acceptableTypes) { return producesMediaTypesContainsAll || anyMediaTypesMatch(producesMediaTypes, acceptableTypes); } + @Override + public final Optional doesProduceRule() { + if (producesMediaTypesContainsAll) { + return Optional.of(MatchRule.pass()); + } else { + return Optional.of(new MatchRule.Accept(producesMediaTypes)); + } + } + @Override public boolean doesProduce(@Nullable MediaType acceptableType) { return producesMediaTypesContainsAll || acceptableType == null || acceptableType.equals(MediaType.ALL_TYPE) || producesMediaTypes.contains(acceptableType); @@ -223,7 +246,7 @@ private boolean anyMediaTypesMatch(List producedMediaTypes, Collectio } @Override - public boolean explicitlyConsumes(MediaType contentType) { + public final boolean explicitlyConsumes(MediaType contentType) { return consumesMediaTypes.contains(contentType); } diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java index 2f380d74bb5..a9981e2d408 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java @@ -35,6 +35,8 @@ import io.micronaut.http.filter.HttpServerFilterResolver; import io.micronaut.http.uri.UriMatchTemplate; import io.micronaut.web.router.exceptions.RoutingException; +import io.micronaut.web.router.shortcircuit.MatchRule; +import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -218,7 +220,7 @@ public List> findAllClosest(@NonNull HttpRequest r return Collections.emptyList(); } List> uriRoutes = toMatches(request.getPath(), routes); - if (routes.size() == 1) { + if (uriRoutes.size() == 1) { return uriRoutes; } @@ -228,7 +230,7 @@ public List> findAllClosest(@NonNull HttpRequest r if (CollectionUtils.isNotEmpty(acceptedProducedTypes)) { // take the highest priority accepted type final MediaType mediaType = acceptedProducedTypes.iterator().next(); - List> mostSpecific = new ArrayList<>(routes.size()); + List> mostSpecific = new ArrayList<>(uriRoutes.size()); for (UriRouteMatch routeMatch : uriRoutes) { if (routeMatch.getRouteInfo().explicitlyProduces(mediaType)) { mostSpecific.add(routeMatch); @@ -530,6 +532,15 @@ public List findFilters(@NonNull HttpRequest request) { return Collections.unmodifiableList(httpFilters); } + @Override + public Optional> getFixedFilters() { + if (preconditionFilterRoutes.isEmpty()) { + return Optional.of(alwaysMatchesHttpFilters.get()); + } else { + return Optional.empty(); + } + } + @SuppressWarnings("unchecked") @NonNull @Override @@ -618,6 +629,92 @@ private boolean shouldSkipForPort(HttpRequest request, UriRouteInfo> builder) { + for (Map.Entry[]> entry : routesByMethod.entrySet()) { + HttpMethod parsed = HttpMethod.parse(entry.getKey()); + if (parsed == HttpMethod.CUSTOM) { + continue; + } + for (UriRouteInfo routeInfo : entry.getValue()) { + List conditions = new ArrayList<>(); + boolean complete = toMatchRule(parsed, routeInfo, conditions); + MatchRule matchRule = MatchRule.and(conditions); + Optional pathMatchRule = routeInfo.pathMatchRule(); + if (complete && pathMatchRule.isPresent() && pathMatchRule.get() instanceof MatchRule.PathMatchExact) { + builder.addRoute(matchRule, routeInfo); + } else { + // we can't match this route using MatchRule, or it needs path variables. Register for legacy handling. + builder.addLegacyRoute(matchRule); + } + } + } + + builder.addLegacyFallbackRouting(); + } + + /** + * Collect the conditions that match a given route. + * + * @param method The request method + * @param route The route + * @param conditions The conditions list to write to + * @return {@code true} iff the match process can be fully mapped to the + * {@link MatchRule}s written to {@code conditions}. When {@code false}, there may be + * additional criteria that were not written to {@code conditions}, so legacy routing must be + * used. + */ + private boolean toMatchRule( + HttpMethod method, + UriRouteInfo route, + List conditions + ) { + boolean complete = true; + + conditions.add(new MatchRule.Method(method)); + + MatchRule pathMatch = route.pathMatchRule().orElse(null); + if (pathMatch == null) { + complete = false; + } else { + conditions.add(pathMatch); + } + + if (ports != null && route.getPort() == null) { + // if the route has its own port configured, it's part of route.matching, and we don't + // need it here + conditions.add(MatchRule.or(ports.stream().map(MatchRule.ServerPort::new).toList())); + } + if (method.permitsRequestBody()) { + if (!route.isPermitsRequestBody()) { + conditions.add(MatchRule.fail()); + return true; + } + MatchRule consumeRule = route.doesConsumeRule().orElse(null); + if (consumeRule == null) { + complete = false; + } else { + conditions.add(consumeRule); + } + } + + MatchRule produceRule = route.doesProduceRule().orElse(null); + if (produceRule == null) { + complete = false; + } else { + conditions.add(produceRule); + } + + MatchRule matchingRule = route.matchingRule().orElse(null); + if (matchingRule == null) { + complete = false; + } else { + conditions.add(matchingRule); + } + + return complete; + } + private UriRouteInfo[] finalizeRoutes(List> routes) { Collections.sort(routes); return routes.toArray(new UriRouteInfo[0]); diff --git a/router/src/main/java/io/micronaut/web/router/DefaultUrlRouteInfo.java b/router/src/main/java/io/micronaut/web/router/DefaultUrlRouteInfo.java index 55cb3b956ce..47d241aa191 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultUrlRouteInfo.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultUrlRouteInfo.java @@ -28,6 +28,7 @@ import io.micronaut.inject.MethodExecutionHandle; import io.micronaut.scheduling.executor.ExecutorSelector; import io.micronaut.scheduling.executor.ThreadSelection; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.nio.charset.Charset; import java.util.List; @@ -100,6 +101,13 @@ public UriRouteMatch tryMatch(String uri) { return null; } + @Override + public Optional pathMatchRule() { + //noinspection OptionalOfNullableMisuse + return Optional.ofNullable(uriMatchTemplate.getExactPath().map(MatchRule.PathMatchExact::new) + .orElseGet(() -> new MatchRule.PathMatchPattern(uriMatchTemplate.getMatchPattern().orElse(null)))); + } + @Override public Integer getPort() { return port; diff --git a/router/src/main/java/io/micronaut/web/router/RequestMatcher.java b/router/src/main/java/io/micronaut/web/router/RequestMatcher.java index de3866f5061..dc7c2162c4f 100644 --- a/router/src/main/java/io/micronaut/web/router/RequestMatcher.java +++ b/router/src/main/java/io/micronaut/web/router/RequestMatcher.java @@ -15,7 +15,11 @@ */ package io.micronaut.web.router; +import io.micronaut.core.annotation.Internal; import io.micronaut.http.HttpRequest; +import io.micronaut.web.router.shortcircuit.MatchRule; + +import java.util.Optional; /** * Route with a request predicate. @@ -33,4 +37,16 @@ public interface RequestMatcher { */ boolean matching(HttpRequest httpRequest); + /** + * Get a {@link MatchRule} that is equivalent to {@link #matching(HttpRequest)}. + * + * @return The equivalent rule or {@link Optional#empty()} if this matcher cannot be expressed + * as a rule + * @since 4.3.0 + */ + @Internal + default Optional matchingRule() { + return Optional.empty(); + } + } diff --git a/router/src/main/java/io/micronaut/web/router/RouteInfo.java b/router/src/main/java/io/micronaut/web/router/RouteInfo.java index 630841782b2..1f98cd792c7 100644 --- a/router/src/main/java/io/micronaut/web/router/RouteInfo.java +++ b/router/src/main/java/io/micronaut/web/router/RouteInfo.java @@ -29,6 +29,7 @@ import io.micronaut.http.body.MessageBodyReader; import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.scheduling.executor.ThreadSelection; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.util.Collection; import java.util.Collections; @@ -166,6 +167,17 @@ The getBodyArgument() method returns arguments for functions where it is */ boolean doesConsume(@Nullable MediaType contentType); + /** + * Get a {@link MatchRule} that is equivalent to {@link #doesConsume(MediaType)}. + * + * @return The equivalent rule or {@link Optional#empty()} if it cannot be expressed as a rule + * @since 4.3.0 + */ + @Internal + default Optional doesConsumeRule() { + return Optional.empty(); + } + /** * Whether the route does produce any of the given types. * @@ -174,6 +186,17 @@ The getBodyArgument() method returns arguments for functions where it is */ boolean doesProduce(@Nullable Collection acceptableTypes); + /** + * Get a {@link MatchRule} that is equivalent to {@link #doesProduce(Collection)}. + * + * @return The equivalent rule or {@link Optional#empty()} if it cannot be expressed as a rule + * @since 4.3.0 + */ + @Internal + default Optional doesProduceRule() { + return Optional.empty(); + } + /** * Whether the route does produce any of the given types. * diff --git a/router/src/main/java/io/micronaut/web/router/Router.java b/router/src/main/java/io/micronaut/web/router/Router.java index 1dfbe74d81c..37718547058 100644 --- a/router/src/main/java/io/micronaut/web/router/Router.java +++ b/router/src/main/java/io/micronaut/web/router/Router.java @@ -15,6 +15,7 @@ */ package io.micronaut.web.router; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.http.HttpMethod; @@ -22,6 +23,7 @@ import io.micronaut.http.HttpStatus; import io.micronaut.http.filter.GenericHttpFilter; import io.micronaut.web.router.exceptions.DuplicateRouteException; +import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; import java.net.URI; import java.util.List; @@ -161,6 +163,16 @@ default UriRouteMatch findClosest(@NonNull HttpRequest request) return null; } + /** + * Collect all routes in this router into the given {@link ShortCircuitRouterBuilder}. + * + * @param builder The builder to write routes to + */ + @Internal + default void collectRoutes(@NonNull ShortCircuitRouterBuilder> builder) { + builder.addLegacyFallbackRouting(); + } + /** * Returns all UriRoutes. * @@ -324,6 +336,18 @@ default Optional> findStatusRoute( @NonNull HttpRequest request ); + /** + * Get the fixed (request-independent) filter list. If this method returns anything but + * optional, any call to {@link #findFilters} must return the same filters as returned + * by this method. + * + * @return The fixed filter list, or {@link Optional#empty()} if the filter list is dynamic + */ + @Internal + default Optional> getFixedFilters() { + return Optional.empty(); + } + /** * Find the first {@link RouteMatch} route for an {@link HttpMethod#GET} method and the given URI. * diff --git a/router/src/main/java/io/micronaut/web/router/UriRouteInfo.java b/router/src/main/java/io/micronaut/web/router/UriRouteInfo.java index 056da146ff0..83e595b0cf3 100644 --- a/router/src/main/java/io/micronaut/web/router/UriRouteInfo.java +++ b/router/src/main/java/io/micronaut/web/router/UriRouteInfo.java @@ -15,11 +15,13 @@ */ package io.micronaut.web.router; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.http.HttpMethod; import io.micronaut.http.uri.UriMatchTemplate; import io.micronaut.http.uri.UriMatcher; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.net.URI; import java.util.Optional; @@ -84,6 +86,17 @@ default UriRouteMatch tryMatch(@NonNull URI uri) { @Nullable UriRouteMatch tryMatch(@NonNull String uri); + /** + * Get a {@link MatchRule} that is equivalent to {@link #tryMatch(String)}. + * + * @return The equivalent rule or {@link Optional#empty()} if it cannot be expressed as a rule + * @since 4.3.0 + */ + @Internal + default Optional pathMatchRule() { + return Optional.empty(); + } + /** * @return The port the route listens to, or null if the default port */ diff --git a/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchRule.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchRule.java new file mode 100644 index 00000000000..a04483328b4 --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchRule.java @@ -0,0 +1,221 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.web.router.shortcircuit; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MediaType; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * This interface represents a boolean expression that can be used to match a request. It's + * basically an introspectable {@link Predicate}<{@link HttpRequest}>. + * + * @author Jonas Konrad + * @since 4.3.0 + */ +@Experimental +@Internal +public sealed interface MatchRule extends Predicate> { + /** + * @return A rule that always returns {@code false} + */ + static MatchRule fail() { + return new Or(List.of()); + } + + /** + * @return A rule that always returns {@code true} + */ + static MatchRule pass() { + return new And(List.of()); + } + + /** + * @param rules The rules + * @return A rule that matches iff all the given rules match + */ + @NonNull + static MatchRule and(@NonNull List rules) { + if (rules.size() == 1) { + return rules.get(0); + } else { + return new And(rules); + } + } + + /** + * @param rules The rules + * @return A rule that matches iff any of the given rules match + */ + @NonNull + static MatchRule or(@NonNull List rules) { + if (rules.size() == 1) { + return rules.get(0); + } else { + return new Or(rules); + } + } + + /** + * A rule that matches iff all the given rules match. + * + * @param rules The rules + */ + record And(@NonNull List rules) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + for (MatchRule rule : rules) { + if (!rule.test(request)) { + return false; + } + } + return true; + } + } + + /** + * A rule that matches iff any of the given rules match.
+ * Note: this should be avoided to avoid exponential formula growth during DNF conversion. + * + * @param rules The rules + */ + record Or(@NonNull List rules) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + for (MatchRule rule : rules) { + if (rule.test(request)) { + return true; + } + } + return false; + } + } + + /** + * Match the request path (not including the URI, query and anchors) exactly. + * + * @param path The exact path + */ + record PathMatchExact(@NonNull String path) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + return extractPath(request).equals(this.path); + } + + private static String extractPath(HttpRequest request) { + String uri = request.getPath(); + if (uri == null) { + throw new IllegalArgumentException("Argument 'uri' cannot be null"); + } + int length = uri.length(); + if (length > 1 && uri.charAt(length - 1) == '/') { + uri = uri.substring(0, length - 1); + } + + //Remove any url parameters before matching + int parameterIndex = uri.indexOf('?'); + if (parameterIndex > -1) { + uri = uri.substring(0, parameterIndex); + } + if (uri.endsWith("/")) { + uri = uri.substring(0, uri.length() - 1); + } + return uri; + } + } + + /** + * Match the request path (not including the URI, query and anchors) using a regular expression. + * + * @param pattern The match pattern + */ + record PathMatchPattern(@NonNull Pattern pattern) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + return pattern.matcher(PathMatchExact.extractPath(request)).matches(); + } + } + + /** + * Match the port of the server. + * + * @param expectedPort The expected server port + */ + record ServerPort(int expectedPort) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + return request.getServerAddress().getPort() == expectedPort; + } + } + + /** + * Match the {@code Content-Type} of the request. + * + * @param expectedType The expected content type. If this is {@code null}, the request must have no content type. + */ + record ContentType(@Nullable MediaType expectedType) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + return Objects.equals(request.getContentType().orElse(null), expectedType); + } + } + + /** + * Match the {@code Accept} header of the request. If no accept header is present, this always + * matches. If there is an accept header, and it contains {@code *}{@code /*}, this always + * matches. If there is an accept header, it matches iff the {@code producedTypes} overlap with + * the types in the header. + * + * @param producedTypes The potential types in the accept header + */ + record Accept(@NonNull List producedTypes) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + Collection accept = request.accept(); + if (accept.isEmpty()) { + return true; + } + for (MediaType t : accept) { + if (t.equals(MediaType.ALL_TYPE) || producedTypes.contains(t)) { + return true; + } + } + return false; + } + } + + /** + * Match the request method. + * + * @param method The expected request method + */ + record Method(@NonNull HttpMethod method) implements MatchRule { + @Override + public boolean test(HttpRequest request) { + return request.getMethod() == method; + } + } +} diff --git a/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java new file mode 100644 index 00000000000..19e119c0c9f --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.web.router.shortcircuit; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; + +/** + * This API can be passed to a {@link io.micronaut.web.router.Router} to make it list all + * available routes explicitly. The implementation of this class can then build an optimized + * router instead of relying on the "legacy" routing exposed by the + * {@link io.micronaut.web.router.Router}. + *

+ * Routes are associated with {@link MatchRule}s. The {@link MatchRule} decides whether the route + * should be matched. When two {@link MatchRule}s for different routes match the same request, it + * is not possible to use this optimized routing, and we have to fall back on legacy routing. + *

+ * Some routes may have matching logic that cannot be expressed as a {@link MatchRule}. These + * routes must still be added if they may potentially conflict with other routes. They can be + * marked for "legacy" (non-optimized) routing. + * + * @param The type of route endpoint to collect. This is set to + * {@link io.micronaut.web.router.UriRouteInfo} by + * {@link io.micronaut.web.router.Router}. + * @author Jonas Konrad + * @since 4.3.0 + */ +@Internal +@Experimental +public interface ShortCircuitRouterBuilder { + /** + * Add a route that can be fully matched by the given rule. + * + * @param rule The rule that matches this route + * @param match The matched route + */ + void addRoute(MatchRule rule, R match); + + /** + * If the given rule is matched, we must revert to legacy routing, even when another + * route matches the same request. + * + * @param rule The rule to match + */ + void addLegacyRoute(MatchRule rule); + + /** + * This method may set a flag that the route list is not exhaustive. If no routes match, we + * should fall back to legacy routing. + */ + void addLegacyFallbackRouting(); +} From 411f68985ec0b749a8324ea3b5add63382d24fa8 Mon Sep 17 00:00:00 2001 From: yawkat Date: Mon, 20 Nov 2023 09:15:05 +0100 Subject: [PATCH 03/16] address minor review comments --- .../body/ShortCircuitNettyBodyWriter.java | 2 +- .../server/netty/RoutingInBoundHandler.java | 2 +- .../NettyHttpServerConfiguration.java | 20 +++++++++---------- .../netty/shortcircuit/FastRoutingSpec.groovy | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java b/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java index 4a3831134dd..0692017574e 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java @@ -38,7 +38,7 @@ */ @Internal @Experimental -public interface ShortCircuitNettyBodyWriter extends NettyBodyWriter { +public sealed interface ShortCircuitNettyBodyWriter extends NettyBodyWriter permits NettyJsonHandler, NettyTextPlainHandler { @Override default void writeTo(HttpRequest request, MutableHttpResponse outgoingResponse, Argument type, MediaType mediaType, T object, NettyWriteContext writeContext) throws CodecException { MutableHttpHeaders headers = outgoingResponse.getHeaders(); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 8d836f1b61c..753fa936d64 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -168,7 +168,7 @@ public final class RoutingInBoundHandler implements RequestHandler { this.routeExecutor = embeddedServerContext.getRouteExecutor(); this.conversionService = conversionService; - if (serverConfiguration.isFastRouting()) { + if (serverConfiguration.isOptimizedRouting()) { NettyShortCircuitRouterBuilder> scrb = new NettyShortCircuitRouterBuilder<>(); routeExecutor.getRouter().collectRoutes(scrb); this.shortCircuitMatchPlan = scrb.transform(this::shortCircuitHandler).plan(); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java index bdfaf96b978..44858d81ab8 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/configuration/NettyHttpServerConfiguration.java @@ -169,7 +169,7 @@ public class NettyHttpServerConfiguration extends HttpServerConfiguration { * @since 4.3.0 */ @SuppressWarnings("WeakerAccess") - public static final boolean DEFAULT_FAST_ROUTING = false; + public static final boolean DEFAULT_OPTIMIZED_ROUTING = false; private static final Logger LOG = LoggerFactory.getLogger(NettyHttpServerConfiguration.class); @@ -204,7 +204,7 @@ public class NettyHttpServerConfiguration extends HttpServerConfiguration { private List listeners = null; private boolean eagerParsing = DEFAULT_EAGER_PARSING; private int jsonBufferMaxComponents = DEFAULT_JSON_BUFFER_MAX_COMPONENTS; - private boolean fastRouting = DEFAULT_FAST_ROUTING; + private boolean optimizedRouting = DEFAULT_OPTIMIZED_ROUTING; /** * Default empty constructor. @@ -740,25 +740,25 @@ public void setJsonBufferMaxComponents(int jsonBufferMaxComponents) { /** * Whether fast routing should be enabled for routes that are simple enough to be supported. * During fast routing, not all framework features may be available, such as the thread-local - * request context or some error handling. Default {@value DEFAULT_FAST_ROUTING}. + * request context or some error handling. Default {@value DEFAULT_OPTIMIZED_ROUTING}. * - * @return {@code true} if fast routing should be enabled + * @return {@code true} if optimized routing should be enabled * @since 4.3.0 */ - public boolean isFastRouting() { - return fastRouting; + public boolean isOptimizedRouting() { + return optimizedRouting; } /** * Whether fast routing should be enabled for routes that are simple enough to be supported. * During fast routing, not all framework features may be available, such as the thread-local - * request context or some error handling. Default {@value DEFAULT_FAST_ROUTING}. + * request context or some error handling. Default {@value DEFAULT_OPTIMIZED_ROUTING}. * - * @param fastRouting {@code true} if fast routing should be enabled + * @param optimizedRouting {@code true} if optimized routing should be enabled * @since 4.3.0 */ - public void setFastRouting(boolean fastRouting) { - this.fastRouting = fastRouting; + public void setOptimizedRouting(boolean optimizedRouting) { + this.optimizedRouting = optimizedRouting; } /** diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy index 815dcc7e11d..917ab1f2372 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/FastRoutingSpec.groovy @@ -16,7 +16,7 @@ import spock.lang.Specification class FastRoutingSpec extends Specification { def test(boolean fastRouting) { given: - def ctx = ApplicationContext.run(['spec.name': 'FastRoutingSpec', 'micronaut.server.netty.fast-routing': fastRouting]) + def ctx = ApplicationContext.run(['spec.name': 'FastRoutingSpec', 'micronaut.server.netty.optimized-routing': fastRouting]) def server = ctx.getBean(EmbeddedServer) server.start() def client = ctx.createBean(HttpClient, server.URI).toBlocking() From 445fd26342c34cd44f886b6ccc74458f2851099b Mon Sep 17 00:00:00 2001 From: yawkat Date: Mon, 20 Nov 2023 11:02:28 +0100 Subject: [PATCH 04/16] fix benchmark --- .../io/micronaut/http/server/stack/FullHttpStackBenchmark.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java b/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java index f2b58066deb..be543538636 100644 --- a/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java +++ b/benchmarks/src/jmh/java/io/micronaut/http/server/stack/FullHttpStackBenchmark.java @@ -164,7 +164,7 @@ Stack openChannel() { "spec.name", "FullHttpStackBenchmark", //"micronaut.server.netty.server-type", NettyHttpServerConfiguration.HttpServerType.FULL_CONTENT, "micronaut.server.date-header", false, // disabling this makes the response identical each time - "micronaut.server.netty.fast-routing", true + "micronaut.server.netty.optimized-routing", true )); EmbeddedServer server = ctx.getBean(EmbeddedServer.class); EmbeddedChannel channel = ((NettyHttpServer) server).buildEmbeddedChannel(false); From 1df33d6c1e8e41bf220af6ef8a0d1e9885be3fe8 Mon Sep 17 00:00:00 2001 From: yawkat Date: Thu, 7 Dec 2023 11:35:27 +0100 Subject: [PATCH 05/16] fix merge --- .../netty/websocket/NettyServerWebSocketUpgradeHandler.java | 2 +- .../micronaut/http/server/netty/filters/FilterFormSpec.groovy | 1 + .../server/netty/handler/PipeliningServerHandlerSpec.groovy | 3 ++- .../src/main/java/io/micronaut/web/router/DefaultRouter.java | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java index f708346a980..4ed25c2db76 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/websocket/NettyServerWebSocketUpgradeHandler.java @@ -135,7 +135,7 @@ static boolean isWebSocketUpgrade(@NonNull io.netty.handler.codec.http.HttpReque @Override public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { if (isWebSocketUpgrade(request)) { - NettyHttpRequest msg = NettyHttpRequest.createSafe(request, body, ctx, conversionService, serverConfiguration); + NettyHttpRequest msg = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); Optional> optionalRoute = router.find(HttpMethod.GET, msg.getPath(), msg) .filter(rm -> rm.isAnnotationPresent(OnMessage.class) || rm.isAnnotationPresent(OnOpen.class)) diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/filters/FilterFormSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/filters/FilterFormSpec.groovy index 40cdbac3ee9..9f3a9844780 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/filters/FilterFormSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/filters/FilterFormSpec.groovy @@ -45,6 +45,7 @@ class FilterFormSpec extends Specification { } } + @Requires(property = "spec.name", value = "FilterFormSpec") @ServerFilter("/form-error") static class Fltr { @RequestFilter diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy index be62acd165e..48f4140219c 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy @@ -303,7 +303,8 @@ class PipeliningServerHandlerSpec extends Specification { int i = 0 @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { + body.release() if (i++ == 0) { outboundAccess.writeStreamed(resp, sink.asFlux()) } else { diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java index 9a16598ff68..8716d96a978 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java @@ -694,7 +694,7 @@ private boolean shouldSkipForPort(HttpRequest request, UriRouteInfo> builder) { - for (Map.Entry[]> entry : routesByMethod.entrySet()) { + for (Map.Entry[]> entry : allRoutesByMethod.entrySet()) { HttpMethod parsed = HttpMethod.parse(entry.getKey()); if (parsed == HttpMethod.CUSTOM) { continue; From 6fd52905c5f5390aa717defb626d30374191320f Mon Sep 17 00:00:00 2001 From: yawkat Date: Thu, 7 Dec 2023 15:01:22 +0100 Subject: [PATCH 06/16] filter support --- core/build.gradle | 3 +- .../propagation/PropagatedContextImpl.java | 45 ++++++------ .../core/propagation/ThreadContext.java | 67 ++++++++++++++++++ gradle/libs.versions.toml | 1 + .../server/netty/RoutingInBoundHandler.java | 68 +++++++++++-------- .../binders/NettyBodyAnnotationBinder.java | 6 +- .../ShortCircuitArgumentBinder.java | 4 +- 7 files changed, 135 insertions(+), 59 deletions(-) create mode 100644 core/src/main/java/io/micronaut/core/propagation/ThreadContext.java diff --git a/core/build.gradle b/core/build.gradle index c01121ddefb..49e855f0093 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,5 +1,5 @@ -import me.champeau.gradle.japicmp.JapicmpTask import io.micronaut.build.internal.japicmp.RemovedPackages +import me.champeau.gradle.japicmp.JapicmpTask plugins { id "io.micronaut.build.internal.convention-core-library" @@ -15,6 +15,7 @@ dependencies { compileOnly libs.managed.jakarta.annotation.api compileOnly libs.graal compileOnly libs.managed.kotlin.stdlib + compileOnly libs.managed.netty.common } spotless { diff --git a/core/src/main/java/io/micronaut/core/propagation/PropagatedContextImpl.java b/core/src/main/java/io/micronaut/core/propagation/PropagatedContextImpl.java index 336ca63a402..bf73b1a146d 100644 --- a/core/src/main/java/io/micronaut/core/propagation/PropagatedContextImpl.java +++ b/core/src/main/java/io/micronaut/core/propagation/PropagatedContextImpl.java @@ -43,14 +43,8 @@ final class PropagatedContextImpl implements PropagatedContext { static final PropagatedContextImpl EMPTY = new PropagatedContextImpl(new PropagatedContextElement[0], false); - private static final ThreadLocal THREAD_CONTEXT = new ThreadLocal<>() { - @Override - public String toString() { - return "Micronaut Propagation Context"; - } - }; - - private static final Scope CLEANUP = THREAD_CONTEXT::remove; + private static final Scope CLEANUP = ThreadContext::remove; + private static final Scope NOOP = () -> {}; private final PropagatedContextElement[] elements; private final boolean containsThreadElements; @@ -78,7 +72,7 @@ private static boolean isThreadElement(PropagatedContextElement element) { } public static boolean exists() { - PropagatedContextImpl propagatedContext = PropagatedContextImpl.THREAD_CONTEXT.get(); + PropagatedContextImpl propagatedContext = ThreadContext.get(); if (propagatedContext == null) { return false; } @@ -86,7 +80,7 @@ public static boolean exists() { } public static PropagatedContextImpl get() { - PropagatedContextImpl propagatedContext = THREAD_CONTEXT.get(); + PropagatedContextImpl propagatedContext = ThreadContext.get(); if (propagatedContext == null) { throw new IllegalStateException("No active propagation context!"); } @@ -94,12 +88,12 @@ public static PropagatedContextImpl get() { } public static Optional find() { - return Optional.ofNullable(THREAD_CONTEXT.get()); + return Optional.ofNullable(ThreadContext.get()); } @NonNull public static PropagatedContextImpl getOrEmpty() { - PropagatedContextImpl propagatedContext = THREAD_CONTEXT.get(); + PropagatedContextImpl propagatedContext = ThreadContext.get(); if (propagatedContext == null) { return EMPTY; } @@ -185,25 +179,30 @@ public List getAllElements() { @Override public Scope propagate() { - PropagatedContextImpl prevCtx = THREAD_CONTEXT.get(); - Scope restore = prevCtx == null ? CLEANUP : () -> THREAD_CONTEXT.set(prevCtx); - if (prevCtx == this) { - return restore; - } - if (elements.length == 0) { - THREAD_CONTEXT.remove(); - return restore; + PropagatedContextImpl prevCtx = ThreadContext.get(); + Scope restore; + if (prevCtx == null && elements.length == 0) { + return NOOP; + } else if (prevCtx == null) { + restore = CLEANUP; + } else { // elements.length == 0 + restore = () -> ThreadContext.set(prevCtx); + if (elements.length == 0) { + ThreadContext.remove(); + return restore; + } } + PropagatedContextImpl ctx = this; - THREAD_CONTEXT.set(ctx); + ThreadContext.set(ctx); if (containsThreadElements) { List, Object>> threadState = ctx.updateThreadState(); return () -> { ctx.restoreState(threadState); if (prevCtx == null) { - THREAD_CONTEXT.remove(); + ThreadContext.remove(); } else { - THREAD_CONTEXT.set(prevCtx); + ThreadContext.set(prevCtx); } }; } diff --git a/core/src/main/java/io/micronaut/core/propagation/ThreadContext.java b/core/src/main/java/io/micronaut/core/propagation/ThreadContext.java new file mode 100644 index 00000000000..837d572b08d --- /dev/null +++ b/core/src/main/java/io/micronaut/core/propagation/ThreadContext.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017-2023 original 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 io.micronaut.core.propagation; + +import io.netty.util.concurrent.FastThreadLocal; + +@SuppressWarnings("unchecked") +final class ThreadContext { + private static final Object FAST; + private static final ThreadLocal SLOW; + + static { + Object fast; + ThreadLocal slow; + try { + fast = new FastThreadLocal(); + slow = null; + } catch (NoClassDefFoundError e) { + fast = null; + slow = new ThreadLocal<>() { + @Override + public String toString() { + return "Micronaut Propagation Context"; + } + }; + } + FAST = fast; + SLOW = slow; + } + + static void remove() { + if (FAST == null) { + SLOW.remove(); + } else { + ((FastThreadLocal) FAST).remove(); + } + } + + static PropagatedContextImpl get() { + if (FAST == null) { + return SLOW.get(); + } else { + return ((FastThreadLocal) FAST).get(); + } + } + + static void set(PropagatedContextImpl value) { + if (FAST == null) { + SLOW.set(value); + } else { + ((FastThreadLocal) FAST).set(value); + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a398eefcf45..1a6923b386a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -134,6 +134,7 @@ managed-methvin-directoryWatcher = { module = "io.methvin:directory-watcher", ve managed-netty-buffer = { module = "io.netty:netty-buffer", version.ref = "managed-netty" } managed-netty-codec-http = { module = "io.netty:netty-codec-http", version.ref = "managed-netty" } managed-netty-codec-http2 = { module = "io.netty:netty-codec-http2", version.ref = "managed-netty" } +managed-netty-common = { module = "io.netty:netty-common", version.ref = "managed-netty" } managed-netty-incubator-codec-http3 = { module = "io.netty.incubator:netty-incubator-codec-http3", version.ref = "managed-netty-http3" } managed-netty-handler = { module = "io.netty:netty-handler", version.ref = "managed-netty" } managed-netty-handler-proxy = { module = "io.netty:netty-handler-proxy", version.ref = "managed-netty" } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index c229268dd5a..c6650469448 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -22,6 +22,7 @@ import io.micronaut.core.annotation.Nullable; import io.micronaut.core.async.publisher.Publishers; import io.micronaut.core.convert.ConversionService; +import io.micronaut.core.execution.ExecutionFlow; import io.micronaut.core.io.buffer.ByteBuffer; import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.propagation.PropagatedContext; @@ -56,7 +57,6 @@ import io.micronaut.http.netty.body.ShortCircuitNettyBodyWriter; import io.micronaut.http.netty.channel.ChannelPipelineCustomizer; import io.micronaut.http.netty.stream.JsonSubscriber; -import io.micronaut.http.netty.stream.StreamedHttpRequest; import io.micronaut.http.netty.stream.StreamedHttpResponse; import io.micronaut.http.server.RouteExecutor; import io.micronaut.http.server.binding.RequestArgumentSatisfier; @@ -110,6 +110,7 @@ import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -290,10 +291,11 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRou return ExecutionLeaf.indeterminate(); } List fixedFilters = routeExecutor.getRouter().getFixedFilters().orElse(null); - // CorsFilter is handled specially here. It's always present, so we can't bail, but it only does anything when the Origin header is set, which is checked in accept(). - if (fixedFilters == null || !fixedFilters.stream().allMatch(ghf -> FilterRunner.isCorsFilter(ghf, CorsFilter.class))) { + if (fixedFilters == null) { return ExecutionLeaf.indeterminate(); } + // CorsFilter is handled specially here. It's always present, so we can't bail, but it only does anything when the Origin header is set, which is checked in accept(). + fixedFilters = fixedFilters.stream().filter(f -> !FilterRunner.isCorsFilter(f, CorsFilter.class)).toList(); MethodExecutionHandle executionHandle = routeInfo.getTargetMethod(); if (executionHandle.getReturnType().isOptional() || executionHandle.getReturnType().getType() == HttpStatus.class) { @@ -346,37 +348,45 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRou } else { return ExecutionLeaf.indeterminate(); } + List finalFixedFilters = fixedFilters; + BiFunction, PropagatedContext, ExecutionFlow>> exec = (httpRequest, propagatedContext) -> { + Object[] arguments = shortCircuitBinders.length == 0 ? ArrayUtils.EMPTY_OBJECT_ARRAY : new Object[shortCircuitBinders.length]; + ImmediateByteBody body = (ImmediateByteBody) ((NettyHttpRequest) httpRequest).byteBody(); + for (int i = 0; i < arguments.length; i++) { + arguments[i] = shortCircuitBinders[i].bind(httpRequest.getHeaders(), body); + } + Object result = unsafeExecutionHandle.invokeUnsafe(arguments); + if (unwrapResponse) { + return ExecutionFlow.just((HttpResponse) result); + } else { + return ExecutionFlow.just(HttpResponse.ok(result)); + } + }; return new ExecutionLeaf.Route<>(new RequestHandler() { @Override public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { try { - NettyHttpHeaders requestHeaders = new NettyHttpHeaders(request.headers(), conversionService); - - Object[] arguments = shortCircuitBinders.length == 0 ? ArrayUtils.EMPTY_OBJECT_ARRAY : new Object[shortCircuitBinders.length]; - for (int i = 0; i < arguments.length; i++) { - arguments[i] = shortCircuitBinders[i].bind(request, requestHeaders, (ImmediateByteBody) body); - } - Object result = unsafeExecutionHandle.invokeUnsafe(arguments); - HttpResponseStatus status = HttpResponseStatus.OK; - HttpHeaders responseHeaders; - if (unwrapResponse) { - HttpResponse resp = (HttpResponse) result; - responseHeaders = ((NettyHttpHeaders) resp.getHeaders()).getNettyHeaders(); - if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { - responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + NettyHttpRequest nhr = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); + outboundAccess.attachment(nhr); + + new FilterRunner(finalFixedFilters, exec).run(nhr, PropagatedContext.empty()).onComplete((response, err) -> { + if (err != null) { + RoutingInBoundHandler.this.handleUnboundError(err); + } else { + HttpHeaders responseHeaders = ((NettyHttpHeaders) response.getHeaders()).getNettyHeaders(); + if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { + responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + } + HttpResponseStatus status = HttpResponseStatus.valueOf(response.code(), response.reason()); + if (scWriter != null) { + scWriter.writeTo(nhr.getHeaders(), status, responseHeaders, response.body(), outboundAccess); + } else { + ByteBuf buf = (ByteBuf) rawWriter.writeTo(responseMediaType, response.body(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); + outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); + } } - status = HttpResponseStatus.valueOf(resp.code(), resp.reason()); - result = resp.body(); - } else { - responseHeaders = new DefaultHttpHeaders(); - responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); - } - if (scWriter != null) { - scWriter.writeTo(requestHeaders, status, responseHeaders, result, outboundAccess); - } else { - ByteBuf buf = (ByteBuf) rawWriter.writeTo(responseMediaType, result, NettyByteBufferFactory.DEFAULT).asNativeBuffer(); - outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); - } + }); + } catch (Exception e) { RoutingInBoundHandler.this.handleUnboundError(e); } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index 4083d88f297..d98c5ac5ab3 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -21,8 +21,8 @@ import io.micronaut.core.convert.ConversionService; import io.micronaut.core.convert.value.ConvertibleValues; import io.micronaut.core.execution.ExecutionFlow; -import io.micronaut.http.HttpAttributes; import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpAttributes; import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Body; @@ -37,8 +37,8 @@ import io.micronaut.http.server.netty.NettyHttpRequest; import io.micronaut.http.server.netty.body.ImmediateByteBody; import io.micronaut.http.server.netty.shortcircuit.ShortCircuitArgumentBinder; -import io.micronaut.web.router.shortcircuit.MatchRule; import io.micronaut.web.router.RouteInfo; +import io.micronaut.web.router.shortcircuit.MatchRule; import java.util.List; import java.util.Optional; @@ -194,7 +194,7 @@ public Optional prepare(Argument argument, MatchRule.ContentType fi } reader = opt.get(); } - return Optional.of((nettyRequest, mnHeaders, body) -> { + return Optional.of((mnHeaders, body) -> { if (body.empty()) { return null; } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java index 4a0ccb55193..1e9d35eba55 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java @@ -23,7 +23,6 @@ import io.micronaut.http.bind.binders.RequestArgumentBinder; import io.micronaut.http.server.netty.body.ImmediateByteBody; import io.micronaut.web.router.shortcircuit.MatchRule; -import io.netty.handler.codec.http.HttpRequest; import java.util.Optional; @@ -52,11 +51,10 @@ interface Prepared { /** * Bind the parameter. * - * @param nettyRequest The netty request * @param mnHeaders The request headers (micronaut-http class) * @param body The request body * @return The bound argument */ - Object bind(@NonNull HttpRequest nettyRequest, HttpHeaders mnHeaders, @NonNull ImmediateByteBody body); + Object bind(HttpHeaders mnHeaders, @NonNull ImmediateByteBody body); } } From 68cf3e614401a573767242ad834886b5f3c08644 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 13 Dec 2023 14:13:15 +0100 Subject: [PATCH 07/16] serverHeader, dateHeader support --- .../http/server/netty/RoutingInBoundHandler.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index c6650469448..83378c7bc7e 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -104,6 +104,9 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.channels.ClosedChannelException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -362,6 +365,8 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRou return ExecutionFlow.just(HttpResponse.ok(result)); } }; + String serverHeader = serverConfiguration.getServerHeader().orElse(null); + boolean dateHeader = serverConfiguration.isDateHeader(); return new ExecutionLeaf.Route<>(new RequestHandler() { @Override public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { @@ -377,6 +382,12 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); } + if (serverHeader != null && !responseHeaders.contains(HttpHeaderNames.SERVER)) { + responseHeaders.set(HttpHeaderNames.SERVER, serverHeader); + } + if (dateHeader && !responseHeaders.contains(HttpHeaderNames.DATE)) { + responseHeaders.set(HttpHeaderNames.DATE, ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME)); + } HttpResponseStatus status = HttpResponseStatus.valueOf(response.code(), response.reason()); if (scWriter != null) { scWriter.writeTo(nhr.getHeaders(), status, responseHeaders, response.body(), outboundAccess); From 585cc4e17f5e16efd00cc9c912c64c4574c12a27 Mon Sep 17 00:00:00 2001 From: yawkat Date: Thu, 4 Jan 2024 09:41:50 +0100 Subject: [PATCH 08/16] fix --- .../server/netty/handler/PipeliningServerHandlerSpec.groovy | 4 ++-- .../src/main/java/io/micronaut/http/uri/UriMatchTemplate.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy index a096065d939..ece2de71479 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/handler/PipeliningServerHandlerSpec.groovy @@ -570,9 +570,9 @@ class PipeliningServerHandlerSpec extends Specification { int unwritten = 0 def ch = new EmbeddedChannel(new PipeliningServerHandler(new RequestHandler() { @Override - void accept(ChannelHandlerContext ctx, HttpRequest request, PipeliningServerHandler.OutboundAccess outboundAccess) { + void accept(ChannelHandlerContext ctx, HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { unwritten++ - request.release() + body.release() outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT)) } diff --git a/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java b/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java index 42f8eb1f750..ecf3c0bc2ce 100644 --- a/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java +++ b/http/src/main/java/io/micronaut/http/uri/UriMatchTemplate.java @@ -229,7 +229,7 @@ public Optional getExactPath() { return Optional.empty(); } if (isRoot) { - return Optional.of("/"); + return Optional.of(""); } else { return Optional.of(templateString); } From 7ee51c9959cf9132b6e750c8ed9bcedbb0249c92 Mon Sep 17 00:00:00 2001 From: yawkat Date: Thu, 4 Jan 2024 09:42:19 +0100 Subject: [PATCH 09/16] fix --- .../http/server/netty/handler/PipeliningServerHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java index 36ace2f1343..4912abed423 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/handler/PipeliningServerHandler.java @@ -1242,6 +1242,8 @@ void discard() { } // else worker is still setting up and will see the discard flag in due time } } + // pretend we wrote to clean up resources + requestHandler.responseWritten(outboundAccess.attachment); } private void work() { From ab9e7de97f0e7181bc4b179a38c983e7f1d5972a Mon Sep 17 00:00:00 2001 From: yawkat Date: Mon, 8 Jan 2024 14:23:14 +0100 Subject: [PATCH 10/16] remove ShortCircuitNettyBodyWriter --- .../http/netty/body/NettyJsonHandler.java | 18 +++-- .../netty/body/NettyTextPlainHandler.java | 22 ++++-- .../body/ShortCircuitNettyBodyWriter.java | 70 ------------------- .../server/netty/RoutingInBoundHandler.java | 11 +-- 4 files changed, 38 insertions(+), 83 deletions(-) delete mode 100644 http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java index b4dd76ae5dc..5c546191818 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyJsonHandler.java @@ -19,18 +19,23 @@ import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; import io.micronaut.core.io.buffer.ByteBuffer; import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.type.Argument; import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Consumes; import io.micronaut.http.annotation.Produces; import io.micronaut.http.body.ChunkedMessageBodyReader; import io.micronaut.http.body.MessageBodyHandler; import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.http.codec.CodecException; +import io.micronaut.http.netty.NettyHttpHeaders; import io.micronaut.json.JsonFeatures; import io.micronaut.json.JsonMapper; import io.micronaut.json.body.JsonMessageHandler; @@ -38,7 +43,7 @@ import io.netty.buffer.ByteBufOutputStream; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.EmptyHttpHeaders; -import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import jakarta.inject.Singleton; @@ -54,6 +59,7 @@ * * @param The type */ +@SuppressWarnings("DefaultAnnotationParam") @Singleton @Internal @Replaces(JsonMessageHandler.class) @@ -79,7 +85,7 @@ }) @BootstrapContextCompatible @Requires(beans = JsonMapper.class) -public final class NettyJsonHandler implements MessageBodyHandler, ChunkedMessageBodyReader, CustomizableNettyJsonHandler, ShortCircuitNettyBodyWriter { +public final class NettyJsonHandler implements MessageBodyHandler, ChunkedMessageBodyReader, CustomizableNettyJsonHandler, NettyBodyWriter { private final JsonMessageHandler jsonMessageHandler; public NettyJsonHandler(JsonMapper jsonMapper) { @@ -144,7 +150,11 @@ public ByteBuffer writeTo(Argument type, MediaType mediaType, T object, Mu } @Override - public void writeTo(io.micronaut.http.HttpHeaders requestHeaders, HttpResponseStatus status, HttpHeaders responseHeaders, T object, NettyWriteContext nettyContext) { + public @NonNull void writeTo(@NonNull HttpRequest request, @NonNull MutableHttpResponse outgoingResponse, @NonNull Argument type, @NonNull MediaType mediaType, @NonNull T object, @NonNull NettyWriteContext nettyContext) throws CodecException { + NettyHttpHeaders nettyHttpHeaders = (NettyHttpHeaders) outgoingResponse.getHeaders(); + if (!nettyHttpHeaders.contains(HttpHeaders.CONTENT_TYPE)) { + nettyHttpHeaders.set(HttpHeaderNames.CONTENT_TYPE, mediaType); + } ByteBuf buffer = nettyContext.alloc().buffer(); JsonMapper jsonMapper = jsonMessageHandler.getJsonMapper(); try { @@ -153,7 +163,7 @@ public void writeTo(io.micronaut.http.HttpHeaders requestHeaders, HttpResponseSt buffer.release(); throw new CodecException("Error encoding object [" + object + "] to JSON: " + e.getMessage(), e); } - nettyContext.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buffer, responseHeaders, EmptyHttpHeaders.INSTANCE)); + nettyContext.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.valueOf(outgoingResponse.code(), outgoingResponse.reason()), buffer, nettyHttpHeaders.getNettyHeaders(), EmptyHttpHeaders.INSTANCE)); } @Override diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java index 31ea4a7be95..fd4ccbecb66 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyTextPlainHandler.java @@ -21,18 +21,23 @@ import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpHeaders; +import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Consumes; import io.micronaut.http.annotation.Produces; import io.micronaut.http.body.MessageBodyHandler; import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.http.body.TextPlainHandler; import io.micronaut.http.codec.CodecException; +import io.micronaut.http.netty.NettyHttpHeaders; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.EmptyHttpHeaders; import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import jakarta.inject.Singleton; @@ -45,17 +50,24 @@ @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.TEXT_PLAIN) @Internal -final class NettyTextPlainHandler implements MessageBodyHandler, ShortCircuitNettyBodyWriter { +final class NettyTextPlainHandler implements MessageBodyHandler, NettyBodyWriter { private final TextPlainHandler defaultHandler = new TextPlainHandler(); @Override - public void writeTo(HttpHeaders requestHeaders, HttpResponseStatus status, io.netty.handler.codec.http.HttpHeaders responseHeaders, CharSequence object, NettyWriteContext nettyContext) { - ByteBuf byteBuf = Unpooled.wrappedBuffer(object.toString().getBytes(MessageBodyWriter.getCharset(requestHeaders))); + public void writeTo(HttpRequest request, MutableHttpResponse outgoingResponse, Argument type, MediaType mediaType, CharSequence object, NettyWriteContext nettyContext) throws CodecException { + MutableHttpHeaders headers = outgoingResponse.getHeaders(); + ByteBuf byteBuf = Unpooled.wrappedBuffer(object.toString().getBytes(MessageBodyWriter.getCharset(headers))); + NettyHttpHeaders nettyHttpHeaders = (NettyHttpHeaders) headers; + io.netty.handler.codec.http.HttpHeaders nettyHeaders = nettyHttpHeaders.getNettyHeaders(); + if (!nettyHttpHeaders.contains(HttpHeaders.CONTENT_TYPE)) { + nettyHttpHeaders.set(HttpHeaderNames.CONTENT_TYPE, mediaType); + } + nettyHeaders.set(HttpHeaderNames.CONTENT_LENGTH, byteBuf.readableBytes()); FullHttpResponse fullHttpResponse = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, - status, + HttpResponseStatus.valueOf(outgoingResponse.code(), outgoingResponse.reason()), byteBuf, - responseHeaders, + nettyHeaders, EmptyHttpHeaders.INSTANCE ); nettyContext.writeFull(fullHttpResponse); diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java b/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java deleted file mode 100644 index 0692017574e..00000000000 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/ShortCircuitNettyBodyWriter.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017-2023 original 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 io.micronaut.http.netty.body; - - -import io.micronaut.core.annotation.Experimental; -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.type.Argument; -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.MediaType; -import io.micronaut.http.MutableHttpHeaders; -import io.micronaut.http.MutableHttpResponse; -import io.micronaut.http.codec.CodecException; -import io.micronaut.http.netty.NettyHttpHeaders; -import io.netty.handler.codec.http.HttpHeaderNames; -import io.netty.handler.codec.http.HttpResponseStatus; - -/** - * {@link NettyBodyWriter} extension that can do without a {@link HttpRequest}. - * - * @param The body type - * @since 4.3.0 - * @author Jonas Konrad - */ -@Internal -@Experimental -public sealed interface ShortCircuitNettyBodyWriter extends NettyBodyWriter permits NettyJsonHandler, NettyTextPlainHandler { - @Override - default void writeTo(HttpRequest request, MutableHttpResponse outgoingResponse, Argument type, MediaType mediaType, T object, NettyWriteContext writeContext) throws CodecException { - MutableHttpHeaders headers = outgoingResponse.getHeaders(); - NettyHttpHeaders nettyHttpHeaders = (NettyHttpHeaders) headers; - io.netty.handler.codec.http.HttpHeaders nettyHeaders = nettyHttpHeaders.getNettyHeaders(); - if (!nettyHttpHeaders.contains(HttpHeaders.CONTENT_TYPE)) { - nettyHttpHeaders.set(HttpHeaderNames.CONTENT_TYPE, mediaType); - } - writeTo(request.getHeaders(), HttpResponseStatus.valueOf(outgoingResponse.code(), outgoingResponse.reason()), nettyHeaders, object, writeContext); - } - - /** - * Write an object to the given context. - * - * @param requestHeaders The request headers - * @param status The response status - * @param responseHeaders The response headers - * @param object The object to write - * @param nettyContext The netty context - * @throws CodecException If an error occurs decoding - */ - void writeTo( - HttpHeaders requestHeaders, - HttpResponseStatus status, - io.netty.handler.codec.http.HttpHeaders responseHeaders, - T object, - NettyWriteContext nettyContext - ); -} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 2d7251e7e2e..9fb06c0080c 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -54,7 +54,6 @@ import io.micronaut.http.netty.NettyMutableHttpResponse; import io.micronaut.http.netty.body.NettyBodyWriter; import io.micronaut.http.netty.body.NettyWriteContext; -import io.micronaut.http.netty.body.ShortCircuitNettyBodyWriter; import io.micronaut.http.netty.channel.ChannelPipelineCustomizer; import io.micronaut.http.netty.stream.JsonSubscriber; import io.micronaut.http.netty.stream.StreamedHttpResponse; @@ -341,17 +340,21 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRou } @SuppressWarnings("unchecked") MessageBodyWriter messageBodyWriter = (MessageBodyWriter) routeInfo.getMessageBodyWriter(); - ShortCircuitNettyBodyWriter scWriter ; + NettyBodyWriter scWriter ; RawMessageBodyHandler rawWriter ; - if (messageBodyWriter instanceof ShortCircuitNettyBodyWriter scw) { + if (messageBodyWriter instanceof NettyBodyWriter scw) { rawWriter = null; scWriter = scw; } else if (messageBodyWriter instanceof RawMessageBodyHandler raw) { rawWriter = raw; scWriter = null; + } else if (!messageBodyWriter.isBlocking()) { + rawWriter = null; + scWriter = new CompatNettyWriteClosure<>(messageBodyWriter); } else { return ExecutionLeaf.indeterminate(); } + Argument responseBodyType = routeInfo.getResponseBodyType(); List finalFixedFilters = fixedFilters; BiFunction, PropagatedContext, ExecutionFlow>> exec = (httpRequest, propagatedContext) -> { Object[] arguments = shortCircuitBinders.length == 0 ? ArrayUtils.EMPTY_OBJECT_ARRAY : new Object[shortCircuitBinders.length]; @@ -391,7 +394,7 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe } HttpResponseStatus status = HttpResponseStatus.valueOf(response.code(), response.reason()); if (scWriter != null) { - scWriter.writeTo(nhr.getHeaders(), status, responseHeaders, response.body(), outboundAccess); + scWriter.writeTo(nhr, (MutableHttpResponse) response, (Argument) responseBodyType, responseMediaType, response.body(), outboundAccess); } else { ByteBuf buf = (ByteBuf) rawWriter.writeTo(responseMediaType, response.body(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); From 280a35a1ed36ac00105de917bb34ee7fa575bec5 Mon Sep 17 00:00:00 2001 From: yawkat Date: Mon, 8 Jan 2024 14:43:26 +0100 Subject: [PATCH 11/16] remove RawMessageBodyHandler changes --- .../body/ByteBufRawMessageBodyHandler.java | 2 +- .../netty/body/NettyWritableBodyWriter.java | 6 ----- .../server/netty/RoutingInBoundHandler.java | 2 +- .../http/body/RawMessageBodyHandler.java | 27 ------------------- .../body/RawMessageBodyHandlerRegistry.java | 9 ++++--- .../http/body/WritableBodyWriter.java | 21 --------------- 6 files changed, 8 insertions(+), 59 deletions(-) diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java b/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java index 0c9cc9a51a6..9dd0e5e08c2 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/ByteBufRawMessageBodyHandler.java @@ -83,7 +83,7 @@ public void writeTo(Argument type, MediaType mediaType, ByteBuf object, } @Override - public ByteBuffer writeTo(MediaType mediaType, ByteBuf object, ByteBufferFactory bufferFactory) throws CodecException { + public ByteBuffer writeTo(Argument type, MediaType mediaType, ByteBuf object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { return NettyByteBufferFactory.DEFAULT.wrap(object); } diff --git a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java index 96346ff1c02..64e991db2f3 100644 --- a/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java +++ b/http-netty/src/main/java/io/micronaut/http/netty/body/NettyWritableBodyWriter.java @@ -21,7 +21,6 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.io.Writable; import io.micronaut.core.io.buffer.ByteBuffer; -import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.type.Argument; import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; @@ -98,11 +97,6 @@ public void writeTo(Argument type, MediaType mediaType, Writable objec defaultWritable.writeTo(type, mediaType, object, outgoingHeaders, outputStream); } - @Override - public ByteBuffer writeTo(MediaType mediaType, Writable object, ByteBufferFactory bufferFactory) throws CodecException { - return defaultWritable.writeTo(mediaType, object, bufferFactory); - } - @Override public Publisher readChunked(Argument type, MediaType mediaType, Headers httpHeaders, Publisher> input) { return defaultWritable.readChunked(type, mediaType, httpHeaders, input); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 9fb06c0080c..7d222ee14f1 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -396,7 +396,7 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe if (scWriter != null) { scWriter.writeTo(nhr, (MutableHttpResponse) response, (Argument) responseBodyType, responseMediaType, response.body(), outboundAccess); } else { - ByteBuf buf = (ByteBuf) rawWriter.writeTo(responseMediaType, response.body(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); + ByteBuf buf = (ByteBuf) rawWriter.writeTo((Argument) responseBodyType, responseMediaType, response.body(), (MutableHeaders) response.getHeaders(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); } } diff --git a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java index a46907a83a9..2ddf07382a8 100644 --- a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java +++ b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandler.java @@ -18,13 +18,6 @@ import io.micronaut.core.annotation.Experimental; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.io.buffer.ByteBuffer; -import io.micronaut.core.io.buffer.ByteBufferFactory; -import io.micronaut.core.type.Argument; -import io.micronaut.core.type.MutableHeaders; -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.MediaType; -import io.micronaut.http.codec.CodecException; import java.util.Collection; @@ -49,24 +42,4 @@ public interface RawMessageBodyHandler extends MessageBodyHandler, Chunked */ @NonNull Collection> getTypes(); - - /** - * Same as {@link #writeTo(Argument, MediaType, Object, MutableHeaders, ByteBufferFactory)} but - * with fewer parameters. - * - * @param mediaType The media type - * @param object The object to write - * @param bufferFactory A byte buffer factory - * @throws CodecException If an error occurs decoding - * @return The encoded byte buffer - */ - ByteBuffer writeTo(MediaType mediaType, T object, ByteBufferFactory bufferFactory) throws CodecException; - - @Override - default ByteBuffer writeTo(Argument type, MediaType mediaType, T object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { - if (mediaType != null && !outgoingHeaders.contains(HttpHeaders.CONTENT_TYPE)) { - outgoingHeaders.set(HttpHeaders.CONTENT_TYPE, mediaType); - } - return writeTo(mediaType, object, bufferFactory); - } } diff --git a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java index b060cc899c5..34b94225e98 100644 --- a/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java +++ b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java @@ -243,7 +243,8 @@ public void writeTo(Argument type, MediaType mediaType, Object object, M } @Override - public ByteBuffer writeTo(MediaType mediaType, Object object, ByteBufferFactory bufferFactory) throws CodecException { + public ByteBuffer writeTo(Argument type, MediaType mediaType, Object object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { + addContentType(outgoingHeaders, mediaType); return bufferFactory.wrap(object.toString().getBytes(getCharset(mediaType))); } @@ -294,7 +295,8 @@ public void writeTo(Argument type, MediaType mediaType, byte[] object, M } @Override - public ByteBuffer writeTo(MediaType mediaType, byte[] object, ByteBufferFactory bufferFactory) throws CodecException { + public ByteBuffer writeTo(Argument type, MediaType mediaType, byte[] object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { + addContentType(outgoingHeaders, mediaType); return bufferFactory.wrap(object); } @@ -352,7 +354,8 @@ public void writeTo(Argument> type, MediaType mediaType, ByteBuffe } @Override - public ByteBuffer writeTo(MediaType mediaType, ByteBuffer object, ByteBufferFactory bufferFactory) throws CodecException { + public ByteBuffer writeTo(Argument> type, MediaType mediaType, ByteBuffer object, MutableHeaders outgoingHeaders, ByteBufferFactory bufferFactory) throws CodecException { + addContentType(outgoingHeaders, mediaType); return object; } diff --git a/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java b/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java index 7f8149ae54e..59823b471a1 100644 --- a/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java +++ b/http/src/main/java/io/micronaut/http/body/WritableBodyWriter.java @@ -20,7 +20,6 @@ import io.micronaut.core.annotation.Experimental; import io.micronaut.core.io.Writable; import io.micronaut.core.io.buffer.ByteBuffer; -import io.micronaut.core.io.buffer.ByteBufferFactory; import io.micronaut.core.io.buffer.ReferenceCounted; import io.micronaut.core.type.Argument; import io.micronaut.core.type.Headers; @@ -79,26 +78,6 @@ public void writeTo(Argument type, MediaType mediaType, Writable objec } } - @Override - public ByteBuffer writeTo(MediaType mediaType, Writable object, ByteBufferFactory bufferFactory) throws CodecException { - ByteBuffer buffer = bufferFactory.buffer(); - try { - try { - OutputStream outputStream = buffer.toOutputStream(); - object.writeTo(outputStream); - outputStream.flush(); - } catch (IOException e) { - throw new CodecException("Error writing body text: " + e.getMessage(), e); - } - } catch (Throwable t) { - if (buffer instanceof ReferenceCounted rc) { - rc.release(); - } - throw t; - } - return buffer; - } - private Writable read0(ByteBuffer byteBuffer) { String s = byteBuffer.toString(applicationConfiguration.getDefaultCharset()); if (byteBuffer instanceof ReferenceCounted rc) { From e9eb93d4b50a95a95459822b841495a17f268398 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 10 Jan 2024 10:38:05 +0100 Subject: [PATCH 12/16] move NettyShortCircuitRouterBuilder to router module --- .../server/netty/RoutingInBoundHandler.java | 112 ++++---- .../NettyShortCircuitRouterBuilder.java | 264 ------------------ .../netty/shortcircuit/PreparedHandler.java | 10 + .../NettyShortCircuitRouterBuilderSpec.groovy | 4 +- .../java/io/micronaut/web/router/Router.java | 2 +- .../shortcircuit/DiscriminatorStage.java | 30 +- .../router}/shortcircuit/ExecutionLeaf.java | 2 +- .../web/router}/shortcircuit/MatchPlan.java | 6 +- .../ShortCircuitRouterBuilder.java | 260 ++++++++++++++--- 9 files changed, 307 insertions(+), 383 deletions(-) delete mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java create mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java rename {http-server-netty/src/main/java/io/micronaut/http/server/netty => router/src/main/java/io/micronaut/web/router}/shortcircuit/DiscriminatorStage.java (81%) rename {http-server-netty/src/main/java/io/micronaut/http/server/netty => router/src/main/java/io/micronaut/web/router}/shortcircuit/ExecutionLeaf.java (96%) rename {http-server-netty/src/main/java/io/micronaut/http/server/netty => router/src/main/java/io/micronaut/web/router}/shortcircuit/MatchPlan.java (87%) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index 7d222ee14f1..ff68a035d60 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -65,16 +65,17 @@ import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.handler.PipeliningServerHandler; import io.micronaut.http.server.netty.handler.RequestHandler; -import io.micronaut.http.server.netty.shortcircuit.ExecutionLeaf; -import io.micronaut.http.server.netty.shortcircuit.MatchPlan; -import io.micronaut.http.server.netty.shortcircuit.NettyShortCircuitRouterBuilder; +import io.micronaut.http.server.netty.shortcircuit.PreparedHandler; import io.micronaut.http.server.netty.shortcircuit.ShortCircuitArgumentBinder; import io.micronaut.inject.MethodExecutionHandle; import io.micronaut.inject.UnsafeExecutionHandle; import io.micronaut.web.router.RouteInfo; import io.micronaut.web.router.UriRouteInfo; import io.micronaut.web.router.resource.StaticResourceResolver; +import io.micronaut.web.router.shortcircuit.ExecutionLeaf; +import io.micronaut.web.router.shortcircuit.MatchPlan; import io.micronaut.web.router.shortcircuit.MatchRule; +import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler.Sharable; @@ -145,7 +146,7 @@ public final class RoutingInBoundHandler implements RequestHandler { final ApplicationEventPublisher terminateEventPublisher; final RouteExecutor routeExecutor; final ConversionService conversionService; - final MatchPlan shortCircuitMatchPlan; + final MatchPlan shortCircuitMatchPlan; /** * @param serverConfiguration The Netty HTTP server configuration @@ -175,7 +176,7 @@ public final class RoutingInBoundHandler implements RequestHandler { this.conversionService = conversionService; if (serverConfiguration.isOptimizedRouting()) { - NettyShortCircuitRouterBuilder> scrb = new NettyShortCircuitRouterBuilder<>(); + ShortCircuitRouterBuilder> scrb = new ShortCircuitRouterBuilder<>(); routeExecutor.getRouter().collectRoutes(scrb); this.shortCircuitMatchPlan = scrb.transform(this::shortCircuitHandler).plan(); } else { @@ -230,29 +231,17 @@ public void handleUnboundError(Throwable cause) { @Override public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { - if (shortCircuitMatchPlan != null && - request.decoderResult().isSuccess() && - // origin needs to be checked by CorsFilter - !request.headers().contains(HttpHeaderNames.ORIGIN) && - body instanceof ImmediateByteBody) { - - ExecutionLeaf instantResult = shortCircuitMatchPlan.execute(request); - if (instantResult instanceof ExecutionLeaf.Route route) { - route.routeMatch().accept(ctx, request, body, outboundAccess); - return; - } - } - - NettyHttpRequest mnRequest = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration);if (serverConfiguration.isValidateUrl()) { + NettyHttpRequest mnRequest = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); + if (serverConfiguration.isValidateUrl()) { try { mnRequest.getUri(); } catch (IllegalArgumentException e) { body.release(); - // invalid URI - NettyHttpRequest errorRequest = new NettyHttpRequest<>( - new DefaultHttpRequest(request.protocolVersion(), request.method(), "/"), - ByteBody.empty(), + // invalid URI + NettyHttpRequest errorRequest = new NettyHttpRequest<>( + new DefaultHttpRequest(request.protocolVersion(), request.method(), "/"), + ByteBody.empty(), ctx, conversionService, serverConfiguration @@ -264,12 +253,24 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe return; } } + outboundAccess.attachment(mnRequest); + if (shortCircuitMatchPlan != null && + request.decoderResult().isSuccess() && + // origin needs to be checked by CorsFilter + !request.headers().contains(HttpHeaderNames.ORIGIN) && + body instanceof ImmediateByteBody) { + + ExecutionLeaf instantResult = shortCircuitMatchPlan.execute(mnRequest); + if (instantResult instanceof ExecutionLeaf.Route route) { + route.routeMatch().accept(mnRequest, outboundAccess); + return; + } + } if (ctx.pipeline().get(ChannelPipelineCustomizer.HANDLER_ACCESS_LOGGER) != null) { // Micronaut Session needs this to extract values from the Micronaut Http Request for logging AttributeKey KEY = AttributeKey.valueOf(NettyHttpRequest.class.getSimpleName()); ctx.channel().attr(KEY).set(mnRequest); } - outboundAccess.attachment(mnRequest); try (PropagatedContext.Scope ignore = PropagatedContext.getOrEmpty().plus(new ServerHttpRequestContext(mnRequest)).propagate()) { new NettyRequestLifecycle(this, outboundAccess).handleNormal(mnRequest); } @@ -289,7 +290,7 @@ private static MatchRule.ContentType findFixedContentType(MatchRule matchRule) { } } - private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRouteInfo routeInfo) { + private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRouteInfo routeInfo) { if (routeInfo.isWebSocketRoute()) { return ExecutionLeaf.indeterminate(); } @@ -371,45 +372,34 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRou }; String serverHeader = serverConfiguration.getServerHeader().orElse(null); boolean dateHeader = serverConfiguration.isDateHeader(); - return new ExecutionLeaf.Route<>(new RequestHandler() { - @Override - public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRequest request, ByteBody body, PipeliningServerHandler.OutboundAccess outboundAccess) { - try { - NettyHttpRequest nhr = new NettyHttpRequest<>(request, body, ctx, conversionService, serverConfiguration); - outboundAccess.attachment(nhr); - - new FilterRunner(finalFixedFilters, exec).run(nhr, PropagatedContext.empty()).onComplete((response, err) -> { - if (err != null) { - RoutingInBoundHandler.this.handleUnboundError(err); + return new ExecutionLeaf.Route<>((nhr, outboundAccess) -> { + try { + new FilterRunner(finalFixedFilters, exec).run(nhr, PropagatedContext.empty()).onComplete((response, err) -> { + if (err != null) { + RoutingInBoundHandler.this.handleUnboundError(err); + } else { + HttpHeaders responseHeaders = ((NettyHttpHeaders) response.getHeaders()).getNettyHeaders(); + if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { + responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + } + if (serverHeader != null && !responseHeaders.contains(HttpHeaderNames.SERVER)) { + responseHeaders.set(HttpHeaderNames.SERVER, serverHeader); + } + if (dateHeader && !responseHeaders.contains(HttpHeaderNames.DATE)) { + responseHeaders.set(HttpHeaderNames.DATE, ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME)); + } + HttpResponseStatus status = HttpResponseStatus.valueOf(response.code(), response.reason()); + if (scWriter != null) { + scWriter.writeTo(nhr, (MutableHttpResponse) response, (Argument) responseBodyType, responseMediaType, response.body(), outboundAccess); } else { - HttpHeaders responseHeaders = ((NettyHttpHeaders) response.getHeaders()).getNettyHeaders(); - if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { - responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); - } - if (serverHeader != null && !responseHeaders.contains(HttpHeaderNames.SERVER)) { - responseHeaders.set(HttpHeaderNames.SERVER, serverHeader); - } - if (dateHeader && !responseHeaders.contains(HttpHeaderNames.DATE)) { - responseHeaders.set(HttpHeaderNames.DATE, ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME)); - } - HttpResponseStatus status = HttpResponseStatus.valueOf(response.code(), response.reason()); - if (scWriter != null) { - scWriter.writeTo(nhr, (MutableHttpResponse) response, (Argument) responseBodyType, responseMediaType, response.body(), outboundAccess); - } else { - ByteBuf buf = (ByteBuf) rawWriter.writeTo((Argument) responseBodyType, responseMediaType, response.body(), (MutableHeaders) response.getHeaders(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); - outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); - } + ByteBuf buf = (ByteBuf) rawWriter.writeTo((Argument) responseBodyType, responseMediaType, response.body(), (MutableHeaders) response.getHeaders(), NettyByteBufferFactory.DEFAULT).asNativeBuffer(); + outboundAccess.writeFull(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buf, responseHeaders, EmptyHttpHeaders.INSTANCE)); } - }); - - } catch (Exception e) { - RoutingInBoundHandler.this.handleUnboundError(e); - } - } + } + }); - @Override - public void handleUnboundError(Throwable cause) { - throw new UnsupportedOperationException(); + } catch (Exception e) { + RoutingInBoundHandler.this.handleUnboundError(e); } }); } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java deleted file mode 100644 index 0902b2743fa..00000000000 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilder.java +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright 2017-2023 original 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 io.micronaut.http.server.netty.shortcircuit; - -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.web.router.shortcircuit.MatchRule; -import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; - -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.stream.Collectors; - -/** - * This is a netty implementation of {@link ShortCircuitRouterBuilder}. - *

- * The principle of operation is that all routes are collected into a tree of {@link MatchNode}s. - * Every level of the tree stands for one {@link DiscriminatorStage}. When all routes have been - * collected, the {@link io.micronaut.web.router.UriRouteInfo}s are transformed to closures that - * actually implement the routes by {@link io.micronaut.http.server.netty.RoutingInBoundHandler}. - * Then a simplification algorithm is run to reduce the size of the decision tree. Finally, the - * decision tree is transformed into a {@link MatchPlan} using the {@link DiscriminatorStage}s. - * - * @param The route type. Initially {@link io.micronaut.web.router.UriRouteInfo} for the router - * to add routes. {@link io.micronaut.http.server.netty.RoutingInBoundHandler} then - * {@link #transform(BiFunction) transforms} the type to its own prepared route call - * object. - * @author Jonas Konrad - * @since 4.3.0 - */ -@Internal -public final class NettyShortCircuitRouterBuilder implements ShortCircuitRouterBuilder { - private final MatchNode topNode = new MatchNode(); - - @Override - public void addRoute(MatchRule rule, R match) { - addRoute(rule, new ExecutionLeaf.Route<>(match)); - } - - @Override - public void addLegacyRoute(MatchRule rule) { - addRoute(rule, ExecutionLeaf.indeterminate()); - } - - @Override - public void addLegacyFallbackRouting() { - } - - /** - * Eagerly apply a transformation function to every {@link ExecutionLeaf.Route} in this builder. - * - * @param transform The transformation function - * @return A builder with the transformed routes - * @param The transformed route type - */ - @SuppressWarnings("unchecked") - @NonNull - public NettyShortCircuitRouterBuilder transform(@NonNull BiFunction> transform) { - topNode.eachNode(List.of(), (path, n) -> { - if (n.leaf instanceof ExecutionLeaf.Route route) { - n.leaf = (ExecutionLeaf) transform.apply(MatchRule.and(path), route.routeMatch()); - } - }); - return (NettyShortCircuitRouterBuilder) this; - } - - public MatchPlan plan() { - topNode.simplify(0); - return topNode.plan(0); - } - - private void addRoute(MatchRule rule, ExecutionLeaf executionLeaf) { - // transform the input rule to DNF. Then add each part of the OR as a separate route. - MatchRule.Or dnf = toDnf(rule); - for (MatchRule conjunction : dnf.rules()) { - if (conjunction instanceof MatchRule.And and) { - addRoute(and.rules(), executionLeaf); - } else { - addRoute(List.of(conjunction), executionLeaf); - } - } - } - - private void addRoute(List leafRules, ExecutionLeaf executionLeaf) { - // split up rules by discriminator - Map rulesByDiscriminator = new EnumMap<>(DiscriminatorStage.class); - for (MatchRule rule : leafRules) { - DiscriminatorStage disc; - if (rule instanceof MatchRule.Method) { - disc = DiscriminatorStage.METHOD; - } else if (rule instanceof MatchRule.ContentType) { - disc = DiscriminatorStage.CONTENT_TYPE; - } else if (rule instanceof MatchRule.Accept) { - disc = DiscriminatorStage.ACCEPT; - } else if (rule instanceof MatchRule.PathMatchExact || rule instanceof MatchRule.PathMatchPattern) { - disc = DiscriminatorStage.PATH; - } else if (rule instanceof MatchRule.ServerPort) { - disc = DiscriminatorStage.SERVER_PORT; - } else { - executionLeaf = ExecutionLeaf.indeterminate(); - continue; - } - MatchRule existing = rulesByDiscriminator.put(disc, rule); - if (existing != null && !existing.equals(rule)) { - // different rules of the same type. this can (probably) never match, just ignore this route. - return; - } - } - // add to decision tree - MatchNode node = topNode; - for (DiscriminatorStage stage : DiscriminatorStage.values()) { - MatchRule rule = rulesByDiscriminator.get(stage); - node = node.next.computeIfAbsent(rule, r -> new MatchNode()); - } - node.leaf = merge(node.leaf, executionLeaf); - } - - private static MatchRule.Or toDnf(MatchRule rule) { - // https://en.wikipedia.org/wiki/Disjunctive_normal_form - if (rule instanceof MatchRule.Or or) { - return new MatchRule.Or( - or.rules().stream() - .flatMap(r -> toDnf(r).rules().stream()) - .toList() - ); - } else if (rule instanceof MatchRule.And and) { - List> combined = List.of(List.of()); - for (MatchRule right : and.rules()) { - List> newCombined = new ArrayList<>(); - for (MatchRule rightPart : toDnf(right).rules()) { - for (List left : combined) { - List linked = new ArrayList<>(left.size() + 1); - linked.addAll(left); - if (rightPart instanceof MatchRule.And ra) { - linked.addAll(ra.rules()); - } else { - linked.add(rightPart); - } - newCombined.add(linked); - } - } - combined = newCombined; - } - return new MatchRule.Or(combined.stream() - .map(MatchRule::and) - .toList()); - } else { - // "Leaf" rule - return new MatchRule.Or(List.of(rule)); - } - } - - private static ExecutionLeaf merge(@Nullable ExecutionLeaf a, @Nullable ExecutionLeaf b) { - if (a == null) { - return b; - } else if (b == null) { - return a; - } else if (a.equals(b)) { - return a; - } else { - return ExecutionLeaf.indeterminate(); - } - } - - private class MatchNode { - /** - * The next rules in the decision tree. A {@code null} key means that all possible requests - * are matched. - */ - final Map next = new HashMap<>(); - /** - * If this is not {@code null}, then the decision tree stops here and we have the final - * result. - */ - ExecutionLeaf leaf = null; - - void simplify(int level) { - // simplify children first - for (MatchNode n : next.values()) { - n.simplify(level + 1); - } - - MatchNode anyMatchNode = next.get(null); - if (anyMatchNode != null) { - if (next.size() == 1) { - this.leaf = merge(this.leaf, anyMatchNode.leaf); - } else { - // give up. we can't merge a wildcard match with the other branches properly - this.leaf = ExecutionLeaf.indeterminate(); - } - } else { - if (level == DiscriminatorStage.PATH.ordinal()) { - for (Map.Entry entry : List.copyOf(next.entrySet())) { - if (entry.getKey() instanceof MatchRule.PathMatchPattern pattern) { - // remove any exact path matches that overlap with a pattern - next.keySet().removeIf(mr -> mr instanceof MatchRule.PathMatchExact exact && pattern.pattern().matcher(exact.path()).matches()); - // refuse to match patterns. - entry.getValue().leaf = ExecutionLeaf.indeterminate(); - } - } - } - } - - // at this point, the next branches do not overlap. remove any nodes that lead to indeterminate decisions. - next.values().removeIf(n -> n.leaf instanceof ExecutionLeaf.Indeterminate); - // if there's no more available choices, indeterminate result. - if (next.isEmpty() && leaf == null) { - leaf = ExecutionLeaf.indeterminate(); - } - - if (this.leaf != null) { - next.clear(); - } - } - - void eachNode(List rulePath, BiConsumer, MatchNode> consumer) { - consumer.accept(rulePath, this); - for (Map.Entry entry : next.entrySet()) { - List newRulePath; - if (entry.getKey() == null) { - newRulePath = rulePath; - } else { - newRulePath = new ArrayList<>(rulePath.size() + 1); - newRulePath.addAll(rulePath); - newRulePath.add(entry.getKey()); - } - entry.getValue().eachNode(newRulePath, consumer); - } - } - - MatchPlan plan(int level) { - if (leaf != null) { - return request -> leaf; - } - if (next.containsKey(null)) { - assert next.size() == 1; - return next.get(null).plan(level + 1); - } else { - DiscriminatorStage ourStage = DiscriminatorStage.values()[level]; - return ourStage.planDiscriminate(next.entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().plan(level + 1)))); - } - } - } -} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java new file mode 100644 index 00000000000..0795a81049f --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java @@ -0,0 +1,10 @@ +package io.micronaut.http.server.netty.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.http.server.netty.NettyHttpRequest; +import io.micronaut.http.server.netty.handler.PipeliningServerHandler; + +@Internal +public interface PreparedHandler { + void accept(NettyHttpRequest nhr, PipeliningServerHandler.OutboundAccess outboundAccess); +} diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy index 13b16ac9c1b..00bb9f5fd0f 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy @@ -11,6 +11,8 @@ import io.micronaut.http.annotation.Post import io.micronaut.http.annotation.Produces import io.micronaut.http.server.RouteExecutor import io.micronaut.web.router.UriRouteInfo +import io.micronaut.web.router.shortcircuit.ExecutionLeaf +import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder import io.netty.handler.codec.http.DefaultHttpRequest import io.netty.handler.codec.http.HttpMethod import io.netty.handler.codec.http.HttpRequest @@ -21,7 +23,7 @@ class NettyShortCircuitRouterBuilderSpec extends Specification { def 'route builder'(HttpRequest request, @Nullable String methodName) { given: def ctx = ApplicationContext.run(['spec.name': "NettyShortCircuitRouterBuilderSpec"]) - def scb = new NettyShortCircuitRouterBuilder>() + def scb = new ShortCircuitRouterBuilder>() ctx.getBean(RouteExecutor).getRouter().collectRoutes(scb) def plan = scb.plan() diff --git a/router/src/main/java/io/micronaut/web/router/Router.java b/router/src/main/java/io/micronaut/web/router/Router.java index 91de453cdec..91042eb658a 100644 --- a/router/src/main/java/io/micronaut/web/router/Router.java +++ b/router/src/main/java/io/micronaut/web/router/Router.java @@ -169,7 +169,7 @@ default UriRouteMatch findClosest(@NonNull HttpRequest request) * @param builder The builder to write routes to */ @Internal - default void collectRoutes(@NonNull ShortCircuitRouterBuilder> builder) { + default void collectRoutes(ShortCircuitRouterBuilder> builder) { builder.addLegacyFallbackRouting(); } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/DiscriminatorStage.java similarity index 81% rename from http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java rename to router/src/main/java/io/micronaut/web/router/shortcircuit/DiscriminatorStage.java index 0b520ec39c2..824ed78315c 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/DiscriminatorStage.java +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/DiscriminatorStage.java @@ -13,17 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.http.server.netty.shortcircuit; +package io.micronaut.web.router.shortcircuit; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpMethod; import io.micronaut.http.MediaType; -import io.micronaut.web.router.shortcircuit.MatchRule; -import io.netty.handler.codec.http.HttpHeaderNames; -import io.netty.handler.codec.http.HttpMethod; -import io.netty.handler.codec.http.QueryStringDecoder; -import java.net.URI; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -47,14 +44,7 @@ MatchPlan planDiscriminate(Map> nextPlans) { .filter(e -> e.getKey() instanceof MatchRule.PathMatchExact) .collect(Collectors.toMap(e -> ((MatchRule.PathMatchExact) e.getKey()).path(), Map.Entry::getValue)); return request -> { - // this replicates AbstractNettyHttpRequest.getPath but not exactly :( - URI uri; - try { - uri = URI.create(request.uri()); - } catch (IllegalArgumentException iae) { - return ExecutionLeaf.indeterminate(); - } - String rawPath = new QueryStringDecoder(uri).rawPath(); + String rawPath = request.getPath(); if (!rawPath.isEmpty() && rawPath.charAt(rawPath.length() - 1) == '/') { rawPath = rawPath.substring(0, rawPath.length() - 1); } @@ -66,10 +56,12 @@ MatchPlan planDiscriminate(Map> nextPlans) { METHOD { @Override MatchPlan planDiscriminate(Map> nextPlans) { - Map> byMethod = coerceRules(MatchRule.Method.class, nextPlans) - .entrySet().stream().collect(Collectors.toMap(e -> HttpMethod.valueOf(e.getKey().method().name()), Map.Entry::getValue)); + Map> byMethod = new EnumMap<>(HttpMethod.class); + for (Map.Entry> entry : coerceRules(MatchRule.Method.class, nextPlans).entrySet()) { + byMethod.put(entry.getKey().method(), entry.getValue()); + } return request -> { - MatchPlan plan = byMethod.get(request.method()); + MatchPlan plan = byMethod.get(request.getMethod()); return plan == null ? ExecutionLeaf.indeterminate() : plan.execute(request); }; } @@ -83,7 +75,7 @@ MatchPlan planDiscriminate(Map> nextPlans) { return expectedType == null ? null : expectedType.getName(); }, Map.Entry::getValue)); return request -> { - MatchPlan plan = byContentType.get(request.headers().get(HttpHeaderNames.CONTENT_TYPE)); + MatchPlan plan = byContentType.get(request.getHeaders().getContentType().orElse(null)); return plan == null ? ExecutionLeaf.indeterminate() : plan.execute(request); }; } @@ -93,7 +85,7 @@ MatchPlan planDiscriminate(Map> nextPlans) { MatchPlan planDiscriminate(Map> nextPlans) { Map> rules = coerceRules(MatchRule.Accept.class, nextPlans); return request -> { - List accept = MediaType.orderedOf(request.headers().getAll(HttpHeaderNames.ACCEPT)); + List accept = request.getHeaders().accept(); if (accept.isEmpty() || accept.contains(MediaType.ALL_TYPE)) { if (rules.size() == 1) { return rules.values().iterator().next().execute(request); diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/ExecutionLeaf.java similarity index 96% rename from http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java rename to router/src/main/java/io/micronaut/web/router/shortcircuit/ExecutionLeaf.java index 5171a8c3cd0..406528414d3 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ExecutionLeaf.java +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/ExecutionLeaf.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.http.server.netty.shortcircuit; +package io.micronaut.web.router.shortcircuit; import io.micronaut.core.annotation.Internal; diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchPlan.java similarity index 87% rename from http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java rename to router/src/main/java/io/micronaut/web/router/shortcircuit/MatchPlan.java index c1124e4be87..e7b7d146f46 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/MatchPlan.java +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchPlan.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.http.server.netty.shortcircuit; +package io.micronaut.web.router.shortcircuit; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; -import io.netty.handler.codec.http.HttpRequest; +import io.micronaut.http.HttpRequest; /** * This class matches a set of routes against their match rules and returns either a successful @@ -36,5 +36,5 @@ public interface MatchPlan { * @return The match result */ @NonNull - ExecutionLeaf execute(@NonNull HttpRequest request); + ExecutionLeaf execute(@NonNull HttpRequest request); } diff --git a/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java index 19e119c0c9f..fa0a3cd7527 100644 --- a/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java @@ -15,51 +15,245 @@ */ package io.micronaut.web.router.shortcircuit; -import io.micronaut.core.annotation.Experimental; import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.stream.Collectors; /** - * This API can be passed to a {@link io.micronaut.web.router.Router} to make it list all - * available routes explicitly. The implementation of this class can then build an optimized - * router instead of relying on the "legacy" routing exposed by the - * {@link io.micronaut.web.router.Router}. - *

- * Routes are associated with {@link MatchRule}s. The {@link MatchRule} decides whether the route - * should be matched. When two {@link MatchRule}s for different routes match the same request, it - * is not possible to use this optimized routing, and we have to fall back on legacy routing. + * This is a netty implementation of {@link ShortCircuitRouterBuilder}. *

- * Some routes may have matching logic that cannot be expressed as a {@link MatchRule}. These - * routes must still be added if they may potentially conflict with other routes. They can be - * marked for "legacy" (non-optimized) routing. + * The principle of operation is that all routes are collected into a tree of {@link MatchNode}s. + * Every level of the tree stands for one {@link DiscriminatorStage}. When all routes have been + * collected, the {@link io.micronaut.web.router.UriRouteInfo}s are transformed to closures that + * actually implement the routes by {@link io.micronaut.http.server.netty.RoutingInBoundHandler}. + * Then a simplification algorithm is run to reduce the size of the decision tree. Finally, the + * decision tree is transformed into a {@link MatchPlan} using the {@link DiscriminatorStage}s. * - * @param The type of route endpoint to collect. This is set to - * {@link io.micronaut.web.router.UriRouteInfo} by - * {@link io.micronaut.web.router.Router}. + * @param The route type. Initially {@link io.micronaut.web.router.UriRouteInfo} for the router + * to add routes. {@link io.micronaut.http.server.netty.RoutingInBoundHandler} then + * {@link #transform(BiFunction) transforms} the type to its own prepared route call + * object. * @author Jonas Konrad * @since 4.3.0 */ @Internal -@Experimental -public interface ShortCircuitRouterBuilder { - /** - * Add a route that can be fully matched by the given rule. - * - * @param rule The rule that matches this route - * @param match The matched route - */ - void addRoute(MatchRule rule, R match); +public final class ShortCircuitRouterBuilder { + private final MatchNode topNode = new MatchNode(); + + public void addRoute(MatchRule rule, R match) { + addRoute(rule, new ExecutionLeaf.Route<>(match)); + } + + public void addLegacyRoute(MatchRule rule) { + addRoute(rule, ExecutionLeaf.indeterminate()); + } + + public void addLegacyFallbackRouting() { + } /** - * If the given rule is matched, we must revert to legacy routing, even when another - * route matches the same request. + * Eagerly apply a transformation function to every {@link ExecutionLeaf.Route} in this builder. * - * @param rule The rule to match + * @param transform The transformation function + * @return A builder with the transformed routes + * @param The transformed route type */ - void addLegacyRoute(MatchRule rule); + @SuppressWarnings("unchecked") + @NonNull + public ShortCircuitRouterBuilder transform(@NonNull BiFunction> transform) { + topNode.eachNode(List.of(), (path, n) -> { + if (n.leaf instanceof ExecutionLeaf.Route route) { + n.leaf = (ExecutionLeaf) transform.apply(MatchRule.and(path), route.routeMatch()); + } + }); + return (ShortCircuitRouterBuilder) this; + } - /** - * This method may set a flag that the route list is not exhaustive. If no routes match, we - * should fall back to legacy routing. - */ - void addLegacyFallbackRouting(); + public MatchPlan plan() { + topNode.simplify(0); + return topNode.plan(0); + } + + private void addRoute(MatchRule rule, ExecutionLeaf executionLeaf) { + // transform the input rule to DNF. Then add each part of the OR as a separate route. + MatchRule.Or dnf = toDnf(rule); + for (MatchRule conjunction : dnf.rules()) { + if (conjunction instanceof MatchRule.And and) { + addRoute(and.rules(), executionLeaf); + } else { + addRoute(List.of(conjunction), executionLeaf); + } + } + } + + private void addRoute(List leafRules, ExecutionLeaf executionLeaf) { + // split up rules by discriminator + Map rulesByDiscriminator = new EnumMap<>(DiscriminatorStage.class); + for (MatchRule rule : leafRules) { + DiscriminatorStage disc; + if (rule instanceof MatchRule.Method) { + disc = DiscriminatorStage.METHOD; + } else if (rule instanceof MatchRule.ContentType) { + disc = DiscriminatorStage.CONTENT_TYPE; + } else if (rule instanceof MatchRule.Accept) { + disc = DiscriminatorStage.ACCEPT; + } else if (rule instanceof MatchRule.PathMatchExact || rule instanceof MatchRule.PathMatchPattern) { + disc = DiscriminatorStage.PATH; + } else if (rule instanceof MatchRule.ServerPort) { + disc = DiscriminatorStage.SERVER_PORT; + } else { + executionLeaf = ExecutionLeaf.indeterminate(); + continue; + } + MatchRule existing = rulesByDiscriminator.put(disc, rule); + if (existing != null && !existing.equals(rule)) { + // different rules of the same type. this can (probably) never match, just ignore this route. + return; + } + } + // add to decision tree + MatchNode node = topNode; + for (DiscriminatorStage stage : DiscriminatorStage.values()) { + MatchRule rule = rulesByDiscriminator.get(stage); + node = node.next.computeIfAbsent(rule, r -> new MatchNode()); + } + node.leaf = merge(node.leaf, executionLeaf); + } + + private static MatchRule.Or toDnf(MatchRule rule) { + // https://en.wikipedia.org/wiki/Disjunctive_normal_form + if (rule instanceof MatchRule.Or or) { + return new MatchRule.Or( + or.rules().stream() + .flatMap(r -> toDnf(r).rules().stream()) + .toList() + ); + } else if (rule instanceof MatchRule.And and) { + List> combined = List.of(List.of()); + for (MatchRule right : and.rules()) { + List> newCombined = new ArrayList<>(); + for (MatchRule rightPart : toDnf(right).rules()) { + for (List left : combined) { + List linked = new ArrayList<>(left.size() + 1); + linked.addAll(left); + if (rightPart instanceof MatchRule.And ra) { + linked.addAll(ra.rules()); + } else { + linked.add(rightPart); + } + newCombined.add(linked); + } + } + combined = newCombined; + } + return new MatchRule.Or(combined.stream() + .map(MatchRule::and) + .toList()); + } else { + // "Leaf" rule + return new MatchRule.Or(List.of(rule)); + } + } + + private static ExecutionLeaf merge(@Nullable ExecutionLeaf a, @Nullable ExecutionLeaf b) { + if (a == null) { + return b; + } else if (b == null) { + return a; + } else if (a.equals(b)) { + return a; + } else { + return ExecutionLeaf.indeterminate(); + } + } + + private class MatchNode { + /** + * The next rules in the decision tree. A {@code null} key means that all possible requests + * are matched. + */ + final Map next = new HashMap<>(); + /** + * If this is not {@code null}, then the decision tree stops here and we have the final + * result. + */ + ExecutionLeaf leaf = null; + + void simplify(int level) { + // simplify children first + for (MatchNode n : next.values()) { + n.simplify(level + 1); + } + + MatchNode anyMatchNode = next.get(null); + if (anyMatchNode != null) { + if (next.size() == 1) { + this.leaf = merge(this.leaf, anyMatchNode.leaf); + } else { + // give up. we can't merge a wildcard match with the other branches properly + this.leaf = ExecutionLeaf.indeterminate(); + } + } else { + if (level == DiscriminatorStage.PATH.ordinal()) { + for (Map.Entry entry : List.copyOf(next.entrySet())) { + if (entry.getKey() instanceof MatchRule.PathMatchPattern pattern) { + // remove any exact path matches that overlap with a pattern + next.keySet().removeIf(mr -> mr instanceof MatchRule.PathMatchExact exact && pattern.pattern().matcher(exact.path()).matches()); + // refuse to match patterns. + entry.getValue().leaf = ExecutionLeaf.indeterminate(); + } + } + } + } + + // at this point, the next branches do not overlap. remove any nodes that lead to indeterminate decisions. + next.values().removeIf(n -> n.leaf instanceof ExecutionLeaf.Indeterminate); + // if there's no more available choices, indeterminate result. + if (next.isEmpty() && leaf == null) { + leaf = ExecutionLeaf.indeterminate(); + } + + if (this.leaf != null) { + next.clear(); + } + } + + void eachNode(List rulePath, BiConsumer, MatchNode> consumer) { + consumer.accept(rulePath, this); + for (Map.Entry entry : next.entrySet()) { + List newRulePath; + if (entry.getKey() == null) { + newRulePath = rulePath; + } else { + newRulePath = new ArrayList<>(rulePath.size() + 1); + newRulePath.addAll(rulePath); + newRulePath.add(entry.getKey()); + } + entry.getValue().eachNode(newRulePath, consumer); + } + } + + MatchPlan plan(int level) { + if (leaf != null) { + return request -> leaf; + } + if (next.containsKey(null)) { + assert next.size() == 1; + return next.get(null).plan(level + 1); + } else { + DiscriminatorStage ourStage = DiscriminatorStage.values()[level]; + return ourStage.planDiscriminate(next.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().plan(level + 1)))); + } + } + } } From cd0c252b312f8596f24985c19554ab9711e0d815 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 10 Jan 2024 11:39:00 +0100 Subject: [PATCH 13/16] move route planning to router --- .../server/netty/RoutingInBoundHandler.java | 30 ++++---- .../netty/shortcircuit/PreparedHandler.java | 15 ++++ .../micronaut/web/router/DefaultRouter.java | 29 +++++++- .../java/io/micronaut/web/router/Router.java | 12 ++-- .../shortcircuit/PreparedMatchResult.java | 72 +++++++++++++++++++ .../ShortCircuitRouterBuilderSpec.groovy | 53 +++++++------- 6 files changed, 157 insertions(+), 54 deletions(-) create mode 100644 router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java rename http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy => router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy (63%) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index ff68a035d60..be7f29b7c63 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -73,9 +73,8 @@ import io.micronaut.web.router.UriRouteInfo; import io.micronaut.web.router.resource.StaticResourceResolver; import io.micronaut.web.router.shortcircuit.ExecutionLeaf; -import io.micronaut.web.router.shortcircuit.MatchPlan; import io.micronaut.web.router.shortcircuit.MatchRule; -import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; +import io.micronaut.web.router.shortcircuit.PreparedMatchResult; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler.Sharable; @@ -129,6 +128,7 @@ @SuppressWarnings("FileLength") public final class RoutingInBoundHandler implements RequestHandler { + private static final PreparedMatchResult.HandlerKey> PREPARED_HANDLER_KEY = new PreparedMatchResult.HandlerKey<>(); private static final Logger LOG = LoggerFactory.getLogger(RoutingInBoundHandler.class); /* * Also present in {@link RouteExecutor}. @@ -146,7 +146,6 @@ public final class RoutingInBoundHandler implements RequestHandler { final ApplicationEventPublisher terminateEventPublisher; final RouteExecutor routeExecutor; final ConversionService conversionService; - final MatchPlan shortCircuitMatchPlan; /** * @param serverConfiguration The Netty HTTP server configuration @@ -174,14 +173,6 @@ public final class RoutingInBoundHandler implements RequestHandler { this.multipartEnabled = isMultiPartEnabled.isEmpty() || isMultiPartEnabled.get(); this.routeExecutor = embeddedServerContext.getRouteExecutor(); this.conversionService = conversionService; - - if (serverConfiguration.isOptimizedRouting()) { - ShortCircuitRouterBuilder> scrb = new ShortCircuitRouterBuilder<>(); - routeExecutor.getRouter().collectRoutes(scrb); - this.shortCircuitMatchPlan = scrb.transform(this::shortCircuitHandler).plan(); - } else { - this.shortCircuitMatchPlan = null; - } } private void cleanupRequest(NettyHttpRequest request) { @@ -254,16 +245,23 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe } } outboundAccess.attachment(mnRequest); - if (shortCircuitMatchPlan != null && + if (serverConfiguration.isOptimizedRouting() && request.decoderResult().isSuccess() && // origin needs to be checked by CorsFilter !request.headers().contains(HttpHeaderNames.ORIGIN) && body instanceof ImmediateByteBody) { - ExecutionLeaf instantResult = shortCircuitMatchPlan.execute(mnRequest); - if (instantResult instanceof ExecutionLeaf.Route route) { - route.routeMatch().accept(mnRequest, outboundAccess); - return; + PreparedMatchResult preparedMatchResult = routeExecutor.getRouter().findPreparedMatchResult(mnRequest); + if (preparedMatchResult != null) { + ExecutionLeaf handler = preparedMatchResult.getHandler(PREPARED_HANDLER_KEY); + if (handler == null) { + handler = shortCircuitHandler(preparedMatchResult.getRule(), preparedMatchResult.getRouteInfo()); + preparedMatchResult.setHandler(PREPARED_HANDLER_KEY, handler); + } + if (handler instanceof ExecutionLeaf.Route route) { + route.routeMatch().accept(mnRequest, outboundAccess); + return; + } } } if (ctx.pipeline().get(ChannelPipelineCustomizer.HANDLER_ACCESS_LOGGER) != null) { diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java index 0795a81049f..c30959bfdb1 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java @@ -1,3 +1,18 @@ +/* + * Copyright 2017-2024 original 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 io.micronaut.http.server.netty.shortcircuit; import io.micronaut.core.annotation.Internal; diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java index 8716d96a978..e4d0fb36965 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java @@ -16,6 +16,7 @@ package io.micronaut.web.router; import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.order.OrderUtil; @@ -36,7 +37,10 @@ import io.micronaut.http.uri.UriMatchTemplate; import io.micronaut.web.router.exceptions.DuplicateRouteException; import io.micronaut.web.router.exceptions.RoutingException; +import io.micronaut.web.router.shortcircuit.ExecutionLeaf; +import io.micronaut.web.router.shortcircuit.MatchPlan; import io.micronaut.web.router.shortcircuit.MatchRule; +import io.micronaut.web.router.shortcircuit.PreparedMatchResult; import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -88,6 +92,7 @@ public class DefaultRouter implements Router, HttpServerFilterResolver preparedMatchPlan = null; /** * Construct a new router for the given route builders. @@ -692,7 +697,7 @@ private boolean shouldSkipForPort(HttpRequest request, UriRouteInfo> builder) { for (Map.Entry[]> entry : allRoutesByMethod.entrySet()) { HttpMethod parsed = HttpMethod.parse(entry.getKey()); @@ -716,6 +721,28 @@ public void collectRoutes(ShortCircuitRouterBuilder> builder) builder.addLegacyFallbackRouting(); } + @Override + public @Nullable PreparedMatchResult findPreparedMatchResult(@NonNull HttpRequest request) { + MatchPlan preparedMatchPlan = this.preparedMatchPlan; + if (preparedMatchPlan == null) { + preparedMatchPlan = prepareMatchPlan(); + } + ExecutionLeaf result = preparedMatchPlan.execute(request); + return result instanceof ExecutionLeaf.Route r ? r.routeMatch() : null; + } + + private MatchPlan prepareMatchPlan() { + synchronized (this) { + if (preparedMatchPlan == null) { + ShortCircuitRouterBuilder> scrb = new ShortCircuitRouterBuilder<>(); + collectRoutes(scrb); + return preparedMatchPlan = scrb.transform((rule, info) -> new ExecutionLeaf.Route<>(new PreparedMatchResult(rule, info))).plan(); + } else { + return preparedMatchPlan; + } + } + } + /** * Collect the conditions that match a given route. * diff --git a/router/src/main/java/io/micronaut/web/router/Router.java b/router/src/main/java/io/micronaut/web/router/Router.java index 91042eb658a..dde531db478 100644 --- a/router/src/main/java/io/micronaut/web/router/Router.java +++ b/router/src/main/java/io/micronaut/web/router/Router.java @@ -23,7 +23,7 @@ import io.micronaut.http.HttpStatus; import io.micronaut.http.filter.GenericHttpFilter; import io.micronaut.web.router.exceptions.DuplicateRouteException; -import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder; +import io.micronaut.web.router.shortcircuit.PreparedMatchResult; import java.net.URI; import java.util.List; @@ -163,14 +163,10 @@ default UriRouteMatch findClosest(@NonNull HttpRequest request) return null; } - /** - * Collect all routes in this router into the given {@link ShortCircuitRouterBuilder}. - * - * @param builder The builder to write routes to - */ @Internal - default void collectRoutes(ShortCircuitRouterBuilder> builder) { - builder.addLegacyFallbackRouting(); + @Nullable + default PreparedMatchResult findPreparedMatchResult(@NonNull HttpRequest request) { + return null; } /** diff --git a/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java new file mode 100644 index 00000000000..47b6690eafe --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2024 original 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 io.micronaut.web.router.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.web.router.UriRouteInfo; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Internal +public final class PreparedMatchResult { + private final MatchRule rule; + private final UriRouteInfo routeInfo; + + private FastEntry fastEntry = new FastEntry<>(null, null); + + private final Map, Object> multipleHandlers = Collections.synchronizedMap(new HashMap<>()); + + public PreparedMatchResult(MatchRule rule, UriRouteInfo routeInfo) { + this.rule = rule; + this.routeInfo = routeInfo; + } + + public MatchRule getRule() { + return rule; + } + + public UriRouteInfo getRouteInfo() { + return routeInfo; + } + + @SuppressWarnings("unchecked") + @Nullable + public H getHandler(@NonNull HandlerKey key) { + FastEntry fastEntry = this.fastEntry; + Object handler; + if (fastEntry.key == key) { + handler = fastEntry.value; + } else { + handler = multipleHandlers.get(key); + } + return (H) handler; + } + + public void setHandler(@NonNull HandlerKey key, @NonNull H handler) { + multipleHandlers.put(key, handler); + fastEntry = new FastEntry<>(key, handler); + } + + private record FastEntry(HandlerKey key, H value) { + } + + public static final class HandlerKey { + } +} diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy similarity index 63% rename from http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy rename to router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy index 00bb9f5fd0f..5faf6fb973d 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/shortcircuit/NettyShortCircuitRouterBuilderSpec.groovy +++ b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy @@ -1,30 +1,25 @@ -package io.micronaut.http.server.netty.shortcircuit +package io.micronaut.web.router.shortcircuit import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Nullable +import io.micronaut.http.HttpRequest import io.micronaut.http.MediaType import io.micronaut.http.annotation.Consumes import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get import io.micronaut.http.annotation.Post import io.micronaut.http.annotation.Produces -import io.micronaut.http.server.RouteExecutor +import io.micronaut.web.router.DefaultRouter import io.micronaut.web.router.UriRouteInfo -import io.micronaut.web.router.shortcircuit.ExecutionLeaf -import io.micronaut.web.router.shortcircuit.ShortCircuitRouterBuilder -import io.netty.handler.codec.http.DefaultHttpRequest -import io.netty.handler.codec.http.HttpMethod -import io.netty.handler.codec.http.HttpRequest -import io.netty.handler.codec.http.HttpVersion import spock.lang.Specification -class NettyShortCircuitRouterBuilderSpec extends Specification { - def 'route builder'(HttpRequest request, @Nullable String methodName) { +class ShortCircuitRouterBuilderSpec extends Specification { + def 'route builder'(HttpRequest request, @Nullable String methodName) { given: def ctx = ApplicationContext.run(['spec.name': "NettyShortCircuitRouterBuilderSpec"]) def scb = new ShortCircuitRouterBuilder>() - ctx.getBean(RouteExecutor).getRouter().collectRoutes(scb) + ctx.getBean(DefaultRouter).collectRoutes(scb) def plan = scb.plan() when: @@ -43,35 +38,35 @@ class NettyShortCircuitRouterBuilderSpec extends Specification { get("/simple") | "simple" get("/pattern-collision/exact") | null get("/produces") | "produces" - get("/produces", ['accept': 'application/json']) | "produces" - get("/produces", ['accept': '*/*']) | "produces" - get("/produces", ['accept': 'text/plain, */*']) | "produces" - get("/produces", ['accept': 'text/plain']) | null - get("/produces-overlap", ['accept': 'text/plain']) | "producesOverlapText" - get("/produces-overlap", ['accept': 'application/json']) | "producesOverlapJson" - get("/produces-overlap", ['accept': '*/*']) | null + get("/produces", ['Accept': 'application/json']) | "produces" + get("/produces", ['Accept': '*/*']) | "produces" + get("/produces", ['Accept': 'text/plain, */*']) | "produces" + get("/produces", ['Accept': 'text/plain']) | null + get("/produces-overlap", ['Accept': 'text/plain']) | "producesOverlapText" + get("/produces-overlap", ['Accept': 'application/json']) | "producesOverlapJson" + get("/produces-overlap", ['Accept': '*/*']) | null get("/produces-overlap") | null - get("/produces-overlap", ['accept': 'text/plain, application/json']) | null - post("/consumes", ['content-type': 'application/json']) | "consumes" - post("/consumes", ['content-type': 'text/plain']) | null + get("/produces-overlap", ['Accept': 'text/plain, application/json']) | null + post("/consumes", ['Content-Type': 'application/json']) | "consumes" + post("/consumes", ['Content-Type': 'text/plain']) | null post("/consumes") | "consumes" - post("/consumes-overlap", ['content-type': 'application/json']) | "consumesOverlapJson" - post("/consumes-overlap", ['content-type': 'text/plain']) | "consumesOverlapText" + post("/consumes-overlap", ['Content-Type': 'application/json']) | "consumesOverlapJson" + post("/consumes-overlap", ['Content-Type': 'text/plain']) | "consumesOverlapText" post("/consumes-overlap") | null } - private static HttpRequest get(String path, Map headers = [:]) { - def request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, path) + private static HttpRequest get(String path, Map headers = [:]) { + def request = HttpRequest.GET(path) for (Map.Entry entry : headers.entrySet()) { - request.headers().add(entry.key, entry.value) + request.getHeaders().add(entry.key, entry.value) } return request } - private static HttpRequest post(String path, Map headers = [:]) { - def request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, path) + private static HttpRequest post(String path, Map headers = [:]) { + def request = HttpRequest.POST(path, null) for (Map.Entry entry : headers.entrySet()) { - request.headers().add(entry.key, entry.value) + request.getHeaders().add(entry.key, entry.value) } return request } From 409c4d96f03d46bd2e2053d942fadbab508497d1 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 10 Jan 2024 11:48:26 +0100 Subject: [PATCH 14/16] dont use private api in test --- .../main/java/io/micronaut/web/router/DefaultRouter.java | 4 +--- .../shortcircuit/ShortCircuitRouterBuilderSpec.groovy | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java index e4d0fb36965..ef053c6fe69 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java @@ -16,7 +16,6 @@ package io.micronaut.web.router; import io.micronaut.core.annotation.AnnotationMetadata; -import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.order.OrderUtil; @@ -697,8 +696,7 @@ private boolean shouldSkipForPort(HttpRequest request, UriRouteInfo> builder) { + private void collectRoutes(ShortCircuitRouterBuilder> builder) { for (Map.Entry[]> entry : allRoutesByMethod.entrySet()) { HttpMethod parsed = HttpMethod.parse(entry.getKey()); if (parsed == HttpMethod.CUSTOM) { diff --git a/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy index 5faf6fb973d..f714a34d691 100644 --- a/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy +++ b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy @@ -23,10 +23,10 @@ class ShortCircuitRouterBuilderSpec extends Specification { def plan = scb.plan() when: - def leaf = plan.execute(request) + def leaf = ctx.getBean(DefaultRouter).findPreparedMatchResult(request) def actualName - if (leaf instanceof ExecutionLeaf.Route) { - actualName = ((UriRouteInfo) leaf.routeMatch()).targetMethod.name + if (leaf != null) { + actualName = leaf.routeInfo.targetMethod.name } else { actualName = null } From f3a384940184ee220e35c03e05bbf4907dc43030 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 10 Jan 2024 11:56:05 +0100 Subject: [PATCH 15/16] docs --- .../shortcircuit/PreparedMatchResult.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java index 47b6690eafe..cb7f6fa3cae 100644 --- a/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java @@ -24,6 +24,12 @@ import java.util.HashMap; import java.util.Map; +/** + * This class represents the result of a {@link io.micronaut.web.router.Router} match operation. It + * is shared between multiple requests with the same parameters. However, it includes additional + * request information that is not present in {@link UriRouteInfo}, such as the request path and + * certain headers. + */ @Internal public final class PreparedMatchResult { private final MatchRule rule; @@ -46,6 +52,13 @@ public MatchRule getRule() { return routeInfo; } + /** + * Get a handler that has been previously set using {@link #setHandler}. + * + * @param key The handler key + * @return The handler, or {@code null if unset} + * @param The handler type + */ @SuppressWarnings("unchecked") @Nullable public H getHandler(@NonNull HandlerKey key) { @@ -59,6 +72,13 @@ public H getHandler(@NonNull HandlerKey key) { return (H) handler; } + /** + * Set a handler for future retrieval with {@link #getHandler}. + * + * @param key The handler key + * @param handler The handler + * @param The handler type + */ public void setHandler(@NonNull HandlerKey key, @NonNull H handler) { multipleHandlers.put(key, handler); fastEntry = new FastEntry<>(key, handler); @@ -67,6 +87,7 @@ public void setHandler(@NonNull HandlerKey key, @NonNull H handler) { private record FastEntry(HandlerKey key, H value) { } + @SuppressWarnings("unused") public static final class HandlerKey { } } From 43c5c25c251180ad00a2c3aeb5e9e303c119cff2 Mon Sep 17 00:00:00 2001 From: yawkat Date: Wed, 10 Jan 2024 16:30:10 +0100 Subject: [PATCH 16/16] address review --- .../micronaut/http/server/netty/RoutingInBoundHandler.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java index be7f29b7c63..0c084f58aed 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/RoutingInBoundHandler.java @@ -376,10 +376,11 @@ private ExecutionLeaf shortCircuitHandler(MatchRule rule, UriRo if (err != null) { RoutingInBoundHandler.this.handleUnboundError(err); } else { - HttpHeaders responseHeaders = ((NettyHttpHeaders) response.getHeaders()).getNettyHeaders(); - if (!responseHeaders.contains(HttpHeaderNames.CONTENT_TYPE)) { - responseHeaders.set(HttpHeaderNames.CONTENT_TYPE, responseMediaType.toString()); + NettyHttpHeaders responseHeadersMn = (NettyHttpHeaders) response.getHeaders(); + if (responseHeadersMn.contentType().isEmpty()) { + responseHeadersMn.contentType(responseMediaType); } + HttpHeaders responseHeaders = responseHeadersMn.getNettyHeaders(); if (serverHeader != null && !responseHeaders.contains(HttpHeaderNames.SERVER)) { responseHeaders.set(HttpHeaderNames.SERVER, serverHeader); }