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

--

--

--

Java Developer and enthusiast

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Handle Exceptions like a Professional (Part 2)

Coding with your Android phone in this 21st century

Apple M1 foreshadows Rise of RISC-V

ActiveSupport::MessageEncryptor Rails 5.x

Views- Modifying and Analyzing Data using SQL

The if __name__ == “__main__” conditional in python

Chaos Engineering with Serverless

Part 2. Express Routing with Open API 3.0 and Swagger UI/Editor

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
PA

PA

Java Developer and enthusiast

More from Medium

Using Java API

SpringBoot Kafka Tutorial With Code

JPA Key Notes

Spring Batch — Parameters Between Steps