Further refined the example: Simplified the setup
authorKai Moritz <kai@juplo.de>
Sun, 27 Sep 2020 09:23:03 +0000 (11:23 +0200)
committerKai Moritz <kai@juplo.de>
Fri, 2 Oct 2020 17:17:55 +0000 (19:17 +0200)
* Goal: Do not rely on knowledge about Thymeleaf
* Goal: Separate MVC (controller) from business logic (service)
* Introduced an ExampleService
* The service checks the answer for
  The Ultimate Question Of Life, The Universe And Everything
* Requests without any answer are tolarated, so that the page with the
  question can be rendered
* Only numbers (Integer) are allowed as answer.
* Negative numbers are not allowed as answers: the service answers with an
  empty Optional.
* The view contains a bug, that results in a 503 for negative answers:
  Optional.get() is called, regardless of the state of the Optional

* The view renders the question, if no answer is specified (initial request!)
* If a valid request is specified, the answer and the outcome is rendered
* If an invalid request (negative number!) is specified, the view generates
  an exception, because it tries to resolve the Optional anyway

src/main/java/de/juplo/demo/ExampleController.java
src/main/java/de/juplo/demo/ExampleService.java [new file with mode: 0644]
src/main/resources/templates/a.html [deleted file]
src/main/resources/templates/b.html [deleted file]
src/main/resources/templates/view.html [new file with mode: 0644]
src/test/java/de/juplo/demo/ExceptionHandlingApplicationTests.java

index b6e75d8..d919b63 100644 (file)
@@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.ResponseStatus;
-import org.thymeleaf.exceptions.TemplateInputException;
+import org.thymeleaf.exceptions.TemplateProcessingException;
+
+import java.util.Optional;
 
 @Controller
 public class ExampleController
@@ -18,21 +20,50 @@ public class ExampleController
             LoggerFactory.getLogger(ExampleController.class);
 
 
+    private final ExampleService service;
+
+
+    public ExampleController(ExampleService service)
+    {
+        this.service = service;
+    }
+
+
     @RequestMapping("/")
     public String controller(
-            @RequestParam(defaultValue = "a") String template,
+            @RequestParam(required = false) Integer answer,
             Model model
     )
     {
-        model.addAttribute("template", template);
-        return template;
+        Optional<Boolean> outcome =
+                answer == null ? null : service.checkAnswer(answer);
+
+        model.addAttribute("answer", answer);
+        model.addAttribute("outcome", outcome);
+        return "view";
     }
 
-    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
-    @ExceptionHandler(TemplateInputException.class)
-    public void templateInputException(TemplateInputException e)
+    /**
+     * This {@link ExceptionHandler @ExceptionHander} is never triggered,
+     * because the exception is not thrown inside the controller:
+     * <strong>It is functionless!</strong>
+     * <p>
+     *     The exception is thrown by Thymeleaf, which is called by the
+     *     {@link DispatcherServlet} after the controller has finished its
+     *     work during the rendering of the outcome.
+     * </p>
+     * <p>
+     *     {@link ExceptionHandler @ExceptionHander's} are not able, to catch
+     *     and resolve exceptions, which are thrown outside of the scope of the
+     *     controller.
+     *     This is also true for {@link ControllerAdvice}.
+     * </p>
+     */
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    @ExceptionHandler(TemplateProcessingException.class)
+    public void templateInputException(TemplateProcessingException e)
     {
-        LOG.error("{}: {}", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
+        LOG.error("{}: {}", HttpStatus.BAD_REQUEST, e.getMessage());
     }
 }
 
diff --git a/src/main/java/de/juplo/demo/ExampleService.java b/src/main/java/de/juplo/demo/ExampleService.java
new file mode 100644 (file)
index 0000000..ec21629
--- /dev/null
@@ -0,0 +1,20 @@
+package de.juplo.demo;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@Component
+public class ExampleService
+{
+    public Optional<Boolean> checkAnswer(int answer)
+    {
+        if (answer < 0)
+            return Optional.empty();
+
+        if (answer == 42)
+            return Optional.of(true);
+        else
+            return Optional.of(false);
+    }
+}
diff --git a/src/main/resources/templates/a.html b/src/main/resources/templates/a.html
deleted file mode 100644 (file)
index 60de028..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!DOCTYPE HTML>
-<html xmlns:th="http://www.thymeleaf.org">
-  <head>
-    <title>Testing Exception-Handling - Template A</title>
-    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
-  </head>
-  <body>
-    <h1>Template A</h1>
-    <div>
-      <p><strong th:text="'Serving with template ::' + ${template} + '::!'">TEXT</strong></p>
-      <p>
-        Type in <strong><code>a</code></strong> or <strong><code>b</code></strong>
-        for an existing template (no exception). Type in any other string for a
-        non-existent template: Thymeleaf will throw a
-        <strong><code>TemplateInputException</code></strong>!
-      </p>
-    </div>
-    <div>
-      <form action="#" th:action="@{/}" method="get">
-        <div>
-          <label for="path">Remote-Path to fetch:</label>
-          <input type="text" name="template" value="a" th:value="${template}"/>
-        </div>
-        <button type="submit">Submit</button>
-      </form>
-    </div>
-  </body>
-</html>
diff --git a/src/main/resources/templates/b.html b/src/main/resources/templates/b.html
deleted file mode 100644 (file)
index c89c69f..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<!DOCTYPE HTML>
-<html xmlns:th="http://www.thymeleaf.org">
-  <head>
-    <title>Testing Exception-Handling - Template B</title>
-    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
-  </head>
-  <body>
-    <h1>Template B</h1>
-    <div>
-      <p><strong th:text="'Serving with template ::' + ${template} + '::!'">TEXT</strong></p>
-      <p>
-        Type in <strong><code>a</code></strong> or <strong><code>b</code></strong>
-        for an existing template (no exception). Type in any other string for a
-        non-existent template: Thymeleaf will throw a
-        <strong><code>TemplateInputException</code></strong>!
-      </p>
-    </div>
-    <div>
-      <form action="#" th:action="@{/}" method="get">
-        <div>
-          <label for="path">Remote-Path to fetch:</label>
-          <input type="text" name="template" value="a" th:value="${template}"/>
-        </div>
-        <button type="submit">Submit</button>
-      </form>
-    </div>
-  </body>
-</html>
diff --git a/src/main/resources/templates/view.html b/src/main/resources/templates/view.html
new file mode 100644 (file)
index 0000000..70d3c51
--- /dev/null
@@ -0,0 +1,28 @@
+<!DOCTYPE HTML>
+<html xmlns:th="http://www.thymeleaf.org">
+  <head>
+    <title>Template: view</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+  </head>
+  <body>
+    <h1>Deep Thought</h1>
+    <div th:switch="${answer}">
+      <p th:case="null">
+        <strong>What is the answer to the ultimate question of life, the universe and everything?</strong>
+      </p>
+      <ul th:case="*">
+        <li>Presented answer: <strong th:text="${answer}">ANSWER</strong></li>
+        <li>Outcome: <strong th:text="${outcome.get()}">OUTCOME</strong></li>
+      </ul>
+    </div>
+    <div>
+      <form action="#" th:action="@{/}" method="get">
+        <div>
+          <label for="answer">Answer:</label>
+          <input type="text" name="answer" value="42" th:value="${answer}"/>
+          <button type="submit">Submit</button>
+        </div>
+      </form>
+    </div>
+  </body>
+</html>
index 9e6d753..a80ffd3 100644 (file)
@@ -1,14 +1,22 @@
 package de.juplo.demo;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.test.web.servlet.MockMvc;
 
 import java.net.URI;
+import java.util.Optional;
 
+import static org.mockito.AdditionalMatchers.geq;
+import static org.mockito.AdditionalMatchers.lt;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -17,6 +25,9 @@ class ExceptionHandlingApplicationTests {
        private final static Logger LOG =
                        LoggerFactory.getLogger(ExceptionHandlingApplicationTests.class);
 
+       @MockBean
+       ExampleService service;
+
        @Autowired
        MockMvc mvc;
 
@@ -26,26 +37,53 @@ class ExceptionHandlingApplicationTests {
        }
 
        @Test
-       void test200() throws Exception {
+       void test200ForNoAnswer() throws Exception {
                mvc
-                               .perform(get(URI.create("http://FOO/?template=a")))
+                               .perform(get(URI.create("http://FOO/")))
                                .andExpect(status().isOk());
+
+               verify(service, times(0)).checkAnswer(anyInt());
+       }
+
+       @ParameterizedTest
+       @ValueSource(ints = { 0, 1, 2, 3, 4, 5, 41, 42, 43, 666, Integer.MAX_VALUE })
+       void test200ForPositiveAnswer(int number) throws Exception {
+               when(service.checkAnswer(eq(42))).thenReturn(Optional.of(true));
+               when(service.checkAnswer(geq(0))).thenReturn(Optional.of(false));
+               when(service.checkAnswer(lt(0))).thenReturn(Optional.empty());
+
                mvc
-                               .perform(get(URI.create("http://FOO/?template=b")))
+                               .perform(get(URI.create("http://FOO/?answer=" + number)))
                                .andExpect(status().isOk());
        }
 
-       @Test
-       void test503_NOT_WORKING() throws Exception {
-               // The expected behaviour of the following test is, that the
-               // TemplateInputException, that is thrown by Thymeleaf because of the non-existent
-               // template-resource, is catched and reported as 503 Internal Server Error, as it
-               // happens in the real Spring-MVC environment, when you start the web-server and
-               // trigger the error manually.
+       @ParameterizedTest
+       @ValueSource(ints = { -1, -2, Integer.MIN_VALUE })
+       void test400ForNegativeAnswer_NOT_WORKING(int number) throws Exception {
+               when(service.checkAnswer(eq(42))).thenReturn(Optional.of(true));
+               when(service.checkAnswer(geq(0))).thenReturn(Optional.of(false));
+               when(service.checkAnswer(lt(0))).thenReturn(Optional.empty());
+
+               // The expected behaviour of the following test is, that the NoSuchElementException
+               // with the message "No value present", that is raised, when the view calls .get()
+               // on the empty Optional and wrapped by Thymeleaf in a TemplateProcessingException
+               // is catched by the @ExceptionHandler, that is defined in the ExampleController
+               // and reported as 400: Bad Request.
                // Instead, the exception bubbles up, becomes wrapped in a NestedServletException
                // and is thrown in the call to perform()!
                mvc
-                               .perform(get(URI.create("http://FOO/?template=foo")))
+                               .perform(get(URI.create("http://FOO/?answer=" + number)))
                                .andExpect(status().isInternalServerError());
        }
+
+       @Test
+       void test400ForStringInput() throws Exception {
+               when(service.checkAnswer(eq(42))).thenReturn(Optional.of(true));
+               when(service.checkAnswer(geq(0))).thenReturn(Optional.of(false));
+               when(service.checkAnswer(lt(0))).thenReturn(Optional.empty());
+
+               mvc
+                               .perform(get(URI.create("http://FOO/?answer=bar")))
+                               .andExpect(status().isBadRequest());
+       }
 }