From 5c7ae749c213ab713fc89a2baded0b5c452af72e Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 12 Jun 2019 18:26:58 +0200 Subject: [PATCH] create new error module, which displays errors before migration --- .../scm/boot/BootstrapContextListener.java | 18 ++- .../main/java/sonia/scm/boot/SingleView.java | 109 ++++++++++++++++++ .../sonia/scm/boot/SingleViewServlet.java | 63 ++++++++++ .../sonia/scm/boot/StaticResourceServlet.java | 39 +++++++ .../src/main/java/sonia/scm/boot/View.java | 20 ++++ .../java/sonia/scm/boot/ViewController.java | 11 ++ .../sonia/scm/boot/SingleViewServletTest.java | 90 +++++++++++++++ .../java/sonia/scm/boot/SingleViewTest.java | 99 ++++++++++++++++ .../scm/boot/StaticResourceServletTest.java | 61 ++++++++++ .../resources/sonia/scm/boot/resource.txt | 1 + 10 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/boot/SingleView.java create mode 100644 scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java create mode 100644 scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java create mode 100644 scm-webapp/src/main/java/sonia/scm/boot/View.java create mode 100644 scm-webapp/src/main/java/sonia/scm/boot/ViewController.java create mode 100644 scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/boot/resource.txt diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java index 76aad46b77..844a04c79f 100644 --- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java @@ -69,7 +69,6 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; -import java.io.Closeable; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; @@ -126,9 +125,7 @@ public class BootstrapContextListener implements ServletContextListener { public void contextInitialized(ServletContextEvent sce) { context = sce.getServletContext(); - File pluginDirectory = getPluginDirectory(); - - createContextListener(pluginDirectory); + createContextListener(); contextListener.contextInitialized(sce); @@ -140,12 +137,23 @@ public class BootstrapContextListener implements ServletContextListener { } } - private void createContextListener(File pluginDirectory) { + private void createContextListener() { + Throwable startupError = SCMContext.getContext().getStartupError(); + if (startupError != null) { + contextListener = SingleView.error(startupError); + } else { + createMigrationOrNormalContextListener(); + } + } + + private void createMigrationOrNormalContextListener() { ClassLoader cl; Set plugins; PluginLoader pluginLoader; try { + File pluginDirectory = getPluginDirectory(); + renameOldPluginsFolder(pluginDirectory); if (!isCorePluginExtractionDisabled()) { diff --git a/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java b/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java new file mode 100644 index 0000000000..f1c57ce9c6 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java @@ -0,0 +1,109 @@ +package sonia.scm.boot; + +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMap; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.servlet.GuiceServletContextListener; +import com.google.inject.servlet.ServletModule; +import sonia.scm.Default; +import sonia.scm.SCMContext; +import sonia.scm.SCMContextProvider; +import sonia.scm.template.MustacheTemplateEngine; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.servlet.ServletContext; +import javax.servlet.ServletContextListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +final class SingleView { + + private SingleView() { + } + + static ServletContextListener error(Throwable throwable) { + String error = Throwables.getStackTraceAsString(throwable); + + ViewController controller = new SimpleViewController("/templates/error.mustache", request -> { + Object model = ImmutableMap.of( + "contextPath", request.getContextPath(), + "error", error + ); + return new View(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, model); + }); + return new SingleViewContextListener(controller); + } + + private static class SingleViewContextListener extends GuiceServletContextListener { + + private final ViewController controller; + + private SingleViewContextListener(ViewController controller) { + this.controller = controller; + } + + @Override + protected Injector getInjector() { + return Guice.createInjector(new SingleViewModule(controller)); + } + } + + private static class SingleViewModule extends ServletModule { + + private final ViewController viewController; + + private SingleViewModule(ViewController viewController) { + this.viewController = viewController; + } + + @Override + protected void configureServlets() { + SCMContextProvider context = SCMContext.getContext(); + + bind(SCMContextProvider.class).toInstance(context); + bind(ViewController.class).toInstance(viewController); + + Multibinder engineBinder = + Multibinder.newSetBinder(binder(), TemplateEngine.class); + + engineBinder.addBinding().to(MustacheTemplateEngine.class); + bind(TemplateEngine.class).annotatedWith(Default.class).to( + MustacheTemplateEngine.class); + bind(TemplateEngineFactory.class); + + bind(ServletContext.class).annotatedWith(Default.class).toInstance(getServletContext()); + + serve("/images/*", "/styles/*", "/favicon.ico").with(StaticResourceServlet.class); + serve("/*").with(SingleViewServlet.class); + } + } + + private static class SimpleViewController implements ViewController { + + private final String template; + private final SimpleViewFactory viewFactory; + + private SimpleViewController(String template, SimpleViewFactory viewFactory) { + this.template = template; + this.viewFactory = viewFactory; + } + + @Override + public String getTemplate() { + return template; + } + + @Override + public View createView(HttpServletRequest request) { + return viewFactory.create(request); + } + } + + @FunctionalInterface + interface SimpleViewFactory { + View create(HttpServletRequest request); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java b/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java new file mode 100644 index 0000000000..3621105976 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java @@ -0,0 +1,63 @@ +package sonia.scm.boot; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.template.Template; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +@Singleton +public class SingleViewServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(SingleViewServlet.class); + + private final Template template; + private final ViewController controller; + + @Inject + public SingleViewServlet(TemplateEngineFactory templateEngineFactory, ViewController controller) { + template = createTemplate(templateEngineFactory, controller.getTemplate()); + this.controller = controller; + } + + private Template createTemplate(TemplateEngineFactory templateEngineFactory, String template) { + TemplateEngine engine = templateEngineFactory.getEngineByExtension(template); + try { + return engine.getTemplate(template); + } catch (IOException e) { + throw new IllegalStateException("failed to parse template: " + template, e); + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + process(req, resp); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + process(req, resp); + } + + private void process(HttpServletRequest request, HttpServletResponse response) { + View view = controller.createView(request); + + response.setStatus(view.getStatusCode()); + response.setContentType("text/html"); + response.setCharacterEncoding("UTF-8"); + + try (PrintWriter writer = response.getWriter()) { + template.execute(writer, view.getModel()); + } catch (IOException ex) { + LOG.error("failed to write view", ex); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java new file mode 100644 index 0000000000..b44fbb64bc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java @@ -0,0 +1,39 @@ +package sonia.scm.boot; + +import com.github.sdorra.webresources.CacheControl; +import com.github.sdorra.webresources.WebResourceSender; +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.util.HttpUtil; + +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +@Singleton +public class StaticResourceServlet extends HttpServlet { + + private final WebResourceSender sender = WebResourceSender.create() + .withGZIP() + .withGZIPMinLength(512) + .withBufferSize(16384) + .withCacheControl(CacheControl.create().noCache()); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + URL resource = createResourceUrlFromRequest(request); + if (resource != null) { + sender.resource(resource).get(request, response); + } else { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + } + + private URL createResourceUrlFromRequest(HttpServletRequest request) throws MalformedURLException { + String uri = HttpUtil.getStrippedURI(request); + return request.getServletContext().getResource(uri); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/View.java b/scm-webapp/src/main/java/sonia/scm/boot/View.java new file mode 100644 index 0000000000..6e1f93bd3f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/View.java @@ -0,0 +1,20 @@ +package sonia.scm.boot; + +class View { + + private final int statusCode; + private final Object model; + + View(int statusCode, Object model) { + this.statusCode = statusCode; + this.model = model; + } + + int getStatusCode() { + return statusCode; + } + + Object getModel() { + return model; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java b/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java new file mode 100644 index 0000000000..26f463f9c2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java @@ -0,0 +1,11 @@ +package sonia.scm.boot; + +import javax.servlet.http.HttpServletRequest; + +public interface ViewController { + + String getTemplate(); + + View createView(HttpServletRequest request); + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java new file mode 100644 index 0000000000..9dfebdc571 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java @@ -0,0 +1,90 @@ +package sonia.scm.boot; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.template.Template; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SingleViewServletTest { + + @Mock + private TemplateEngineFactory templateEngineFactory; + + @Mock + private TemplateEngine templateEngine; + + @Mock + private Template template; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private PrintWriter writer; + + @Mock + private ViewController controller; + + @Test + void shouldRenderTheTemplateOnGet() throws IOException { + prepareTemplate("/template"); + doReturn(new View(200, "hello")).when(controller).createView(request); + + new SingleViewServlet(templateEngineFactory, controller).doGet(request, response); + + verifyResponse(200, "hello"); + } + + private void verifyResponse(int sc, Object model) throws IOException { + verify(response).setStatus(sc); + verify(response).setContentType("text/html"); + verify(response).setCharacterEncoding("UTF-8"); + + verify(template).execute(writer, model); + } + + @Test + void shouldRenderTheTemplateOnPost() throws IOException { + prepareTemplate("/template"); + + doReturn(new View(201, "hello")).when(controller).createView(request); + + new SingleViewServlet(templateEngineFactory, controller).doPost(request, response); + + verifyResponse(201, "hello"); + } + + @Test + void shouldThrowIllegalStateExceptionOnIOException() throws IOException { + doReturn("/template").when(controller).getTemplate(); + doReturn(templateEngine).when(templateEngineFactory).getEngineByExtension("/template"); + doThrow(IOException.class).when(templateEngine).getTemplate("/template"); + + assertThrows(IllegalStateException.class, () -> new SingleViewServlet(templateEngineFactory, controller)); + } + + private void prepareTemplate(String templatePath) throws IOException { + doReturn(templateEngine).when(templateEngineFactory).getEngineByExtension(templatePath); + doReturn(template).when(templateEngine).getTemplate(templatePath); + doReturn(templatePath).when(controller).getTemplate(); + + doReturn(writer).when(response).getWriter(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java new file mode 100644 index 0000000000..64c5ab98f8 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java @@ -0,0 +1,99 @@ +package sonia.scm.boot; + +import com.google.inject.Injector; +import com.google.inject.servlet.GuiceFilter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SingleViewTest { + + @Mock + private ServletContext servletContext; + + @Mock + private HttpServletRequest request; + + @Captor + private ArgumentCaptor captor; + + private GuiceFilter guiceFilter; + + @BeforeEach + void setUpGuiceFilter() throws ServletException { + guiceFilter = new GuiceFilter(); + FilterConfig config = mock(FilterConfig.class); + doReturn(servletContext).when(config).getServletContext(); + guiceFilter.init(config); + } + + @AfterEach + void tearDownGuiceFilter() { + guiceFilter.destroy(); + } + + @Test + void shouldCreateViewControllerForError() { + ServletContextListener listener = SingleView.error(new IOException("awesome io")); + when(request.getContextPath()).thenReturn("/scm"); + + ViewController instance = findViewController(listener); + assertErrorViewController(instance, "awesome io"); + } + + @Test + void shouldBindServlets() { + ServletContextListener listener = SingleView.error(new IOException("awesome io")); + Injector injector = findInjector(listener); + + assertThat(injector.getInstance(StaticResourceServlet.class)).isNotNull(); + assertThat(injector.getInstance(SingleViewServlet.class)).isNotNull(); + } + + @SuppressWarnings("unchecked") + private void assertErrorViewController(ViewController instance, String contains) { + assertThat(instance.getTemplate()).isEqualTo("/templates/error.mustache"); + + View view = instance.createView(request); + assertThat(view.getStatusCode()).isEqualTo(500); + assertThat(view.getModel()).isInstanceOfSatisfying(Map.class, map -> { + assertThat(map).containsEntry("contextPath", "/scm"); + String error = (String) map.get("error"); + assertThat(error).contains(contains); + } + ); + } + + private ViewController findViewController(ServletContextListener listener) { + Injector injector = findInjector(listener); + return injector.getInstance(ViewController.class); + } + + private Injector findInjector(ServletContextListener listener) { + listener.contextInitialized(new ServletContextEvent(servletContext)); + + verify(servletContext).setAttribute(anyString(), captor.capture()); + + return captor.getValue(); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java b/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java new file mode 100644 index 0000000000..a350f4d9f3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java @@ -0,0 +1,61 @@ +package sonia.scm.boot; + +import com.google.common.io.Resources; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.ServletContext; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URL; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class StaticResourceServletTest { + + @Mock + private HttpServletRequest request; + + @Mock + private ServletOutputStream stream; + + @Mock + private HttpServletResponse response; + + @Mock + private ServletContext context; + + @Test + void shouldServeResource() throws IOException { + doReturn("/scm").when(request).getContextPath(); + doReturn("/scm/resource.txt").when(request).getRequestURI(); + doReturn(context).when(request).getServletContext(); + URL resource = Resources.getResource("sonia/scm/boot/resource.txt"); + doReturn(resource).when(context).getResource("/resource.txt"); + doReturn(stream).when(response).getOutputStream(); + + StaticResourceServlet servlet = new StaticResourceServlet(); + servlet.doGet(request, response); + + verify(response).setStatus(HttpServletResponse.SC_OK); + } + + @Test + void shouldReturnNotFound() throws IOException { + doReturn("/scm").when(request).getContextPath(); + doReturn("/scm/resource.txt").when(request).getRequestURI(); + doReturn(context).when(request).getServletContext(); + + StaticResourceServlet servlet = new StaticResourceServlet(); + servlet.doGet(request, response); + + verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + } + +} diff --git a/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt b/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt new file mode 100644 index 0000000000..c4a1132fa5 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt @@ -0,0 +1 @@ +Resource for testing