caio.co/de/cerberus

Render responses in html

Turns out playing with more than one box gets expensive very
quickly. Who knew!

This patch introduces a dummy / endpoint, where the index page will
live; And a /search endpoint that acts as the previous one, but now
rendering an html result.

Using [rocker][1] as the template engine leads to some coupling as
I'll always either return something that is/contains
a RockerTemplate or is the output of it - I don't think it fits the
current model spring uses to decouple controllers (I can make it
slightly less coupled though - will do it in the future), but rocker
is such a neat little project that I'll try to keep it :-)

[1]: https://github.com/fizzed/rocker
Id
fc41e886ec4243f75d5334cbef0dd16f101890bc
Author
Caio
Commit time
2018-12-13T09:58:08+01:00

Modified pom.xml

@@ -33,6 +33,9
<logback.version>1.2.3</logback.version>
<lucene.version>7.5.0</lucene.version>
<junit.jupiter.version>5.3.2</junit.jupiter.version>
+ <rocker.version>1.2.0</rocker.version>
+
+ <rocker.optimize>true</rocker.optimize>
</properties>

<dependencyManagement>
@@ -111,6 +114,12
</dependency>

<dependency>
+ <groupId>com.fizzed</groupId>
+ <artifactId>rocker-runtime</artifactId>
+ <version>${rocker.version}</version>
+ </dependency>
+
+ <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
@@ -157,6 +166,20
<configuration>
<verbose>false</verbose>
</configuration>
+ </plugin>
+ <plugin>
+ <groupId>com.fizzed</groupId>
+ <artifactId>rocker-maven-plugin</artifactId>
+ <version>${rocker.version}</version>
+ <executions>
+ <execution>
+ <id>generate-rocker-templates</id>
+ <phase>generate-sources</phase>
+ <goals>
+ <goal>generate</goal>
+ </goals>
+ </execution>
+ </executions>
</plugin>
</plugins>
</build>

Renamed src/test/java/co/caio/cerberus/boot/SearchControllerTest.java to src/test/java/co/caio/cerberus/boot/WebControllerTest.java

@@ -1,103 +1,91
package co.caio.cerberus.boot;

-import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;

-import co.caio.cerberus.boot.FailureResponse.ErrorKind;
import co.caio.cerberus.model.SearchResult;
+import co.caio.cerberus.search.Searcher;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.mockito.stubbing.Answer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;

-class SearchControllerTest extends BaseSearchControllerTest {
+@WebFluxTest(WebController.class)
+class WebControllerTest {
+
+ @Autowired WebTestClient testClient;
+ @MockBean Searcher searcher;

@TestConfiguration
static class TestConfig {
@Bean("searchTimeout")
- public Duration timeout() {
- // No timeout
- return Duration.ofMillis(Long.MAX_VALUE);
+ static Duration getTimeout() {
+ return Duration.ofMillis(100);
}
}

@Test
- void badInputReturnsJson() {
+ void badInputTriggersError400() {
+ var badQueries =
+ List.of(
+ "q=oi", // too short
+ "q=oil&n=0", // n is >= 1
+ "q=oil&n=1.2", // n is not an int
+ "q=oil&n=notANumber", // n is >= 1
+ "q=oil&n=1000", // too big
+ "q=oil&nf=-1", // negative number
+ "q=oil&sort=random", // invalid sort order
+ "q=oil&ni=2,1", // invalid range
+ "q=oil&trololo=hue" // unknown parameter
+ );

- // Missing required params
- expectFailure("/search", HttpStatus.UNPROCESSABLE_ENTITY, ErrorKind.QUERY_ERROR);
-
- // Required parameter is too short
- expectFailure("/search?q=oi", HttpStatus.UNPROCESSABLE_ENTITY, ErrorKind.QUERY_ERROR);
-
- // `n` is too big
- expectFailure("/search?q=oil&n=200", HttpStatus.UNPROCESSABLE_ENTITY, ErrorKind.QUERY_ERROR);
+ for (String badQuery : badQueries) {
+ assertGet("/search?" + badQuery, HttpStatus.BAD_REQUEST);
+ }
}

@Test
- void apiReturnsJsonOnInternalError() {
+ void unknownExceptionTriggersError500() {
given(searcher.search(any())).willThrow(RuntimeException.class);
- expectFailure("/search?q=salt", HttpStatus.INTERNAL_SERVER_ERROR, ErrorKind.UNKNOWN_ERROR);
+ assertGet("/search?q=potato", HttpStatus.INTERNAL_SERVER_ERROR);
}

@Test
- void unknownParameterTriggersError() {
- expectFailure(
- "/search?q=valid query&unknownParam=drop",
- HttpStatus.UNPROCESSABLE_ENTITY,
- ErrorKind.QUERY_ERROR);
+ void timeoutTriggersError408() {
+ given(searcher.search(any()))
+ .will(
+ (Answer<SearchResult>)
+ invocation -> {
+ try {
+ // Sleep for 2s, which should be interrupted in 100ms
+ Thread.sleep(2000);
+ } catch (InterruptedException expected) {
+ // nothing to do
+ }
+ return new SearchResult.Builder().build();
+ });
+ assertGet("/search?q=salt", HttpStatus.REQUEST_TIMEOUT);
}

- @Test
- void invalidRangeTriggersCorrectError() {
- var prefix = "/search?q=salt&ni=";
- var badRanges = List.of("1notANumber", "10,bad", "100,1", "1,2,3", "42,", ",5");
-
- for (String spec : badRanges) {
- expectFailure(prefix + spec, HttpStatus.UNPROCESSABLE_ENTITY, ErrorKind.QUERY_ERROR);
- }
- }
-
- @Test
- void badNumericParameterTriggersCorrectError() {
- var prefix = "/search?q=oil";
- var queries = List.of("&n=-10", "&n=notAnumber", "&n=4.2");
-
- for (String spec : queries) {
- expectFailure(prefix + spec, HttpStatus.UNPROCESSABLE_ENTITY, ErrorKind.QUERY_ERROR);
- }
- }
-
- private SearchResult fakeResult(String name) {
- // The name parameter is used so that we can generate multiple
- // distinct fake results in the same test
- return new SearchResult.Builder()
- .addRecipe(42, name, "https://nowhere.here")
- .totalHits(100)
- .build();
- }
-
- @Test
- void apiReturnsJsonOnSuccess() {
- var searchResult = fakeResult("recipe name");
-
- given(searcher.search(any())).willReturn(searchResult);
-
- var response =
- testClient
- .get()
- .uri("/search?q=a valid query")
- .exchange()
- .expectStatus()
- .isOk()
- .expectBody(SuccessResponse.class)
- .returnResult()
- .getResponseBody();
-
- assertTrue(response.isSuccess());
- assertEquals(searchResult, response.result);
+ void assertGet(String uri, HttpStatus status) {
+ testClient
+ .get()
+ .uri(uri)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(status)
+ .expectHeader()
+ .contentTypeCompatibleWith(MediaType.TEXT_HTML)
+ .expectBody(String.class)
+ .returnResult()
+ .getResponseBody();
}
}

Renamed src/main/java/co/caio/cerberus/boot/SearchController.java to src/main/java/co/caio/cerberus/boot/WebController.java

@@ -1,68 +1,87
package co.caio.cerberus.boot;

import co.caio.cerberus.boot.SearchParameterParser.SearchParameterException;
+import co.caio.cerberus.model.SearchQuery;
import co.caio.cerberus.search.Searcher;
+import io.micrometer.core.annotation.Timed;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.TimeoutException;
-import javax.validation.ConstraintViolationException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
-import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

-@RestController
-public class SearchController {
+@Controller
+public class WebController {

private final Searcher searcher;
private final Duration timeout;
private final SearchParameterParser parser;

- public SearchController(Searcher searcher, @Qualifier("searchTimeout") Duration timeout) {
+ public WebController(Searcher searcher, @Qualifier("searchTimeout") Duration timeout) {
this.searcher = searcher;
this.timeout = timeout;
this.parser = new SearchParameterParser();
}

- @GetMapping("search")
- public Mono<SuccessResponse> search(@RequestParam Map<String, String> params) {
- var query = parser.buildQuery(params);
+ @GetMapping("/")
+ @ResponseBody
+ public String index() {
+ return views.index.template("Cerberus").render().toString();
+ }

- return Mono.fromCallable(() -> new SuccessResponse(searcher.search(query)))
- .publishOn(Schedulers.parallel())
+ @GetMapping("/search")
+ @Timed
+ @ResponseBody
+ public Mono<String> search(@RequestParam Map<String, String> params) {
+ SearchQuery query = parser.buildQuery(params);
+
+ return Mono.fromCallable(
+ () -> {
+ // System.out.println(Thread.currentThread().getName());
+ return views.search.template(searcher.search(query)).render().toString();
+ })
+ .subscribeOn(Schedulers.parallel())
.timeout(timeout);
}

@ExceptionHandler({
IllegalStateException.class,
ServerWebInputException.class,
- ConstraintViolationException.class,
- SearchParameterException.class,
+ SearchParameterException.class
})
- @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
- FailureResponse handleQueryBuildError(Exception exc) {
- return FailureResponse.queryError(exc.getMessage());
+ ResponseEntity<String> handleBadParameters(Exception ex) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+ .contentType(MediaType.TEXT_HTML)
+ .body(views.index.template("Bad parameter: " + ex.getMessage()).render().toString());
}

@ExceptionHandler
- @ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
@ResponseBody
- Mono<FailureResponse> handleTimeout(TimeoutException exc) {
- return Mono.just(FailureResponse.timeoutError(exc.getMessage()));
+ ResponseEntity<String> handleTimeout(TimeoutException ex) {
+ return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT)
+ .contentType(MediaType.TEXT_HTML)
+ .body(views.index.template("Timeout: " + ex.getMessage()).render().toString());
}

- @ExceptionHandler(Exception.class)
- @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ @ExceptionHandler
@ResponseBody
- FailureResponse handleException(Exception exc) {
- return FailureResponse.unknownError(exc.getMessage());
+ ResponseEntity<String> handleUnknown(Exception ex) {
+ var msg = ex.getMessage();
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .contentType(MediaType.TEXT_HTML)
+ .body(views.index.template(msg != null ? msg : ex.toString()).render().toString());
}
}

Deleted src/main/java/co/caio/cerberus/boot/BaseResponse.java

@@ -1,5 +1,0
-package co.caio.cerberus.boot;
-
-abstract class BaseResponse {
- public abstract boolean isSuccess();
-}

Deleted src/main/java/co/caio/cerberus/boot/FailureResponse.java

@@ -1,34 +1,0
-package co.caio.cerberus.boot;
-
-class FailureResponse extends BaseResponse {
- public final String message;
- public final ErrorKind error;
-
- @Override
- public boolean isSuccess() {
- return false;
- }
-
- private FailureResponse(ErrorKind error, String message) {
- this.error = error;
- this.message = message;
- }
-
- static FailureResponse queryError(String message) {
- return new FailureResponse(ErrorKind.QUERY_ERROR, message);
- }
-
- static FailureResponse unknownError(String message) {
- return new FailureResponse(ErrorKind.UNKNOWN_ERROR, message);
- }
-
- static FailureResponse timeoutError(String message) {
- return new FailureResponse(ErrorKind.TIMEOUT_ERROR, message);
- }
-
- enum ErrorKind {
- QUERY_ERROR,
- UNKNOWN_ERROR,
- TIMEOUT_ERROR,
- }
-}

Deleted src/main/java/co/caio/cerberus/boot/SuccessResponse.java

@@ -1,23 +1,0
-package co.caio.cerberus.boot;
-
-import co.caio.cerberus.model.SearchResult;
-
-class SuccessResponse extends BaseResponse {
- public SearchResult result;
-
- @Override
- public boolean isSuccess() {
- return true;
- }
-
- SuccessResponse(SearchResult result) {
- this.result = result;
- }
-
- // For testing
- private SuccessResponse() {}
-
- private void setResult(SearchResult result) {
- this.result = result;
- }
-}

Created src/main/java/views/index.rocker.html

@@ -1,0 +1,5
+@args (String message)
+
+@views.main.template("Index") -> {
+<h1>Hello @message!</h1>
+}

Created src/main/java/views/main.rocker.html

@@ -1,0 +1,10
+@args (String title, RockerBody content)
+
+<html>
+<head>
+ <title>@title</title>
+</head>
+<body>
+@content
+</body>
+</html>

Created src/main/java/views/search.rocker.html

@@ -1,0 +1,18
+@import co.caio.cerberus.model.SearchResult
+@args (SearchResult result)
+
+@views.main.template("Search") -> {
+<h1>Search results</h1>
+<div>
+ Search matched @result.totalHits() recipes. Displaying the top @result.recipes().size():
+ <ul>
+ @for (srr: result.recipes()) {
+ <li>
+ <a href="@srr.crawlUrl()" title="Link to original @srr.name()">
+ @srr.name() <small>(@srr.recipeId())</small>
+ </a>
+ </li>
+ }
+ </ul>
+</div>
+}

Deleted src/test/java/co/caio/cerberus/boot/BaseSearchControllerTest.java

@@ -1,35 +1,0
-package co.caio.cerberus.boot;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-
-import co.caio.cerberus.boot.FailureResponse.ErrorKind;
-import co.caio.cerberus.search.Searcher;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.http.HttpStatus;
-import org.springframework.test.web.reactive.server.WebTestClient;
-
-@WebFluxTest(SearchController.class)
-abstract class BaseSearchControllerTest {
-
- @Autowired WebTestClient testClient;
- @MockBean Searcher searcher;
-
- void expectFailure(String uri, HttpStatus status, ErrorKind kind) {
- var result =
- testClient
- .get()
- .uri(uri)
- .exchange()
- .expectStatus()
- .isEqualTo(status)
- .expectBody(FailureResponse.class)
- .returnResult()
- .getResponseBody();
-
- assertFalse(result.isSuccess());
- assertEquals(kind, result.error);
- }
-}

Deleted src/test/java/co/caio/cerberus/boot/SearchControllerTimeoutTest.java

@@ -1,42 +1,0
-package co.caio.cerberus.boot;
-
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.BDDMockito.given;
-
-import co.caio.cerberus.boot.FailureResponse.ErrorKind;
-import java.time.Duration;
-import org.junit.jupiter.api.Test;
-import org.mockito.stubbing.Answer;
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.http.HttpStatus;
-
-class SearchControllerTimeoutTest extends BaseSearchControllerTest {
-
- @TestConfiguration
- static class TestConfig {
- @Bean("searchTimeout")
- public Duration timeout() {
- return Duration.ofMillis(100);
- }
- }
-
- @Test
- void timedOutRequestReturnsJson() {
- given(searcher.search(any()))
- .will(
- (Answer<Void>)
- invocation -> {
- try {
- // Sleep for 2s, which should be interrupted in 100ms
- Thread.sleep(2000);
- } catch (InterruptedException expected) {
- // nothing to do
- }
- return null;
- });
-
- expectFailure(
- "/search?q=this will timeout", HttpStatus.REQUEST_TIMEOUT, ErrorKind.TIMEOUT_ERROR);
- }
-}