Exercises
1. Creating a New Project
-
Go to http://start.spring.io to access the Spring Initializr
-
In the "Generate a" drop-down, choose "Gradle Project"
-
Specify the Group as
com.oreilly
and the Artifact asdemo
-
Add the Spring Web and Thymeleaf dependencies
-
Click the "Generate Project" button to download a zip file containing the project files
-
Unzip the downloaded "demo.zip" file into any directory you like (but remember where it is)
-
Import the project into your IDE
-
If you are using IntelliJ IDEA, import the project by selecting the "Import Project" link on the Welcome page and navigating to the
build.gradle
file inside the unzipped archive -
If you are using Spring Tool Suite (or any other Eclipse-based tool) with Gradle support, you can import the project as an "Existing Gradle project" by navigating to the root of the project and accepting all the defaults.
-
If you don’t have Gradle support in your Eclipse-based IDE, generate an Eclipse project using the included
gradlew
script. -
First you need to add the
eclipse
plugin to thebuild.gradle
file. Open that file in any text editor and type the following line inside theplugins
block:plugins { // ... existing plugins ... id 'eclipse' }
-
Now navigate to the project root directory in a command window and run the following command:
> gradlew cleanEclipse eclipse
On a Unix-based machine (including Macs), use ./gradlew
for the command -
Now you should be able to import the project into Eclipse as an existing Eclipse project (File → Import… → General → Existing Projects Into Workspace)
-
-
As part of the import process, the IDE will download all the required dependencies
-
Open the file
src/main/java/com/oreilly/demo/DemoApplication.java
and note that it contains a standard Java "main" method (with signature:public static void main(String[] args)
) -
Start the application by running this method. There won’t be any web components available yet, but you can see the start up of the application in the command window.
-
Add a controller by creating a file called
com.oreilly.demo.controllers.HelloController
in thesrc/main/java
directoryThe goal is to have the HelloController
class in thecom.oreilly.demo.controllers
package starting at the root directorysrc/main/java
-
The code for the
HelloController
is:package com.oreilly.demo.controllers; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller public class HelloController { @GetMapping("/hello") public String sayHello( // setting 'defaultValue' implicitly sets 'required' to false @RequestParam(defaultValue = "World") String name, Model model) { model.addAttribute("user", name); return "welcome"; } }
-
Create a file called
welcome.html
in thesrc/main/resources/templates
folder -
The code for the
welcome.html
file is:<!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org" lang="en"> <head> <title>Hello, World!</title> </head> <body> <h2 th:text="'Hello, ' + ${user} + '!'"></h2> </body> </html>
-
Start up the application and navigate to http://localhost:8080/hello. You should see the string "Hello, World!" in the browser
-
Change the URL in the browser to http://localhost:8080/hello?name=Dolly. You should now see the string "Hello, Dolly!" in the browser
-
Shut down the application (there’s no graceful way to do that — just hit the stop button in your IDE)
-
Add a home page to the app by creating a file called
index.html
in thesrc/main/resources/static
folder -
The code for the
index.html
file is:<!DOCTYPE HTML> <html lang="en"> <head> <title>Hello, World!</title> </head> <body> <h2>Say hello</h2> <form method="get" action="/hello"> <label for="name">Name:</label> <input type="text" id="name" name="name"><br><br> <input type="submit" value="Say Hello"> </form> </body> </html>
-
From a command prompt in the root of the project, build the application:
> gradlew build
-
Now you can start the application with a generated executable jar file:
> java -jar build/libs/demo-0.0.1-SNAPSHOT.jar
-
Navigate to http://localhost:8080 and see the new home page. From there you can navigate to the greeting page, and manually try adding a
name
parameter to the URL there -
Again stop the application (use Ctrl-C in the command window)
-
Because the controller is a simple POJO, you can unit test it by simply instantiating the controller and calling its
sayHello
method directly. To do so, add a class calledHelloControllerUnitTest
to thecom.oreilly.demo.controllers
package in the test folder,src/test/java
-
The code for the test class is:
package com.oreilly.demo.controllers; import org.junit.jupiter.api.Test; import org.springframework.ui.Model; import org.springframework.validation.support.BindingAwareModelMap; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; public class HelloControllerUnitTest { @Test public void sayHello() { HelloController controller = new HelloController(); Model model = new BindingAwareModelMap(); String result = controller.sayHello("World", model); assertAll( () -> assertEquals("World", model.getAttribute("user")), () -> assertEquals("welcome", result) ); } }
-
Run the test by executing this class as a JUnit test. It should pass. It’s not terribly useful, however, since it isn’t affected by the request mapping or the request parameter.
-
To perform an integration test instead, use the
MockMVC
classes available in Spring. Create a new class calledHelloControllerMockMVCTest
in thecom.oreilly.demo.controllers
package insrc/test/java
-
The code for the integration test is:
package com.oreilly.demo.controllers; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(HelloController.class) public class HelloControllerMockMVCTest { @Autowired private MockMvc mvc; @Test public void testHelloWithoutName() throws Exception { mvc.perform(get("/hello").accept(MediaType.TEXT_HTML)) .andExpectAll( status().isOk(), view().name("welcome"), model().attribute("user", "World") ); } @Test public void testHelloWithName() throws Exception { mvc.perform(get("/hello").param("name", "Dolly") .accept(MediaType.TEXT_HTML)) .andExpectAll( status().isOk(), view().name("welcome"), model().attribute("user", "Dolly") ); } }
-
The tests should pass successfully. One of the advantages of the
@WebMvcTest
annotation over the generic@SpringBootTest
annotation is that it allows you to automatically inject an instance ofMockMvc
, as shown.
2. Add a Rest Controller
-
Add another class to the
com.oreilly.demo.controllers
package calledHelloRestController
. This controller will be used to model a RESTful web service, though at this stage it will be limited to HTTP GET requests (for reasons explained below). -
Add the
@RestController
annotation to the class. -
By default, REST controllers will serialize and deserialize Java classes into JSON data using the Jackson 2 JSON library, which is currently on the classpath by default. To have an object (other than a trivial
String
) to serialize, add arecord
calledGreeting
to thecom.oreilly.demo.json
package. In a larger application. -
In the
Greeting
record, add a private attribute of typeString
calledmessage
.package com.oreilly.demo.json; public record Greeting(String message) {}
-
Back in the
HelloRestController
, add a method calledgreet
that takes aString
calledname
as an argument and returns aGreeting
. -
Annotate the
greet
method with a@GetMapping
whose argument is"/rest"
, which means that the URL to access the method will be http://localhost:8080/rest . -
Add the
@RequestParam
annotation to the argument, with the propertiesrequired
set tofalse
anddefaultValue
set toWorld
. -
In the body of the method, return a new instance of
Greeting
whose constructor argument should be"Hello, " + name + "!"
. -
The full class looks like (note that the string concatenation has been replaced with a
String.format
method)package com.oreilly.hello.controllers; import com.oreilly.hello.json.Greeting; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloRestController { @GetMapping("/rest") public Greeting greet(@RequestParam(defaultValue = "World") String name) { return new Greeting(return new Greeting("Hello, %s!".formatted(name)); } }
-
You can now run the application and check the behavior using either
curl
or a similar command-line tool, or simply accessing the URL in a browser, either with or without a name. -
To create a test for the REST controller, we will use the
TestRestTemplate
class, because we included theweb
dependency rather thanwebflux
which we’ll use in the next exercise. Add a class calledHelloRestControllerIntegrationTest
in thesrc/test/java
tree in the same package as the REST controller class. -
This time, when adding the
@SpringBootTest
annotation, add the argumentwebEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
. This will autoconfigure several properties of the test, including making aTestRestTemplate
available to inject. -
Add two tests, one for greetings without a name and one for greetings with a name.
-
The tests should look like:
@Test public void greetWithName(@Autowired TestRestTemplate template) { Greeting response = template.getForObject("/rest?name=Dolly", Greeting.class); assertEquals("Hello, Dolly!", response.message()); } @Test public void greetWithoutName(@Autowired TestRestTemplate template) { ResponseEntity<Greeting> entity = template.getForEntity("/rest", Greeting.class); assertEquals(HttpStatus.OK, entity.getStatusCode()); assertEquals(MediaType.APPLICATION_JSON, entity.getHeaders().getContentType()); Greeting response = entity.getBody(); assert response != null; assertEquals("Hello, World!", response.message()); }
-
One test uses the
getForEntity
method of the template, which returns aResponseEntity<Greeting>
. The response entity gives access to the headers, so the two provided asserts check the status code and the media type of the response. The actual response is inside the body. By callinggetBody
, the response is returned as a de-serializedGreeting
instance, which allows you to check its message. -
The other test uses the
getForObject
method, which returns the de-serialized response directly. This is simpler, but does not allow access to the headers. You can use either approach in your code. -
The tests should now pass. This application only checks HTTP GET requests, because the application doesn’t have any way to save
Greeting
instances. Once that is added, you could include analogous POST, PUT, and DELETE operations.
3. Building a REST client
This exercise uses the new reactive web client added in Spring 6.1 called RestClient
to access a RESTful web service. The template is used to convert the response into an object for the rest of the system. Older Spring applications used RestTemplate
for synchronous access and WebClient
for asynchronous access. Since WebClient
is used for reactive applications, it returns responses of type Mono
and Flux
, which may be discussed briefly in class. They are essentially "promises" that return a single object (for Mono
) or a collection (for Flux
) of objects.
-
Create a new Spring Boot project (either by using the Initializr at http://start.spring.io or using your IDE) called
restclient
. Add the Spring Web dependency, but no others are necessary. -
Create a service class called
AstroService
in acom.oreilly.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 typeRestClient
(fromorg.springframework.web.client
package) calledrestClient
. -
Add a private, final attribute of type
String
calledbaseUrl
and set it tohttp://api.open-notify.org
. Note that this service does NOT support https, so you will likely get a warning about that in your IDE. -
Add a default constructor to
AstroService
. -
Inside the constructor, initialize the
restClient
attribute using the staticcreate(baseUrl)
method on `RestClient.If you provide only a single constructor in a class, Spring will inject all the arguments automatically. There is no harm, however, in adding the annotation @Autowired
to the constructor if you wish. -
The site providing the Astro API is http://api.open-notify.org, which processes NASA data. One of its services returns the list of astronauts currently in space.
-
Add a
public
method to the service calledgetAstroResponse
. -
The
RestClient
class provides a fluent interface for making a call to a restful web service. This will require creating Java classes that map to the JSON structure. A typical example of the JSON response is:{ "message": "success", "number": NUMBER_OF_PEOPLE_IN_SPACE, "people": [ {"name": NAME, "craft": SPACECRAFT_NAME}, ... ] }
-
Each of the two JSON objects needs to be mapped to a Java record. Create a record called
AstroResponse
in thecom.oreilly.restclient.json
package that maps to the outermost JSON object, giving it propertiesmessage
of typeString
,number
of typeint
, and aList<Assignment>
calledpeople
. Inside theAstroResponse
record, add a record calledAssignment
that holds thename
andcraft
fields. The source for theAstroResponse
record is as followspublic record AstroResponse(String message, int number, List<Assignment> people) { public record Assignment(String craft, String name) {} }
It is not actually necessary to map all the included fields, but the response is simple enough to do so in this case. -
Now the JSON response from the web service can be converted into an instance of the
AstroResponse
class. The following code should be added to thegetAstroResponse
method to do so:return restClient.get() .uri("/astros.json") .accept(MediaType.APPLICATION_JSON) .retrieve() .body(AstroResponse.class);
-
This method retrieves the JSON response and converts it to an instance of the
AstroResponse
class via thebody
method. -
To demonstrate how to use the service, create a JUnit 5 test for it. Create a class called
AstroServiceTest
in thecom.oreilly.services
package under the test hierarchy,src/test/java
. -
The source for the test is:
package com.kousenit.restclient.services; import com.kousenit.restclient.json.AstroResponse; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest public class AstroServiceTest { private final Logger logger = LoggerFactory.getLogger(AstroService.class); @Autowired private AstroService service; @Test public void getAstroResponse() { AstroResponse response = service.getAstroResponse(); logger.info(response.toString()); assertAll( () -> assertTrue(response.number() >= 0), () -> assertEquals("success", response.message()), () -> assertEquals(response.number(), response.people().size()) ); } }
-
Note the use of the SLF4J
Logger
class to log the responses to the console. Not everything in Spring needs to be injected. Spring includes multiple loggers in the classpath. This example uses SLF4J. -
Execute the test and make any needed corrections until it passes.
-
The
RestClient
API is based on the methods in the (slightly) olderWebClient
class, added in Spring Boot 3.0, that performs asynchronous requests. TheRestClient
class is a synchronous version ofWebClient
that returns a single object or a collection of objects. TheWebClient
class returns aMono
orFlux
object, respectively, which are part of the reactive programming model. TheMono
class is a promise that returns a single object, and theFlux
class is a promise that returns a collection of objects. TheRestClient
class is a more traditional, blocking API that returns the actual objects. TheWebClient
class is used in reactive programming, which is beyond the scope of this course. TheRestClient
class is used in traditional, blocking applications. If there is time, your instructor will discuss the reactive programming model contained in Spring’s webflux module in class. If not, you can read about it in the Spring documentation at https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client.
4. Http Interfaces
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() { RestClient client = RestClient.create("http://api.open-notify.org"); RestClientAdapter adapter = RestClientAdapter.create(client); HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); return factory.createClient(AstroInterface.class); }
-
That method creates a
RestClient
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) { var response = astroInterface.getAstroResponse(); 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. -
In the GitHub repository for this guide, the
rest-client
module contains an additional example which uses theJsonPlaceHolder
API to demonstrate how to use the@PostExchange
annotation to send a POST request with a JSON body. The@PutExchange
and@DeleteExchange
annotations work similarly.
5. Accessing the Google Geocoder
Google provides a free geocoding web service that converts addresses into geographical coordinates.
This exercise uses the WebClient
to access the Google geocoder and converts the responses into Java objects.
-
The documentation for the Google geocoder is at https://developers.google.com/maps/documentation/geocoding/intro. Take a look at the page there to see how the geocoder is intended to be used. The base URL for the service is (assuming you want JSON responses) https://maps.googleapis.com/maps/api/geocode/json?address=street,city,state. The
address
parameter needs to be URL encoded and the parts of the address are joined using commas.The address components can be anything appropriate to the host country. The URL includes a string which separates the values by commas. The components don’t have to be street, city, and state. -
Rather than creating a new project, we’ll add a
GeocoderService
to the existingrestclient
project. In that project, add the new class to theservices
package -
Add the
@Service
annotation to the class so that Spring will automatically load and manage the bean during its component scan at start up. -
Give the class an attribute of type
WebClient
calledclient
-
Add a constructor to the class that takes an argument of type
WebClient.Builder
calledbuilder
-
Inside the constructor, set the value of the
client
field by setting the base URL usingbaseUrl("https://maps.googleapis.com")
and invoking thebuild
method on the builder. -
Map the JSON response to classes in a
json
package. The JSON response for the URL https://maps.googleapis.com/maps/api/geocode/json?address=1600+Amphitheatre+Parkway,Mountain+View,CA is:{ "results" : [ { "address_components" : [ { "long_name" : "1600", "short_name" : "1600", "types" : [ "street_number" ] }, { "long_name" : "Amphitheatre Pkwy", "short_name" : "Amphitheatre Pkwy", "types" : [ "route" ] }, { "long_name" : "Mountain View", "short_name" : "Mountain View", "types" : [ "locality", "political" ] }, { "long_name" : "Santa Clara County", "short_name" : "Santa Clara County", "types" : [ "administrative_area_level_2", "political" ] }, { "long_name" : "California", "short_name" : "CA", "types" : [ "administrative_area_level_1", "political" ] }, { "long_name" : "United States", "short_name" : "US", "types" : [ "country", "political" ] }, { "long_name" : "94043", "short_name" : "94043", "types" : [ "postal_code" ] } ], "formatted_address" : "1600 Amphitheatre Parkway, Mountain View, CA 94043, USA", "geometry" : { "location" : { "lat" : 37.4224764, "lng" : -122.0842499 }, "location_type" : "ROOFTOP", "viewport" : { "northeast" : { "lat" : 37.4238253802915, "lng" : -122.0829009197085 }, "southwest" : { "lat" : 37.4211274197085, "lng" : -122.0855988802915 } } }, "place_id" : "ChIJ2eUgeAK6j4ARbn5u_wAGqWA", "types" : [ "street_address" ] } ], "status" : "OK" }
We don’t care about the address components, though the formatted address looks useful. In a
json
subpackage, create the following classes:package com.oreilly.restclient.json; import java.util.List; public class Response { private List<Result> results; private String status; public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public List<Result> getResults() { return results; } public void setResults(List<Result> results) { this.results = results; } public Location getLocation() { return results.get(0).getGeometry().getLocation(); } public String getFormattedAddress() { return results.get(0).getFormattedAddress(); } } package com.oreilly.restclient.json; public class Result { private String formattedAddress; private Geometry geometry; public String getFormattedAddress() { return formattedAddress; } public void setFormattedAddress(String formattedAddress) { this.formattedAddress = formattedAddress; } public Geometry getGeometry() { return geometry; } public void setGeometry(Geometry geometry) { this.geometry = geometry; } } package com.oreilly.restclient.json; public class Geometry { private Location location; public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } } package com.oreilly.restclient.json; public class Location { private double lat; private double lng; public double getLat() { return lat; } public void setLat(double lat) { this.lat = lat; } public double getLng() { return lng; } public void setLng(double lng) { this.lng = lng; } public String toString() { return String.format("(%s,%s)", lat, lng); } }
-
In the
GeocoderService
class, add constants for the key.private static final String KEY = "AIzaSyDw_d6dfxDEI7MAvqfGXEIsEMwjC1PWRno";
-
Add a
public
method that formulates the complete URL with an encoded address and converts it to aResponse
object. The code is simple if you are using Java 11:public Site getLatLng(String... address) { String encoded = Stream.of(address) .map(component -> URLEncoder.encode(component, StandardCharsets.UTF_8)) .collect(Collectors.joining(",")); String path = "/maps/api/geocode/json"; Response response = client.get() .uri(uriBuilder -> uriBuilder.path(path) .queryParam("address", encoded) .queryParam("key", KEY) .build()) .retrieve() .bodyToMono(Response.class) .block(Duration.ofSeconds(2)); return new Site(response.getFormattedAddress(), response.getLocation().getLat(), response.getLocation().getLng()); }
-
If, however, you are still on Java 8, then the
StandardCharsets
class is not available, and theencode
version you have to use instead throws a checked exception. In that case, use the following instead:private String encodeString(String s) { try { return URLEncoder.encode(s,"UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return s; } public Site getLatLng(String... address) { String encoded = Stream.of(address) .map(this::encodeString) .collect(Collectors.joining(",")); String path = "/maps/api/geocode/json"; Response response = client.get() .uri(uriBuilder -> uriBuilder.path(path) .queryParam("address", encoded) .queryParam("key", KEY) .build()) .retrieve() .bodyToMono(Response.class) .block(Duration.ofSeconds(2)); return new Site(response.getFormattedAddress(), response.getLocation().getLat(), response.getLocation().getLng()); }
The use of the
private
method is to avoid the try/catch block inside themap
method directly, just to improve readability. -
To use this service, we need an entity called
Site
. Add a POJO to thecom.oreilly.restclient.entities
package calledSite
that wraps a formatted address string and doubles for the latitude and longitude. The code is:package com.oreilly.restclient.entities; public class Site { private Integer id; private String address; private double latitude; private double longitude; public Site() {} public Site(String address, double latitude, double longitude) { this.address = address; this.latitude = latitude; this.longitude = longitude; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getAddress() { return address; } public void setName(String address) { this.address = address; } public double getLatitude() { return latitude; } public void setLatitude(double latitude) { this.latitude = latitude; } public double getLongitude() { return longitude; } public void setLongitude(double longitude) { this.longitude = longitude; } @Override public String toString() { return "Site{" + "address='" + address + '\'' + ", latitude=" + latitude + ", longitude=" + longitude + '}'; } }
-
Now we need a test to make sure this is working properly. Add a test class called
GeocoderServiceTest
to thecom.oreilly.restclient.services
package in the test directorysrc/test/java
. -
Add the test annotation to the test:
@SpringBootTest
-
Autowire in the
GeocoderService
into a field calledservice
-
Add two tests: one using a city and state of Boston, MA, and one using a street address of 1600 Ampitheatre Parkway, Mountain View, CA. The tests are:
@Test public void getLatLngWithoutStreet() { Site site = service.getLatLng("Boston", "MA"); assertAll( () -> assertEquals(42.36, site.getLatitude(), 0.01), () -> assertEquals(-71.06, site.getLongitude(), 0.01) ); } @Test public void getLatLngWithStreet() throws Exception { Site site = service.getLatLng("1600 Ampitheatre Parkway", "Mountain View", "CA"); assertAll( () -> assertEquals(37.42, site.getLatitude(), 0.01), () -> assertEquals(-122.08, site.getLongitude(), 0.01) ); }
-
Run the tests and make sure they pass.
-
We actually still have a problem. To see it, log the returned
Site
object to the console. First add a SLF4J logger to theGeocoderServiceTest
import org.slf4j.Logger; import org.slf4j.LoggerFactory; // ... private Logger logger = LoggerFactory.getLogger(GeocoderServiceTest.class);
-
Then, in the test methods, log the site.
@Test public void getLatLngWithoutStreet() { Site site = service.getLatLng("Boston", "MA"); logger.info(site.toString()); // ... asserts as before ... }
-
Run either or both of the tests and look at the logged site(s).
-
The address fields of the sites are null! That’s because our
Result
class has aString
field calledformattedAddress
, but the JSON response uses underscores instead of camel case (i.e.,formatted_address
).There are a couple of different ways to solve this. As a one-time fix, you can add an annotation to the
formatted_address
field in theResult
classimport com.fasterxml.jackson.annotation.JsonProperty; public class Result { @JsonProperty("formatted_address") private String formattedAddress; // ... rest as before ...
The
@JsonProperty
annotation is a general purpose mechanism you can use whenever the property in the bean does not match the JSON field. Run your test again and see that thename
value in theSite
is now correct. -
The other way to fix the issue is to set a global property that converts all camel case properties to underscores during the JSON parsing process. To use this, first remove the
@JsonProperty
annotation fromResult
. -
We will then add the required property to a YAML properties file. By default, Spring Boot generates a file called
application.properties
in thesrc/main/resources
folder. Rename that file toapplication.yml
-
Inside
application.yml
, add the following setting:spring: jackson: property-naming-strategy: SNAKE_CASE
-
Once again run the tests and see that the
address
field inSite
is set correctly. The advantage of the YAML file is that you can nest multiple properties without too much code duplication.In principle, now we could save the
Site
instances in a database (generating id values in the process), and since they have latitudes and longitudes, we could then plot them on a map.
6. Using the JDBC Client
Starting in Spring 6.1, Spring provides a new class called JdbcClient
in the org.springframework.jdbc.core.simple
package. All it needs in order to work is a data source. It removes almost all the boilerplate code normally associated with JDBC. This class is a simpler version of the older JdbcTemplate
class used to implement the standard CRUD (create, read, update, delete) methods on an entity. This exercise will use the JdbcClient
class instead.
-
Make a new Spring Boot project with group
com.oreilly
and artifact calledpersistence
using the Spring Initializr. Generate a Gradle build file and select the JPA dependency, which will include JDBC. Also select the H2 dependency, which will provide a JDBC driver for the H2 database as well as a connection pool. -
Import the project into your IDE in the usual manner.
-
For this exercise, as well as the related exercises using JPA and Spring Data, we’ll use a domain class called
Officer
. AnOfficer
will have a generatedid
of typeInteger
, strings forfirstName
andlastName
, and aRank
. TheRank
will be a Java enum. -
First define the
Rank
enum in thecom.oreilly.persistence.entities
package and give it a few constants:public enum Rank { ENSIGN, LIEUTENANT, COMMANDER, CAPTAIN, COMMODORE, ADMIRAL }
-
Now add the
Officer
class with the attributes as specified. Note that in earlier exercises we used a Java record for this. We could use a record with theJdbcClient
as well, but the remaining exercises use Jakarta Persistence API (JPA) with Hibernate, and records violate the JPA specification. So we’ll use a standard POJO for this exercise.public class Officer { private Integer id; private Rank rank; private String firstName; private String lastName; public Officer() {} public Officer(Rank rank, String firstName, String lastName) { this.rank = rank; this.firstName = firstName; this.lastName = lastName; } public Officer(Integer id, Rank rank, String firstName, String lastName) { this.id = id; this.rank = rank; this.firstName = firstName; this.lastName = lastName; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public Rank getRank() { return rank; } public void setRank(Rank rank) { this.rank = rank; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } @Override public String toString() { return "Officer{" + "id=" + id + ", rank=" + rank + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; } @Override public boolean equals(Object o) { if (obj == this) return true; if (obj == null || obj.getClass() != this.getClass()) return false; var that = (Officer) obj; return Objects.equals(this.id, that.id) && Objects.equals(this.rank, that.rank) && Objects.equals(this.firstName, that.firstName) && Objects.equals(this.lastName, that.lastName); } @Override public int hashCode() { return Objects.hash(id, rank, firstName, lastName); } }
-
One of the features of Spring Boot is that you can create and populate database tables by define scripts with the names
schema.sql
anddata.sql
in thesrc/main/resources
folder. First define the database table inschema.sql
:DROP TABLE IF EXISTS officers; CREATE TABLE officers ( id INT NOT NULL AUTO_INCREMENT, rank VARCHAR(20) NOT NULL, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL, PRIMARY KEY (id) );
-
Next populate the table by adding the following
INSERT
statements indata.sql
INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'James', 'Kirk'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Jean-Luc', 'Picard'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Benjamin', 'Sisko'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Kathryn', 'Janeway'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Jonathan', 'Archer'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Christopher', 'Pike'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Carol', 'Freeman'); INSERT INTO officers(rank, first_name, last_name) VALUES('CAPTAIN', 'Michael', 'Burnham');
-
When Spring starts up, the framework will automatically create a DB connection pool based on the H2 driver and then create and populate the database tables for you. Now we need a DAO (data access object) interface holding the CRUD methods that will be implemented in the different technologies. Define a Java interface called
OfficerDAO
in thecom.oreilly.persistence.dao
package.package com.oreilly.persistence.dao; import com.oreilly.persistence.entities.Officer; import java.util.List; import java.util.Optional; public interface OfficerDAO { Officer save(Officer officer); Optional<Officer> findById(Integer id); List<Officer> findAll(); long count(); void delete(Officer officer); boolean existsById(Integer id); }
As an aside, the names and signatures of these methods were chosen for a reason, which will become obvious when you do the Spring Data implementation later.
-
In this exercise, implement the interface using the
JdbcTemplate
class. Start by creating a class in thecom.oreilly.persistence.dao
package calledJdbcOfficerDAO
. -
Normally in Spring you would create an instance of
JdbcTemplate
by injecting aDataSource
into the constructor and using it to instantiate theJdbcTemplate
. Spring Boot, however, let’s you inject aJdbcTemplate
directly.public class JdbcClientDAO implements OfficerDAO { private final JdbcClient jdbcClient; @Autowired public JdbcClientDAO(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; } // ... more to come ... }
-
To make Spring detect this as a bean it should manage, add the
@Repository
annotation to the class@Repository public class JdbcOfficerDAO implements OfficerDAO { // ... as before ... }
-
Some of the DAO methods are trivially easy to implement. Implement the
count
method by executing asql
statementselect count(*) from officers
, followed by aquery
that maps the result toLong.class
and return asingle()
result.@Override public long count() { return jdbcClient.sql("select count(*) from officers") .query(Long.class) .single(); }
-
Likewise, the
delete
method is easy to implement. Use a SQLdelete
with awhere
clause for the particular officer with a parameter namedid
. Specify the parameter with theparam
method, and then call theupdate
method to execute the statement.@Override public void delete(Officer officer) { jdbcClient.sql("delete from officers where id = :id") .param("id", officer.id()) .update(); }
-
The
existsById
method is also easy to implement. Use a SQLselect
statement with awhere
clause for the particular officer with a parameter namedid
. Specify the parameter with theparam
method, and then call thequery
method to execute the statement. The result will be aLong
that you can compare to zero to determine if the officer exists.@Override public boolean existsById(Integer id) { return jdbcClient.sql("select count(*) from officers where id = :id") .param("id", id) .query(Long.class) .single() > 0; }
-
Now for the finder methods. To implement the
findById
method, use a SQLselect
statement with awhere
clause for the particular officer with a parameter namedid
. Specify the parameter with theparam
method, and then call thequery
method to execute the statement. The result will be anOptional
ofOfficer
.@Override public Optional<Officer> findById(Integer id) { return jdbcClient.sql("select * from officers where id = :id") .param("id", id) .query(Officer.class) .optional(); }
-
To implement the
findAll
method, use a SQLselect
statement with nowhere
clause. Call thequery
method to execute the statement. The result will be aList
ofOfficer
, which you return using thelist
method.@Override public List<Officer> findAll() { return jdbcClient.sql("select * from officers") .query(Officer.class) .list(); }
-
Finally, for the insert, we’ll take a different approach. While you can write the SQL insert statement and use the
update
method on theJdbcClient
, there is no easy way to return the generated primary key. So instead let’s use a related class called aSimpleJdbcInsert
. Add that class as an attribute and instantiate and configure it in the constructor, using an additional argument of typeDataSource
.public class JdbcOfficerDAO implements OfficerDAO { // ... jdbcTemplate from earlier ... private SimpleJdbcInsert insertOfficer; @Autowired public JdbcClientDAO(JdbcClient jdbcClient, DataSource dataSource) { this.jdbcClient = jdbcClient; this.insertOfficer = new SimpleJdbcInsert(dataSource) .withTableName("officers") .usingGeneratedKeyColumns("id"); }
Note how you can specify the table that the insert will use, as well as any generated key columns.
-
Implement the
save
method using theSimpleJdbcInsert
instance@Override public Officer save(Officer officer) { Integer newId = insertOfficer.executeAndReturnKey( Map.of( "rank", officer.rank(), "first_name", officer.firstName(), "last_name", officer.lastName() )).intValue(); return new Officer(newId, officer.rank(), officer.firstName(), officer.lastName()); }
-
We need a test case to make sure everything is working properly. Create a test class called
JdbcClientDAOTest
that autowires in the DAO class@SpringBootTest public class JdbcClientDAOTest { @Autowired private JdbcClientDAO dao; // ... more to come ... }
-
Now comes the fun part — add the
@Transactional
annotation to the class. In a test class like this, Spring will interpret that to mean that each test should run in a transaction that rolls back at the end of the test. That will keep the test database from being affected by the tests and keep the tests themselves all independent -
Add a test for the
save
method@Test public void save() throws Exception { Officer officer = new Officer(Rank.LIEUTENANT, "Nyota", "Uhuru"); officer = dao.save(officer); assertNotNull(officer.getId()); }
The presence of the
@Transactional
annotation means that the new officer will be added, and we can check that theid
value is correctly generated, but at the end of the test the insert will be rolled back -
Test
findById
but using one of the known ids (which are known because the database is being reset each time)@Test public void findByIdThatExists() throws Exception { Optional<Officer> officer = dao.findById(1); assertTrue(officer.isPresent()); assertEquals(1, officer.get().getId().intValue()); } @Test public void findByIdThatDoesNotExist() throws Exception { Optional<Officer> officer = dao.findById(999); assertFalse(officer.isPresent()); }
-
The test for the count method also relies on knowing the number of rows in the test database
@Test public void count() throws Exception { assertEquals(8, dao.count()); }
-
The rest of the tests are pretty straightforward, other than the fact we will use Java 8 constructs to implement them.
@Test public void findAll() throws Exception { List<String> dbNames = dao.findAll().stream() .map(Officer::getLastName) .collect(Collectors.toList()); assertThat(dbNames).contains("Archer", "Burnham", "Freeman", "Janeway", "Kirk", "Picard", "Sisko"); } @Test public void delete() throws Exception { IntStream.rangeClosed(1, 8) .forEach(id -> { Optional<Officer> officer = dao.findById(id); assertTrue(officer.isPresent()); dao.delete(officer.get()); }); assertEquals(0, dao.count()); } @Test public void existsById() throws Exception { IntStream.rangeClosed(1, 8) .forEach(id -> assertTrue(dao.existsById(id))); }
We’ll talk about the details of these tests in class. Note, however, that the test for
delete
removes all the officers from the table and verifies that they’re gone. That would be a problem, except for, once again, the automatic rollback we’re relying on at the end of each test. -
Make sure all the tests work properly, then you’re finished.
-
The SQL code executed has been provided, with one exception — the
INSERT
statement generated by theSimpleJdbcInsert
. To see it, you can log it to the console. In the fileapplication.properties
insrc/main/resoures
, add the following line:logging.level.sql=debug
This will enable logging for that specific class. You can use the logger for many parts of the underlying system, including the embedded container, Hibernate, and Spring Boot.
7. Implementing the CRUD layer using JPA
The Java Persistence API (JPA) is a layer over the so-called persistence providers, the most common of which is Hibernate. With regular Spring, configuring JPA requires several beans, including an entity manger factory and a JPA vendor adapter. Fortunately, in Spring Boot, the presence of the JPA dependency causes the framework to implement all of that for you.
-
To use JPA, we need an entity. We’ll use the same
Officer
class from the previous exercise, but this time we will add the appropriate JPA annotations@Entity
,@Id
,@GeneratedValue
,@Table
, and@Column
@Entity @Table(name = "officers") public class Officer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Enumerated(EnumType.STRING) private Rank rank; private String firstName; private String lastName; // ... rest as before ... }
The
@Enumerated
annotation tells Hibernate to store the value of the enum as a string rather than an index. -
Create a class called
JpaOfficerDAO
that implements theOfficerDAO
interface and adds anEntityManagerFactory
as an attribute@Repository public class JpaOfficerDAO implements OfficerDAO { @PersistenceContext private EntityManager entityManager; // ... more to come ... }
The
@PersistenceContext
annotation is used to inject an entity manager into the DAO. Normally we would also need to make the class transactional, but in keeping with common practice that can be handled in a service layer. In this particular case, however, we’ll so the transactions in the tests -
The implementations of the individual methods is very simple. Since this is a course on Spring and not on JPA, they are given here without comment. Add them to the
JpaOfficerDAO
class@Override public Officer save(Officer officer) { entityManager.persist(officer); return officer; } @Override public Optional<Officer> findById(Integer id) { return Optional.ofNullable(entityManager.find(Officer.class, id)); } @Override public List<Officer> findAll() { return entityManager.createQuery("select o from Officer o", Officer.class) .getResultList(); } @Override public long count() { return entityManager.createQuery("select count(o.id) from Officer o", Long.class) .getSingleResult(); } @Override public void delete(Officer officer) { entityManager.remove(officer); } @Override public boolean existsById(Integer id) { Object result = entityManager.createQuery( "SELECT 1 from Officer o where o.id=:id") .setParameter("id", id) .getSingleResult(); return result != null; }
-
The same tests used to check the
JdbcOfficerDAO
can be done again, just using a different DAO as the class under test, with one exception:@SpringBootTest @Transactional public class JpaOfficerDAOTest { @Autowired private JpaOfficerDAO dao; @Autowired private JdbcTemplate template; // private method to retrieve the current ids in the database private List<Integer> getIds() { return template.query("select id from officers", (rs, num) -> rs.getInt("id")); } @Test public void testSave() throws Exception { Officer officer = new Officer(Rank.LIEUTENANT, "Nyota", "Uhuru"); officer = dao.save(officer); assertNotNull(officer.getId()); } @Test public void findOneThatExists() throws Exception { getIds().forEach(id -> { Optional<Officer> officer = dao.findById(id); assertTrue(officer.isPresent()); assertEquals(id, officer.get().getId()); }); } @Test public void findOneThatDoesNotExist() throws Exception { Optional<Officer> officer = dao.findById(999); assertFalse(officer.isPresent()); } @Test public void findAll() throws Exception { List<String> dbNames = dao.findAll().stream() .map(Officer::getLastName) .collect(Collectors.toList()); assertThat(dbNames).contains("Kirk", "Picard", "Sisko", "Janeway", "Archer"); } @Test public void count() throws Exception { assertEquals(5, dao.count()); } @Test public void delete() throws Exception { getIds().forEach(id -> { Optional<Officer> officer = dao.findById(id); assertTrue(officer.isPresent()); dao.delete(officer.get()); }); assertEquals(0, dao.count()); } @Test public void existsById() throws Exception { getIds().forEach(id -> assertTrue(dao.existsById(id))); } }
Because there are now two separate beans available to Spring that implement the same
OfficerDAO
interface, the@Autowired
annotation would fail, claiming it expected a single bean of that type but found two. The@Qualifier
annotation is used to tell Spring the name of the bean to inject. Several of the tests are going to fail, however, because we have one other setting we have to modify -
If you run the tests, you see that we quickly run into a problem, which is that the sample data is not there! This is because, by default, Hibernate is in what is called "create-drop" mode, which means it drops the database after each execution and re-creates it on startup. We can prevent that, however, by adding a setting to the
application.yml
file:spring: jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate.format_sql: true
We switched the
spring.jpa.hibernate.ddl-auto
property toupdate
(other options arecreate
,create-drop
, andvalidate
), which will add columns as necessary but not drop any tables or data. We are also logging the generated SQL and formatting it as well. -
There’s one step of clean up required, however. This test should pass, but the
JdbcOfficerDAOTest
won’t because we have to add the@Qualifier
there, too.public class JdbcOfficerDAOTest { @Autowired @Qualifier("jdbcOfficerDAO") private OfficerDAO dao;
Now both tests should work properly.
8. Using Spring Data
The Spring Data JPA project makes it incredibly easy to implement a DAO layer. You extend the proper interface, and the underlying infrastructure generates all the implementations for you.
Spring Data is a large, powerful API. In this exercise, we’ll just show the basics.
-
Since we created this project based on the Spring Data JPA dependency, we don’t need to modify the Gradle build file to add it. Note that the build file already includes the required dependencies:
dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.h2database:h2' testImplementation('org.springframework.boot:spring-boot-starter-test') }
-
Spring Data works by defining an interface that extends one of a few provided interfaces, where you specify the domain class and its primary key type. Therefore, create an interface called
OfficerRepository
in thecom.oreilly.persistence.dao
packagepublic interface OfficerRepository extends JpaRepository<Officer, Integer> { }
The interface can extend
CrudRepository
,PagingAndSortingRepository
, or, as here,JpaRepository
. You only have to specify the two generic parameters that represent the domain class and the primary key type. Here we useOfficer
andInteger
.The framework will now generate the implementations of about a dozen different methods, including all the methods listed in the
OfficerDAO
interface (which is why those methods were chosen in the first place) -
The test class is similar to the others, except that it’s written in terms of the
OfficerRepository
bean. Simply copy the existingJpaOfficerDAOTest
class insrc/test/java
into a class calledOfficerRepositoryTest
in the same package and change the autowired repository to be of typeOfficerRepository
.@SpringBootTest @Transactional public class OfficerRepositoryTest { @Autowired private OfficerRepository repository; // ... more to come ... }
-
All the tests should pass, as before.
-
If you have time, you can use the Spring Data feature where it will will generate queries based on a naming convention. Simply add methods to the
OfficerRepository
interface of the formfindAllBy<property>
and you can useAnd
orOr
to chain where clauses together. For example, to find officers by their last names and by their rank, just add the following methods:List<Officer> findByRank(Rank rank); List<Officer> findAllByLastNameLikeAndRank(String like, Rank rank);
-
If you want to see the H2 console, add the Spring DevTools dependency and the Web dependency to your project. Then, to be sure to see the proper URL for the database (assuming you don’t set it in
application.properties
), add the following log level:logging.level.org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration=debug
-
Then you can go to "http://localhost:8080/db-console" and log in with the URL shown in the log, the user name "sa", and no password.
-
Once the tests are running, add two dependencies to the Gradle build file: one for the Spring Data Rest project (which will expose the data via a REST interface) and for the HAL browser, which will give us a convenient client to use
implementation 'org.springframework.boot:spring-boot-starter-data-rest' implementation 'org.springframework.data:spring-data-rest-hal-explorer'
-
After rebuilding the project, start up the application (using the class with the main method) and navigate to http://localhost:8080. Spring will insert the HAL browser at that point to allow you to add, update, and remove individual elements, which we’ll do in class.