diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java new file mode 100644 index 00000000000..3c541413de0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.net.URL; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link SourceFolderUrlFilter} that attempts to match URLs + * using common naming conventions. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultSourceFolderUrlFilter implements SourceFolderUrlFilter { + + private static final String[] COMMON_ENDINGS = { "/target/classes", "/bin" }; + + private static final Pattern URL_MODULE_PATTERN = Pattern.compile(".*\\/(.+)\\.jar"); + + private static final Pattern VERSION_PATTERN = Pattern + .compile("^-\\d+(?:\\.\\d+)*(?:[.-].+)?$"); + + @Override + public boolean isMatch(String sourceFolder, URL url) { + String jarName = getJarName(url); + if (!StringUtils.hasLength(jarName)) { + return false; + } + return isMatch(sourceFolder, jarName); + } + + private String getJarName(URL url) { + Matcher matcher = URL_MODULE_PATTERN.matcher(url.toString()); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private boolean isMatch(String sourceFolder, String jarName) { + sourceFolder = stripTrailingSlash(sourceFolder); + sourceFolder = stripCommonEnds(sourceFolder); + String[] folders = StringUtils.delimitedListToStringArray(sourceFolder, "/"); + for (int i = folders.length - 1; i >= 0; i--) { + if (isFolderMatch(folders[i], jarName)) { + return true; + } + } + return false; + } + + private boolean isFolderMatch(String folder, String jarName) { + if (!jarName.startsWith(folder)) { + return false; + } + String version = jarName.substring(folder.length()); + return version.isEmpty() || VERSION_PATTERN.matcher(version).matches(); + } + + private String stripTrailingSlash(String string) { + if (string.endsWith("/")) { + return string.substring(0, string.length() - 1); + } + return string; + } + + private String stripCommonEnds(String string) { + for (String ending : COMMON_ENDINGS) { + if (string.endsWith(ending)) { + return string.substring(0, string.length() - ending.length()); + } + } + return string; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java new file mode 100644 index 00000000000..91af3bba847 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.io.IOException; +import java.io.ObjectInputStream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * A HTTP server that can be used to upload updated {@link ClassLoaderFiles} and trigger + * restarts. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartServer + */ +public class HttpRestartServer { + + private static final Log logger = LogFactory.getLog(HttpRestartServer.class); + + private final RestartServer server; + + /** + * Create a new {@link HttpRestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + */ + public HttpRestartServer(SourceFolderUrlFilter sourceFolderUrlFilter) { + Assert.notNull(sourceFolderUrlFilter, "SourceFolderUrlFilter must not be null"); + this.server = new RestartServer(sourceFolderUrlFilter); + } + + /** + * Create a new {@link HttpRestartServer} instance. + * @param restartServer the underlying restart server + */ + public HttpRestartServer(RestartServer restartServer) { + Assert.notNull(restartServer, "RestartServer must not be null"); + this.server = restartServer; + } + + /** + * Handle a server request. + * @param request the request + * @param response the response + * @throws IOException + */ + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + try { + Assert.state(request.getHeaders().getContentLength() > 0, "No content"); + ObjectInputStream objectInputStream = new ObjectInputStream(request.getBody()); + ClassLoaderFiles files = (ClassLoaderFiles) objectInputStream.readObject(); + objectInputStream.close(); + this.server.updateAndRestart(files); + response.setStatusCode(HttpStatus.OK); + } + catch (Exception ex) { + logger.warn("Unable to handler restart server HTTP request", ex); + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java new file mode 100644 index 00000000000..10f72fa715a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.io.IOException; + +import org.springframework.boot.developertools.remote.server.Handler; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * Adapts {@link HttpRestartServer} to a {@link Handler}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpRestartServerHandler implements Handler { + + private final HttpRestartServer server; + + /** + * Create a new {@link HttpRestartServerHandler} instance. + * @param server the server to adapt + */ + public HttpRestartServerHandler(HttpRestartServer server) { + Assert.notNull(server, "Server must not be null"); + this.server = server; + } + + @Override + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + this.server.handle(request, response); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java new file mode 100644 index 00000000000..219ada356b0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.LinkedHashSet; +import java.util.Map.Entry; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.Restarter; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles.SourceFolder; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Server used to {@link Restarter restart} the current application with updated + * {@link ClassLoaderFiles}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class RestartServer { + + private static final Log logger = LogFactory.getLog(RestartServer.class); + + private final SourceFolderUrlFilter sourceFolderUrlFilter; + + private final ClassLoader classLoader; + + /** + * Create a new {@link RestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + */ + public RestartServer(SourceFolderUrlFilter sourceFolderUrlFilter) { + this(sourceFolderUrlFilter, Thread.currentThread().getContextClassLoader()); + } + + /** + * Create a new {@link RestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + * @param classLoader the application classloader + */ + public RestartServer(SourceFolderUrlFilter sourceFolderUrlFilter, + ClassLoader classLoader) { + Assert.notNull(sourceFolderUrlFilter, "SourceFolderUrlFilter must not be null"); + Assert.notNull(classLoader, "ClassLoader must not be null"); + this.sourceFolderUrlFilter = sourceFolderUrlFilter; + this.classLoader = classLoader; + } + + /** + * Update the current running application with the specified {@link ClassLoaderFiles} + * and trigger a reload. + * @param files updated class loader files + */ + public void updateAndRestart(ClassLoaderFiles files) { + Set urls = new LinkedHashSet(); + Set classLoaderUrls = getClassLoaderUrls(); + for (SourceFolder folder : files.getSourceFolders()) { + for (Entry entry : folder.getFilesEntrySet()) { + for (URL url : classLoaderUrls) { + if (updateFileSystem(url, entry.getKey(), entry.getValue())) { + urls.add(url); + } + } + } + urls.addAll(getMatchingUrls(classLoaderUrls, folder.getName())); + } + updateTimeStamp(urls); + restart(urls, files); + + } + + private boolean updateFileSystem(URL url, String name, ClassLoaderFile classLoaderFile) { + if (!isFolderUrl(url.toString())) { + return false; + } + try { + File folder = ResourceUtils.getFile(url); + File file = new File(folder, name); + if (file.exists() && file.canWrite()) { + if (classLoaderFile.getKind() == Kind.DELETED) { + return file.delete(); + } + FileCopyUtils.copy(classLoaderFile.getContents(), file); + return true; + } + } + catch (IOException ex) { + // Ignore + } + return false; + } + + private boolean isFolderUrl(String urlString) { + return urlString.startsWith("file:") && urlString.endsWith("/"); + } + + private Set getMatchingUrls(Set urls, String sourceFolder) { + Set matchingUrls = new LinkedHashSet(); + for (URL url : urls) { + if (this.sourceFolderUrlFilter.isMatch(sourceFolder, url)) { + if (logger.isDebugEnabled()) { + logger.debug("URL " + url + " matched against source folder " + + sourceFolder); + } + matchingUrls.add(url); + } + } + return matchingUrls; + } + + private Set getClassLoaderUrls() { + Set urls = new LinkedHashSet(); + ClassLoader classLoader = this.classLoader; + while (classLoader != null) { + if (classLoader instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) classLoader).getURLs()) { + urls.add(url); + } + } + classLoader = classLoader.getParent(); + } + return urls; + + } + + private void updateTimeStamp(Iterable urls) { + for (URL url : urls) { + updateTimeStamp(url); + } + } + + private void updateTimeStamp(URL url) { + try { + URL actualUrl = ResourceUtils.extractJarFileURL(url); + File file = ResourceUtils.getFile(actualUrl, "Jar URL"); + file.setLastModified(System.currentTimeMillis()); + } + catch (Exception ex) { + // Ignore + } + } + + /** + * Called to restart the application. + * @param urls the updated URLs + * @param files the updated files + */ + protected void restart(Set urls, ClassLoaderFiles files) { + Restarter restarter = Restarter.getInstance(); + restarter.addUrls(urls); + restarter.addClassLoaderFiles(files); + restarter.restart(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java new file mode 100644 index 00000000000..72c5257ee58 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.net.URL; + +/** + * Filter URLs based on a source folder name. Used to match URLs from the running + * classpath against source folders on a remote system. + * + * @author Phillip Webb + * @since 1.3.0 + * @see DefaultSourceFolderUrlFilter + */ +public interface SourceFolderUrlFilter { + + /** + * Determine if the specified URL matches a source folder. + * @param sourceFolder the source folder + * @param url the URL to check + * @return {@code true} if the URL matches + */ + boolean isMatch(String sourceFolder, URL url); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java new file mode 100644 index 00000000000..9a2d583aa5a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 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. + */ + +/** + * Remote restart server + */ +package org.springframework.boot.developertools.restart.server; + diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilterTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilterTests.java new file mode 100644 index 00000000000..5e529f34be5 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilterTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link DefaultSourceFolderUrlFilter}. + * + * @author Phillip Webb + */ +public class DefaultSourceFolderUrlFilterTests { + + private static final String SOURCE_ROOT = "/Users/me/code/some-root/"; + + private static final List COMMON_POSTFIXES; + static { + List postfixes = new ArrayList(); + postfixes.add(".jar"); + postfixes.add("-1.3.0.jar"); + postfixes.add("-1.3.0-SNAPSHOT.jar"); + postfixes.add("-1.3.0.BUILD-SNAPSHOT.jar"); + postfixes.add("-1.3.0.M1.jar"); + postfixes.add("-1.3.0.RC1.jar"); + postfixes.add("-1.3.0.RELEASE.jar"); + postfixes.add("-1.3.0.Final.jar"); + postfixes.add("-1.3.0.GA.jar"); + postfixes.add("-1.3.0.0.0.0.jar"); + COMMON_POSTFIXES = Collections.unmodifiableList(postfixes); + } + + private DefaultSourceFolderUrlFilter filter = new DefaultSourceFolderUrlFilter(); + + @Test + public void mavenSourceFolder() throws Exception { + doTest("my-module/target/classes/"); + } + + @Test + public void gradleEclipseSourceFolder() throws Exception { + doTest("my-module/bin/"); + } + + @Test + public void unusualSourceFolder() throws Exception { + doTest("my-module/something/quite/quite/mad/"); + } + + private void doTest(String sourcePostfix) throws MalformedURLException { + doTest(sourcePostfix, "my-module", true); + doTest(sourcePostfix, "my-module-other", false); + doTest(sourcePostfix, "my-module-other-again", false); + doTest(sourcePostfix, "my-module.other", false); + } + + private void doTest(String sourcePostfix, String moduleRoot, boolean expected) + throws MalformedURLException { + String sourceFolder = SOURCE_ROOT + sourcePostfix; + for (String postfix : COMMON_POSTFIXES) { + for (URL url : getUrls(moduleRoot + postfix)) { + boolean match = this.filter.isMatch(sourceFolder, url); + assertThat(url + " against " + sourceFolder, match, equalTo(expected)); + } + } + } + + private List getUrls(String name) throws MalformedURLException { + List urls = new ArrayList(); + urls.add(new URL("file:/some/path/" + name)); + urls.add(new URL("file:/some/path/" + name + "!/")); + for (String postfix : COMMON_POSTFIXES) { + urls.add(new URL("jar:file:/some/path/lib-module" + postfix + "!/lib/" + name)); + urls.add(new URL("jar:file:/some/path/lib-module" + postfix + "!/lib/" + name + + "!/")); + } + return urls; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandlerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandlerTests.java new file mode 100644 index 00000000000..a184fdfd034 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandlerTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link HttpRestartServerHandler}. + * + * @author Phillip Webb + */ +public class HttpRestartServerHandlerTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void serverMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Server must not be null"); + new HttpRestartServerHandler(null); + } + + @Test + public void handleDelegatesToServer() throws Exception { + HttpRestartServer server = mock(HttpRestartServer.class); + HttpRestartServerHandler handler = new HttpRestartServerHandler(server); + ServerHttpRequest request = mock(ServerHttpRequest.class); + ServerHttpResponse response = mock(ServerHttpResponse.class); + handler.handle(request, response); + verify(server).handle(request, response); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerTests.java new file mode 100644 index 00000000000..651530559d3 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/HttpRestartServerTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests for {@link HttpRestartServer}. + * + * @author Phillip Webb + */ +public class HttpRestartServerTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private RestartServer delegate; + + private HttpRestartServer server; + + @Captor + private ArgumentCaptor filesCaptor; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + this.server = new HttpRestartServer(this.delegate); + } + + @Test + public void sourceFolderUrlFilterMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("SourceFolderUrlFilter must not be null"); + new HttpRestartServer((SourceFolderUrlFilter) null); + } + + @Test + public void restartServerMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("RestartServer must not be null"); + new HttpRestartServer((RestartServer) null); + } + + @Test + public void sendClassLoaderFiles() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + ClassLoaderFiles files = new ClassLoaderFiles(); + files.addFile("name", new ClassLoaderFile(Kind.ADDED, new byte[0])); + byte[] bytes = serialize(files); + request.setContent(bytes); + this.server.handle(new ServletServerHttpRequest(request), + new ServletServerHttpResponse(response)); + verify(this.delegate).updateAndRestart(this.filesCaptor.capture()); + assertThat(this.filesCaptor.getValue().getFile("name"), notNullValue()); + assertThat(response.getStatus(), equalTo(200)); + } + + @Test + public void sendNoContent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.server.handle(new ServletServerHttpRequest(request), + new ServletServerHttpResponse(response)); + verifyZeroInteractions(this.delegate); + assertThat(response.getStatus(), equalTo(500)); + + } + + @Test + public void sendBadData() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setContent(new byte[] { 0, 0, 0 }); + this.server.handle(new ServletServerHttpRequest(request), + new ServletServerHttpResponse(response)); + verifyZeroInteractions(this.delegate); + assertThat(response.getStatus(), equalTo(500)); + } + + private byte[] serialize(Object object) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(object); + oos.close(); + return bos.toByteArray(); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/RestartServerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/RestartServerTests.java new file mode 100644 index 00000000000..73fe86c5505 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/server/RestartServerTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2012-2015 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.developertools.restart.server; + +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link RestartServer}. + * + * @author Phillip Webb + */ +public class RestartServerTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void sourceFolderUrlFilterMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("SourceFolderUrlFilter must not be null"); + new RestartServer((SourceFolderUrlFilter) null); + } + + @Test + public void updateAndRestart() throws Exception { + URL url1 = new URL("file:/proj/module-a.jar!/"); + URL url2 = new URL("file:/proj/module-b.jar!/"); + URL url3 = new URL("file:/proj/module-c.jar!/"); + URL url4 = new URL("file:/proj/module-d.jar!/"); + URLClassLoader classLoaderA = new URLClassLoader(new URL[] { url1, url2 }); + URLClassLoader classLoaderB = new URLClassLoader(new URL[] { url3, url4 }, + classLoaderA); + SourceFolderUrlFilter filter = new DefaultSourceFolderUrlFilter(); + MockRestartServer server = new MockRestartServer(filter, classLoaderB); + ClassLoaderFiles files = new ClassLoaderFiles(); + ClassLoaderFile fileA = new ClassLoaderFile(Kind.ADDED, new byte[0]); + ClassLoaderFile fileB = new ClassLoaderFile(Kind.ADDED, new byte[0]); + files.addFile("my/module-a", "ClassA.class", fileA); + files.addFile("my/module-c", "ClassB.class", fileB); + server.updateAndRestart(files); + Set expectedUrls = new LinkedHashSet(Arrays.asList(url1, url3)); + assertThat(server.restartUrls, equalTo(expectedUrls)); + assertThat(server.restartFiles, equalTo(files)); + } + + @Test + public void updateSetsJarLastModified() throws Exception { + long startTime = System.currentTimeMillis(); + File folder = this.temp.newFolder(); + File jarFile = new File(folder, "module-a.jar"); + new FileOutputStream(jarFile).close(); + jarFile.setLastModified(0); + URL url = jarFile.toURI().toURL(); + URLClassLoader classLoader = new URLClassLoader(new URL[] { url }); + SourceFolderUrlFilter filter = new DefaultSourceFolderUrlFilter(); + MockRestartServer server = new MockRestartServer(filter, classLoader); + ClassLoaderFiles files = new ClassLoaderFiles(); + ClassLoaderFile fileA = new ClassLoaderFile(Kind.ADDED, new byte[0]); + files.addFile("my/module-a", "ClassA.class", fileA); + server.updateAndRestart(files); + assertThat(jarFile.lastModified(), greaterThan(startTime - 1000)); + } + + @Test + public void updateReplacesLocalFilesWhenPossible() throws Exception { + // This is critical for Cloud Foundry support where the application is + // run exploded and resources can be found from the servlet root (outside of the + // classloader) + File folder = this.temp.newFolder(); + File classFile = new File(folder, "ClassA.class"); + FileCopyUtils.copy("abc".getBytes(), classFile); + URL url = folder.toURI().toURL(); + URLClassLoader classLoader = new URLClassLoader(new URL[] { url }); + SourceFolderUrlFilter filter = new DefaultSourceFolderUrlFilter(); + MockRestartServer server = new MockRestartServer(filter, classLoader); + ClassLoaderFiles files = new ClassLoaderFiles(); + ClassLoaderFile fileA = new ClassLoaderFile(Kind.ADDED, "def".getBytes()); + files.addFile("my/module-a", "ClassA.class", fileA); + server.updateAndRestart(files); + assertThat(FileCopyUtils.copyToByteArray(classFile), equalTo("def".getBytes())); + } + + private static class MockRestartServer extends RestartServer { + + public MockRestartServer(SourceFolderUrlFilter sourceFolderUrlFilter, + ClassLoader classLoader) { + super(sourceFolderUrlFilter, classLoader); + } + + private Set restartUrls; + + private ClassLoaderFiles restartFiles; + + @Override + protected void restart(Set urls, ClassLoaderFiles files) { + this.restartUrls = urls; + this.restartFiles = files; + } + + } + +}