diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/pathmap/PathMappings.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/pathmap/PathMappings.java index 8664fbf3896c..bd339006fc36 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/pathmap/PathMappings.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/pathmap/PathMappings.java @@ -21,6 +21,7 @@ import java.util.Set; import java.util.TreeSet; import java.util.function.Predicate; +import java.util.stream.Stream; import org.eclipse.jetty.util.Index; import org.eclipse.jetty.util.annotation.ManagedAttribute; @@ -88,6 +89,11 @@ public void reset() _suffixMap.clear(); } + public Stream> streamResources() + { + return _mappings.stream(); + } + public void removeIf(Predicate> predicate) { _mappings.removeIf(predicate); diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/PathMappingsHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/PathMappingsHandler.java new file mode 100644 index 000000000000..3dffc3e3acd3 --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/PathMappingsHandler.java @@ -0,0 +1,103 @@ +// +// ======================================================================== +// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server.handler; + +import java.io.IOException; +import java.util.List; +import java.util.function.Supplier; + +import org.eclipse.jetty.http.pathmap.MappedResource; +import org.eclipse.jetty.http.pathmap.MatchedResource; +import org.eclipse.jetty.http.pathmap.PathMappings; +import org.eclipse.jetty.http.pathmap.PathSpec; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.util.component.Dumpable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Handler that delegates to other handlers through a configured {@link PathMappings}. + */ + +public class PathMappingsHandler extends Handler.AbstractContainer +{ + private static final Logger LOG = LoggerFactory.getLogger(PathMappingsHandler.class); + + private final PathMappings mappings = new PathMappings<>(); + + @Override + public void addHandler(Handler handler) + { + throw new UnsupportedOperationException("Arbitrary addHandler() not supported, use addMapping() instead"); + } + + @Override + public void addHandler(Supplier supplier) + { + throw new UnsupportedOperationException("Arbitrary addHandler() not supported, use addMapping() instead"); + } + + @Override + public List getHandlers() + { + return mappings.streamResources().map(MappedResource::getResource).toList(); + } + + public void addMapping(PathSpec pathSpec, Handler handler) + { + if (isStarted()) + throw new IllegalStateException("Cannot add mapping: " + this); + + // check that self isn't present + if (handler == this || handler instanceof Handler.Container container && container.getDescendants().contains(this)) + throw new IllegalStateException("Unable to addHandler of self: " + handler); + + // check existing mappings + for (MappedResource entry : mappings) + { + Handler entryHandler = entry.getResource(); + + if (entryHandler == this || + entryHandler == handler || + (entryHandler instanceof Handler.Container container && container.getDescendants().contains(this))) + throw new IllegalStateException("addMapping loop detected: " + handler); + } + + mappings.put(pathSpec, handler); + addBean(handler); + } + + @Override + public void dump(Appendable out, String indent) throws IOException + { + Dumpable.dumpObjects(out, indent, this, mappings); + } + + @Override + public Request.Processor handle(Request request) throws Exception + { + String pathInContext = request.getPathInContext(); + MatchedResource matchedResource = mappings.getMatched(pathInContext); + if (matchedResource == null) + { + if (LOG.isDebugEnabled()) + LOG.debug("No match on pathInContext of {}", pathInContext); + return null; + } + if (LOG.isDebugEnabled()) + LOG.debug("Matched pathInContext of {} to {} -> {}", pathInContext, matchedResource.getPathSpec(), matchedResource.getResource()); + return matchedResource.getResource().handle(request); + } +} diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerTest.java index 733a6bb0433b..e742c4ba9d79 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerTest.java @@ -60,6 +60,7 @@ import static org.hamcrest.Matchers.sameInstance; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ContextHandlerTest @@ -586,6 +587,31 @@ public void process(Request request, Response response, Callback callback) assertThat(result.get(), equalTo("OK")); } + @Test + public void testSetHandlerLoopSelf() + { + ContextHandler contextHandlerA = new ContextHandler(); + assertThrows(IllegalStateException.class, () -> contextHandlerA.setHandler(contextHandlerA)); + } + + @Test + public void testSetHandlerLoopDeepWrapper() + { + ContextHandler contextHandlerA = new ContextHandler(); + Handler.Wrapper handlerWrapper = new Handler.Wrapper(); + contextHandlerA.setHandler(handlerWrapper); + assertThrows(IllegalStateException.class, () -> handlerWrapper.setHandler(contextHandlerA)); + } + + @Test + public void testAddHandlerLoopDeep() + { + ContextHandler contextHandlerA = new ContextHandler(); + Handler.Collection handlerCollection = new Handler.Collection(); + contextHandlerA.setHandler(handlerCollection); + assertThrows(IllegalStateException.class, () -> handlerCollection.addHandler(contextHandlerA)); + } + private static class ScopeListener implements ContextHandler.ContextScopeListener { private static final Request NULL = new Request.Wrapper(null); diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/PathMappingsHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/PathMappingsHandlerTest.java new file mode 100644 index 000000000000..95af61ccc32d --- /dev/null +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/PathMappingsHandlerTest.java @@ -0,0 +1,283 @@ +// +// ======================================================================== +// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server.handler; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.http.pathmap.ServletPathSpec; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PathMappingsHandlerTest +{ + private Server server; + private LocalConnector connector; + + public void startServer(Handler handler) throws Exception + { + server = new Server(); + connector = new LocalConnector(server); + server.addConnector(connector); + + server.addHandler(handler); + server.start(); + } + + @AfterEach + public void stopServer() + { + LifeCycle.stop(server); + } + + public HttpTester.Response executeRequest(String rawRequest) throws Exception + { + String rawResponse = connector.getResponse(rawRequest); + return HttpTester.parseResponse(rawResponse); + } + + /** + * Test where there are no mappings, and no wrapper. + */ + @Test + public void testEmpty() throws Exception + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + contextHandler.setHandler(pathMappingsHandler); + + startServer(contextHandler); + + HttpTester.Response response = executeRequest(""" + GET / HTTP/1.1\r + Host: local\r + Connection: close\r + + """); + assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus()); + } + + /** + * Test where there is only a single mapping, and no wrapper. + */ + @Test + public void testOnlyMappingSuffix() throws Exception + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new SimpleHandler("PhpExample Hit")); + contextHandler.setHandler(pathMappingsHandler); + + startServer(contextHandler); + + HttpTester.Response response = executeRequest(""" + GET /hello HTTP/1.1\r + Host: local\r + Connection: close\r + + """); + assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus()); + + response = executeRequest(""" + GET /hello.php HTTP/1.1\r + Host: local\r + Connection: close\r + + """); + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertEquals("PhpExample Hit", response.getContent()); + } + + public static Stream severalMappingsInput() + { + return Stream.of( + Arguments.of("/hello", HttpStatus.OK_200, "FakeResourceHandler Hit"), + Arguments.of("/index.html", HttpStatus.OK_200, "FakeSpecificStaticHandler Hit"), + Arguments.of("/index.php", HttpStatus.OK_200, "PhpHandler Hit"), + Arguments.of("/config.php", HttpStatus.OK_200, "PhpHandler Hit"), + Arguments.of("/css/main.css", HttpStatus.OK_200, "FakeResourceHandler Hit") + ); + } + + /** + * Test where there are a few mappings, with a root mapping, and no wrapper. + * This means the wrapper would not ever be hit, as all inputs would match at + * least 1 mapping. + */ + @ParameterizedTest + @MethodSource("severalMappingsInput") + public void testSeveralMappingAndNoWrapper(String requestPath, int expectedStatus, String expectedResponseBody) throws Exception + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("/"), new SimpleHandler("FakeResourceHandler Hit")); + pathMappingsHandler.addMapping(new ServletPathSpec("/index.html"), new SimpleHandler("FakeSpecificStaticHandler Hit")); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new SimpleHandler("PhpHandler Hit")); + contextHandler.setHandler(pathMappingsHandler); + + startServer(contextHandler); + + HttpTester.Response response = executeRequest(""" + GET %s HTTP/1.1\r + Host: local\r + Connection: close\r + + """.formatted(requestPath)); + assertEquals(expectedStatus, response.getStatus()); + assertEquals(expectedResponseBody, response.getContent()); + } + + @Test + public void testDump() throws Exception + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("/"), new SimpleHandler("FakeResourceHandler Hit")); + pathMappingsHandler.addMapping(new ServletPathSpec("/index.html"), new SimpleHandler("FakeSpecificStaticHandler Hit")); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new SimpleHandler("PhpHandler Hit")); + contextHandler.setHandler(pathMappingsHandler); + + startServer(contextHandler); + + String dump = contextHandler.dump(); + assertThat(dump, containsString("FakeResourceHandler")); + assertThat(dump, containsString("FakeSpecificStaticHandler")); + assertThat(dump, containsString("PhpHandler")); + assertThat(dump, containsString("PathMappings[size=3]")); + + } + + @Test + public void testGetDescendantsSimple() + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("/"), new SimpleHandler("default")); + pathMappingsHandler.addMapping(new ServletPathSpec("/index.html"), new SimpleHandler("specific")); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new SimpleHandler("php")); + + List actualHandlers = pathMappingsHandler.getDescendants().stream().map(Objects::toString).toList(); + + String[] expectedHandlers = { + "SimpleHandler[msg=\"default\"]", + "SimpleHandler[msg=\"specific\"]", + "SimpleHandler[msg=\"php\"]" + }; + assertThat(actualHandlers, containsInAnyOrder(expectedHandlers)); + } + + @Test + public void testGetDescendantsDeep() + { + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + + Handler.Collection handlerCollection = new Handler.Collection(); + handlerCollection.addHandler(new SimpleHandler("phpIndex")); + Handler.Wrapper handlerWrapper = new Handler.Wrapper(new SimpleHandler("other")); + handlerCollection.addHandler(handlerWrapper); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("/"), new SimpleHandler("default")); + pathMappingsHandler.addMapping(new ServletPathSpec("/index.html"), new SimpleHandler("specific")); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), handlerCollection); + + List actualHandlers = pathMappingsHandler.getDescendants().stream().map(Objects::toString).toList(); + + String[] expectedHandlers = { + "SimpleHandler[msg=\"default\"]", + "SimpleHandler[msg=\"specific\"]", + handlerCollection.toString(), + handlerWrapper.toString(), + "SimpleHandler[msg=\"phpIndex\"]", + "SimpleHandler[msg=\"other\"]" + }; + assertThat(actualHandlers, containsInAnyOrder(expectedHandlers)); + } + + @Test + public void testAddLoopSelf() + { + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + assertThrows(IllegalStateException.class, () -> pathMappingsHandler.addMapping(new ServletPathSpec("/self"), pathMappingsHandler)); + } + + @Test + public void testAddLoopContext() + { + ContextHandler contextHandler = new ContextHandler(); + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + contextHandler.setHandler(pathMappingsHandler); + + assertThrows(IllegalStateException.class, () -> pathMappingsHandler.addMapping(new ServletPathSpec("/loop"), contextHandler)); + } + + private static class SimpleHandler extends Handler.Processor + { + private final String message; + + public SimpleHandler(String message) + { + this.message = message; + } + + @Override + public void process(Request request, Response response, Callback callback) + { + assertTrue(isStarted()); + response.setStatus(HttpStatus.OK_200); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + response.write(true, BufferUtil.toBuffer(message, StandardCharsets.UTF_8), callback); + } + + @Override + public String toString() + { + return String.format("%s[msg=\"%s\"]", SimpleHandler.class.getSimpleName(), message); + } + } +}