From b443b745fb21203d127caf88426547141e3ab724 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Sun, 19 Feb 2017 20:28:30 +0000 Subject: [PATCH] Make static resource handling consistent across embedded containers Previously, there were a number of inconsistencies in the embedded containers' handling of static resources. The Servlet spec requires that static resources can be served from the META-INF/resources/ directory of jars nested inside a war in WEB-INF/lib/. The intention was also to extend this to cover jar packaging when jars are nested in BOOT-INF/lib/. This worked when using Tomcat as long as Jasper was on the classpath. If you didn't have Jasper on the classpath or you were using Jetty or Undertow it did not work. This commit updates the configuration of embedded Jetty, Tomcat, and Undertow so that all three containers handle static resources in the same way, serving them from jars in WEB-INF/lib/ or /BOOT-INF/lib/. Numerous intergration tests have been added to verify the behaviour, including tests for Tomcat 8.0 and 7.0 which is supported in addition to the default 8.5.x. Note that static resource handling only works with Jetty 9.3.x and 9.2 and earlier does not support nested jars ( see https://github.com/eclipse/jetty.project/issues/518 for details). Closes gh-8299 --- spring-boot-integration-tests/pom.xml | 1 + .../pom.xml | 66 ++++++++ .../example/ResourceHandlingApplication.java | 72 ++++++++ .../embedded/AbstractApplicationLauncher.java | 96 +++++++++++ ...eddedServletContainerIntegrationTests.java | 110 ++++++++++++ .../context/embedded/ApplicationBuilder.java | 159 ++++++++++++++++++ ...ContainerJarPackagingIntegrationTests.java | 85 ++++++++++ ...ContainerWarPackagingIntegrationTests.java | 85 ++++++++++ .../embedded/ExplodedApplicationLauncher.java | 78 +++++++++ .../embedded/PackagedApplicationLauncher.java | 40 +++++ .../boot/context/embedded/Versions.java | 56 ++++++ .../src/test/resources/pom-template.xml | 68 ++++++++ ...stractEmbeddedServletContainerFactory.java | 29 ++++ .../JettyEmbeddedServletContainerFactory.java | 24 ++- ...TomcatEmbeddedServletContainerFactory.java | 16 +- .../embedded/tomcat/TomcatResources.java | 36 ++-- .../undertow/CompositeResourceManager.java | 74 ++++++++ ...dertowEmbeddedServletContainerFactory.java | 59 ++++++- 18 files changed, 1112 insertions(+), 42 deletions(-) create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/pom.xml create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/com/example/ResourceHandlingApplication.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerIntegrationTests.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ApplicationBuilder.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerJarPackagingIntegrationTests.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerWarPackagingIntegrationTests.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/PackagedApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/Versions.java create mode 100644 spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/resources/pom-template.xml create mode 100644 spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/CompositeResourceManager.java diff --git a/spring-boot-integration-tests/pom.xml b/spring-boot-integration-tests/pom.xml index 7a8f2e0971..d1a61d0da4 100644 --- a/spring-boot-integration-tests/pom.xml +++ b/spring-boot-integration-tests/pom.xml @@ -22,6 +22,7 @@ spring-boot-devtools-tests + spring-boot-integration-tests-embedded-servlet-container spring-boot-gradle-tests spring-boot-launch-script-tests spring-boot-security-tests diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/pom.xml b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/pom.xml new file mode 100644 index 0000000000..ed91b23347 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-integration-tests + 1.4.5.BUILD-SNAPSHOT + + spring-boot-integration-tests-embedded-servlet-container + jar + Spring Boot Embedded Servlet Container Integration Tests + http://projects.spring.io/spring-boot/ + + Pivotal Software, Inc. + http://www.spring.io + + + ${basedir}/../.. + + + + org.springframework.boot + spring-boot-starter-logging + test + + + org.springframework.boot + spring-boot-starter-test + test + + + com.samskivert + jmustache + test + + + javax.servlet + javax.servlet-api + test + + + org.apache.maven.shared + maven-invoker + 3.0.0 + test + + + org.springframework + spring-web + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${maven.home} + + + + + + diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/com/example/ResourceHandlingApplication.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/com/example/ResourceHandlingApplication.java new file mode 100644 index 0000000000..a09270a664 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/com/example/ResourceHandlingApplication.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example; + +import java.io.IOException; +import java.net.URL; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.system.EmbeddedServerPortFileWriter; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * Test application for verifying an embedded container's static resource handling. + * + * @author Andy Wilkinson + */ +@SpringBootApplication +public class ResourceHandlingApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder(ResourceHandlingApplication.class) + .properties("server.port:0") + .listeners(new EmbeddedServerPortFileWriter("target/server.port")) + .run(args); + } + + @Bean + public ServletRegistrationBean resourceServletRegistration() { + ServletRegistrationBean registration = new ServletRegistrationBean( + new HttpServlet() { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + URL resource = getServletContext() + .getResource(req.getQueryString()); + if (resource == null) { + resp.sendError(404); + } + else { + resp.getWriter().println(resource); + resp.getWriter().flush(); + } + } + + }); + registration.addUrlMappings("/servletContext"); + return registration; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractApplicationLauncher.java new file mode 100644 index 0000000000..bd16fdf01b --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractApplicationLauncher.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.File; +import java.io.FileReader; +import java.lang.ProcessBuilder.Redirect; +import java.util.ArrayList; +import java.util.List; + +import org.junit.rules.ExternalResource; + +import org.springframework.util.FileCopyUtils; + +/** + * Base {@link ExternalResource} for launching a Spring Boot application as part of a + * JUnit test. + * + * @author Andy Wilkinson + */ +abstract class AbstractApplicationLauncher extends ExternalResource { + + private final File serverPortFile = new File("target/server.port"); + + private final ApplicationBuilder applicationBuilder; + + private Process process; + + private int httpPort; + + protected AbstractApplicationLauncher(ApplicationBuilder applicationBuilder) { + this.applicationBuilder = applicationBuilder; + } + + @Override + protected final void before() throws Throwable { + this.process = startApplication(); + } + + @Override + protected final void after() { + this.process.destroy(); + } + + public final int getHttpPort() { + return this.httpPort; + } + + protected abstract List getArguments(File archive); + + private Process startApplication() throws Exception { + this.serverPortFile.delete(); + File archive = this.applicationBuilder.buildApplication(); + List arguments = new ArrayList(); + arguments.add(System.getProperty("java.home") + "/bin/java"); + arguments.addAll(getArguments(archive)); + ProcessBuilder processBuilder = new ProcessBuilder( + arguments.toArray(new String[arguments.size()])); + processBuilder.redirectOutput(Redirect.INHERIT); + processBuilder.redirectError(Redirect.INHERIT); + Process process = processBuilder.start(); + this.httpPort = awaitServerPort(process); + return process; + } + + private int awaitServerPort(Process process) throws Exception { + long end = System.currentTimeMillis() + 30000; + while (this.serverPortFile.length() == 0) { + if (System.currentTimeMillis() > end) { + throw new IllegalStateException( + "server.port file was not written within 30 seconds"); + } + if (!process.isAlive()) { + throw new IllegalStateException("Application failed to launch"); + } + Thread.sleep(100); + } + return Integer.parseInt( + FileCopyUtils.copyToString(new FileReader(this.serverPortFile))); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerIntegrationTests.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerIntegrationTests.java new file mode 100644 index 0000000000..02822f5cc4 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerIntegrationTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.codehaus.plexus.util.StringUtils; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriTemplateHandler; + +/** + * Base class for embedded servlet container integration tests. + * + * @author Andy Wilkinson + */ +public abstract class AbstractEmbeddedServletContainerIntegrationTests { + + @ClassRule + public static final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public final AbstractApplicationLauncher launcher; + + protected final RestTemplate rest = new RestTemplate(); + + public static Object[] parameters(String packaging) { + List parameters = new ArrayList(); + parameters.addAll(createParameters(packaging, "jetty", "current")); + parameters.addAll( + createParameters(packaging, "tomcat", "current", "8.0.41", "7.0.75")); + parameters.addAll(createParameters(packaging, "undertow", "current")); + return parameters.toArray(new Object[parameters.size()]); + } + + private static List createParameters(String packaging, String container, + String... versions) { + List parameters = new ArrayList(); + for (String version : versions) { + ApplicationBuilder applicationBuilder = new ApplicationBuilder( + temporaryFolder, packaging, container, version); + parameters.add(new Object[] { + StringUtils.capitalise(container) + " " + version + " packaged " + + packaging, + new PackagedApplicationLauncher(applicationBuilder) }); + parameters.add(new Object[] { + StringUtils.capitalise(container) + " " + version + " exploded " + + packaging, + new ExplodedApplicationLauncher(applicationBuilder) }); + } + return parameters; + } + + protected AbstractEmbeddedServletContainerIntegrationTests(String name, + AbstractApplicationLauncher launcher) { + this.launcher = launcher; + this.rest.setErrorHandler(new ResponseErrorHandler() { + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return false; + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + + } + + }); + this.rest.setUriTemplateHandler(new UriTemplateHandler() { + + @Override + public URI expand(String uriTemplate, Object... uriVariables) { + return URI.create( + "http://localhost:" + launcher.getHttpPort() + uriTemplate); + } + + @Override + public URI expand(String uriTemplate, Map uriVariables) { + return URI.create( + "http://localhost:" + launcher.getHttpPort() + uriTemplate); + } + + }); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ApplicationBuilder.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ApplicationBuilder.java new file mode 100644 index 0000000000..5d14ae2923 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ApplicationBuilder.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import com.samskivert.mustache.Mustache; +import org.apache.maven.shared.invoker.DefaultInvocationRequest; +import org.apache.maven.shared.invoker.DefaultInvoker; +import org.apache.maven.shared.invoker.InvocationRequest; +import org.apache.maven.shared.invoker.InvocationResult; +import org.apache.maven.shared.invoker.MavenInvocationException; +import org.junit.rules.TemporaryFolder; + +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Builds a Spring Boot application using Maven. To use this class, the {@code maven.home} + * system property must be set. + * + * @author Andy Wilkinson + */ +class ApplicationBuilder { + + private final TemporaryFolder temp; + + private final String packaging; + + private final String container; + + private final String containerVersion; + + ApplicationBuilder(TemporaryFolder temp, String packaging, String container, + String containerVersion) { + this.temp = temp; + this.packaging = packaging; + this.container = container; + this.containerVersion = containerVersion; + } + + File buildApplication() throws Exception { + File containerFolder = new File(this.temp.getRoot(), + this.container + "-" + this.containerVersion); + if (containerFolder.exists()) { + return new File(containerFolder, "app/target/app-0.0.1." + this.packaging); + } + return doBuildApplication(containerFolder); + } + + private File doBuildApplication(File containerFolder) + throws IOException, FileNotFoundException, MavenInvocationException { + File resourcesJar = createResourcesJar(); + File appFolder = new File(containerFolder, "app"); + appFolder.mkdirs(); + writePom(appFolder, resourcesJar); + copyApplicationSource(appFolder); + packageApplication(appFolder); + return new File(appFolder, "target/app-0.0.1." + this.packaging); + } + + private File createResourcesJar() throws IOException, FileNotFoundException { + File resourcesJar = new File(this.temp.getRoot(), "resources.jar"); + if (resourcesJar.exists()) { + return resourcesJar; + } + JarOutputStream resourcesJarStream = new JarOutputStream( + new FileOutputStream(resourcesJar)); + resourcesJarStream.putNextEntry(new ZipEntry("META-INF/resources/")); + resourcesJarStream.closeEntry(); + resourcesJarStream.putNextEntry( + new ZipEntry("META-INF/resources/nested-meta-inf-resource.txt")); + resourcesJarStream.write("nested".getBytes()); + resourcesJarStream.closeEntry(); + resourcesJarStream.close(); + return resourcesJar; + } + + private void writePom(File appFolder, File resourcesJar) + throws FileNotFoundException, IOException { + Map context = new HashMap(); + context.put("packaging", this.packaging); + context.put("container", this.container); + context.put("bootVersion", Versions.getBootVersion()); + context.put("resourcesJarPath", resourcesJar.getAbsolutePath()); + context.put("containerVersion", + "current".equals(this.containerVersion) ? "" + : String.format("<%s.version>%s", this.container, + this.containerVersion, this.container)); + context.put("additionalDependencies", getAdditionalDependencies()); + FileWriter out = new FileWriter(new File(appFolder, "pom.xml")); + Mustache.compiler().escapeHTML(false) + .compile(new FileReader("src/test/resources/pom-template.xml")) + .execute(context, out); + out.close(); + } + + private List> getAdditionalDependencies() { + List> additionalDependencies = new ArrayList>(); + if ("tomcat".equals(this.container) && !"current".equals(this.containerVersion)) { + Map juli = new HashMap(); + juli.put("groupId", "org.apache.tomcat"); + juli.put("artifactId", "tomcat-juli"); + juli.put("version", "${tomcat.version}"); + additionalDependencies.add(juli); + } + return additionalDependencies; + } + + private void copyApplicationSource(File appFolder) throws IOException { + File examplePackage = new File(appFolder, "src/main/java/com/example"); + examplePackage.mkdirs(); + FileCopyUtils.copy( + new File("src/test/java/com/example/ResourceHandlingApplication.java"), + new File(examplePackage, "ResourceHandlingApplication.java")); + if ("war".equals(this.packaging)) { + File srcMainWebapp = new File(appFolder, "src/main/webapp"); + srcMainWebapp.mkdirs(); + FileCopyUtils.copy("webapp resource", + new FileWriter(new File(srcMainWebapp, "webapp-resource.txt"))); + } + } + + private void packageApplication(File appFolder) throws MavenInvocationException { + InvocationRequest invocation = new DefaultInvocationRequest(); + invocation.setBaseDirectory(appFolder); + invocation.setGoals(Collections.singletonList("package")); + InvocationResult execute = new DefaultInvoker().execute(invocation); + assertThat(execute.getExitCode()).isEqualTo(0); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerJarPackagingIntegrationTests.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerJarPackagingIntegrationTests.java new file mode 100644 index 0000000000..fd1a7cc466 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerJarPackagingIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Spring Boot's embedded servlet container support using jar + * packaging. + * + * @author Andy Wilkinson + */ +@RunWith(Parameterized.class) +public class EmbeddedServletContainerJarPackagingIntegrationTests + extends AbstractEmbeddedServletContainerIntegrationTests { + + @Parameters(name = "{0}") + public static Object[] parameters() { + return AbstractEmbeddedServletContainerIntegrationTests.parameters("jar"); + } + + public EmbeddedServletContainerJarPackagingIntegrationTests(String name, + AbstractApplicationLauncher launcher) { + super(name, launcher); + } + + @Test + public void nestedMetaInfResourceIsAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/nested-meta-inf-resource.txt", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void nestedMetaInfResourceIsAvailableViaServletContext() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/nested-meta-inf-resource.txt", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void nestedJarIsNotAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/BOOT-INF/lib/resources-1.0.jar", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void applicationClassesAreNotAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest.getForEntity( + "/BOOT-INF/classes/com/example/ResourceHandlingApplication.class", + String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void launcherIsNotAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest.getForEntity( + "/org/springframework/boot/loader/Launcher.class", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerWarPackagingIntegrationTests.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerWarPackagingIntegrationTests.java new file mode 100644 index 0000000000..131319bf08 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/EmbeddedServletContainerWarPackagingIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Spring Boot's embedded servlet container support using war + * packaging. + * + * @author Andy Wilkinson + */ +@RunWith(Parameterized.class) +public class EmbeddedServletContainerWarPackagingIntegrationTests + extends AbstractEmbeddedServletContainerIntegrationTests { + + @Parameters(name = "{0}") + public static Object[] parameters() { + return AbstractEmbeddedServletContainerIntegrationTests.parameters("war"); + } + + public EmbeddedServletContainerWarPackagingIntegrationTests(String name, + AbstractApplicationLauncher launcher) { + super(name, launcher); + } + + @Test + public void nestedMetaInfResourceIsAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/nested-meta-inf-resource.txt", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void nestedMetaInfResourceIsAvailableViaServletContext() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/nested-meta-inf-resource.txt", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void nestedJarIsNotAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest + .getForEntity("/WEB-INF/lib/resources-1.0.jar", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void applicationClassesAreNotAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest.getForEntity( + "/WEB-INF/classes/com/example/ResourceHandlingApplication.class", + String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void webappResourcesAreAvailableViaHttp() throws Exception { + ResponseEntity entity = this.rest.getForEntity("/webapp-resource.txt", + String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java new file mode 100644 index 0000000000..021c219291 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.springframework.util.FileSystemUtils; +import org.springframework.util.StreamUtils; + +/** + * {@link AbstractApplicationLauncher} that launches an exploded Spring Boot application + * using Spring Boot's Jar or War launcher. + * + * @author Andy Wilkinson + */ +class ExplodedApplicationLauncher extends AbstractApplicationLauncher { + + private final File exploded = new File("target/exploded"); + + ExplodedApplicationLauncher(ApplicationBuilder applicationBuilder) { + super(applicationBuilder); + } + + @Override + protected List getArguments(File archive) { + String mainClass = archive.getName().endsWith(".war") + ? "org.springframework.boot.loader.WarLauncher" + : "org.springframework.boot.loader.JarLauncher"; + try { + explodeArchive(archive); + return Arrays.asList("-cp", this.exploded.getAbsolutePath(), mainClass); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + private void explodeArchive(File archive) throws IOException { + FileSystemUtils.deleteRecursively(this.exploded); + JarFile jarFile = new JarFile(archive); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry jarEntry = entries.nextElement(); + File extracted = new File(this.exploded, jarEntry.getName()); + if (jarEntry.isDirectory()) { + extracted.mkdirs(); + } + else { + FileOutputStream extractedOutputStream = new FileOutputStream(extracted); + StreamUtils.copy(jarFile.getInputStream(jarEntry), extractedOutputStream); + extractedOutputStream.close(); + } + } + jarFile.close(); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/PackagedApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/PackagedApplicationLauncher.java new file mode 100644 index 0000000000..0c352ec733 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/PackagedApplicationLauncher.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +/** + * {@link AbstractApplicationLauncher} that launches a packaged Spring Boot application + * using {@code java -jar}. + * + * @author Andy Wilkinson + */ +class PackagedApplicationLauncher extends AbstractApplicationLauncher { + + PackagedApplicationLauncher(ApplicationBuilder applicationBuilder) { + super(applicationBuilder); + } + + @Override + protected List getArguments(File archive) { + return Arrays.asList("-jar", archive.getAbsolutePath()); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/Versions.java b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/Versions.java new file mode 100644 index 0000000000..2f1c05e8a8 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/java/org/springframework/boot/context/embedded/Versions.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded; + +import java.io.FileReader; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathFactory; + +import org.xml.sax.InputSource; + +/** + * Provides access to dependency versions by querying the project's pom. + * + * @author Andy Wilkinson + */ +final class Versions { + + private Versions() { + } + + public static String getBootVersion() { + return evaluateExpression( + "/*[local-name()='project']/*[local-name()='parent']/*[local-name()='version']" + + "/text()"); + } + + private static String evaluateExpression(String expression) { + try { + XPathFactory xPathFactory = XPathFactory.newInstance(); + XPath xpath = xPathFactory.newXPath(); + XPathExpression expr = xpath.compile(expression); + String version = expr.evaluate(new InputSource(new FileReader("pom.xml"))); + return version; + } + catch (Exception ex) { + throw new IllegalStateException("Failed to evaluate expression", ex); + } + } + +} diff --git a/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/resources/pom-template.xml b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/resources/pom-template.xml new file mode 100644 index 0000000000..82cb5905df --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-integration-tests-embedded-servlet-container/src/test/resources/pom-template.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + {{bootVersion}} + + + + com.example + app + 0.0.1 + {{packaging}} + + + {{resourcesJarPath}} + {{containerVersion}} + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-{{container}} + + + org.springframework + spring-web + + + javax.servlet + javax.servlet-api + provided + + + com.example + resources + 1.0 + system + ${resourcesJarPath} + + {{#additionalDependencies}} + + {{groupId}} + {{artifactId}} + {{version}} + + {{/additionalDependencies}} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerFactory.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerFactory.java index b2c14c9be6..9a668b7491 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerFactory.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/AbstractEmbeddedServletContainerFactory.java @@ -20,9 +20,13 @@ import java.io.File; import java.io.IOException; import java.net.JarURLConnection; import java.net.URL; +import java.net.URLClassLoader; import java.net.URLConnection; import java.security.CodeSource; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.jar.JarFile; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -86,6 +90,31 @@ public abstract class AbstractEmbeddedServletContainerFactory return getExplodedWarFileDocumentRoot(getCodeSourceArchive()); } + protected List getUrlsOfJarsWithMetaInfResources() { + ClassLoader classLoader = getClass().getClassLoader(); + List staticResourceUrls = new ArrayList(); + if (classLoader instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) classLoader).getURLs()) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection) { + JarURLConnection jarConnection = (JarURLConnection) connection; + JarFile jar = jarConnection.getJarFile(); + if (jar.getName().endsWith(".jar") + && jar.getJarEntry("META-INF/resources") != null) { + staticResourceUrls.add(url); + } + jar.close(); + } + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + } + return staticResourceUrls; + } + File getExplodedWarFileDocumentRoot(File codeSourceFile) { if (this.logger.isDebugEnabled()) { this.logger.debug("Code archive: " + codeSourceFile); diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/jetty/JettyEmbeddedServletContainerFactory.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/jetty/JettyEmbeddedServletContainerFactory.java index 43c9a116ed..243132fb84 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/embedded/jetty/JettyEmbeddedServletContainerFactory.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/jetty/JettyEmbeddedServletContainerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,6 +58,7 @@ import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlet.ServletMapping; import org.eclipse.jetty.util.resource.JarResource; import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceCollection; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.ThreadPool; import org.eclipse.jetty.webapp.AbstractConfiguration; @@ -404,14 +405,21 @@ public class JettyEmbeddedServletContainerFactory File root = getValidDocumentRoot(); root = (root != null ? root : createTempDir("jetty-docbase")); try { - if (!root.isDirectory()) { - Resource resource = JarResource - .newJarResource(Resource.newResource(root)); - handler.setBaseResource(resource); - } - else { - handler.setBaseResource(Resource.newResource(root.getCanonicalFile())); + List resources = new ArrayList(); + resources.add( + root.isDirectory() ? Resource.newResource(root.getCanonicalFile()) + : JarResource.newJarResource(Resource.newResource(root))); + for (URL resourceJarUrl : this.getUrlsOfJarsWithMetaInfResources()) { + Resource resource = Resource + .newResource(resourceJarUrl + "META-INF/resources"); + // Jetty 9.2 and earlier do not support nested jars. See + // https://github.com/eclipse/jetty.project/issues/518 + if (resource.exists() && resource.isDirectory()) { + resources.add(resource); + } } + handler.setBaseResource(new ResourceCollection( + resources.toArray(new Resource[resources.size()]))); } catch (Exception ex) { throw new IllegalStateException(ex); diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatEmbeddedServletContainerFactory.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatEmbeddedServletContainerFactory.java index 5761f314a2..347aa7c24e 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatEmbeddedServletContainerFactory.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatEmbeddedServletContainerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -187,7 +187,7 @@ public class TomcatEmbeddedServletContainerFactory protected void prepareContext(Host host, ServletContextInitializer[] initializers) { File docBase = getValidDocumentRoot(); docBase = (docBase != null ? docBase : createTempDir("tomcat-docbase")); - TomcatEmbeddedContext context = new TomcatEmbeddedContext(); + final TomcatEmbeddedContext context = new TomcatEmbeddedContext(); context.setName(getContextPath()); context.setDisplayName(getDisplayName()); context.setPath(getContextPath()); @@ -217,6 +217,17 @@ public class TomcatEmbeddedServletContainerFactory addJasperInitializer(context); context.addLifecycleListener(new StoreMergedWebXmlListener()); } + context.addLifecycleListener(new LifecycleListener() { + + @Override + public void lifecycleEvent(LifecycleEvent event) { + if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) { + TomcatResources.get(context) + .addResourceJars(getUrlsOfJarsWithMetaInfResources()); + } + } + + }); ServletContextInitializer[] initializersToUse = mergeInitializers(initializers); configureContext(context, initializersToUse); host.addChild(context); @@ -802,7 +813,6 @@ public class TomcatEmbeddedServletContainerFactory if (servletContext.getAttribute(MERGED_WEB_XML) == null) { servletContext.setAttribute(MERGED_WEB_XML, getEmptyWebXml()); } - TomcatResources.get(context).addClasspathResources(); } private String getEmptyWebXml() { diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatResources.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatResources.java index a7edb2d64d..84b9ccbac5 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatResources.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/tomcat/TomcatResources.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2014 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,11 +16,10 @@ package org.springframework.boot.context.embedded.tomcat; -import java.io.File; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; -import java.net.URLClassLoader; +import java.util.List; import javax.naming.directory.DirContext; import javax.servlet.ServletContext; @@ -37,6 +36,7 @@ import org.springframework.util.ReflectionUtils; * * @author Dave Syer * @author Phillip Webb + * @author Andy Wilkinson */ abstract class TomcatResources { @@ -46,28 +46,16 @@ abstract class TomcatResources { this.context = context; } - /** - * Add resources from the classpath. - */ - public void addClasspathResources() { - ClassLoader loader = getClass().getClassLoader(); - if (loader instanceof URLClassLoader) { - for (URL url : ((URLClassLoader) loader).getURLs()) { - String file = url.getFile(); - if (file.endsWith(".jar") || file.endsWith(".jar!/")) { - String jar = url.toString(); - if (!jar.startsWith("jar:")) { - // A jar file in the file system. Convert to Jar URL. - jar = "jar:" + jar + "!/"; - } - addJar(jar); - } - else if (url.toString().startsWith("file:")) { - String dir = url.toString().substring("file:".length()); - if (new File(dir).isDirectory()) { - addDir(dir, url); - } + void addResourceJars(List resourceJarUrls) { + for (URL url : resourceJarUrls) { + String file = url.getFile(); + if (file.endsWith(".jar") || file.endsWith(".jar!/")) { + String jar = url.toString(); + if (!jar.startsWith("jar:")) { + // A jar file in the file system. Convert to Jar URL. + jar = "jar:" + jar + "!/"; } + addJar(jar); } } } diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/CompositeResourceManager.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/CompositeResourceManager.java new file mode 100644 index 0000000000..661f78f65a --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/CompositeResourceManager.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.embedded.undertow; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import io.undertow.UndertowMessages; +import io.undertow.server.handlers.resource.Resource; +import io.undertow.server.handlers.resource.ResourceChangeListener; +import io.undertow.server.handlers.resource.ResourceManager; + +/** + * A {@ResourceManager} that delegates to multiple {@code ResourceManager} instances. + * + * @author Andy Wilkinson + */ +class CompositeResourceManager implements ResourceManager { + + private final List resourceManagers; + + CompositeResourceManager(ResourceManager... resourceManagers) { + this.resourceManagers = Arrays.asList(resourceManagers); + } + + @Override + public void close() throws IOException { + for (ResourceManager resourceManager : this.resourceManagers) { + resourceManager.close(); + } + } + + @Override + public Resource getResource(String path) throws IOException { + for (ResourceManager resourceManager : this.resourceManagers) { + Resource resource = resourceManager.getResource(path); + if (resource != null) { + return resource; + } + } + return null; + } + + @Override + public boolean isResourceChangeListenerSupported() { + return false; + } + + @Override + public void registerResourceChangeListener(ResourceChangeListener listener) { + throw UndertowMessages.MESSAGES.resourceChangeListenerNotSupported(); + } + + @Override + public void removeResourceChangeListener(ResourceChangeListener listener) { + throw UndertowMessages.MESSAGES.resourceChangeListenerNotSupported(); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/UndertowEmbeddedServletContainerFactory.java b/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/UndertowEmbeddedServletContainerFactory.java index c264f996be..75a5f3219d 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/UndertowEmbeddedServletContainerFactory.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/embedded/undertow/UndertowEmbeddedServletContainerFactory.java @@ -19,6 +19,7 @@ package org.springframework.boot.context.embedded.undertow; import java.io.File; import java.io.IOException; import java.net.URL; +import java.net.URLConnection; import java.nio.charset.Charset; import java.security.KeyManagementException; import java.security.KeyStore; @@ -49,7 +50,10 @@ import io.undertow.server.handlers.accesslog.AccessLogHandler; import io.undertow.server.handlers.accesslog.AccessLogReceiver; import io.undertow.server.handlers.accesslog.DefaultAccessLogReceiver; import io.undertow.server.handlers.resource.FileResourceManager; +import io.undertow.server.handlers.resource.Resource; +import io.undertow.server.handlers.resource.ResourceChangeListener; import io.undertow.server.handlers.resource.ResourceManager; +import io.undertow.server.handlers.resource.URLResource; import io.undertow.server.session.SessionManager; import io.undertow.servlet.Servlets; import io.undertow.servlet.api.DeploymentInfo; @@ -464,13 +468,11 @@ public class UndertowEmbeddedServletContainerFactory private ResourceManager getDocumentRootResourceManager() { File root = getCanonicalDocumentRoot(); - if (root.isDirectory()) { - return new FileResourceManager(root, 0); - } - if (root.isFile()) { - return new JarResourceManager(root); - } - return ResourceManager.EMPTY_RESOURCE_MANAGER; + List metaInfResourceJarUrls = getUrlsOfJarsWithMetaInfResources(); + ResourceManager rootResourceManager = root.isDirectory() + ? new FileResourceManager(root, 0) : new JarResourceManager(root); + return new CompositeResourceManager(rootResourceManager, + new MetaInfResourcesResourceManager(metaInfResourceJarUrls)); } /** @@ -597,6 +599,49 @@ public class UndertowEmbeddedServletContainerFactory this.useForwardHeaders = useForwardHeaders; } + /** + * {@link ResourceManager} that exposes resource in {@code META-INF/resources} + * directory of nested (in {@code BOOT-INF/lib} or {@code WEB-INF/lib}) jars. + */ + private static final class MetaInfResourcesResourceManager + implements ResourceManager { + + private final List metaInfResourceJarUrls; + + private MetaInfResourcesResourceManager(List metaInfResourceJarUrls) { + this.metaInfResourceJarUrls = metaInfResourceJarUrls; + } + + @Override + public void close() throws IOException { + } + + @Override + public Resource getResource(String path) throws IOException { + for (URL url : this.metaInfResourceJarUrls) { + URL resourceUrl = new URL(url + "META-INF/resources" + path); + URLConnection connection = resourceUrl.openConnection(); + if (connection.getContentLength() >= 0) { + return new URLResource(resourceUrl, connection, path); + } + } + return null; + } + + @Override + public boolean isResourceChangeListenerSupported() { + return false; + } + + @Override + public void registerResourceChangeListener(ResourceChangeListener listener) { + } + + @Override + public void removeResourceChangeListener(ResourceChangeListener listener) { + } + } + /** * {@link ServletContainerInitializer} to initialize {@link ServletContextInitializer * ServletContextInitializers}.