Use webflux instead of webmvc
Reactor pattern + separate executor for lucene calls is a more reasonable concurrency model IMO.
- Id
- 0fafb60eb1db65c05914528f4cc9eda1c43440d4
- Author
- Caio
- Commit time
- 2018-12-08T17:23:30+01:00
Modified pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- <exclusions>
- <exclusion>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-tomcat</artifactId>
- </exclusion>
- </exclusions>
- </dependency>
-
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-undertow</artifactId>
+ <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
Modified src/main/resources/application.properties
+#logging.level.web=TRACE
spring.main.banner-mode=off
spring.devtools.restart.enabled=true
management.endpoint.prometheus.enabled=true
Modified src/main/java/co/caio/cerberus/boot/FailureResponse.java
package co.caio.cerberus.boot;
class FailureResponse extends BaseResponse {
- public final String path;
public final String message;
public final ErrorKind error;
return false;
}
- FailureResponse(ErrorKind error, String requestPath, String message) {
+ private FailureResponse(ErrorKind error, String message) {
this.error = error;
- this.path = requestPath;
this.message = message;
}
- public static FailureResponse queryError(String path, String message) {
- return new FailureResponse(ErrorKind.QUERY_ERROR, path, message);
+ static FailureResponse queryError(String message) {
+ return new FailureResponse(ErrorKind.QUERY_ERROR, message);
}
- public static FailureResponse unknownError(String path, String message) {
- return new FailureResponse(ErrorKind.UNKNOWN_ERROR, path, message);
+ static FailureResponse unknownError(String message) {
+ return new FailureResponse(ErrorKind.UNKNOWN_ERROR, message);
}
enum ErrorKind {
Modified src/main/java/co/caio/cerberus/boot/SearchController.java
package co.caio.cerberus.boot;
import co.caio.cerberus.model.SearchQuery;
import co.caio.cerberus.model.SearchQuery.SortOrder;
import co.caio.cerberus.search.Searcher;
-import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolationException;
import javax.validation.constraints.Size;
-import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.ServletRequestBindingException;
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
@Validated
}
@GetMapping("search")
- public SuccessResponse search(
+ public Mono<SuccessResponse> search(
@RequestParam("q")
@Size(
min = 3,
if (order != null) builder.sort(order);
builder.maxResults(maxResults);
+ var query = builder.build();
- return new SuccessResponse(searcher.search(builder.build()));
+ return Mono.fromCallable(() -> new SuccessResponse(searcher.search(query)))
+ .publishOn(Schedulers.parallel());
}
@ExceptionHandler({
IllegalStateException.class,
- ConversionFailedException.class,
+ ServerWebInputException.class,
ConstraintViolationException.class,
- ServletRequestBindingException.class
})
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ResponseBody
- FailureResponse handleQueryBuildError(HttpServletRequest req, Exception exc) {
- return FailureResponse.queryError(req.getRequestURI(), exc.getMessage());
+ FailureResponse handleQueryBuildError(Exception exc) {
+ return FailureResponse.queryError(exc.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseBody
- FailureResponse handleException(HttpServletRequest req, Exception exc) {
- return FailureResponse.unknownError(req.getRequestURI(), exc.getMessage());
+ FailureResponse handleException(Exception exc) {
+ return FailureResponse.unknownError(exc.getMessage());
}
}
Modified src/main/java/co/caio/cerberus/boot/SuccessResponse.java
package co.caio.cerberus.boot;
import co.caio.cerberus.model.SearchResult;
class SuccessResponse extends BaseResponse {
- public final SearchResult result;
+ public SearchResult result;
@Override
public boolean isSuccess() {
}
SuccessResponse(SearchResult result) {
+ this.result = result;
+ }
+
+ // For testing
+ private SuccessResponse() {}
+
+ private void setResult(SearchResult result) {
this.result = result;
}
}
Modified src/test/java/co/caio/cerberus/boot/BootApplicationTests.java
package co.caio.cerberus.boot;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.junit.jupiter.SpringExtension;
@SpringBootTest
-@ExtendWith(SpringExtension.class)
class BootApplicationTests {
@Test
Modified src/test/java/co/caio/cerberus/boot/SearchControllerTest.java
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import co.caio.cerberus.boot.FailureResponse.ErrorKind;
import co.caio.cerberus.model.SearchResult;
import co.caio.cerberus.search.Searcher;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.reactive.server.WebTestClient;
-@WebMvcTest(SearchController.class)
+@WebFluxTest(SearchController.class)
+@AutoConfigureWebTestClient
class SearchControllerTest {
- @Autowired private MockMvc mvc;
-
+ @Autowired WebTestClient testClient;
@MockBean Searcher searcher;
- @Test
- void badInputReturnsJson() throws Exception {
- // too short of a query
- mvc.perform(get("/search?q=oi"))
- .andExpect(status().is4xxClientError())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.success").value(Boolean.FALSE))
- .andExpect(jsonPath("$.path").value("/search"))
- .andExpect(jsonPath("$.error").value("QUERY_ERROR"));
+ private void expectFailure(String uri, HttpStatus status, ErrorKind kind) {
+ var result =
+ testClient
+ .get()
+ .uri(uri)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(status)
+ .expectBody(FailureResponse.class)
+ .returnResult()
+ .getResponseBody();
- // too big of an n
- mvc.perform(get("/search?q=oil&n=200"))
- .andExpect(status().is4xxClientError())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.success").value(Boolean.FALSE))
- .andExpect(jsonPath("$.path").value("/search"))
- .andExpect(jsonPath("$.error").value("QUERY_ERROR"));
-
- // empty query
- mvc.perform(get("/search"))
- .andExpect(status().is4xxClientError())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.success").value(Boolean.FALSE))
- .andExpect(jsonPath("$.path").value("/search"))
- .andExpect(jsonPath("$.error").value("QUERY_ERROR"));
+ assertFalse(result.isSuccess());
+ assertEquals(kind, result.error);
}
@Test
- void apiReturnsJsonOnInternalError() throws Exception {
+ void badInputReturnsJson() {
+
+ // 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);
+ }
+
+ @Test
+ void apiReturnsJsonOnInternalError() {
given(searcher.search(any())).willThrow(RuntimeException.class);
-
- mvc.perform(get("/search?q=salt"))
- .andExpect(status().is5xxServerError())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.success").value(Boolean.FALSE))
- .andExpect(jsonPath("$.path").value("/search"))
- .andExpect(jsonPath("$.error").value("UNKNOWN_ERROR"));
+ expectFailure("/search?q=salt", HttpStatus.INTERNAL_SERVER_ERROR, ErrorKind.UNKNOWN_ERROR);
}
@Test
- void apiReturnsJsonOnSuccess() throws Exception {
+ void apiReturnsJsonOnSuccess() {
var searchResult =
new SearchResult.Builder()
.addRecipe(42, "the answer", "https://nowhere.here")
given(searcher.search(any())).willReturn(searchResult);
- mvc.perform(get("/search?q=keto"))
- .andExpect(status().isOk())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.success").value(Boolean.TRUE))
- .andExpect(jsonPath("$.error").doesNotExist())
- .andExpect(jsonPath("$.result").exists())
- .andExpect(jsonPath("$.result.totalHits").value(100))
- .andExpect(jsonPath("$.result.recipes").isArray())
- .andExpect(jsonPath("$.result.recipes[0].recipeId").value(42));
+ 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);
}
}