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.oreillyand 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.gradlefile 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
gradlewscript. -
First you need to add the
eclipseplugin to thebuild.gradlefile. Open that file in any text editor and type the following line inside thepluginsblock: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 ./gradlewfor 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.javaand 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.HelloControllerin thesrc/main/javadirectoryThe goal is to have the HelloControllerclass in thecom.oreilly.demo.controllerspackage starting at the root directorysrc/main/java -
The code for the
HelloControlleris: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.htmlin thesrc/main/resources/templatesfolder -
The code for the
welcome.htmlfile 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.htmlin thesrc/main/resources/staticfolder -
The code for the
index.htmlfile 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
nameparameter 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
sayHellomethod directly. To do so, add a class calledHelloControllerUnitTestto thecom.oreilly.demo.controllerspackage 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
MockMVCclasses available in Spring. Create a new class calledHelloControllerMockMVCTestin thecom.oreilly.demo.controllerspackage 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
@WebMvcTestannotation over the generic@SpringBootTestannotation 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.controllerspackage 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
@RestControllerannotation 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 arecordcalledGreetingto thecom.oreilly.demo.jsonpackage. In a larger application. -
In the
Greetingrecord, add a private attribute of typeStringcalledmessage.package com.oreilly.demo.json; public record Greeting(String message) {} -
Back in the
HelloRestController, add a method calledgreetthat takes aStringcallednameas an argument and returns aGreeting. -
Annotate the
greetmethod with a@GetMappingwhose argument is"/rest", which means that the URL to access the method will be http://localhost:8080/rest . -
Add the
@RequestParamannotation to the argument, with the propertiesrequiredset tofalseanddefaultValueset toWorld. -
In the body of the method, return a new instance of
Greetingwhose constructor argument should be"Hello, " + name + "!". -
The full class looks like (note that the string concatenation has been replaced with a
String.formatmethod)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
curlor 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
TestRestTemplateclass, because we included thewebdependency rather thanwebfluxwhich we’ll use in the next exercise. Add a class calledHelloRestControllerIntegrationTestin thesrc/test/javatree in the same package as the REST controller class. -
This time, when adding the
@SpringBootTestannotation, add the argumentwebEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT. This will autoconfigure several properties of the test, including making aTestRestTemplateavailable 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
getForEntitymethod 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-serializedGreetinginstance, which allows you to check its message. -
The other test uses the
getForObjectmethod, 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
Greetinginstances. 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
AstroServicein acom.oreilly.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 typeRestClient(fromorg.springframework.web.clientpackage) calledrestClient. -
Add a private, final attribute of type
StringcalledbaseUrland 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
restClientattribute 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 @Autowiredto 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
publicmethod to the service calledgetAstroResponse. -
The
RestClientclass 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
AstroResponsein thecom.oreilly.restclient.jsonpackage that maps to the outermost JSON object, giving it propertiesmessageof typeString,numberof typeint, and aList<Assignment>calledpeople. Inside theAstroResponserecord, add a record calledAssignmentthat holds thenameandcraftfields. The source for theAstroResponserecord 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
AstroResponseclass. The following code should be added to thegetAstroResponsemethod 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
AstroResponseclass via thebodymethod. -
To demonstrate how to use the service, create a JUnit 5 test for it. Create a class called
AstroServiceTestin thecom.oreilly.servicespackage 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
Loggerclass 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
RestClientAPI is based on the methods in the (slightly) olderWebClientclass, added in Spring Boot 3.0, that performs asynchronous requests. TheRestClientclass is a synchronous version ofWebClientthat returns a single object or a collection of objects. TheWebClientclass returns aMonoorFluxobject, respectively, which are part of the reactive programming model. TheMonoclass is a promise that returns a single object, and theFluxclass is a promise that returns a collection of objects. TheRestClientclass is a more traditional, blocking API that returns the actual objects. TheWebClientclass is used in reactive programming, which is beyond the scope of this course. TheRestClientclass 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
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() { 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
RestClientconfigured 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) { 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
getAstroResponsemethod ofAstroInterfacetoAstroResponseinstead of theMono. See the documentation for additional details. -
In the GitHub repository for this guide, the
rest-clientmodule contains an additional example which uses theJsonPlaceHolderAPI to demonstrate how to use the@PostExchangeannotation to send a POST request with a JSON body. The@PutExchangeand@DeleteExchangeannotations 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
addressparameter 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
GeocoderServiceto the existingrestclientproject. In that project, add the new class to theservicespackage -
Add the
@Serviceannotation 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
WebClientcalledclient -
Add a constructor to the class that takes an argument of type
WebClient.Buildercalledbuilder -
Inside the constructor, set the value of the
clientfield by setting the base URL usingbaseUrl("https://maps.googleapis.com")and invoking thebuildmethod on the builder. -
Map the JSON response to classes in a
jsonpackage. 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
jsonsubpackage, 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
GeocoderServiceclass, add constants for the key.private static final String KEY = "AIzaSyDw_d6dfxDEI7MAvqfGXEIsEMwjC1PWRno"; -
Add a
publicmethod that formulates the complete URL with an encoded address and converts it to aResponseobject. 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
StandardCharsetsclass is not available, and theencodeversion 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
privatemethod is to avoid the try/catch block inside themapmethod directly, just to improve readability. -
To use this service, we need an entity called
Site. Add a POJO to thecom.oreilly.restclient.entitiespackage calledSitethat 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
GeocoderServiceTestto thecom.oreilly.restclient.servicespackage in the test directorysrc/test/java. -
Add the test annotation to the test:
@SpringBootTest -
Autowire in the
GeocoderServiceinto 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
Siteobject to the console. First add a SLF4J logger to theGeocoderServiceTestimport 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
Resultclass has aStringfield 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_addressfield in theResultclassimport com.fasterxml.jackson.annotation.JsonProperty; public class Result { @JsonProperty("formatted_address") private String formattedAddress; // ... rest as before ...The
@JsonPropertyannotation 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 thenamevalue in theSiteis 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
@JsonPropertyannotation fromResult. -
We will then add the required property to a YAML properties file. By default, Spring Boot generates a file called
application.propertiesin thesrc/main/resourcesfolder. 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
addressfield inSiteis 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
Siteinstances 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.oreillyand artifact calledpersistenceusing 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. AnOfficerwill have a generatedidof typeInteger, strings forfirstNameandlastName, and aRank. TheRankwill be a Java enum. -
First define the
Rankenum in thecom.oreilly.persistence.entitiespackage and give it a few constants:public enum Rank { ENSIGN, LIEUTENANT, COMMANDER, CAPTAIN, COMMODORE, ADMIRAL } -
Now add the
Officerclass with the attributes as specified. Note that in earlier exercises we used a Java record for this. We could use a record with theJdbcClientas 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.sqlanddata.sqlin thesrc/main/resourcesfolder. 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
INSERTstatements indata.sqlINSERT 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
OfficerDAOin thecom.oreilly.persistence.daopackage.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
JdbcTemplateclass. Start by creating a class in thecom.oreilly.persistence.daopackage calledJdbcOfficerDAO. -
Normally in Spring you would create an instance of
JdbcTemplateby injecting aDataSourceinto the constructor and using it to instantiate theJdbcTemplate. Spring Boot, however, let’s you inject aJdbcTemplatedirectly.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
@Repositoryannotation to the class@Repository public class JdbcOfficerDAO implements OfficerDAO { // ... as before ... } -
Some of the DAO methods are trivially easy to implement. Implement the
countmethod by executing asqlstatementselect count(*) from officers, followed by aquerythat maps the result toLong.classand return asingle()result.@Override public long count() { return jdbcClient.sql("select count(*) from officers") .query(Long.class) .single(); } -
Likewise, the
deletemethod is easy to implement. Use a SQLdeletewith awhereclause for the particular officer with a parameter namedid. Specify the parameter with theparammethod, and then call theupdatemethod to execute the statement.@Override public void delete(Officer officer) { jdbcClient.sql("delete from officers where id = :id") .param("id", officer.id()) .update(); } -
The
existsByIdmethod is also easy to implement. Use a SQLselectstatement with awhereclause for the particular officer with a parameter namedid. Specify the parameter with theparammethod, and then call thequerymethod to execute the statement. The result will be aLongthat 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
findByIdmethod, use a SQLselectstatement with awhereclause for the particular officer with a parameter namedid. Specify the parameter with theparammethod, and then call thequerymethod to execute the statement. The result will be anOptionalofOfficer.@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
findAllmethod, use a SQLselectstatement with nowhereclause. Call thequerymethod to execute the statement. The result will be aListofOfficer, which you return using thelistmethod.@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
updatemethod 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
savemethod using theSimpleJdbcInsertinstance@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
JdbcClientDAOTestthat autowires in the DAO class@SpringBootTest public class JdbcClientDAOTest { @Autowired private JdbcClientDAO dao; // ... more to come ... } -
Now comes the fun part — add the
@Transactionalannotation 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
savemethod@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
@Transactionalannotation means that the new officer will be added, and we can check that theidvalue is correctly generated, but at the end of the test the insert will be rolled back -
Test
findByIdbut 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
deleteremoves 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
INSERTstatement generated by theSimpleJdbcInsert. To see it, you can log it to the console. In the fileapplication.propertiesinsrc/main/resoures, add the following line:logging.level.sql=debugThis 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
Officerclass 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
@Enumeratedannotation tells Hibernate to store the value of the enum as a string rather than an index. -
Create a class called
JpaOfficerDAOthat implements theOfficerDAOinterface and adds anEntityManagerFactoryas an attribute@Repository public class JpaOfficerDAO implements OfficerDAO { @PersistenceContext private EntityManager entityManager; // ... more to come ... }The
@PersistenceContextannotation 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
JpaOfficerDAOclass@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
JdbcOfficerDAOcan 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
OfficerDAOinterface, the@Autowiredannotation would fail, claiming it expected a single bean of that type but found two. The@Qualifierannotation 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.ymlfile:spring: jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate.format_sql: trueWe switched the
spring.jpa.hibernate.ddl-autoproperty 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
JdbcOfficerDAOTestwon’t because we have to add the@Qualifierthere, 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
OfficerRepositoryin thecom.oreilly.persistence.daopackagepublic 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 useOfficerandInteger.The framework will now generate the implementations of about a dozen different methods, including all the methods listed in the
OfficerDAOinterface (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
OfficerRepositorybean. Simply copy the existingJpaOfficerDAOTestclass insrc/test/javainto a class calledOfficerRepositoryTestin 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
OfficerRepositoryinterface of the formfindAllBy<property>and you can useAndorOrto 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.