Spring WebFlux — Rest API with Annotated Controllers

Reference from: https://app.pluralsight.com/library/courses/getting-started-spring-webflux/table-of-contents

In this tutorial, will develop a Rest API with the Annotated controllers.

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>product-api-annotation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>product-api-annotation</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

</project>

Product.java

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.mongodb.core.mapping.Document;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
@Document
public class Product {
private String id;
private String name;
private Double price;
}

ProductRepository.java

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.mongodb.core.mapping.Document;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
@Document
public class Product {
private String id;
private String name;
private Double price;
}

ProductController.java

package com.example.controller;

import com.example.model.Product;
import com.example.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping(value = "/products")
public class ProductController {
@Autowired
private ProductRepository productRepository;

@GetMapping
public Flux<Product> getAllProducts() {
return productRepository.findAll();
}

@GetMapping("{id}")
public Mono<ResponseEntity<Product>> getProduct(@PathVariable String id) {
return productRepository.findById(id)
.map(product -> ResponseEntity.ok(product))
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Product> saveProduct(@RequestBody Product product) {
return productRepository.save(product);
}

@PutMapping("{id}")
public Mono<ResponseEntity<Product>> updateProduct(@PathVariable(value = "id") String id,
@RequestBody Product product) {
return productRepository.findById(id)
.flatMap(existingProduct -> {
existingProduct.setPrice(product.getPrice());
existingProduct.setName(product.getName());
return productRepository.save(existingProduct);
})
.map(updateProduct -> ResponseEntity.ok(updateProduct))
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@DeleteMapping("{id}")
public Mono<ResponseEntity<Void>> deleteProduct(@PathVariable(value = "id") String id) {
return productRepository.findById(id)
.flatMap(product ->
productRepository.delete(product)
.then(Mono.just(ResponseEntity.ok().<Void>build()))
)
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@DeleteMapping
public Mono<Void> deleteAllProduct() {
return productRepository.deleteAll();
}
}

MainApp.java

package com.example.controller;

import com.example.model.Product;
import com.example.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping(value = "/products")
public class ProductController {
@Autowired
private ProductRepository productRepository;

@GetMapping
public Flux<Product> getAllProducts() {
return productRepository.findAll();
}

@GetMapping("{id}")
public Mono<ResponseEntity<Product>> getProduct(@PathVariable String id) {
return productRepository.findById(id)
.map(product -> ResponseEntity.ok(product))
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Product> saveProduct(@RequestBody Product product) {
return productRepository.save(product);
}

@PutMapping("{id}")
public Mono<ResponseEntity<Product>> updateProduct(@PathVariable(value = "id") String id,
@RequestBody Product product) {
return productRepository.findById(id)
.flatMap(existingProduct -> {
existingProduct.setPrice(product.getPrice());
existingProduct.setName(product.getName());
return productRepository.save(existingProduct);
})
.map(updateProduct -> ResponseEntity.ok(updateProduct))
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@DeleteMapping("{id}")
public Mono<ResponseEntity<Void>> deleteProduct(@PathVariable(value = "id") String id) {
return productRepository.findById(id)
.flatMap(product ->
productRepository.delete(product)
.then(Mono.just(ResponseEntity.ok().<Void>build()))
)
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@DeleteMapping
public Mono<Void> deleteAllProduct() {
return productRepository.deleteAll();
}
}

MainApp.java

package com.example;

import com.example.model.Product;
import com.example.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Flux;

@SpringBootApplication
public class ProductApiAnnotationApplication {

public static void main(String[] args) {
SpringApplication.run(ProductApiAnnotationApplication.class, args);
}

@Bean
CommandLineRunner init(ProductRepository productRepository) {
return args -> {
Flux<Product> productFlux = Flux.just(new Product(null, "Big Latte", 2.99),
new Product(null, "Big Decaf", 2.49),
new Product(null, "Green team", 1.99))
.flatMap(productRepository::save);

productRepository.deleteAll()
.thenMany(productFlux)
.thenMany(productRepository.findAll())
.subscribe(System.out::println);

};
}
}
  • Get All Products

curl http://localhost:8080/products

[{“id”:”622a3b5a77776216a961c6f0",”name”:”Big Latte”,”price”:2.99},{“id”:”622a3b5a77776216a961c6f2",”name”:”Green team”,”price”:1.99},{“id”:”622a3b5a77776216a961c6f1",”name”:”Big Decaf”,”price”:2.49}]

  • Get Product By Id

curl http://localhost:8080/products/622a3b5a77776216a961c6f0

{“id”:”622a3b5a77776216a961c6f0",”name”:”Big Latte”,”price”:2.99}

  • Save the Product

curl -X POST \
http://localhost:8080/products \
-H ‘postman-token: ef1b4117–5b40-a11b-60dd-94eb3926f931’ \
-d ‘{
“name” : “Malai Milk”,
“price” : 3.10
}’

Response:

{
“id”: “622a3d8477776216a961c6f3”,
“name”: “Malai Milk”,
“price”: 3.1
}

  • Get All Products:

curl -X GET \
http://localhost:8080/products \
-H ‘cache-control: no-cache’ \
-H ‘postman-token: 7ec36e73–5dce-1080-b598–25ee29144db1’

[
{
“id”: “622a3b5a77776216a961c6f0”,
“name”: “Big Latte”,
“price”: 2.99
},
{
“id”: “622a3b5a77776216a961c6f2”,
“name”: “Green team”,
“price”: 1.99
},
{
“id”: “622a3b5a77776216a961c6f1”,
“name”: “Big Decaf”,
“price”: 2.49
},
{
“id”: “622a3d8477776216a961c6f3”,
“name”: “Malai Milk”,
“price”: 3.1
}
]

  • Update Product

curl -X PUT \
http://localhost:8080/products/622a3d8477776216a961c6f3 \
-H ‘cache-control: no-cache’ \
-H ‘content-type: application/json’ \
-H ‘postman-token: ab3a2dc3–0a2f-8256-afee-39de92646128’ \
-d ‘{
“name” : “Malai Milk”,
“price” : 3.99
}’

Response:

{
“id”: “622a3d8477776216a961c6f3”,
“name”: “Malai Milk”,
“price”: 3.99
}

  • Delete Product By Id

curl -X DELETE \
http://localhost:8080/products/622a3d8477776216a961c6f3 \
-H ‘cache-control: no-cache’ \
-H ‘postman-token: 2132d665–587f-d793-bf86–9bc4eff866a9’

Adding one more endpoint for the reactive programming —

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
public class ProductEvent {
private Long eventId;
private String eventType;
}

ProductController.java

@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ProductEvent> getProductEvents(){
return Flux.interval(Duration.ofSeconds(1))
.map(x -> new ProductEvent(x, "Product Event"));
}

Then try to hit — http://localhost:8080/products/events, response should be like events should be generated after 1 sec—

data:{"eventId":0,"eventType":"Product Event"}

data:{"eventId":1,"eventType":"Product Event"}

data:{"eventId":2,"eventType":"Product Event"}

data:{"eventId":3,"eventType":"Product Event"}

data:{"eventId":4,"eventType":"Product Event"}

data:{"eventId":5,"eventType":"Product Event"}

data:{"eventId":6,"eventType":"Product Event"}

data:{"eventId":7,"eventType":"Product Event"}

data:{"eventId":8,"eventType":"Product Event"}

data:{"eventId":9,"eventType":"Product Event"}

data:{"eventId":10,"eventType":"Product Event"}

data:{"eventId":11,"eventType":"Product Event"}

data:{"eventId":12,"eventType":"Product Event"}

GitHub: https://github.com/javaHelper/spring-webflux

Test Cases:

package com.example;

import com.example.model.Product;
import com.example.model.ProductEvent;
import com.example.repository.ProductRepository;
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.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.test.StepVerifier;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;

@SpringBootTest
public class JUnit5ApplicationContextTest {
private WebTestClient client;

private List<Product> expectedList;

@Autowired
private ProductRepository repository;

@Autowired
private ApplicationContext context;

@BeforeEach
void beforeEach() {
this.client = WebTestClient
.bindToApplicationContext(context)
.configureClient()
.baseUrl("/products")
.build();
this.expectedList = repository.findAll().collectList().block();
}

@Test
void testGetAllProducts() {
client
.get()
.uri("/")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(Product.class)
.isEqualTo(expectedList);
}

@Test
void testProductInvalidIdNotFound() {
client
.get()
.uri("/aaa")
.exchange()
.expectStatus()
.isNotFound();
}

@Test
void testProductIdFound() {
Product expectedProduct = expectedList.get(0);
client
.get()
.uri("/{id}", expectedProduct.getId())
.exchange()
.expectStatus()
.isOk()
.expectBody(Product.class)
.isEqualTo(expectedProduct);
}

@Test
void testProductEvents() {
ProductEvent expectedEvent = new ProductEvent(0L, "Product Event");

FluxExchangeResult<ProductEvent> result =
client.get().uri("/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(ProductEvent.class);

StepVerifier.create(result.getResponseBody())
.expectNext(expectedEvent)
.expectNextCount(2)
.consumeNextWith(event -> assertEquals(Long.valueOf(3), event.getEventId()))
.thenCancel()
.verify();
}
}

Mock

package com.example;

import com.example.controller.ProductController;
import com.example.model.Product;
import com.example.model.ProductEvent;
import com.example.repository.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(SpringExtension.class)
public class JUnit5ControllerMockTest {
private WebTestClient client;

private List<Product> expectedList;

@MockBean
private ProductRepository repository;

@BeforeEach
void beforeEach() {
this.client =
WebTestClient
.bindToController(new ProductController(repository))
.configureClient()
.baseUrl("/products")
.build();

this.expectedList = Arrays.asList(
new Product("1", "Big Latte", 2.99)
);
}

@Test
void testGetAllProducts() {
when(repository.findAll()).thenReturn(Flux.fromIterable(this.expectedList));

client
.get()
.uri("/")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(Product.class)
.isEqualTo(expectedList);
}

@Test
void testProductInvalidIdNotFound() {
String id = "aaa";
when(repository.findById(id)).thenReturn(Mono.empty());

client
.get()
.uri("/{id}", id)
.exchange()
.expectStatus()
.isNotFound();
}

@Test
void testProductIdFound() {
Product expectedProduct = this.expectedList.get(0);
when(repository.findById(expectedProduct.getId())).thenReturn(Mono.just(expectedProduct));

client
.get()
.uri("/{id}", expectedProduct.getId())
.exchange()
.expectStatus()
.isOk()
.expectBody(Product.class)
.isEqualTo(expectedProduct);
}

@Test
void testProductEvents() {
ProductEvent expectedEvent = new ProductEvent(0L, "Product Event");

FluxExchangeResult<ProductEvent> result =
client.get().uri("/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(ProductEvent.class);

StepVerifier.create(result.getResponseBody())
.expectNext(expectedEvent)
.expectNextCount(2)
.consumeNextWith(event -> assertEquals(Long.valueOf(3), event.getEventId()))
.thenCancel()
.verify();
}
}

Controller.java

package com.example;

import com.example.controller.ProductController;
import com.example.model.Product;
import com.example.model.ProductEvent;
import com.example.repository.ProductRepository;
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.http.MediaType;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.test.StepVerifier;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class JUnit5ControllerTest {
private WebTestClient client;

private List<Product> expectedList;

@Autowired
private ProductRepository repository;

@BeforeEach
void beforeEach() {
this.client =
WebTestClient
.bindToController(new ProductController(repository))
.configureClient()
.baseUrl("/products")
.build();

this.expectedList =
repository.findAll().collectList().block();
}

@Test
void testGetAllProducts() {
client.get()
.uri("/")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(Product.class)
.isEqualTo(expectedList);
}

@Test
void testProductInvalidIdNotFound() {
client.get()
.uri("/aaa")
.exchange()
.expectStatus()
.isNotFound();
}

@Test
void testProductIdFound() {
Product expectedProduct = expectedList.get(0);
client.get()
.uri("/{id}", expectedProduct.getId())
.exchange()
.expectStatus()
.isOk()
.expectBody(Product.class)
.isEqualTo(expectedProduct);
}

@Test
void testProductEvents() {
ProductEvent expectedEvent =
new ProductEvent(0L, "Product Event");

FluxExchangeResult<ProductEvent> result =
client.get().uri("/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(ProductEvent.class);

StepVerifier.create(result.getResponseBody())
.expectNext(expectedEvent)
.expectNextCount(2)
.consumeNextWith(event ->
assertEquals(Long.valueOf(3), event.getEventId()))
.thenCancel()
.verify();
}
}

Annotation

package com.example;

import com.example.controller.ProductController;
import com.example.model.Product;
import com.example.model.ProductEvent;
import com.example.repository.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
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.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@WebFluxTest(ProductController.class)
public class JUnit5WebFluxTestAnnotationTest {

@Autowired
private WebTestClient client;

private List<Product> expectedList;

@MockBean
private ProductRepository repository;

@MockBean
private CommandLineRunner commandLineRunner;

@BeforeEach
void beforeEach() {
this.expectedList = Arrays.asList(
new Product("1", "Big Latte", 2.99)
);
}

@Test
void testGetAllProducts() {
when(repository.findAll()).thenReturn(Flux.fromIterable(this.expectedList));

client
.get()
.uri("/products")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(Product.class)
.isEqualTo(expectedList);
}

@Test
void testProductInvalidIdNotFound() {
String id = "aaa";
when(repository.findById(id)).thenReturn(Mono.empty());

client
.get()
.uri("/products/{id}", id)
.exchange()
.expectStatus()
.isNotFound();
}

@Test
void testProductIdFound() {
Product expectedProduct = this.expectedList.get(0);
when(repository.findById(expectedProduct.getId())).thenReturn(Mono.just(expectedProduct));

client
.get()
.uri("/products/{id}", expectedProduct.getId())
.exchange()
.expectStatus()
.isOk()
.expectBody(Product.class)
.isEqualTo(expectedProduct);
}

@Test
void testProductEvents() {
ProductEvent expectedEvent =
new ProductEvent(0L, "Product Event");

FluxExchangeResult<ProductEvent> result =
client.get().uri("/products/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(ProductEvent.class);

StepVerifier.create(result.getResponseBody())
.expectNext(expectedEvent)
.expectNextCount(2)
.consumeNextWith(event ->
assertEquals(Long.valueOf(3), event.getEventId()))
.thenCancel()
.verify();
}
}

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store