Exercises
1. Building a REST client
This exercise uses the RestTemplate class to synchronously access a RESTful web service. The template is used to convert the response into an object for the rest of the system. Later the WebClient class, introduced in Spring 5, will be used to do the same asynchronously.
-
Create a new Spring Boot project (either by using the Initializr at http://start.spring.io or using your IDE) called
restclient. Add both the Spring Web and the Spring Reactive Web dependencies. -
Create a service class called
AstroServicein acom.nfjs.restclient.servicespackage undersrc/main/java -
Add the annotation
@Serviceto the class (from theorg.springframework.stereotypepackage, so you’ll need animportstatement) -
Add a private attribute to
AstroServiceof typeRestTemplatecalledtemplate -
Add a constructor to
AstroServicethat takes a single argument of typeRestTemplateBuilder.Because there are so many possible configuration options, Spring does not automatically provide a RestTemplate. It does, however, provide aRestTemplateBuilder, which can be used to configure and create theRestTemplate. -
Inside the constructor, invoke the
build()method on theRestTemplateBuilderand assign the result to thetemplateattribute.If you provide only a single constructor in a class, you do not need to add the @Autowiredannotation to it. Spring will inject the arguments anyway -
The site providing the API is http://open-notify.org/, which is an API based on NASA data. We’ll access the Number of People in Space service using a GET request.
-
Add a
publicmethod to our service calledgetPeopleInSpacethat takes no arguments and returns aString. -
Access the API using the
getForObjectmethod ofRestTemplateas shown:public String getPeopleInSpace() { return template.getForObject("http://api.open-notify.org/astros.json", String.class); } -
The
getForObjectmethod that takes two arguments: the URL to access, and the class to instantiate with the resulting JSON response. It performs an HTTP GET request and parses the returned JSON data. At the moment, all we are asking is for the JSON data to be returned as aStringin order to verify everything is working properly. To do so, add a test class calledAstroServiceTestin the same package undersrc/test/java:@SpringBootTest class AstroServiceTest { @Autowired private AstroService service; @Test void getPeopleInSpace() { String people = service.getPeopleInSpace(); assertNotNull(people); assertTrue(people.contains("people")); System.out.println(people); } } -
The test asserts that the JSON response contains a field called "people", but that’s about all we can do until we parse the data into Java classes. The general form of the response is:
{ "message": "success", "number": NUMBER_OF_PEOPLE_IN_SPACE, "people": [ {"name": NAME, "craft": SPACECRAFT_NAME}, ... ] } -
Since there are only two nested JSON objects, you can create two classes that model them. Create the classes
Assignment, which will be the combination of "name" and "craft" for each astronaut, andAstroResponse, which holds the complete response, both in thecom.nfjs.restclient.jsonpackage. -
The code for the classes are shown below. Note how the properties match the keys in the JSON response exactly. You can use annotations from the included Jackson 2 JSON parser to customize the attributes if you like, but in this case it’s easy enough to make them the same as the JSON variable names.
package com.nfjs.restclient.json; public class Assignment { private String name; private String craft; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getCraft() { return craft; } public void setCraft(String craft) { this.craft = craft; } } public class AstroResponse { private String message; private int number; private List<Assignment> people; // ... getters and setters ... } -
Note that if you are using Java 17, you can replace these with records instead, because the included Jackson JSON parser understands how to parse JSON into records (in two separate files in the
com.nfjs.restclient.jsonpackage):public record Assignment(String name, String craft) { } public record AstroResponse(String message, int number, List<Assignment> people) { } -
The JSON response from the web service can now be converted into an instance of the
AstroResponseclass. Add a method calledgetAstroResponseSyncto theAstroServicethat takes no arguments and returns anAstroResponse:public AstroResponse getAstroResponseSync() { return template.getForObject( "http://api.open-notify.org/astros.json", AstroResponse.class); } -
To use the new method, create a test for it. The source for the test is:
@Test void getAstroResponseSync() { AstroResponse response = service.getAstroResponseSync(); assertNotNull(response); assertEquals("success", response.getMessage()); assertTrue(response.getNumber() >= 0); assertEquals(response.getNumber(), response.getPeople().size()); System.out.println(response); } -
Note that if you used records for the parsed data, replace
getMessage()withmessage(),getNumber()withnumber(), andgetPeople()withpeople(). -
The test verifies that the returned message string is "success", that the number of people in space is non-negative, and that the reported number matches the size of the people collection.
-
Execute the test and make any needed corrections until it passes.
2. Asynchronous Access
The webflux module in Spring allows you to use the Project Reactor types Flux and Mono. Methods that work synchronously can be converted to asynchronous by changing the return type to one of those types. The WebClient class then knows how produce those types, and is now the preferred asynchronous rest client (the class AsyncRestTemplate is now deprecated).
-
In the
AstroServiceclass, add an attribute of typeWebClientthat is initialize in theAstroServiceconstructor using thestaticmethodWebClient.create, which takes the base URL of the service.@Service public class AstroService { private final RestTemplate template; private final WebClient client; @Autowired public AstroService(RestTemplateBuilder builder) { this.template = builder.build(); this.client = WebClient.create("http://api.open-notify.org"); } // ... other methods ... } -
Now add a new method called
getAstroResponseAsyncthat takes no arguments and returns aMono<AstroResponse>instead of theAstroReponsewe used previously. The implementation is:public Mono<AstroResponse> getAstroResponseAsync() { return client.get() .uri("/astros.json") .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(AstroResponse.class) .log(); } -
The
getmethod is used to make an HTTP GET request. theurimethod takes the path, which is the part of the URL after the base. Theretrievemethod schedules the retrieval. Then thebodyToMonomethod extracts the body from the HTTP response and converts it to an instance ofAstroResponseand wraps it in aMono. Finally, thelogmethod onMonowill log to the console all the reactive stream interactions, which is useful for debugging. -
To test this, go back to the
AstroServiceTestclass. There are two ways to test the method. One is to invoke it and block until the request is complete. A test to do that is shown here:@Test void getAstroResponseAsync() { AstroResponse response = service.getAstroResponseAsync() .block(Duration.ofSeconds(2)); assertNotNull(response); assertEquals("success", response.getMessage()); assertTrue(response.getNumber() >= 0); assertEquals(response.getNumber(), response.getPeople().size()); System.out.println(response); } -
As an alternative, the Reactor Test project includes a class called
StepVerifier, which includes assertion methods. A test using that class is given by:@Test void getAstroResponseAsyncStepVerifier() { service.getAstroResponseAsync() .as(StepVerifier::create) .assertNext(response -> { assertNotNull(response); assertEquals("success", response.message()); assertTrue(response.number() >= 0); assertEquals(response.number(), response.people().size()); System.out.println(response); }) .verifyComplete(); } -
Both of the new tests should now pass. The details of the
StepVerifierclass will be discussed during the course.
3. Http Interfaces (Spring Boot 3+ only)
If you are using Spring Boot 3.0 or above (and therefore Spring 6.0 or above), there is a new way to access external restful web services. The https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#spring-integration(Spring 6 documentation) has a section on REST clients, which includes the RestTemplate and WebClient classes discussed above, as well as something called HTTP Interface.
The idea is to declare an interface with the access methods you want, and add a proxy factory bean to the application context, and Spring will implement the interface methods for you. This exercise is a quick example of how to do that for our current application.
-
Add an interface called
AstroInterfaceto theservicespackage. -
Inside that interface, add a method to perform an HTTP GET request to our "People In Space" endpoint:
public interface AstroInterface { @GetExchange("/astros.json") Mono<AstroResponse> getAstroResponse(); } -
Like most publicly available services, this service only supports GET requests. For those that support other HTTP methods, there are annotations
@PutExchange,@PostExchange,@DeleteExchange, and so on. Also, this particular request does not take any parameters, so it is particularly simple. If it took parameters, they would appear in the URL at Http Template variables, and in the parameter list of the method annotated with@PathVariableor something similar. -
We now need the proxy factory bean, which goes in a Java configuration class. Since the
RestClientApplicationclass (the class with the standard Javamainmethod) is annotated with@SpringBootApplication, it ultimately contains the annotation@Configuration. That means we can add@Beanmethods to it, which Spring will use to add beans to the application context. Therefore, add the following bean to that class:@Bean public AstroInterface astroInterface() { WebClient client = WebClient.create("http://api.open-notify.org/"); HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build(); return factory.createClient(AstroInterface.class); } -
That method creates a
WebClientconfigured for the base URL, and uses that to build anHttpServiceProxyFactory. From the factory, we use thecreateClientmethod to tell Spring to create a class that implements theAstroInterface. -
To test this, simply reuse the
AstroServiceTestclass by adding another test:@Test void getAstroResponseFromInterface(@Autowired AstroInterface astroInterface) { AstroResponse response = astroInterface.getAstroResponse() .block(Duration.ofSeconds(2)); assertNotNull(response); assertAll( () -> assertEquals("success", response.message()), () -> assertTrue(response.number() >= 0), () -> assertEquals(response.number(), response.people().size()) ); System.out.println(response); } -
That test should pass. Note that for synchronous access, simply change the return type of the method inside the
getAstroResponsemethod ofAstroInterfacetoAstroResponseinstead of theMono. See the documentation for additional details.
4. Project Reactor tutorial
This exercise works with a tutorial provided by
Project Reactor to teach the basics of the classes Flux and Mono.
-
Project Reactor is located at https://projectreactor.io. Under the Documentation header you will find the Reference Guide for Reactor Core at https://projectreactor.io/docs/core/release/reference/ and the Javadocs for that project at https://projectreactor.io/docs/core/release/api/ .
-
Inside the Reference Guide, go to Appendix A: Which Operator Do I Need?. This will help you solve the tutorial exercises.
-
The tutorial project is located on GitHub at https://github.com/reactor/lite-rx-api-hands-on, entitled Lite Rx API Hands On. It is a Maven project that requires only Java 8.
-
Clone the project and import it into your IDE.
-
There are two branches that matter here. The master branch contains the exercises as a series of TODO statements inside tests, and the solution branch contains the answers to those exercises.
-
Under
src/main/java, in theio.pivotal.literxpackage, find the classesPart01FluxandPart02Mono. The corresponding tests are in the same package undersrc/test/java. -
Complete those exercises as the comments describe.
-
If you have time, feel free to look at the other exercises, which are classes labeled from
Part03StepVerifiertoPart11BlockingToReactive. Alternatively, you can simply browse the code for them in the solution branch. Hopefully, you will find Appendix A in the reference guide helpful in this, along with the Javadocs.
5. Reactive Spring Data
-
Create a new project called
reactive-customers. Add in theSpring Reactive Web,Spring Data R2DBC, andH2Databasedependencies. -
Add a domain class called
Customeras an entity thecom.kousenit.reactivecustomers.entitiespackage.import org.springframework.data.annotation.Id; public class Customer { @Id private Long id; private String firstName; private String lastName; public Customer() {} public Customer(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public Customer(Long id, String firstName, String lastName) { this.id = id; this.firstName = firstName; this.lastName = lastName; } // ... getters and setters ... // ... equals and hashCode (without id) ... // ... toString ... } -
As shown, annotate
idwith@Idfromorg.springframework.data.annotation. -
If you are on Java 17, you can use a record instead, as long as you leave the
idproperty out of theequalsandhashCodecalculations:package com.kousenit.reactivecustomers.entities; import org.springframework.data.annotation.Id; import java.util.Objects; // Note: You can use records here, but be sure to override equals() and hashCode() // so that they use the non-id properties only public record Customer(@Id Long id, String firstName, String lastName) { @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Customer customer = (Customer) o; return Objects.equals(firstName, customer.firstName) && Objects.equals(lastName, customer.lastName); } @Override public int hashCode() { return Objects.hash(firstName, lastName); } } -
Make a Spring Data interface called
CustomerRepositorythat extendsReactiveCrudRepository<Customer, Long>in thecom.kousenit.reactivecustomers.daopackage. Add to the interface a query method to retrieve the customers by last name.package com.oreilly.reactiveofficers.dao; import com.kousenit.reactivecustomers.entities.Customer; import org.springframework.data.repository.reactive.ReactiveCrudRepository; public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long> { Flux<Customer> findByLastName(String lastName); } -
We need to create a database to store the data. Here we’ll use H2. When we created the project, the Spring Initializr provided an H2 database driver that supports R2DBC. To create the database, add a file called
schema.sqlto thesrc/main/resourcesfolder, containing the following table definition (don’t forget the trailing semicolon):create table customer ( id long generated always as identity primary key, first_name varchar(20) not null, last_name varchar(20) not null ); -
Create a test for the repository called
CustomerRepositoryTest. Add the annotation@DataR2dbcTestto the class. -
Autowire in an instance of
CustomerRepositorycalledrepository. -
Provide initialization data in the form of a list of customers:
private final List<Customer> customers = List.of( new Customer(null, "Malcolm", "Reynolds"), new Customer(null, "Zoë", "Washburne"), new Customer(null, "Hoban", "Washburne"), new Customer(null, "Jayne", "Cobb"), new Customer(null, "Kaylee", "Frye")); -
Note that the
idfields will be null until the officers are saved. To save them, add a method calledsetUpthat takes no arguments and returnsvoid. Annotated it with@BeforeEachfrom JUnit 5. This method reset the database it before each test, though the individual rows will use different primary keys. -
The body of the
setUpmethod is:@BeforeEach void setUp() { customers = repository.deleteAll() .thenMany(Flux.fromIterable(customers)) .flatMap(repository::save) .collectList().block(); } -
Test
findAllby checking that there are five customers in the test collection:@Test public void fetchAllCustomers() { repository.findAll() .doOnNext(System.out::println) .as(StepVerifier::create) .expectNextCount(5) .verifyComplete(); } -
Check the other query methods by fetching the first customer by id, then searching by last name.
@Test void fetchCustomerById() { repository.findById(customers.get(0).id()) .doOnNext(System.out::println) .as(StepVerifier::create) .expectNextMatches(customer -> customer.firstName().equals("Malcolm")) .verifyComplete(); } @Test void fetchCustomersByLastName() { repository.findByLastName("Washburne") .doOnNext(System.out::println) .as(StepVerifier::create) .expectNextCount(2) .verifyComplete(); } -
Add three more tests to verify you can insert, update, and delete customers:
@Test void insertCustomer() { Customer newCustomer = new Customer(null, "Inara", "Serra"); repository.save(newCustomer) .doOnNext(System.out::println) .as(StepVerifier::create) .expectNextMatches(customer -> customer.firstName().equals("Inara")) .verifyComplete(); } @Test void updateCustomer() { Customer updatedCustomer = new Customer(customers.get(0).id(), "Malcolm", "Reynolds, Jr."); repository.save(updatedCustomer) .doOnNext(System.out::println) .as(StepVerifier::create) .expectNextMatches(customer -> customer.firstName().equals("Malcolm")) .verifyComplete(); } @Test void deleteCustomer() { repository.deleteById(customers.get(0).id()) .doOnNext(System.out::println) .as(StepVerifier::create) .verifyComplete(); } -
The tests should all pass. You can see the SQL being executed by adding the following line to the file
application.propertiesin thesrc/main/resourcesfolder:logging.level.org.springframework.r2dbc=debug.Add the .log()method to any reactive stream to see the underlying calls tosubscribe,onNext, and so on.
6. Spring WebFlux with Annotated Controllers
-
Initialize a collection with sample data using a
CommandLineRunnerfrom Spring. To do so, create a class calledAppInitin thecom.kousen.reactiveofficers.configpackage. We could use a@Componentclass for this, but let’s use the Java configuration approach instead, since it’s a useful technique to know.If the database was not being reset every time the application starts, this step would not be necessary. Since it is, a CommandLineRunneris a convenient way to initialize it. -
Annotate the class with
@Configuration, marking it as a Java configuration class that will be read on start up. -
Add a method to the class called
initializeDatabase, annotated with@Bean, which takes an argument of typeCustomerRepositoryand returns aCommandLineRunner, containing the following code:@Configuration public class AppInit { @Bean public CommmandLineRunner initializeDatabase(CustomerRepository repository) { return args -> repository.count().switchIfEmpty(Mono.just(0L)) .flatMapMany(count -> repository.deleteAll() .thenMany(Flux.just( new Customer(null, "Malcolm", "Reynolds"), new Customer(null, "Zoë", "Washburne"), new Customer(null, "Hoban", "Washburne"), new Customer(null, "Jayne", "Cobb"), new Customer(null, "Kaylee", "Frye"))) .flatMap(repository::save)) .subscribe(System.out::println); } } -
The initialization adds new customers from the database if the count is zero.
-
To add a controller, let’s start with the controller tests. We’ll use Spring’s functional testing capability, where it can automatically start up a test server, deploy our application, run a series of tests, and shut down the server.
-
Create a class in the
controllerspackage undersrc/test/javacalledCustomerControllerTest. -
Annotate the class with
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT). This tells Spring to start up a test server on any available open port. -
Inside the class, autowire properties for the
WebTestClientand aDatabaseClient:@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class CustomerControllerTest { @Autowired private WebTestClient client; @Autowired private DatabaseClient databaseClient; // ... more to come ... -
To reinitialize the database between each test, use the
DatabaseClientto drop the table, recreate it, and insert five rows:@BeforeEach void setUp() { var statements = List.of( """ DROP TABLE IF EXISTS customer; CREATE TABLE customer( id long generated always as identity primary key, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL ); INSERT INTO customer (first_name, last_name) VALUES ('Malcolm', 'Reynolds'); INSERT INTO customer (first_name, last_name) VALUES ('Zoë', 'Washburne'); INSERT INTO customer (first_name, last_name) VALUES ('Hoban', 'Washburne'); INSERT INTO customer (first_name, last_name) VALUES ('Jayne', 'Cobb'); INSERT INTO customer (first_name, last_name) VALUES ('Kaylee', 'Frye'); """ ); statements.forEach(it -> databaseClient.sql(it) .fetch() .rowsUpdated() .as(StepVerifier::create) .expectNextCount(1) .verifyComplete()); } -
Note that this uses Text Blocks from Java 17, as well as Local Variable Type Inference (the
varreserved word) from Java 11. Neither of these are required, but they make entering SQL inside Java much easier. -
Now add a private method to retrieve all the current id values from the table:
private List<Long> getIds() { return databaseClient.sql("select id from customer") .map(row -> row.get("id", Long.class)) .all() .collectList() .block(); } -
The advantage of the
WebTestClientis that it already knows the URL of the test server, including the selected port number. Therefore, you can use it like a regularWebClient. Here is a test that retrieves all the available customers:@Test void findAll() { client.get() .uri("/customers") .exchange() .expectStatus().isOk() .expectBodyList(Customer.class) .hasSize(5); } -
Methods like
expectBodyList(Class)make it easy to verify that the response JSON body containsCustomerinstances. -
The test for
findByIduses the private methodgetIds():@Test void findById() { getIds().forEach(id -> client.get() .uri("/customers/%d".formatted(id)) .exchange() .expectStatus().isOk() .expectBody(Customer.class) .value(customer -> assertEquals(id, customer.id()))); } -
Test the
createmethod by executing an HTTP POST request with a customer in the body:@Test void create() { Customer customer = new Customer(null, "Inara", "Serra"); client.post() .uri("/customers") .bodyValue(customer) .exchange() .expectStatus().isCreated() .expectBody(Customer.class) .value(c -> assertEquals("Inara", c.firstName())); } -
Finally, here are two
deletetests, one for ids that we know exist, and one for an id that does not exist:@Test void delete() { getIds().forEach(id -> client.delete() .uri("/customers/%d".formatted(id)) .exchange() .expectStatus().isNoContent()); } @Test void deleteNotFound() { client.delete() .uri("/customers/999") .exchange() .expectStatus().isNotFound(); } -
All of these tests should fail at this point, because we have not yet implemented the controller.
-
Now add a REST controller by creating a class called
CustomerControllerin thecom.kousenit.reactivecustomers.controllerspackage and annotate the class with@RestController. -
Since all of the methods will be based on the URL path
customers, add a@RequestMapping('/customers')annotation for that to the class. -
Autowire in the
CustomerRepository, as shown:@RestController @RequestMapping("/customers") public class CustomerController { private final CustomerRepository repository; @Autowired public CustomerController(CustomerRepository repository) { this.repository = repository; } // ... more to come ... } -
Here are the controller methods, which will be discussed in class:
@GetMapping public Flux<Customer> findAll() { return repository.findAll(); } @GetMapping("{id}") public Mono<Customer> findById(@PathVariable Long id) { return repository.findById(id).switchIfEmpty( Mono.error(new IllegalArgumentException( "Customer with id %d not found".formatted(id)))); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Mono<Customer> create(@RequestBody Customer customer) { return repository.save(customer); } @DeleteMapping("{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public Mono<Void> delete(@PathVariable Long id) { return repository.findById(id) .switchIfEmpty(Mono.error(new IllegalArgumentException( "Customer with id %d not found".formatted(id)))) .flatMap(repository::delete); } -
Most of the tests will now pass. Two of the controller methods, however, throw an
IllegalArgumentExceptionwhen the desiredidis not in the database. We want to convert that to a "bad request" response instead. -
To do that, in the
controllerspackage, add a class calledCustomerAdvice, with the following contents:@RestControllerAdvice public class CustomerAdvice { @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public String handleIllegalArgumentException(IllegalArgumentException e) { return e.getMessage(); } } -
This will convert any
IllegalArgumentExceptioninto aNOT_FOUND. Spring Boot 3 also contains an interesting class calledProblemDetailsthat can be used as an alternative to give back more information. If there is time, this will be discussed during class.