diff --git a/spring-boot-modules/spring-boot-client/pom.xml b/spring-boot-modules/spring-boot-client/pom.xml index 167e343f6996..c22e938fd34d 100644 --- a/spring-boot-modules/spring-boot-client/pom.xml +++ b/spring-boot-modules/spring-boot-client/pom.xml @@ -62,4 +62,16 @@ + + + + org.springframework.boot + spring-boot-maven-plugin + + com.baeldung.boot.Application + + + + + \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java new file mode 100644 index 000000000000..f2660a60dfe1 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java @@ -0,0 +1,12 @@ +package com.baeldung.restclient; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java new file mode 100644 index 000000000000..0f2c892e1743 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java @@ -0,0 +1,30 @@ +package com.baeldung.restclient; + +public class Article { + private Integer id; + private String title; + + public Article() { + } + + public Article(Integer id, String title) { + this.id = id; + this.title = title; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java new file mode 100644 index 000000000000..4ffb635db7f4 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java @@ -0,0 +1,82 @@ +package com.baeldung.restclient; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/articles") +class ArticleController { + + Map database = new HashMap<>(); + + @GetMapping + ResponseEntity> getArticles() { + Collection
values = database.values(); + if (values.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(values); + } + + @GetMapping("/{id}") + ResponseEntity
getArticle(@PathVariable("id") Integer id) { + Article article = database.get(id); + if (article == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(article); + } + + @GetMapping(value = "/{id}", headers = "API-Version=2") + ResponseEntity
getArticleV2(@PathVariable("id") Integer id) { + return ResponseEntity.ok(new Article(100, "SECRET ARTICLE")); + } + + @GetMapping("/search") + ResponseEntity
searchArticleByTitle(@RequestParam(name = "title") String title) { + Optional
article = database.values().stream() + .filter(a -> a.getTitle().contains(title)) + .findFirst(); + if (article.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(article.get()); + } + + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) + void createArticle(@RequestBody Article article) { + database.put(article.getId(), article); + } + + @PutMapping("/{id}") + void updateArticle(@PathVariable("id") Integer id, @RequestBody Article article) { + assert Objects.equals(id, article.getId()); + database.remove(id); + database.put(id, article); + } + + @DeleteMapping("/{id}") + void deleteArticle(@PathVariable("id") Integer id) { + database.remove(id); + } + + @DeleteMapping + void deleteAllArticles() { + database.clear(); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java new file mode 100644 index 000000000000..a0a1cb6c540a --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java @@ -0,0 +1,4 @@ +package com.baeldung.restclient; + +class ArticleNotFoundException extends RuntimeException { +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java new file mode 100644 index 000000000000..b43837997e88 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java @@ -0,0 +1,4 @@ +package com.baeldung.restclient; + +class InvalidArticleResponseException extends RuntimeException { +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java b/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java new file mode 100644 index 000000000000..fa0bf9d4bc74 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java @@ -0,0 +1,327 @@ +package com.baeldung.restclient; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.test.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.RestClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RestClientLiveTest { + + @LocalServerPort + private int port; + private String uriBase; + RestClient restClient = RestClient.create(); + + @Autowired + JsonMapper jsonMapper; + + @BeforeEach + void setup() { + uriBase = "http://localhost:" + port; + } + + @AfterEach + void teardown() { + restClient.delete() + .uri(uriBase + "/articles") + .retrieve() + .toBodilessEntity(); + } + + @Test + void whenSavedArticleFetchedAsString_thenCorrectValueReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + String articlesAsString = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(String.class); + + assertThat(articlesAsString) + .isEqualToIgnoringWhitespace(""" + [{"id":1,"title":"How to use RestClient"}] + """); + } + + @Test + void whenArticleFetchedById_thenCorrectArticleReturned() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article fetchedArticle = restClient.get() + .uri(uriBase + "/articles/" + id) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle) + .usingRecursiveComparison() + .isEqualTo(article); + } + + @Test + void whenArticlesFetchedAsParameterizedTypeReference_thenListReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + List
articles = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + assertThat(articles) + .hasSize(1) + .first() + .usingRecursiveComparison() + .isEqualTo(article); + } + + @Test + void whenUsingCustomJsonMapper_thenArticleSerializedWithCustomFormat() { + JsonMapper jsonMapper = JsonMapper.builder() + .findAndAddModules() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + RestClient customClient = restClient + .mutate() + .configureMessageConverters(converters -> converters + .registerDefaults() + .jsonMessageConverter(new JacksonJsonHttpMessageConverter(jsonMapper))) + .build(); + + int id = 1; + Article article = new Article(id, "How to use RestClient"); + customClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + String fetchedArticle = customClient.get() + .uri(uriBase + "/articles/" + id) + .retrieve() + .body(String.class); + + assertThat(fetchedArticle) + .isEqualToIgnoringWhitespace(""" + {"id":1,"title":"How to use RestClient"} + """); + } + + @Test + void whenUpdatingExistingArticle_thenArticleUpdatedSuccessfully() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article updatedArticle = new Article(id, "How to use RestClient even better"); + restClient.put() + .uri(uriBase + "/articles/" + id) + .contentType(MediaType.APPLICATION_JSON) + .body(updatedArticle) + .retrieve() + .toBodilessEntity(); + + List
articles = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + assertThat(articles) + .hasSize(1) + .first() + .usingRecursiveComparison() + .isEqualTo(updatedArticle); + } + + @Test + void whenDeletingExistingArticle_thenArticleDeletedSuccessfully() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + restClient.delete() + .uri(uriBase + "/articles/" + id) + .retrieve() + .toBodilessEntity(); + + ResponseEntity fetchedArticleResponse = restClient.get() + .uri(uriBase + "/articles") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toBodilessEntity(); + + assertThat(fetchedArticleResponse.getStatusCode()) + .isEqualTo(HttpStatusCode.valueOf(204)); + } + + @Test + void shouldPostAndGetArticlesWithExchange() { + assertThatThrownBy(this::getArticlesWithExchange).isInstanceOf(ArticleNotFoundException.class); + + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + List
articles = getArticlesWithExchange(); + + assertThat(articles) + .usingRecursiveComparison() + .isEqualTo(List.of(article)); + } + + private List
getArticlesWithExchange() { + return restClient.get() + .uri(uriBase + "/articles") + .exchange((request, response) -> { + if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(204))) { + throw new ArticleNotFoundException(); + } else if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(200))) { + return jsonMapper.readValue(response.getBody(), new TypeReference<>() {}); + } else { + throw new InvalidArticleResponseException(); + } + }); + } + + @Test + void shouldPostAndGetArticlesWithErrorHandling() { + assertThatThrownBy(() -> { + restClient + .get() + .uri(uriBase + "/articles/1234") + .retrieve() + .onStatus( + status -> status.value() == 404, + (request, response) -> { + throw new ArticleNotFoundException(); + } + ) + .body(new ParameterizedTypeReference() {}); + }).isInstanceOf(ArticleNotFoundException.class); + } + + @Test + void whenUsingApiVersionInserter_thenVersionHeaderAddedToRequest() { + RestClient versionedClient = restClient.mutate() + .defaultApiVersion("2") + .apiVersionInserter(ApiVersionInserter.useHeader("API-Version")) + .build(); + + Article fetchedArticle = versionedClient.get() + .uri(uriBase + "/articles/" + 1) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle.getId()) + .isEqualTo(100); + assertThat(fetchedArticle.getTitle()) + .isEqualTo("SECRET ARTICLE"); + } + + @Test + void whenInterceptorSetsRequestAttribute_thenAttributeAvailableDuringExecution() { + String key = "test-key"; + String value = "test-value"; + + Map capturedAttributes = new HashMap<>(); + + ClientHttpRequestInterceptor interceptor = (request, body, execution) -> { + request.getAttributes().put(key, value); + capturedAttributes.putAll(request.getAttributes()); + return execution.execute(request, body); + }; + RestClient interceptedClient = restClient + .mutate() + .requestInterceptor(interceptor) + .build(); + + interceptedClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + assertThat(capturedAttributes) + .containsEntry(key, value); + } + + @Test + void whenSearchingArticleByTitle_thenCorrectArticleReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article fetchedArticle = restClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("http") + .host("localhost") + .port(port) + .path("/articles/search") + .queryParam("title", "RestClient") + .build()) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle) + .usingRecursiveComparison() + .isEqualTo(article); + } + +}