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 b0f72d9eb72..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 @@ -128,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)); @@ -164,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.optimized-routing", true )); EmbeddedServer server = ctx.getBean(EmbeddedServer.class); EmbeddedChannel channel = ((NettyHttpServer) server).buildEmbeddedChannel(false); 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 ea50d6f2520..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 @@ -22,26 +22,34 @@ 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; 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; @@ -51,12 +59,22 @@ 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.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.MatchRule; +import io.micronaut.web.router.shortcircuit.PreparedMatchResult; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler.Sharable; @@ -66,8 +84,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 io.netty.util.AttributeKey; @@ -83,11 +104,16 @@ 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; 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; @@ -102,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}. @@ -212,22 +239,170 @@ public void accept(ChannelHandlerContext ctx, io.netty.handler.codec.http.HttpRe ); outboundAccess.attachment(errorRequest); try (PropagatedContext.Scope ignore = PropagatedContext.getOrEmpty().plus(new ServerHttpRequestContext(errorRequest)).propagate()) { - new NettyRequestLifecycle(this, outboundAccess).handleException(errorRequest, e.getCause() == null ? e : e.getCause()); + new NettyRequestLifecycle(this, outboundAccess).handleException(errorRequest,e.getCause() == null ? e : e.getCause()); } return; } } + outboundAccess.attachment(mnRequest); + if (serverConfiguration.isOptimizedRouting() && + request.decoderResult().isSuccess() && + // origin needs to be checked by CorsFilter + !request.headers().contains(HttpHeaderNames.ORIGIN) && + body instanceof ImmediateByteBody) { + + 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) { // 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); } } + @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); + 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) { + 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(); + NettyBodyWriter scWriter ; + RawMessageBodyHandler rawWriter ; + 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]; + 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)); + } + }; + String serverHeader = serverConfiguration.getServerHeader().orElse(null); + boolean dateHeader = serverConfiguration.isDateHeader(); + 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 { + 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); + } + 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)); + } + } + }); + + } catch (Exception e) { + RoutingInBoundHandler.this.handleUnboundError(e); + } + }); + } + public void writeResponse(PipeliningServerHandler.OutboundAccess outboundAccess, NettyHttpRequest nettyHttpRequest, HttpResponse 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 44af2a46ea6..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,9 +21,11 @@ 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.HttpAttributes; 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; @@ -34,12 +36,14 @@ 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.RouteInfo; +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; @@ -167,4 +171,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((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 3feb1ab1a9a..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 @@ -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_OPTIMIZED_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 optimizedRouting = DEFAULT_OPTIMIZED_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_OPTIMIZED_ROUTING}. + * + * @return {@code true} if optimized routing should be enabled + * @since 4.3.0 + */ + 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_OPTIMIZED_ROUTING}. + * + * @param optimizedRouting {@code true} if optimized routing should be enabled + * @since 4.3.0 + */ + public void setOptimizedRouting(boolean optimizedRouting) { + this.optimizedRouting = optimizedRouting; + } + /** * Http2 settings. */ 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..c30959bfdb1 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/PreparedHandler.java @@ -0,0 +1,25 @@ +/* + * 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; +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/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..1e9d35eba55 --- /dev/null +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/shortcircuit/ShortCircuitArgumentBinder.java @@ -0,0 +1,60 @@ +/* + * 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 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 mnHeaders The request headers (micronaut-http class) + * @param body The request body + * @return The bound argument + */ + Object bind(HttpHeaders mnHeaders, @NonNull ImmediateByteBody body); + } +} 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/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..917ab1f2372 --- /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.optimized-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/threading/ThreadSelectionSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/threading/ThreadSelectionSpec.groovy index 0a371fd155f..c786e5e5cc2 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 @@ -45,7 +46,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: @@ -67,7 +68,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) @@ -90,7 +91,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: @@ -117,7 +118,7 @@ class ThreadSelectionSpec extends Specification { @Ignore // pending feature, only works sometimes: https://github.com/micronaut-projects/micronaut-core/pull/10104 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: @@ -151,6 +152,7 @@ class ThreadSelectionSpec extends Specification { } @Client("/thread-selection") + @Requires(property = "spec.name", value = "ThreadSelectionSpec") static interface ThreadSelectionClient { @Get("/blocking") String blocking() @@ -190,6 +192,7 @@ class ThreadSelectionSpec extends Specification { } @Controller("/thread-selection") + @Requires(property = "spec.name", value = "ThreadSelectionSpec") static class ThreadSelectionController { @Get("/blocking") String blocking() { @@ -268,6 +271,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 b68ebb3f0c6..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,12 +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 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/RawMessageBodyHandlerRegistry.java b/http/src/main/java/io/micronaut/http/body/RawMessageBodyHandlerRegistry.java index eb4d54a5229..34b94225e98 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 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 249b897cd73..46b92526281 100644 --- a/http/src/main/java/io/micronaut/http/filter/FilterRunner.java +++ b/http/src/main/java/io/micronaut/http/filter/FilterRunner.java @@ -95,6 +95,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..ecf3c0bc2ce 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/inject-java/src/test/groovy/io/micronaut/visitors/ClassElementSpec.groovy b/inject-java/src/test/groovy/io/micronaut/visitors/ClassElementSpec.groovy index 48f759838f7..53189621cf8 100644 --- a/inject-java/src/test/groovy/io/micronaut/visitors/ClassElementSpec.groovy +++ b/inject-java/src/test/groovy/io/micronaut/visitors/ClassElementSpec.groovy @@ -3426,7 +3426,6 @@ interface SpecificInterface { declaredMethods.size() == 1 declaredMethods.get(0).isDefault() == true } - void "test bean properties interfaces"() { def ce = buildClassElement(''' package test; 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 392bd17d30a..e8c56b46d8f 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouteInfo.java @@ -33,11 +33,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. @@ -205,7 +207,7 @@ public boolean consumesAll() { } @Override - public boolean doesConsume(MediaType contentType) { + public final boolean doesConsume(MediaType contentType) { return contentType == null || consumesMediaTypesContainsAll || explicitlyConsumes(contentType); } @@ -215,10 +217,31 @@ public boolean producesAll() { } @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); @@ -237,7 +260,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 2bfc75b0a17..ef053c6fe69 100644 --- a/router/src/main/java/io/micronaut/web/router/DefaultRouter.java +++ b/router/src/main/java/io/micronaut/web/router/DefaultRouter.java @@ -36,6 +36,11 @@ 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; @@ -86,6 +91,7 @@ public class DefaultRouter implements Router, HttpServerFilterResolver preparedMatchPlan = null; /** * Construct a new router for the given route builders. @@ -265,7 +271,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; } uriRoutes = resolveAmbiguity(request, uriRoutes); @@ -581,6 +587,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 @@ -681,6 +696,113 @@ private boolean shouldSkipForPort(HttpRequest request, UriRouteInfo> builder) { + for (Map.Entry[]> entry : allRoutesByMethod.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(); + } + + @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. + * + * @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 528e5cfc348..e6f986fa9d3 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; @@ -101,6 +102,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 5b39cd65fde..37595c1f368 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; @@ -176,6 +177,17 @@ default boolean consumesAll() { */ 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 this is producing any content type. * @@ -194,6 +206,16 @@ default boolean producesAll() { */ 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 0b2a23d851e..dde531db478 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.PreparedMatchResult; import java.net.URI; import java.util.List; @@ -161,6 +163,12 @@ default UriRouteMatch findClosest(@NonNull HttpRequest request) return null; } + @Internal + @Nullable + default PreparedMatchResult findPreparedMatchResult(@NonNull HttpRequest request) { + return null; + } + /** * Returns all UriRoutes. * @@ -324,6 +332,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/DiscriminatorStage.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/DiscriminatorStage.java new file mode 100644 index 00000000000..824ed78315c --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/DiscriminatorStage.java @@ -0,0 +1,136 @@ +/* + * 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.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.MediaType; + +import java.util.EnumMap; +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 -> { + String rawPath = request.getPath(); + 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 = 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.getMethod()); + 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.getHeaders().getContentType().orElse(null)); + 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 = request.getHeaders().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/router/src/main/java/io/micronaut/web/router/shortcircuit/ExecutionLeaf.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/ExecutionLeaf.java new file mode 100644 index 00000000000..406528414d3 --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/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.web.router.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/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchPlan.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/MatchPlan.java new file mode 100644 index 00000000000..e7b7d146f46 --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/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.web.router.shortcircuit; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.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/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/PreparedMatchResult.java b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java new file mode 100644 index 00000000000..cb7f6fa3cae --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/PreparedMatchResult.java @@ -0,0 +1,93 @@ +/* + * 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; + +/** + * 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; + 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; + } + + /** + * 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) { + FastEntry fastEntry = this.fastEntry; + Object handler; + if (fastEntry.key == key) { + handler = fastEntry.value; + } else { + handler = multipleHandlers.get(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); + } + + private record FastEntry(HandlerKey key, H value) { + } + + @SuppressWarnings("unused") + public static final class HandlerKey { + } +} 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..fa0a3cd7527 --- /dev/null +++ b/router/src/main/java/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilder.java @@ -0,0 +1,259 @@ +/* + * 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.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 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 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() { + } + + /** + * 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 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; + } + + 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/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy new file mode 100644 index 00000000000..f714a34d691 --- /dev/null +++ b/router/src/test/groovy/io/micronaut/web/router/shortcircuit/ShortCircuitRouterBuilderSpec.groovy @@ -0,0 +1,119 @@ +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.web.router.DefaultRouter +import io.micronaut.web.router.UriRouteInfo +import spock.lang.Specification + +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(DefaultRouter).collectRoutes(scb) + def plan = scb.plan() + + when: + def leaf = ctx.getBean(DefaultRouter).findPreparedMatchResult(request) + def actualName + if (leaf != null) { + actualName = leaf.routeInfo.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 = HttpRequest.GET(path) + for (Map.Entry entry : headers.entrySet()) { + request.getHeaders().add(entry.key, entry.value) + } + return request + } + + private static HttpRequest post(String path, Map headers = [:]) { + def request = HttpRequest.POST(path, null) + for (Map.Entry entry : headers.entrySet()) { + request.getHeaders().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() { + } + } +}