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);
+ }
+
+}