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
AstroService
in acom.nfjs.restclient.services
package undersrc/main/java
-
Add the annotation
@Service
to the class (from theorg.springframework.stereotype
package, so you’ll need animport
statement) -
Add a private attribute to
AstroService
of typeRestTemplate
calledtemplate
-
Add a constructor to
AstroService
that 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 theRestTemplateBuilder
and assign the result to thetemplate
attribute.If you provide only a single constructor in a class, you do not need to add the @Autowired
annotation 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
public
method to our service calledgetPeopleInSpace
that takes no arguments and returns aString
. -
Access the API using the
getForObject
method ofRestTemplate
as shown:public String getPeopleInSpace() { return template.getForObject("http://api.open-notify.org/astros.json", String.class); }
-
The
getForObject
method 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 aString
in order to verify everything is working properly. To do so, add a test class calledAstroServiceTest
in 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.json
package. -
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.json
package):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
AstroResponse
class. Add a method calledgetAstroResponseSync
to theAstroService
that 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
AstroService
class, add an attribute of typeWebClient
that is initialize in theAstroService
constructor using thestatic
methodWebClient.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
getAstroResponseAsync
that takes no arguments and returns aMono<AstroResponse>
instead of theAstroReponse
we 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
get
method is used to make an HTTP GET request. theuri
method takes the path, which is the part of the URL after the base. Theretrieve
method schedules the retrieval. Then thebodyToMono
method extracts the body from the HTTP response and converts it to an instance ofAstroResponse
and wraps it in aMono
. Finally, thelog
method onMono
will log to the console all the reactive stream interactions, which is useful for debugging. -
To test this, go back to the
AstroServiceTest
class. 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
StepVerifier
class 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
AstroInterface
to theservices
package. -
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@PathVariable
or something similar. -
We now need the proxy factory bean, which goes in a Java configuration class. Since the
RestClientApplication
class (the class with the standard Javamain
method) is annotated with@SpringBootApplication
, it ultimately contains the annotation@Configuration
. That means we can add@Bean
methods 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
WebClient
configured for the base URL, and uses that to build anHttpServiceProxyFactory
. From the factory, we use thecreateClient
method to tell Spring to create a class that implements theAstroInterface
. -
To test this, simply reuse the
AstroServiceTest
class 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
getAstroResponse
method ofAstroInterface
toAstroResponse
instead 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.literx
package, find the classesPart01Flux
andPart02Mono
. 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
Part03StepVerifier
toPart11BlockingToReactive
. 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
, andH2Database
dependencies. -
Add a domain class called
Customer
as an entity thecom.kousenit.reactivecustomers.entities
package.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
id
with@Id
fromorg.springframework.data.annotation
. -
If you are on Java 17, you can use a record instead, as long as you leave the
id
property out of theequals
andhashCode
calculations: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
CustomerRepository
that extendsReactiveCrudRepository<Customer, Long>
in thecom.kousenit.reactivecustomers.dao
package. 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.sql
to thesrc/main/resources
folder, 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@DataR2dbcTest
to the class. -
Autowire in an instance of
CustomerRepository
calledrepository
. -
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
id
fields will be null until the officers are saved. To save them, add a method calledsetUp
that takes no arguments and returnsvoid
. Annotated it with@BeforeEach
from JUnit 5. This method reset the database it before each test, though the individual rows will use different primary keys. -
The body of the
setUp
method is:@BeforeEach void setUp() { customers = repository.deleteAll() .thenMany(Flux.fromIterable(customers)) .flatMap(repository::save) .collectList().block(); }
-
Test
findAll
by 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.properties
in thesrc/main/resources
folder: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
CommandLineRunner
from Spring. To do so, create a class calledAppInit
in thecom.kousen.reactiveofficers.config
package. We could use a@Component
class 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 CommandLineRunner
is 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 typeCustomerRepository
and 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
controllers
package undersrc/test/java
calledCustomerControllerTest
. -
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
WebTestClient
and 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
DatabaseClient
to 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
var
reserved 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
WebTestClient
is 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 containsCustomer
instances. -
The test for
findById
uses 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
create
method 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
delete
tests, 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
CustomerController
in thecom.kousenit.reactivecustomers.controllers
package 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
IllegalArgumentException
when the desiredid
is not in the database. We want to convert that to a "bad request" response instead. -
To do that, in the
controllers
package, 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
IllegalArgumentException
into aNOT_FOUND
. Spring Boot 3 also contains an interesting class calledProblemDetails
that can be used as an alternative to give back more information. If there is time, this will be discussed during class.