Skip to content

13. Web Services

Prerequisites: Version 9 of the [Tax] application, presented at the end of this chapter, reuses the [tax-dao], [tax-business], and [tax-ui] modules from version 5 (Chapter 6). Before building or opening your projects, you must have installed version 5 in the local Maven repository using the command [mvn install] entered in the [06-architectures/TaxV5] folder (see Chapter 6, section on [mvn install]).

13.1. Introduction

In the previous chapter, we presented several TCP/IP client-server applications. Since the clients and the server exchange lines of text, they can be written in any language. The client simply needs to know the communication protocol expected by the server.

Web services are also server applications (TCP/IP). They have the following characteristics:

  • they are hosted by web servers, and the client-server exchange protocol is HTTP (HyperText Transfer Protocol), a protocol built on top of TCP/IP, which we explored in the previous chapter;
  • the web service has a standard communication protocol, regardless of the service provided. A web service offers various services: S1, S2…, Sn. Each of these services expects parameters provided by the client and returns a result to the client. For each service, the client needs to know:
    • how to specify the service Si;
    • the list of parameters to be provided and their types;
    • the type of result returned by the service. Once these elements are known, the client-server dialogue follows the same format, regardless of which web service is being queried. Client code is thus standardized;
  • To protect themselves from attacks originating on the Internet, many organizations open only certain ports on their servers to the outside world: primarily ports 80 (HTTP) and 443 (HTTPS) for the web service. All other ports are locked. Consequently, client-server applications such as those described in the previous chapter are generally restricted to the organization’s private network (intranet). Hosting a service on a web server makes it accessible to the entire Internet community;
  • the web service can be viewed as a remote object: the services it offers then become methods of that object. A client can use this remote object as if it were local, through an intermediary object called a proxy, which hides all network communication. This allows us to build a client that is independent of this layer: if the layer changes, the rest of the client does not need to be modified;
  • As with the TCP/IP client-server applications from the previous chapter, the client and server can be written in any programming language. They exchange lines of text consisting of two parts: the headers required by the HTTP protocol and the message body, whose format is agreed upon between the client and the server.

The architecture of a web service-based client-server application is as follows:

Image

This is an extension of the layered architecture from Chapter 6, to which specialized network communication layers are added: the client-side web service client and the web service itself on the server side. We have already encountered a similar architecture with the TCP tax calculation server (version 8) from the previous chapter.

13.1.1. Yesterday: SOAP and WSDL

In the 2000s, web services most often followed the SOAP standard (Simple Object Access Protocol). The client sent a HTTP request to POST, whose body was a XML document: an “envelope” containing the name of the called method and its parameters. The server responded with another envelope, XML, containing the result. Each service was described by a XML document in WSDL format (Web Services Description Language), available at the service’s URL followed by [?wsdl]: it listed the methods, their parameters, and their types. Based on this document, tools automatically generated the service proxy.

In Java, these services were written using the API JAX-WS: a class annotated with [@WebService], whose public methods (which might be annotated with [@WebMethod]) became the service’s methods; the [wsimport] tool generated the client proxy from the WSDL. JAX-WS was part of the JDK from Java 6 through Java 10; it was removed in Java 11 (JEP 320) and now exists outside of JDK, under the name Jakarta XML Web Services. It is still found in legacy applications, but new services rarely use it anymore. The reasons for this phase-out include the cumbersomeness of XML and SOAP envelopes, the complexity of the associated standards (WS-Security, WS-Addressing, etc.), and the difficulty of calling these services from a browser.

13.1.2. Today: REST and JSON

Most current web services follow the REST style (REpresentational State Transfer) and exchange JSON documents (JavaScript Object Notation, presented in Chapter 5). The REST style is based on the following principles:

  • the service exposes resources, each designated by a URL: [/tax], [/clients/12], [/clients/12/orders];
  • these resources are accessed using the methods (also known as verbs) of the HTTP protocol, each of which has a specific meaning;
  • the result of the action is indicated by the HTTP status code in the response;
  • the body of requests and responses is most often a JSON document, which is more compact and simpler than XML;
  • The service is stateless: each request contains all the information necessary for its processing; the server does not store any information from one request to the next for a given client.

The most commonly used HTTP methods are as follows:

Method
Usage
Example
GET
Read a resource; any parameters are in the URL
GET /clients/12
POST
Create a resource or request processing; the data is in the request body
POST /clients
PUT
Replace a resource
PUT /clients/12
PATCH
Modify part of a resource
PATCH /clients/12
DELETE
Delete a resource
DELETE /clients/12

The status codes, which were introduced in the previous chapter, take on their full meaning here:

Code
Meaning
200 OK
The request was successful; the response body contains the result
201 Created
The resource was created; the [Location] header provides its URL
204 No Content
The request was successful; the response has no body
400 Bad Request
The request is invalid (missing or invalid parameter, etc.)
404 Not Found
The requested resource does not exist
415 Unsupported Media Type
The type of the document sent (header [Content-Type]) is not accepted
500 Internal Server Error
An unexpected error occurred on the server

A REST service does not have a required equivalent in WSDL. However, it is common practice to describe it using a OpenAPI document (formerly Swagger), in the JSON or YAML format, from which tools can generate documentation, test pages, and clients. We’ll come back to this later.

Finally, note gRPC, designed by Google: the service’s methods and their messages are described in a [.proto] file, from which tools generate the server and client, just as they did in the days of WSDL; the messages are encoded in a compact binary format (Protocol Buffers) and transmitted via HTTP/2. gRPC is very efficient and popular for communication between services within the same organization, but it is ill-suited for browsers. It is available in Java with the [grpc-java] library, which we will not be using.

13.1.3. Spring Boot and Spring MVC

To write our web services, we’ll use Spring Boot. In Chapter 6, we used the Spring Framework’s dependency injection container. This framework also includes a web module, Spring MVC, which maps a URL and a HTTP method to a Java method using annotations: [@RestController] on the class, [@GetMapping], [@PostMapping]… on its methods. Spring Boot (version 1.0 in 2014, version 4 in November 2025, with Spring Framework 7) simplifies the implementation of Spring:

  • it automatically configures the application based on the available libraries: if Spring MVC and Jackson are present, it sets up everything needed for a web service JSON, without a single line of configuration;
  • it embeds the web server within the application: the Tomcat server (version 11) is simply a library, launched by the main program. Therefore, there is no need to install a separate web server or application server (Tomcat, GlassFish, WildFly…), on which the application used to be deployed as a [.war] archive;
  • it provides starters—Maven dependencies that bundle everything needed for a specific use case. The [spring-boot-starter-webmvc] starter includes Spring MVC, Tomcat, Jackson 3 (Chapter 5), and logging (SLF4J and Logback);
  • it provides a Maven parent project, [spring-boot-starter-parent], which specifies the versions of all these libraries—which are compatible with one another—and a Maven plugin, [spring-boot-maven-plugin], which launches the application ([mvn spring-boot:run]) and packages it as a single executable file, [.jar].

Spring Boot is not the only solution: Jakarta EE (formerly Java EE) defines the API standard JAX-RS (Jakarta RESTful Web Services), and frameworks such as Quarkus, Micronaut, and Helidon are also widely used. Spring Boot is, however, the most widespread, and it naturally builds on what we covered in Chapter 6.

13.2. A First Web Service

We’re going to build our first client-server application with the following simplified architecture:

Image

The projects in this chapter are Maven projects, grouped in the [13-web-services] folder:

13-web-services/
  pom.xml                   aggregation project of the six Maven projects
  WsHello/                  "Hello" web service
    pom.xml
    src/main/java/wshello/WsHelloApplication.java
    src/main/java/wshello/HelloController.java
    src/main/resources/application.properties
  ClientWsHello/            its console client
    pom.xml
    src/main/java/clientwshello/Main.java
  WsOperations/             web service of the four operations
    pom.xml
    src/main/java/wsoperations/WsOperationsApplication.java
    src/main/java/wsoperations/OperationsController.java
    src/main/resources/application.properties
  ClientWsOperations/       its console client
    pom.xml
    src/main/java/clientwsoperations/Main.java
  WsTax/                    tax calculation web service (version 9)
    pom.xml
    TaxData.json
    src/main/java/tax/ws/...
    src/main/resources/application.properties
    src/main/resources/static/index.html    the web client
  ClientWsTax/              its console client (version 9)
    pom.xml
    src/main/java/tax/client/TaxBusinessHttp.java
    src/main/java/tax/client/Main.java
  ClientNode/               a JavaScript client
    client-tax.mjs

The projects are already set up in the course materials: the reader simply needs to open the [13-web-services] folder within their IDE (IntelliJ, IDEA, or VS Code), or navigate to a project folder and enter the specified Maven command. Services are launched using [mvn spring-boot:run], and console clients using [mvn compile exec:java]. The [pom.xml] file located at the root of the folder is not a parent project: it simply lists the six projects within a [<modules>] tag, which allows you to build them all at once (by typing [mvn compile] in the [13-web-services] folder).

To create a Spring Boot project yourself, the easiest way is to use the Spring Initializr website: There, you select Maven, Java, the Spring Boot version, the project name, and its dependencies (in this case, “Spring Web”), and then download a ZIP file containing a ready-to-use project: the [pom.xml] file, the main class, and the [application.properties] file. IntelliJ, IDEA, and VS Code (with the “Spring Boot Extension Pack” extension) offer the same wizard.

13.2.1. The server-side

Image

The file [pom.xml] from the project [WsHello] is as follows:

<?xml version="1.0" encoding="UTF-8"?>
<!-- the WsHello web service: a Spring Boot application -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- the Spring Boot parent project: library versions and plugin configuration -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>examples.webservices</groupId>
    <artifactId>ws-hello</artifactId>
    <version>1.0</version>

    <properties>
        <java.version>25</java.version>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Spring MVC, the embedded Tomcat server and Jackson -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.15.0</version>
                <configuration>
                    <parameters>true</parameters>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
            <!-- mvn spring-boot:run starts the application; mvn package makes an executable jar of it -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Comments:

  • Lines 9–14: The project inherits from the Spring Boot parent project, [spring-boot-starter-parent], downloaded as a dependency from Maven Central (the empty tag [<relativePath/>] indicates that it should not be searched for in the parent folder). This parent project specifies the versions of several hundred libraries and configures the Maven plugins. This is why the dependencies and plugin on lines 28–31 and 48–51 do not have the [<version>] tag: their version is the one chosen by Spring Boot 4.1.1;
  • line 21: the [java.version] property is the one used by the parent project to configure the compiler; line 22 duplicates it, in line with the other projects in the course;
  • Lines 28–31: The project’s sole dependency is the web starter. It provides Spring MVC (Spring Framework 7.0), the embedded Tomcat 11 server, Jackson 3, and logging;
  • line 41: the compiler option [-parameters], which preserves the names of method parameters in the [.class] files. Spring needs this to map a method parameter ([name]) to the parameter of the same name in a URL. The parent project already enables this option; we include it here to make it visible;
  • lines 48–51: the Spring Boot plugin. [mvn spring-boot:run] compiles and runs the application; [mvn package] creates a [target/ws-hello-1.0.jar] file containing the application and all its dependencies, which is then run by [java -jar target/ws-hello-1.0.jar].

The main class is as follows:

package wshello;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// the WsHello Spring Boot application
@SpringBootApplication
public class WsHelloApplication {

    public static void main(String[] args) {
        // starts Spring, then the Tomcat server that waits for the requests
        SpringApplication.run(WsHelloApplication.class, args);
    }
}
  • Line 7: The annotation [@SpringBootApplication] combines three annotations: [@Configuration] (the class can define beans, as in Chapter 6), [@EnableAutoConfiguration] (automatic configuration based on the available libraries), and [@ComponentScan] (Spring searches for annotated classes—controllers, configurations, etc.—in this class’s package and its subpackages);
  • line 10: Here we retain the standard form of the [main] and [public static] methods, under which Spring Boot tools search for the main class;
  • line 12: [SpringApplication.run] reads the configuration ([application.properties] file, environment variables, command-line arguments), creates the Spring container and its beans, starts the Tomcat server, and then returns control. However, the program does not stop there: the Tomcat threads wait for requests until the application is terminated (Ctrl-C).

The application configuration is stored in the file [src/main/resources/application.properties]:

1
2
3
4
5
6
7
8
# the name of the application
spring.application.name=WsHello
# the listening port of the Tomcat server
server.port=5100
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n
  • line 2: the application name;
  • line 4: the server listens on port 5100 (8080 by default). This value can also be passed as an argument: [mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=5100], or via the environment variable [SERVER_PORT];
  • line 6: By default, Spring Boot displays a large banner with the text ASCII on startup; we remove it;
  • line 8: the format of log messages. The default format is very comprehensive (date, level, process ID, application name, thread, full class name, message) but results in very long lines. We’re reducing it to the time, the message level, the thread name, the short name of the issuing class ([%logger{0}]), and the message.

The web service itself is the class [HelloController]. It provides a method that returns the string [Hello Mrs xx], where xx is the received name:

package wshello;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

// the WsHello web service: each annotated public method processes a URL
@RestController
public class HelloController {

    // the root of the site: a welcome text
    @GetMapping("/")
    public String home() {
        return "WsHello web service: try /hello/{name} or /greeting/{name}";
    }

    // a "web method" that has become a URL: it says hello to the lady
    // the {name} segment of the URL is passed to the [name] parameter of the method
    // a string is returned as it is (text/plain)
    @GetMapping("/hello/{name}")
    public String hello(@PathVariable String name) {
        return "Hello Mrs " + name;
    }

    // the same thing with a JSON response: an object is serialized into JSON (application/json)
    @GetMapping("/greeting/{name}")
    public Greeting greeting(@PathVariable String name) {
        return new Greeting(name, "Hello Mrs " + name);
    }

    // the result of the /greeting method
    public record Greeting(String name, String message) {
    }
}

Comments:

  • Line 8: The annotation [@RestController] designates the class as a controller: Spring creates a single instance (a singleton) of it at startup and assigns it the requests HTTP, where URL corresponds to one of its methods. The result of each method is sent as-is to the client, in the response body;
  • lines 12–15: The [@GetMapping("/")] annotation associates the HTTP and GET methods with the URL and [/] (the site root) with the method [home], which returns text explaining how to use the service;
  • Lines 20–23: The template URL (known as the route) [/hello/{name}] contains a variable [{name}]: it corresponds to any segment of URL that comes after [/hello/]. Thus, URL [/hello/Carla%20Bruni] assigns the value [Carla Bruni] to [{name}] (the code [%20] represents a space in URL). The annotation [@PathVariable] (line 21) instructs Spring to pass this value to the method parameter of the same name: this is data binding. The method returns a string: this is sent as-is to the client as a text document (type [text/plain]);
  • lines 26–29: the same process, but the method returns an object of type [Greeting]. Spring serializes it to JSON using Jackson (Chapter 5) and sends it to the client as a document of type [application/json]. This is the standard format for a REST web service response;
  • lines 32–33: the result type, a record (Chapter 4), declared within the controller. Jackson knows how to serialize records: each component becomes a JSON property with the same name.

The service is launched by the command [mvn spring-boot:run], entered in the project folder. It displays its log (the course has abbreviated the paths in line 2 to [...]):

> mvn spring-boot:run
18:57:19.455 INFO  [main] WsHelloApplication : Starting WsHelloApplication using Java 25.0.4.1 with PID 6430 (... in .../13-web-services/WsHello)
18:57:19.911 INFO  [main] WsHelloApplication : No active profile set, falling back to 1 default profile: "default"
18:57:24.871 INFO  [main] TomcatWebServer : Tomcat initialized with port 5100 (http)
18:57:24.915 INFO  [main] StandardService : Starting service [Tomcat]
18:57:24.915 INFO  [main] StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
18:57:25.041 INFO  [main] WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 4582 ms
18:57:27.317 INFO  [main] TomcatWebServer : Tomcat started on port 5100 (http) with context path '/'
18:57:27.394 INFO  [main] WsHelloApplication : Started WsHelloApplication in 10.545 seconds (process running for 11.704)
18:57:30.107 INFO  [http-nio-5100-exec-1] [/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
18:57:30.108 INFO  [http-nio-5100-exec-1] DispatcherServlet : Initializing Servlet 'dispatcherServlet'
18:57:30.113 INFO  [http-nio-5100-exec-1] DispatcherServlet : Completed initialization in 5 ms
18:58:33.536 INFO  [SpringApplicationShutdownHook] GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
18:58:33.585 INFO  [tomcat-shutdown] GracefulShutdown : Graceful shutdown complete
  • line 2: the application startup, the Java version, and the execution directory, which is the project directory;
  • line 3: Spring allows you to define profiles (development, test, production, etc.) that enable different configurations ([application-dev.properties] files, etc.). No profile is active here;
  • lines 4–6: the embedded Tomcat server is created; it will listen on port 5100;
  • line 7: The Spring container is ready: it has created its beans, including our controller;
  • lines 8–9: Tomcat is now accepting connections; the application is ready. Startup took about ten seconds on our test machine, which is rather slow; it usually takes two or three seconds;
  • lines 10–12: These messages appeared during the first request: the Spring servlet [DispatcherServlet], which receives all requests and distributes them among the controller methods, is initialized only at this point. A servlet is a Java component that processes requests in a web server such as Tomcat (Jakarta Servlet); Spring is built on top of this. The thread name, [http-nio-5100-exec-1], indicates that the request is being processed by one of the Tomcat server’s threads;
  • lines 13–14: these messages appeared when the server was shut down using Ctrl-C, after the tests described in the following paragraphs; the shutdown was “graceful”—Tomcat completes any pending requests before shutting down.

Each log message indicates its time, its level ([TRACE], [DEBUG], [INFO], [WARN], [ERROR]), the thread that sent it, and the sender class. Messages at level [INFO] and above are displayed by default; the [logging.level.<package>=DEBUG] property also displays lower-level messages for a given package.

13.2.2. Querying the service with curl

While the service is running, let’s query it in a second command window using the [curl] tool introduced in the previous chapter. The [-i] option displays the response headers before the body, and the [-s] (silent) option suppresses the progress bar:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/hello/Carla%20Bruni
HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 21
Date: Sat, 03 Oct 2026 16:57:30 GMT

Hello Mrs Carla Bruni
  • Line 2: The request was successful. Tomcat does not send the text that usually follows the status code ([OK]): it has been optional since HTTP/1.1 (and no longer exists in HTTP/2); only the code matters;
  • Line 3: The document is text (UTF-8);
  • line 4: the document size, in bytes;
  • line 5: the response time, always expressed in Coordinated Universal Time (GMT) in the HTTP headers, whereas the server log uses local time;
  • line 7: the result of the [hello] method.

Now let’s request the JSON version:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/greeting/Carla%20Bruni
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 56
Date: Sat, 03 Oct 2026 16:57:30 GMT

{"name":"Carla Bruni","message":"Hello Mrs Carla Bruni"}
  • line 3: the document is now of type [application/json]. It is not necessary to specify the character set: a JSON document exchanged over the network is always encoded in UTF-8 (RFC 8259);
  • Line 7: The serialized object [Greeting]. The properties JSON, [name], and [message] correspond to the names of the record's components. Since the Java names are already in the format “camelCase,” and the usage is in JavaScript and JSON, there is nothing to convert.

A URL that does not match any route returns a 404 response:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/hello
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:57:31 GMT

{"timestamp":"2026-10-03T16:57:31.003Z","status":404,"error":"Not Found","path":"/hello"}
  • Line 4: The document is sent in chunks, each preceded by its size; the server did not have to calculate the total size of the document before starting to send it;
  • line 7: the error document generated by Spring Boot: the date, the status code, its text, and the requested path. For security reasons, it contains neither the message of any exception nor the call stack (properties [server.error.include-message] and [server.error.include-stacktrace], disabled by default). We’ll see in the next service how to obtain a standardized error document.

You can also type URL [http://localhost:5100/hello/Carla Bruni] into a browser’s address bar, which will display the received text: a web service queried by GET can be tested very easily.

13.2.3. A console client

Image

It is possible to write the service client using a simple TCP client, as in the previous chapter: simply send the request HTTP and read the response. However, we use the [java.net.http.HttpClient] class (Java 11)—also introduced in the previous chapter—which handles the HTTP protocol for us and uses Jackson for the JSON documents. The [ClientWsHello] project is a standard Maven project, whose [pom.xml] file declares the Jackson dependency and the main class:

    <dependencies>
        <!-- Jackson for reading the JSON responses -->
        <dependency>
            <groupId>tools.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>3.1.5</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.15.0</version>
                <configuration>
                    <parameters>true</parameters>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
            <!-- mvn exec:java executes the main class -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.5.1</version>
                <configuration>
                    <mainClass>clientwshello.Main</mainClass>
                </configuration>
            </plugin>

Its code is as follows:

package clientwshello;

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import tools.jackson.databind.json.JsonMapper;

// console client of the WsHello web service
public class Main {

    // the JSON response of the service: same component names as on the server side
    record Greeting(String name, String message) {
    }

    public static void main(String[] args) throws InterruptedException {
        // the URL of the service can be passed as an argument
        URI serviceUrl = URI.create(args.length > 0 ? args[0] : "http://localhost:5100/");
        // the Jackson JSON <-> objects converter
        JsonMapper mapper = JsonMapper.builder().build();

        // a single HTTP client for the whole duration of the program
        try (HttpClient client = HttpClient.newHttpClient()) {
            while (true) {
                // the name of the lady
                String input = IO.readln("Name of the lady (nothing to stop): ");
                String name = input == null ? "" : input.strip();
                if (name.isEmpty()) {
                    break;
                }
                try {
                    // the name is placed in the path of the URL: it must be encoded in it
                    String encodedName = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
                    // 1 - text response
                    String text = get(client, serviceUrl.resolve("hello/" + encodedName));
                    IO.println("[text] " + text);
                    // 2 - JSON response, deserialized into a Greeting object
                    String json = get(client, serviceUrl.resolve("greeting/" + encodedName));
                    Greeting greeting = mapper.readValue(json, Greeting.class);
                    IO.println("[JSON] " + greeting);
                } catch (ConnectException e) {
                    // no service listens at this address (the exception has no message)
                    IO.println("Error: ConnectException: connection refused (" + serviceUrl + ")");
                } catch (IOException e) {
                    // the service responded with an error, or the communication failed
                    IO.println("Error: " + e.getClass().getSimpleName() + " : " + e.getMessage());
                }
            }
        }
    }

    // a GET request whose response body is returned as a string
    private static String get(HttpClient client, URI url) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(url).GET().build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        // a status code other than 2xx is an error
        if (response.statusCode() / 100 != 2) {
            throw new IOException("the service responded " + response.statusCode() + " for " + url);
        }
        return response.body();
    }
}

Comments:

  • Lines 16–18: The service’s response, JSON, will be deserialized into a [Greeting] object. The client does not have access to the server’s [Greeting] type; it declares its own, with the same component names. A client does not share the server’s code, only the format of the exchanged documents;
  • line 22: the service’s URL is passed as an argument or, failing that, defaults to [http://localhost:5100/]: if the service’s address changes, the client does not need to be recompiled;
  • line 24: the JSON converter from Jackson 3 (Chapter 5);
  • line 27: a single [HttpClient] for the entire program. It manages a set of reusable connections and must be shared, not recreated for each request. Starting with Java 21, [HttpClient] implements [AutoCloseable]: the [try] statement with a resource closes it at the end of the program;
  • line 37: the entered name becomes a segment of URL: characters not allowed there must be encoded. [URLEncoder.encode] encodes the “é” as [%C3%A9], but it is intended for form parameters, where the space is encoded as [+]. In a URL path, the space must be encoded as [%20]: so we replace the [+] (a valid [+] entry is encoded as [%2B] and is not affected);
  • line 39: The method [resolve] of the class [URI] constructs a URL from the base URL and a relative URL. Note: The base URL must end with [/] for [hello/...] to be added to it;
  • lines 42–43: The JSON response is deserialized into a [Greeting] object using Jackson’s [readValue] method;
  • lines 45–47: The exception [ConnectException] is thrown when a connection to the service is impossible, for example, when no service is listening at the specified address. It has no message; we construct one. This [catch] must precede the one on line 48, because [ConnectException] is derived from [IOException];
  • lines 48–50: other communication errors and error responses from the service (see line 62);
  • lines 57–65: a GET request, whose response body is rendered as a string;
  • line 58: the request is built using a builder: URL, the HTTP method, then [build];
  • Line 59: The [send] method sends the request and waits for the response. Its second parameter, a [BodyHandler], specifies what to do with the response body: [ofString] reads the entire body into a string, decoded according to the character set specified by the [Content-Type] header (UTF-8 by default). [send] triggers a [IOException] in the event of a communication error and a [InterruptedException] if the thread is interrupted while waiting; the main program allows the latter to propagate upward (line 20);
  • lines 61–63: Unlike other libraries, [HttpClient] does not treat an error status code (4xx, 5xx) as an exception: the response is returned normally, and it is up to us to examine its code. Here, we launch a [IOException] for any code other than 2xx.

Results, with the service running:

1
2
3
4
5
6
7
8
> mvn -q compile exec:java
Name of the lady (nothing to stop): Carla Bruni
[text] Hello Mrs Carla Bruni
[JSON] Greeting[name=Carla Bruni, message=Hello Mrs Carla Bruni]
Name of the lady (nothing to stop): Marie Curie
[text] Hello Mrs Marie Curie
[JSON] Greeting[name=Marie Curie, message=Hello Mrs Marie Curie]
Name of the lady (nothing to stop): 

Line 4 displays the received [Greeting] object, in the format of the [toString] method generated for the records. The Maven option [-q] (quiet) suppresses its own messages. If you specify an address where no service is listening, the connection is refused:

1
2
3
4
> mvn -q compile exec:java -Dexec.args="http://localhost:5199/"
Name of the lady (nothing to stop): Carla Bruni
Error: ConnectException: connection refused (http://localhost:5199/)
Name of the lady (nothing to stop): 

And if the service’s URL is incorrect, the service returns a 404:

1
2
3
4
> mvn -q compile exec:java -Dexec.args="http://localhost:5100/hello/"
Name of the lady (nothing to stop): Carla Bruni
Error: IOException : the service responded 404 for http://localhost:5100/hello/hello/Carla%20Bruni
Name of the lady (nothing to stop): 

With a service SOAP, the client used a proxy generated from the service’s WSDL and called the remote method as if it were a local method. Here, we are calling the service’s URL directly. We will write a proper proxy for the tax calculation service.

13.2.4. Service description: OpenAPI

The WSDL described a SOAP service in a standardized format. Its current equivalent is the OpenAPI document. For Spring Boot, the [springdoc-openapi] library ([org.springdoc:springdoc-openapi-starter-webmvc-ui] dependency) generates it without a single line of code: it scans the controllers and their annotations at startup and publishes the document to URL and [/v3/api-docs], as well as an interactive test page, Swagger UI, to URL and [/swagger-ui.html]. The document describes each route: its URL, its method HTTP, its parameters and their types, and the schema JSON of its responses. Generators such as the OpenAPI Generator use this to generate a typed client in various languages, just as [wsimport] did with WSDL. This library is not part of Spring Boot, and we will not use it in this chapter.

13.3. A Web Service for Arithmetic Operations

The previous service offered a single method. We will now consider a web service that provides the four arithmetic operations: add(a, b), subtract(a, b), multiply(a, b), and divide(a, b). We’ll take this opportunity to explore the two ways to pass parameters to a web service and how to handle errors.

13.3.1. The Server Side

The [WsOperations] service accepts two types of requests:

Request
Parameters
GET /add?a=1.5&b=2
The operation in the URL path, the numbers in its query string
POST /calculation
a document JSON in the request body: [{"operation":"add","a":1.5,"b":2}]

In both cases, the response is a document JSON: [{"operation":"add","a":1.5,"b":2.0,"result":3.5}]. GET is suitable for simple requests that do not modify anything on the server: URL can be typed into a browser, bookmarked, and its response can be cached. POST is suitable for large or structured data. The project’s [pom.xml] file is the same as [WsHello], except for the project name ([ws-operations]), and its main class, [WsOperationsApplication], is identical to that of [WsHello]. Its [application.properties] file is as follows:

# the name of the application
spring.application.name=WsOperations
# the listening port of the Tomcat server
server.port=5110
# the errors detected by Spring MVC are reported in the "Problem Details" format (RFC 9457)
spring.mvc.problemdetails.enabled=true
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n
  • line 4: the service is listening on port 5110;
  • line 6: errors detected by Spring MVC itself (unknown URL, invalid JSON document, unsupported document type, etc.) will be reported via a response in the Problem Details format. This format, standardized by RFC 9457, is a JSON document of type [application/problem+json] that describes an error: its type, a title, the status code, a detail, and any additional information. This is the standard way for a REST service to indicate what is wrong.

The validator is as follows:

package wsoperations;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.DoubleBinaryOperator;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

// the WsOperations web service: the four arithmetic operations
@RestController
public class OperationsController {

    // GET /add?a=1.5&b=2 (likewise for subtract, multiply, divide)
    // [operation] comes from the path of the URL, [a] and [b] from its query string
    @GetMapping("/{operation}")
    public ResponseEntity<?> calculateGet(@PathVariable String operation,
            @RequestParam(required = false) String a, @RequestParam(required = false) String b) {
        return calculate(operation, readNumber(a), readNumber(b));
    }

    // POST /calculation with a JSON body: {"operation":"add","a":1.5,"b":2}
    // the JSON body is deserialized into the [request] parameter
    @PostMapping("/calculation")
    public ResponseEntity<?> calculatePost(@RequestBody CalculationRequest request) {
        return calculate(request.operation(), request.a(), request.b());
    }

    // a number written with a decimal point, null if it is missing or incorrect
    private static Double readNumber(String text) {
        if (text == null) {
            return null;
        }
        try {
            double number = Double.parseDouble(text);
            // NaN and infinities are refused
            return Double.isFinite(number) ? number : null;
        } catch (NumberFormatException e) {
            return null;
        }
    }

    // the calculation common to the two HTTP methods
    private static ResponseEntity<?> calculate(String operation, Double a, Double b) {
        // the requested operation, null if it is unknown
        String name = operation == null ? "" : operation.toLowerCase(Locale.ROOT);
        DoubleBinaryOperator calculation = switch (name) {
            case "add" -> (x, y) -> x + y;
            case "subtract" -> (x, y) -> x - y;
            case "multiply" -> (x, y) -> x * y;
            case "divide" -> (x, y) -> x / y;
            default -> null;
        };
        // checking the parameters: the errors are accumulated
        Map<String, List<String>> errors = new LinkedHashMap<>();
        if (calculation == null) {
            errors.put("operation", List.of("Unknown operation [" + name
                    + "]: use add, subtract, multiply or divide"));
        }
        if (a == null) {
            errors.put("a", List.of("The number a is missing or incorrect"));
        }
        if (b == null) {
            errors.put("b", List.of("The number b is missing or incorrect"));
        }
        if (!errors.isEmpty()) {
            // 400 response with the list of errors
            ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
            problem.setTitle("Incorrect parameters");
            problem.setProperty("errors", errors);
            return ResponseEntity.badRequest().body(problem);
        }
        // division by zero is refused: 400 response
        if (name.equals("divide") && b == 0) {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
                    "The number b must be non-zero for a division");
            problem.setTitle("Division by zero");
            return ResponseEntity.badRequest().body(problem);
        }
        // 200 response with the result in JSON
        return ResponseEntity.ok(new CalculationResult(name, a, b, calculation.applyAsDouble(a, b)));
    }

    // the calculation request (body of the POST request): the numbers missing from the JSON are null
    public record CalculationRequest(String operation, Double a, Double b) {
    }

    // the result of a calculation (body of the response)
    public record CalculationResult(String operation, double a, double b, double result) {
    }
}

Comments:

  • Lines 24–28: The GET request. The method’s [operation] parameter is linked to the route variable [{operation}] ([@PathVariable]). The parameters [a] and [b], annotated as [@RequestParam], are bound to the parameters of the same name in the query string ([?a=1.5&b=2]). They are optional ([required = false]): a missing parameter is treated as [null]; otherwise, Spring would reject a request missing any of them with a 400 response;
  • Line 26: [a] and [b] are declared as type [String]. We could have declared them as type [double] or [Double]: Spring would then have performed the conversion itself and rejected an incorrect value with a 400 response containing a generic error message. By receiving them as strings, we retain control over the error messages;
  • lines 32–35: the request POST. The parameter [request], annotated as [@RequestBody], is constructed by Jackson from the request body JSON. Its components are of type [Double] (line 93), not [double]: a property missing from the JSON document is then evaluated as [null], which allows it to be detected;
  • lines 38–49: converting a string to a number; if this fails, the result is [null]. In both URL and JSON, numbers are written with a decimal point: [Double.parseDouble], which is independent of the system language, is therefore the correct tool (and not [NumberFormat], which would expect a comma on a French server, Chapter 5). It also accepts the texts [NaN] and [Infinity], which we reject (line 45);
  • line 54: the operation name, in lowercase. [Locale.ROOT] performs the conversion regardless of the system language;
  • lines 55–61: the requested operation is represented by a functional interface (Chapter 7), [DoubleBinaryOperator], whose method [applyAsDouble(double, double)] returns a [double]. A [switch] expression selects the lambda corresponding to the operation; for an unknown operation, it selects [null];
  • lines 63–73: the three parameters are validated by accumulating errors in a dictionary that associates each parameter with its list of error messages. A [LinkedHashMap] preserves the order of insertion;
  • lines 74–80: If an error is found, a 400 response is returned. The Spring class [ProblemDetail] represents a “Problem Details” document: [forStatus] creates it with a status code, [setTitle] sets its title, and [setProperty] adds a property to it—in this case, [errors], the list of errors. The class [ResponseEntity] represents a complete HTTP response: status code, headers, and body. [ResponseEntity.badRequest()] prepares a 400 response, and [body] provides its body;
  • lines 82–87: division by zero is rejected with a 400 response, whose document this time includes a detail ([forStatusAndDetail]). This rejection is necessary: in Java, dividing a [double] by zero does not throw an exception but returns infinity ([Double.POSITIVE_INFINITY]), which a JSON document cannot represent (Jackson would write it as a string, ["Infinity"]). The [b == 0] test compares the value of the [Double] object to zero: Java automatically “unboxes” it;
  • line 89: the 200 response, whose body is the [CalculationResult] object serialized to JSON;
  • lines 93–98: the two types exchanged with clients. These objects, which are used solely to transport data between two applications, are called DTO (Data Transfer Object). Records are ideal for this purpose.

Both methods of the controller return the type [ResponseEntity<?>]: a response whose body can be of any type, in this case a [CalculationResult] or a [ProblemDetail]. Spring examines the actual type of the body to serialize it; it assigns the document the type [application/json], or [application/problem+json] for a [ProblemDetail].

13.3.2. Tests with curl

The service is launched by [mvn spring-boot:run] in the [WsOperations] folder. An addition:

1
2
3
4
5
6
7
> curl -s -i "http://localhost:5110/add?a=1.5&b=2"
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:40 GMT

{"operation":"add","a":1.5,"b":2.0,"result":3.5}

The number 2 has become 2.0: Jackson always writes a [double] with its decimal part. For a client—JavaScript in particular—it’s the same number. A division by zero:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5110/divide?a=1&b=0"
HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:40 GMT
Connection: close

{"detail":"The number b must be non-zero for a division","instance":"/divide","status":400,"title":"Division by zero"}
  • Line 2: The request is denied;
  • line 3: the body is a “Problem Details” document;
  • line 6: After a 400 response, Tomcat closes the connection as a precaution: an invalid request could have left unexpected data in the connection. The client will open another one for its next request;
  • line 8: the document contains the properties [title], [status], and [detail], set by our code, and [instance], added by Spring: the path of the request that caused the error. The property [type]—a URL that documents the error type—is missing: the RFC 9457 then assigns it the value [about:blank], which means that the status code is sufficient to describe the problem. Note that the properties are sorted alphabetically: this is the default behavior of Jackson 3 for ordinary classes such as [ProblemDetail]; records, on the other hand, retain the order of their components.

An unknown operation, a number written with a decimal comma, and a missing number:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5110/power?a=1,5"
HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:40 GMT
Connection: close

{"instance":"/power","status":400,"title":"Incorrect parameters","errors":{"operation":["Unknown operation [power]: use add, subtract, multiply or divide"],"a":["The number a is missing or incorrect"],"b":["The number b is missing or incorrect"]}}

The document’s [errors] property contains the validation errors, parameter by parameter; added by [setProperty], it is placed after the normalized properties. Now a POST query. The [-d] option of [curl] sends the text that follows it in the body of a POST request, and the [-H] option adds a header to the request, in this case the type of the document being sent:

1
2
3
4
5
6
7
> curl -s -i -H "Content-Type: application/json" -d '{"operation":"multiply","a":1.5,"b":-4}' http://localhost:5110/calculation
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:41 GMT

{"operation":"multiply","a":1.5,"b":-4.0,"result":-6.0}

If the sent JSON document is invalid, Jackson cannot construct the [request] parameter: Spring responds with a 400 error on its own, without executing our method:

1
2
3
4
5
6
7
8
> curl -s -i -H "Content-Type: application/json" -d '{"operation":"multiply","a":1.5,' http://localhost:5110/calculation
HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:41 GMT
Connection: close

{"detail":"Failed to read request","instance":"/calculation","status":400,"title":"Bad Request"}

The “Problem Details” document on line 8 was generated by Spring MVC, thanks to the property [spring.mvc.problemdetails.enabled]; without it, we would get the Spring Boot error document seen with [WsHello]. The same applies if the [Content-Type] header is omitted: [curl] then sends the body as a HTML form, and Spring rejects this type of document:

1
2
3
4
5
6
7
8
> curl -s -i -d 'x' http://localhost:5110/calculation
HTTP/1.1 415 
Accept: application/json, application/*+json
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 16:59:41 GMT

{"detail":"Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported.","instance":"/calculation","status":415,"title":"Unsupported Media Type"}

Code 415 (Unsupported Media Type) means that the type of document sent is not accepted; the [Accept] header on line 3 tells the client which types are accepted.

13.3.3. The client side

Image

Our console client requests the operation and the two numbers on a single line, then queries the service twice: first with GET, then with POST, to demonstrate both methods. Its [pom.xml] file is identical to [ClientWsHello], except for the project name and the main class. Its program is as follows:

package clientwsoperations;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.List;
import java.util.Map;
import tools.jackson.databind.json.JsonMapper;

// console client of the WsOperations web service
public class Main {

    // the objects exchanged with the service (the component names are those of the JSON)
    record CalculationRequest(String operation, double a, double b) {
    }

    record CalculationResult(String operation, double a, double b, double result) {
    }

    // an error response in the "Problem Details" format
    record Problem(String title, String detail, Integer status, Map<String, List<String>> errors) {
    }

    // the numbers are typed and displayed in the current culture (fr-FR: decimal comma, en-US: decimal point)
    private static final NumberFormat FORMAT = NumberFormat.getInstance();

    public static void main(String[] args) throws InterruptedException {
        URI serviceUrl = URI.create(args.length > 0 ? args[0] : "http://localhost:5110/");
        // the JSON converter; Jackson 3 ignores the unknown JSON properties (instance...)
        JsonMapper mapper = JsonMapper.builder().build();

        try (HttpClient client = HttpClient.newHttpClient()) {
            while (true) {
                // input of the operation and of the two numbers (decimal separator of the current culture)
                String input = IO.readln("Operation a b (e.g.: add 1.5 2), nothing to stop: ");
                String[] fields = input == null ? new String[0] : input.strip().split("\\s+");
                if (fields.length == 0 || fields[0].isEmpty()) {
                    break;
                }
                if (fields.length != 3) {
                    IO.println("Three elements are needed: operation a b");
                    continue;
                }
                // local check of the two numbers, before any call to the service
                String operation = fields[0];
                Double a = readNumber(fields[1]);
                if (a == null) {
                    IO.println("Incorrect number a...");
                }
                Double b = readNumber(fields[2]);
                if (b == null) {
                    IO.println("Incorrect number b...");
                }
                if (a == null || b == null) {
                    continue;
                }
                String calculation = operation + "(" + FORMAT.format(a) + " ; " + FORMAT.format(b) + ")";

                // 1 - GET request: in the URL, the numbers are written with a decimal point
                String url = URLEncoder.encode(operation, StandardCharsets.UTF_8) + "?a=" + a + "&b=" + b;
                try {
                    HttpRequest request = HttpRequest.newBuilder(serviceUrl.resolve(url)).GET().build();
                    HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
                    if (response.statusCode() == 200) {
                        CalculationResult result = mapper.readValue(response.body(), CalculationResult.class);
                        IO.println("[GET " + url + "] " + calculation
                                + " = " + FORMAT.format(result.result()));
                    } else {
                        // failure: the status code and the document received are displayed, as they are
                        IO.println("[GET " + url + "] error " + response.statusCode()
                                + " : " + response.body());
                    }
                } catch (IOException e) {
                    // the service is unreachable
                    IO.println("[GET " + url + "] error: " + e);
                }

                // 2 - POST request: the request is sent in JSON in the body of the request
                try {
                    String json = mapper.writeValueAsString(new CalculationRequest(operation, a, b));
                    HttpRequest request = HttpRequest.newBuilder(serviceUrl.resolve("calculation"))
                            .header("Content-Type", "application/json")
                            .POST(HttpRequest.BodyPublishers.ofString(json))
                            .build();
                    HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
                    if (response.statusCode() == 200) {
                        // success: the body contains the result
                        CalculationResult result = mapper.readValue(response.body(), CalculationResult.class);
                        IO.println("[POST calculation] " + calculation + " = " + FORMAT.format(result.result()));
                    } else {
                        // failure: the body contains the description of the problem
                        Problem problem = mapper.readValue(response.body(), Problem.class);
                        IO.println("[POST calculation] error " + response.statusCode() + " : " + problem.title());
                        if (problem.detail() != null) {
                            IO.println("  " + problem.detail());
                        }
                        if (problem.errors() != null) {
                            problem.errors().forEach((parameter, messages) ->
                                    IO.println("  " + parameter + " : " + String.join(" ; ", messages)));
                        }
                    }
                } catch (IOException e) {
                    // the service is unreachable
                    IO.println("[POST calculation] error: " + e);
                }
            }
        }
    }

    // a number typed in the current culture, null if the input is not one
    private static Double readNumber(String text) {
        ParsePosition position = new ParsePosition(0);
        Number number = FORMAT.parse(text, position);
        // the input must have been read entirely: "2.5x" is refused
        return number == null || position.getIndex() != text.length() ? null : number.doubleValue();
    }
}

Comments:

  • Lines 20–29: the client’s DTO files. They are declared here again: a client does not share the server’s code, only the format of the exchanged JSON documents. Spring’s [ProblemDetail] class is part of the web framework; a simple record is sufficient for the client. Its components are object types ([String], [Integer], [Map]): a property missing from the document, such as [detail] or [errors], is set to [null];
  • line 32: numbers are entered and displayed in the system language format, in this case English: decimal point;
  • Line 37: From Jackson 3, unknown properties in the record (here, [instance]) are ignored by default during deserialization;
  • lines 51–63: The numbers are verified locally before any call to the service is made. A service must always verify its parameters (since it does not know who is calling it), but a client can avoid unnecessary network traffic;
  • line 64: the text describing the calculation, with the numbers in the system language format;
  • line 67: the URL for the GET request. Numbers must be written with a decimal point: concatenating a [Double] to a string uses its [toString] method, which is independent of the system language. The operation, entered by the user, is coded as [URLEncoder];
  • lines 69–70: the request GET;
  • lines 71–74: if successful (status code 200), the response JSON is deserialized into a [CalculationResult] object;
  • lines 75–79: if the request fails, the status code and the received document are displayed as-is;
  • lines 80–83: The exception [IOException] indicates a communication error, such as a service that cannot be reached;
  • lines 87–92: the POST request. The [CalculationRequest] object is serialized into JSON by [writeValueAsString]; the request receives the header [Content-Type], followed by its body: [BodyPublishers.ofString] sends a string, encoded as UTF-8;
  • lines 97–107: in case of failure, the body is a “Problem Details” document, deserialized into a [Problem] object, whose title, details, and validation errors are displayed. The [forEach] method of a [Map] receives a lambda with two parameters: the key and value of each element (Chapter 7);
  • lines 118–123: reading a number in the system language format. The [parse(String, ParsePosition)] method of [NumberFormat] reads the beginning of the string and indicates in the [ParsePosition] object where it stopped: if it did not stop at the end, the input contains extra characters ([2.5x]).

Results, with the service running:

> mvn -q compile exec:java
Operation a b (e.g.: add 1.5 2), nothing to stop: add 1.5 2
[GET add?a=1.5&b=2.0] add(1.5 ; 2) = 3.5
[POST calculation] add(1.5 ; 2) = 3.5
Operation a b (e.g.: add 1.5 2), nothing to stop: divide 1 0
[GET divide?a=1.0&b=0.0] error 400 : {"detail":"The number b must be non-zero for a division","instance":"/divide","status":400,"title":"Division by zero"}
[POST calculation] error 400 : Division by zero
  The number b must be non-zero for a division
Operation a b (e.g.: add 1.5 2), nothing to stop: power 2 3
[GET power?a=2.0&b=3.0] error 400 : {"instance":"/power","status":400,"title":"Incorrect parameters","errors":{"operation":["Unknown operation [power]: use add, subtract, multiply or divide"]}}
[POST calculation] error 400 : Incorrect parameters
  operation : Unknown operation [power]: use add, subtract, multiply or divide
Operation a b (e.g.: add 1.5 2), nothing to stop: multiply 2.5 x
Incorrect number b...
Operation a b (e.g.: add 1.5 2), nothing to stop: 
  • line 3: the URL from the GET query: the numbers 1.5 and 2 entered there are written as 1.5 and 2.0;
  • Lines 6–8: The same error observed in both requests: the raw document for request GET, and its processing for request POST;
  • Lines 10–12: The service detected an unknown operation;
  • line 14: the incorrect number was detected by the client, which did not query the service.

13.4. Sample Application—Version 9: A Web Service for Calculating Taxes

We’ll revisit the tax calculation application. In the previous chapter (version 8), we had created a server named TCP that could be queried remotely, using its own communication protocol. We’ll now turn it into a web service named REST.

13.4.1. The Architecture

The architecture of version 9 is as follows:

Image

  • on the server side, the [WsTax] web service receives HTTP requests and has them processed by the [business] and [dao] layers from Version 5 (Chapter 6), which are reused as-is;
  • on the client side, the [ui] layer from version 5 is also reused as-is. It communicates with a new type of [business] layer, [TaxBusinessHttp], which implements the [ITaxBusiness] interface by querying the web service: it acts as a proxy for the service. The [ui] layer is unaware that the calculation is performed on another machine;
  • on both sides, the integration of the layers is handled by Spring’s dependency injection container, just as in version 5.

13.4.2. The web service

The service contract is as follows:

Request
Response on success
Response in case of an error
GET /tax?married=y&children=2&salary=60000
200: [{"tax":4282}]
400: "Problem Details" document
POST /tax with the body [{"married":true,"children":2,"salary":60000}]
200: [{"tax":4282}]
400: "Problem Details" document

The [WsTax] project depends on the [tax-dao] and [tax-business] modules from version 5. For Maven to find them, you must first install them in the machine’s local Maven repository (the [.m2/repository] folder in the user’s home directory) by typing [mvn install] in the [06-architectures/TaxV5] folder (Chapter 6). The dependencies of the [pom.xml] file from [WsTax] are then as follows (the rest of the file is the same as [WsHello], except for the project name: [examples.tax:ws-tax:9.0]):

    <dependencies>
        <!-- Spring MVC, the embedded Tomcat server and Jackson -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <!-- the [dao] and [business] layers of version 5, reused as they are -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-dao</artifactId>
            <version>5.0</version>
        </dependency>
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-business</artifactId>
            <version>5.0</version>
        </dependency>
    </dependencies>
  • Lines 7–17: the two modules from version 5, identified by their Maven coordinates. The [tax-dao] module also includes the [TaxBracket] and [TaxException] classes from the [entities] layer. These modules depend on Jackson 3.1.5, which the web starter already provides.

The [application.properties] configuration file is as follows:

# the name of the application
spring.application.name=WsTax
# the listening port of the Tomcat server
server.port=5120
# the errors detected by Spring MVC are reported in the "Problem Details" format (RFC 9457)
spring.mvc.problemdetails.enabled=true
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n

# the [dao] layer: its implementation and its data file
dao.implementation=JsonTax
dao.file=TaxData.json
  • line 4: the service listens on port 5120;
  • Lines 13–14: the configuration for the [dao] layer, in the same format as in the [tax.properties] file from version 5: the implementation to use and its data file. These are not Spring Boot properties, but properties specific to our application: a [application.properties] file can contain as many as desired. Here we are using the files JSON and [TaxData.json], located in the project folder, which is the service’s runtime folder along with [mvn spring-boot:run].

The [dao] and [business] layers are integrated into the Spring container via a configuration class:

package tax.ws;

import tax.dao.FileTax;
import tax.dao.HardwiredTax;
import tax.dao.ITaxDao;
import tax.dao.JsonTax;
import tax.entities.TaxException;
import tax.business.ITaxBusiness;
import tax.business.TaxBusiness;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// the integration of the [dao] and [business] layers of version 5 in the Spring container
@Configuration
public class TaxConfig {

    // the [dao] layer: its implementation and its file come from the configuration
    @Bean
    public ITaxDao dao(@Value("${dao.implementation:HardwiredTax}") String implementation,
            @Value("${dao.file:}") String file) {
        return switch (implementation) {
            case "HardwiredTax" -> new HardwiredTax();
            case "FileTax" -> new FileTax(file);
            case "JsonTax" -> new JsonTax(file);
            default -> throw new TaxException(
                    "Unknown implementation [" + implementation + "] of the [dao] layer");
        };
    }

    // the [business] layer: Spring passes it the [dao] layer
    @Bean
    public ITaxBusiness business(ITaxDao dao) {
        return new TaxBusiness(dao);
    }
}

Comments:

  • line 15: the annotation [@Configuration] (Chapter 6): the class defines beans. Spring finds it because it is located in the same package as the main class ([@ComponentScan]);
  • Lines 19–29: The annotated method [@Bean] creates the layer [dao]. Its parameters are provided by Spring: the annotation [@Value] injects the value of a configuration property into it. In [${dao.implementation:HardwiredTax}], the text following the character [:] is the default value, used if the property is missing. This method and the next one are the same as those in the [TaxConfig] class from Version 5 (Chapter 6), without the [@PropertySource] annotation: Spring Boot reads the [application.properties] file itself;
  • lines 32–35: the [business] layer. The [dao] parameter of the method receives the bean of type [ITaxDao]: Spring therefore creates the [dao] layer before the [business] layer.

These lines replace the layer integration that the main program performed in version 5: the Spring container is the same, but it is now Spring Boot that creates it and locates the configuration class on its own. The beans defined this way are singletons, created when the application starts up, before any requests are processed: if the data file is missing or incorrect, the service does not start, rather than responding to all requests with a 500 error. We’ll verify this later.

The web service is the [TaxController] controller:

package tax.ws;

import tax.business.ITaxBusiness;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

// the tax calculation web service (version 9)
@RestController
public class TaxController {

    // the log of the class
    private static final Logger log = LoggerFactory.getLogger(TaxController.class);

    // the [business] layer, injected by Spring
    private final ITaxBusiness business;

    public TaxController(ITaxBusiness business) {
        this.business = business;
    }

    // GET /tax?married=y&children=2&salary=60000
    @GetMapping("/tax")
    public TaxResponse calculateGet(@RequestParam(required = false) String married,
            @RequestParam(required = false) String children, @RequestParam(required = false) String salary) {
        Boolean isMarried = switch (married == null ? "" : married.toLowerCase(Locale.ROOT)) {
            case "y" -> true;
            case "n" -> false;
            default -> null;
        };
        return calculate(isMarried, readInteger(children), readInteger(salary));
    }

    // POST /tax with the JSON body {"married":true,"children":2,"salary":60000}
    @PostMapping("/tax")
    public TaxResponse calculatePost(@RequestBody TaxRequest request) {
        return calculate(request.married(), request.children(), request.salary());
    }

    // the processing common to the two HTTP methods: the tax, or an exception if the request is incorrect
    private TaxResponse calculate(Boolean married, Integer children, Integer salary) {
        // correct parameters: the calculation is requested from the [business] layer
        if (married != null && children != null && children >= 0 && salary != null && salary >= 0) {
            int tax = business.calculateTax(married, children, salary);
            log.info("Tax(married={}, children={}, salary={}) = {}", married, children, salary, tax);
            return new TaxResponse(tax);
        }
        // incorrect parameters: the list of errors
        Map<String, List<String>> errors = new LinkedHashMap<>();
        if (married == null) {
            errors.put("married",
                    List.of("Indicate whether the taxpayer is married: y/n (GET) or true/false (POST)"));
        }
        if (children == null || children < 0) {
            errors.put("children", List.of("The number of children must be a positive or zero integer"));
        }
        if (salary == null || salary < 0) {
            errors.put("salary", List.of("The salary must be a positive or zero integer"));
        }
        log.warn("Incorrect request: {}", String.join(", ", errors.keySet()));
        throw new BadRequestException(errors);
    }

    // an integer, null if it is missing or incorrect
    private static Integer readInteger(String text) {
        try {
            return text == null ? null : Integer.valueOf(text);
        } catch (NumberFormatException e) {
            return null;
        }
    }

    // an incorrect request gives a 400 response in the "Problem Details" format
    @ExceptionHandler
    public ProblemDetail badRequest(BadRequestException e) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Incorrect parameters");
        problem.setProperty("errors", e.getErrors());
        return problem;
    }

    // the calculation request (body of the POST request): the properties missing from the JSON are null
    public record TaxRequest(Boolean married, Integer children, Integer salary) {
    }

    // the response: the amount of the tax
    public record TaxResponse(int tax) {
    }
}

Comments:

  • Line 24: the class log. Spring Boot uses the API logging framework (Simple Logging Facade for Java), which is implemented here using Logback. [LoggerFactory.getLogger] generates a log entry whose category is the full name of the class;
  • Lines 27–31: The controller receives the [business] layer from its constructor. Spring, which creates the controller, passes it the bean of type [ITaxBusiness] defined by [TaxConfig]: this is constructor injection as described in Chapter 6. A field named [final] ensures that the layer will not change;
  • lines 34–43: the GET request. The three parameters of the query string are received as strings and converted: [y]/[n] to [married] (lines 37–41, an expression [switch] whose type is [Boolean]), integers for [children] and [salary] ([readInteger], lines 76–82), [null] in case of an error;
  • lines 46–49: the query POST: the body JSON is deserialized into an object [TaxRequest] (line 94), whose object-type components ([Boolean], [Integer]) are used to detect missing values. The same URL [/tax] is associated with two methods, depending on the HTTP method of the query;
  • lines 52–73: the processing common to both requests. It returns a response if successful and throws an exception if an error occurs. The type of its result, like that of the methods on lines 35 and 47, is therefore simply [TaxResponse]: reading their signatures shows what the service returns when everything goes well;
  • line 54: Are the three parameters correct? The comparisons [children >= 0] and [salary >= 0] unpack the [Integer] objects: they are evaluated only if these are not [null], thanks to the lazy evaluation of the [&&] operator;
  • line 55: the calculation is delegated to the [business] layer of version 5;
  • line 56: each calculation is logged at the [INFO] level. The message text is a template: each instance of [{}] is replaced by the corresponding argument, in order. Unlike concatenation, the message is constructed only if it needs to be displayed (depending on the log level), and logging tools can record each value separately;
  • lines 60–72: In the event of an error, the error list is constructed, a warning ([warn]) is logged, and a [BadRequestException] exception is thrown that carries this list;
  • lines 85–91: An annotated method [@ExceptionHandler] acts as an exception handler: Spring calls it when a controller method throws an exception of the type specified by its parameter, in this case [BadRequestException]. What it returns becomes the HTTP response: a [ProblemDetail], whose status code (400) becomes the response’s status code;
  • lines 94–99: the service’s DTO responses.

The [BadRequestException] exception is as follows:

package tax.ws;

import java.io.Serial;
import java.util.List;
import java.util.Map;

// the exception thrown by the controller when the calculation parameters are incorrect
public class BadRequestException extends RuntimeException {

    @Serial
    private static final long serialVersionUID = 1L;

    // the error messages, parameter by parameter
    // (transient: a Map is not necessarily serializable)
    private final transient Map<String, List<String>> errors;

    public BadRequestException(Map<String, List<String>> errors) {
        super("Incorrect parameters: " + String.join(", ", errors.keySet()));
        this.errors = errors;
    }

    public Map<String, List<String>> getErrors() {
        return errors;
    }
}
  • line 8: an unchecked exception, which does not need to be declared by the methods that throw it;
  • lines 10–11: an exception is serializable (in the sense of Java binary serialization): the compiler requires, with the [-Xlint:all] option, that it declare a version number;
  • line 15: the list of errors. The keyword [transient] excludes this field from binary serialization, since the type [Map] is not necessarily serializable; without it, the compiler would issue a warning. This has no consequence here: our exceptions are never serialized.

The main class [WsTaxApplication] is identical to that of the other two services. The [src/main/resources/static] folder contains the web client, which is discussed in the following section.

The web service is stateless: the controller's methods do not retain any state from one request to the next. The controller and the layers [business] and [dao] are singletons, created once and for all, that exist for as long as the application runs; they are shared by all requests, which may be processed simultaneously by different threads of the Tomcat server (Chapter 8). This isn’t a problem here because these objects are never modified after they are created: the tax brackets are read once and for all, and the controller has only one [final] field. A controller should never store request-specific data in its fields. The singleton is the default scope for Spring beans; but there are others: [prototype] (one object per request to the container), [request] (one object per request HTTP), [session] (one object per session HTTP). Finally, note that the [spring.threads.virtual.enabled=true] property causes each request to be processed by a virtual thread (Java 21, Chapter 8) instead of a thread from the Tomcat pool: the service can then handle a very large number of concurrent requests that are waiting for I/O operations.

13.4.3. Running the Service

Let’s start with a configuration error. Command-line arguments in the form [--property=value] are part of the application configuration and override the values of [application.properties]. With [mvn spring-boot:run], these are passed via the [spring-boot.run.arguments] property. Let’s specify a nonexistent data file (the course has abbreviated the lines in this log—which are very long—to [...]):

> mvn spring-boot:run -Dspring-boot.run.arguments=--dao.file=missing.json
19:01:41.197 INFO  [main] WsTaxApplication : Starting WsTaxApplication using Java 25.0.4.1 with PID 22677 (...)
...
19:01:49.390 INFO  [main] TomcatWebServer : Tomcat initialized with port 5120 (http)
...
19:01:50.956 WARN  [main] AnnotationConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'taxController' ... Error creating bean with name 'business' ... Error creating bean with name 'dao' ... Factory method 'dao' threw exception with message: Error while reading the file missing.json
19:01:50.991 INFO  [main] StandardService : Stopping service [Tomcat]
...
19:01:51.454 ERROR [main] LoggingFailureAnalysisReporter : 

***************************
APPLICATION FAILED TO START
***************************

Description:

The service cannot start: Error while reading the file missing.json (missing.json: The file [missing.json] does not exist)

Action:

Check the dao.implementation and dao.file properties of the configuration.
  • line 4: the Tomcat server was created, but it hasn’t opened its port yet;
  • line 6: bean creation failed. To create the controller, Spring had to create the [business] layer, and therefore the [dao] layer, whose constructor instantiated a [TaxException] (Chapter 6). The messages for our layers, like those for Spring, are in English;
  • line 7: the Tomcat server is stopped; the service did not accept any requests;
  • lines 9–21: the startup failure report. Its description (line 17) and the recommended action (line 21) are generated by a class we wrote for this purpose.

In fact, when startup fails, Spring Boot normally displays the exception and the entire call stack: dozens of lines, where the relevant message is hard to find. Spring Boot can also pass the exception to failure analyzers, which translate it into a readable report. It provides analyzers for the most common errors (port already in use, bean not found, etc.), and we can add our own:

package tax.ws;

import tax.entities.TaxException;
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;

// Spring Boot calls this analyzer if the startup fails because of a TaxException
// (missing or incorrect data file, unknown implementation)
public class TaxFailureAnalyzer extends AbstractFailureAnalyzer<TaxException> {

    @Override
    protected FailureAnalysis analyze(Throwable rootFailure, TaxException cause) {
        // the description of the error: the message of the exception and that of its cause
        String description = "The service cannot start: " + cause.getMessage()
                + (cause.getCause() == null ? "" : " (" + cause.getCause().getMessage() + ")");
        // what the user must do
        String action = "Check the dao.implementation and dao.file properties of the configuration.";
        return new FailureAnalysis(description, action, cause);
    }
}
  • Line 9: Spring Boot’s [AbstractFailureAnalyzer<T>] class searches for an exception of type [T]—in this case, [TaxException]—in the chain of causes leading to the exception that caused the startup to fail. If it finds one, it calls the [analyze] method;
  • line 12: the parameter [rootFailure] is the initial exception, and [cause] is the [TaxException] found in the chain;
  • lines 14–18: The report is a [FailureAnalysis] object consisting of a description, a recommended action, and the analyzed exception.

This analyzer is not a bean (it must function even if the container could not be created): Spring Boot searches for it in the [META-INF/spring.factories] files of the libraries and the application. Ours is located in [src/main/resources/META-INF]:

# the analyzer of the startup failures caused by a TaxException
org.springframework.boot.diagnostics.FailureAnalyzer=tax.ws.TaxFailureAnalyzer
  • line 2: the key is the name of the [FailureAnalyzer] interface, and the value is the name of our class (multiple classes would be separated by commas).

Now let’s start the service as usual, using [mvn spring-boot:run], and then query it with [curl]:

1
2
3
4
5
6
7
> curl -s -i "http://localhost:5120/tax?married=y&children=2&salary=60000"
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 12
Date: Sat, 03 Oct 2026 17:04:03 GMT

{"tax":4282}

This is the expected test result: a married taxpayer with two children and an annual salary of 60,000 euros pays 4,282 euros in taxes (note that the tax schedule used—the 2004 schedule—is retained only for this fiscal year). With incorrect parameters:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5120/tax?married=x&children=-2"
HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sat, 03 Oct 2026 17:04:03 GMT
Connection: close

{"instance":"/tax","status":400,"title":"Incorrect parameters","errors":{"married":["Indicate whether the taxpayer is married: y/n (GET) or true/false (POST)"],"children":["The number of children must be a positive or zero integer"],"salary":["The salary must be a positive or zero integer"]}}

Then in POST, with a document JSON:

1
2
3
4
5
6
7
> curl -s -i -H "Content-Type: application/json" -d '{"married":true,"children":2,"salary":60000}' http://localhost:5120/tax
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 12
Date: Sat, 03 Oct 2026 17:04:04 GMT

{"tax":4282}

The service log will be shown at the end of the chapter: it contains a record of all the requested calculations.

13.4.4. The console output: a new [business] layer

The Version 9 client uses the [ui] layer from Version 5 without modifying it: the [tax.ui.Dialog] class, whose static method [run(ITaxBusiness business)] interacts with the user and requests calculations from the [business] layer that is passed to it. Simply pass it a layer named [business] that queries the web service. The dependencies of the [ClientWsTax] project are as follows (the rest of the [pom.xml] file is the same as that of [ClientWsHello], except for the project name and the main class: [examples.tax:client-ws-tax:9.0], [tax.client.Main]):

    <dependencies>
        <!-- the interface of the [business] layer and the [ui] layer of version 5 -->
        <!-- (tax-ui also brings tax-dao and the Spring container) -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-business</artifactId>
            <version>5.0</version>
        </dependency>
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-ui</artifactId>
            <version>5.0</version>
        </dependency>
        <!-- Jackson for the JSON documents exchanged with the service -->
        <dependency>
            <groupId>tools.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>3.1.5</version>
        </dependency>
    </dependencies>
  • Lines 4–13: Version 5 modules: [tax-business] for the [ITaxBusiness] interface, [tax-ui] for the [Dialog] class. A project may depend on a module that contains a main program: in this case, it uses the module’s public classes without executing the program itself. The module [tax-dao] is an indirect (or transitive) dependency: the client uses only the class [TaxException] from it. Similarly, [tax-ui] provides the Spring container ([spring-context]);
  • lines 15–19: Jackson, for the JSON documents. It is also provided by [tax-dao], but the client uses it directly: it is preferable to declare it.

The new [business] layer is the class [TaxBusinessHttp]:

package tax.client;

import tax.entities.TaxException;
import tax.business.ITaxBusiness;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import tools.jackson.databind.json.JsonMapper;

// the [business] layer on the client side: a proxy of the tax calculation web service
// it implements the same interface as the local [business] layer of version 5
public class TaxBusinessHttp implements ITaxBusiness {

    // the objects exchanged with the web service
    private record TaxRequest(boolean married, int children, int salary) {
    }

    private record TaxResponse(int tax) {
    }

    // the HTTP client, the URL of the POST /tax method, the JSON converter
    private final HttpClient client;
    private final URI taxUrl;
    private final JsonMapper mapper = JsonMapper.builder().build();

    public TaxBusinessHttp(HttpClient client, URI serviceUrl) {
        this.client = client;
        this.taxUrl = serviceUrl.resolve("tax");
    }

    @Override
    public int calculateTax(boolean married, int nbChildren, int salary) {
        // the POST /tax request, with the request in JSON in its body
        String json = mapper.writeValueAsString(new TaxRequest(married, nbChildren, salary));
        HttpRequest request = HttpRequest.newBuilder(taxUrl)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();
        HttpResponse<String> response;
        try {
            // synchronous sending: the method waits for the response
            response = client.send(request, HttpResponse.BodyHandlers.ofString());
        } catch (IOException e) {
            // the service is unreachable
            throw new TaxException("The web service " + taxUrl + " is unreachable", e);
        } catch (InterruptedException e) {
            // the thread was interrupted during the wait: this is signaled again
            Thread.currentThread().interrupt();
            throw new TaxException("The tax calculation was interrupted", e);
        }
        // did the service accept the request?
        if (response.statusCode() != 200) {
            throw new TaxException("The web service responded " + response.statusCode()
                    + " : " + response.body(), response.statusCode());
        }
        // the JSON response {"tax":4282}
        return mapper.readValue(response.body(), TaxResponse.class).tax();
    }
}

Comments:

  • line 14: the class implements the [ITaxBusiness] interface from version 5;
  • lines 17–21: the DTO variables are declared inside the class ([private]) because no one else uses them;
  • lines 28–31: the class receives, via its constructor, the client HTTP and the service’s URL, from which it calculates the URL for the [POST /tax] method;
  • lines 36–40: the request [POST tax], with the request serialized as JSON in its body;
  • line 44: the request is sent synchronously: the [send] method waits for the response, which is consistent with the [calculateTax] method of the interface, which is itself synchronous (it returns a [int]);
  • Lines 45–47: [send] triggers a [IOException] if the service is unreachable. This is a caught exception, which the [calculateTax] method of the interface does not declare: it is encapsulated within a [TaxException], the uncaught exception of the [Tax] application;
  • lines 48–52: the same applies to [InterruptedException], which is thrown if the thread is interrupted while waiting. Catching the exception clears the thread’s interruption flag: best practice (Chapter 8) is to reset it using [Thread.currentThread().interrupt()] so that the calling code knows the thread was interrupted;
  • lines 54–57: An error response is converted to [TaxException], whose code is the status code HTTP;
  • line 59: the response JSON [{"tax":4282}] is deserialized, and the tax amount is returned.

With a SOAP service, the [wsimport] tool used to generate a proxy, and a class similar to this one implemented [ITaxBusiness] by delegating the calculation to it: today, we write the few dozen lines of the proxy ourselves.

Since the [calculateTax] method of the [ITaxBusiness] interface is synchronous, we used the synchronous [send] method of [HttpClient]. This interface also offers an asynchronous method, [sendAsync], which immediately returns a [CompletableFuture<HttpResponse<T>>] (Chapter 9). To take advantage of this, we would need to make the interface asynchronous: [CompletableFuture<Integer> calculateTaxAsync(boolean married, int nbChildren, int salary)], which would require modifying the [ITaxBusiness] interface, its implementations, and the [ui] layer. We did not do this in order to keep the layers from version 5 intact. With virtual threads (Java 21), synchronous code has once again become the recommended way to write programs that involve a lot of I/O: a virtual thread blocked by [send] does not block any system threads.

The client’s main program integrates the layers:

package tax.client;

import tax.business.ITaxBusiness;
import tax.ui.Dialog;
import java.net.URI;
import java.net.http.HttpClient;
import org.springframework.context.support.GenericApplicationContext;

public class Main {

    public static void main(String[] args) {
        // the URL of the web service: the one passed as an argument, or the default one
        URI serviceUrl = URI.create(args.length > 0 ? args[0] : "http://localhost:5120/");

        // the exit code of the program
        int code = 0;
        // integration of the layers: the [business] layer is now a client of the web service
        try (GenericApplicationContext context = new GenericApplicationContext()) {
            // the URL of the service and the HTTP client, which Spring will pass to the constructor of TaxBusinessHttp
            context.registerBean(URI.class, () -> serviceUrl);
            context.registerBean(HttpClient.class, HttpClient::newHttpClient);
            context.registerBean(TaxBusinessHttp.class);
            context.refresh();
            ITaxBusiness business = context.getBean(ITaxBusiness.class);

            // the dialog of version 5, without any modification
            try {
                Dialog.run(business);
            } catch (RuntimeException e) {
                // the service is unreachable or refused the request
                Dialog.displayError(e);
                code = 1;
            }
        }
        if (code != 0) {
            System.exit(code);
        }
    }
}

Comments:

  • line 13: the service’s URL, passed as an argument or, by default, [http://localhost:5120/];
  • line 18: the Spring container, as in version 5. It is closed at the end of the [try] block;
  • Lines 20–22: three beans are registered: the service bean URL and the client bean HTTP, provided by lambdas (of type [Supplier]), followed by the class [TaxBusinessHttp]. To create this class, Spring examines its constructor and passes it the beans of types [HttpClient] and [URI];
  • line 23: the container creates the beans;
  • line 24: the container is asked for a bean of type [ITaxBusiness]; it returns the object [TaxBusinessHttp], which implements this interface. The rest of the program is unaware of the implementation;
  • line 28: the dialog from version 5, without any modifications;
  • lines 29–33: if the service cannot be reached, the exception is propagated from the [business] layer through the [ui] layer, which does not handle it: it is displayed using the [Dialog.displayError] method from version 5;
  • lines 35–37: in the event of an error, the program terminates with code 1 after closing the container.

Results, with the service running:

> mvn -q compile exec:java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y -2 60000
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect NbChildren argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

The tax calculation check values are displayed: 4,282, 2,959, and 11,519 euros. The incorrect entry on line 8 was detected by layer [ui], which did not call layer [business]: the service received only valid requests. If the service is stopped:

> mvn -q compile exec:java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Exception chain:
----------------------------------------
tax.entities.TaxException: The web service http://localhost:5120/tax is unreachable
----------------------------------------
java.net.ConnectException: null
----------------------------------------
java.net.ConnectException: null
----------------------------------------
java.nio.channels.ClosedChannelException: null
----------------------------------------

Our proxy’s [TaxException] encapsulates the [ConnectException] exception thrown by [send], which itself encapsulates the network layer exceptions (Chapter 12). None of them have a message: [getMessage()] returns [null].

13.5. A web client for the tax calculation service

A “traditional” web application generates its pages (HTML) on the server: each time a button is clicked, the form is sent to the server, which returns a new, complete page to the browser. Today, the browser most often queries a web service itself, in JavaScript, and updates only the part of the page that has changed. That’s what we’re going to do. The architecture is as follows:

Image

  • 1: The browser requests the page [/]; the server [WsTax] sends it the static file [index.html];
  • 2: Each time the [Calculate] button is clicked, the JavaScript code on the page sends the [POST /tax] request to the web service and displays the received tax amount on the page without reloading it.

The web service has not been modified: it simply serves one additional file. Spring Boot automatically serves files placed in the [static] folder within the application’s resources ([src/main/resources/static]) as static files: HTML pages, images, scripts, style sheets, etc. The [static/index.html] file is also served for the URL [/]: this is the welcome page. A single server can thus host both a website and a web service. The [index.html] page is as follows:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Calculate your tax</title>
  <style>
    body { font-family: sans-serif; background: #ffffe0; margin: 2em; }
    td { padding: 4px 8px; white-space: nowrap; }
    .error { color: #cc2200; }
    #tax:not(:empty) { background: #99ccff; padding: 2px 8px; font-weight: bold; }
  </style>
</head>
<body>
  <h2>Calculate your tax</h2>
  <form id="form">
    <table>
      <tr>
        <td>Are you married</td>
        <td>
          <label><input type="radio" name="married" value="yes"> Yes</label>
          <label><input type="radio" name="married" value="no" checked> No</label>
        </td>
      </tr>
      <tr>
        <td>Number of children</td>
        <td><input type="text" id="children" size="3"></td>
        <td class="error" id="childrenError"></td>
      </tr>
      <tr>
        <td>Annual salary</td>
        <td><input type="text" id="salary" size="8"></td>
        <td class="error" id="salaryError"></td>
      </tr>
      <tr>
        <td>Tax to pay</td>
        <td><span id="tax"></span></td>
      </tr>
    </table>
    <p class="error" id="message"></p>
    <p>
      <button type="submit">Calculate</button>
      <button type="button" id="clear">Clear</button>
    </p>
  </form>

  <script>
    // the elements of the page
    const form = document.getElementById("form");
    const children = document.getElementById("children");
    const salary = document.getElementById("salary");
    const tax = document.getElementById("tax");
    const childrenError = document.getElementById("childrenError");
    const salaryError = document.getElementById("salaryError");
    const message = document.getElementById("message");

    // an entered integer, or null if the input is not an integer
    function readInteger(text) {
      return /^\s*-?\d+\s*$/.test(text) ? parseInt(text, 10) : null;
    }

    // clears the result and the error messages
    function clearMessages() {
      tax.textContent = "";
      childrenError.textContent = "";
      salaryError.textContent = "";
      message.textContent = "";
    }

    // [Calculate] button: we query the web service, without reloading the page
    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      clearMessages();
      // the request, in the format expected by POST /tax
      const request = {
        married: form.married.value === "yes",
        children: readInteger(children.value),
        salary: readInteger(salary.value)
      };
      try {
        const response = await fetch("tax", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(request)
        });
        const body = await response.json();
        if (response.ok) {
          // 200: {"tax": 4282}
          tax.textContent = `${body.tax} euros`;
        } else {
          // 400: a "Problem Details" document with the errors per parameter
          const errors = body.errors ?? {};
          childrenError.textContent = errors.children?.join(" ") ?? "";
          salaryError.textContent = errors.salary?.join(" ") ?? "";
          message.textContent = body.title;
        }
      } catch (e) {
        // the service is unreachable
        message.textContent = `Error communicating with the service: ${e.message}`;
      }
    });

    // [Clear] button: reset of the form
    document.getElementById("clear").addEventListener("click", () => {
      children.value = "";
      salary.value = "";
      clearMessages();
    });
  </script>
</body>
</html>

Comments:

  • Lines 15–44: the form: two radio buttons for marital status, two input fields for the number of children and salary—each followed by a cell where an error message (if any) will be displayed—one field for taxes, a paragraph for a general message, and two buttons;
  • lines 46–108: the page’s code, JavaScript, executed by the browser. The course does not cover JavaScript; however, this code is close enough to Java to be read without difficulty;
  • line 57: an input that is not an integer results in [null]; it is up to the service to report the error;
  • line 70: the click handler for the [Calculate] button (submit form). This is an asynchronous function ([async]): like [CompletableFuture] (Chapter 9), it does not block the browser while waiting for a response;
  • line 71: we prevent the browser from submitting the form on its own, which would cause a new page to load;
  • lines 74–77: the request, a JavaScript object that has the format expected by [POST /tax];
  • lines 80–83: The browser’s [fetch] function sends the HTTP request: the method [POST], the header [Content-Type], and the body—the request serialized into JSON by [JSON.stringify]. The URL [tax] is relative to the page: the request is sent to the server that provided the page. [await] waits for the response without blocking the browser;
  • line 85: the body of the response is deserialized into an object;
  • lines 86–88: if successful (2xx code), the tax amount is displayed;
  • lines 91–94: if an error occurs, the document title “Problem Details” is displayed, along with error messages regarding the number of children and the salary, each next to its corresponding input field. The operator [?.] returns [undefined] if the object to its left does not exist, rather than causing an error; the operator [??] replaces a missing value with the value that follows it. Java has no direct equivalent for these operators: in Java, you use [null] tests or a [Optional];
  • Lines 103–106: The [Clear] button resets the form locally, without querying the service.

Now that the service is running, let’s request the URL [http://localhost:5120/] using a browser:

Image

After entering a calculation and clicking the [Calculate] button:

Image

If the input is incorrect, the error messages are those from the web service:

Image

These screenshots were taken using the Chromium browser, controlled by a program via the Playwright library: this is how we automate testing of a web interface.

Here, the page and the service share the same origin (same protocol, same machine, same port: [http://localhost:5120]). If the page were served by another site, for example [http://localhost:5000], the browser would enforce the same-origin policy: it would only allow the code JavaScript to read the service’s response if the service authorizes it via the header [Access-Control-Allow-Origin]. For a request like ours (POST with a body of JSON), the browser would even request this authorization before sending the request, via a preliminary request using the OPTIONS method (preflight). This is the CORS mechanism (Cross-Origin Resource Sharing), which protects the user from malicious pages that might attempt to use services where the user is logged in without their knowledge. To allow other origins, a Spring controller can be annotated with [@CrossOrigin(origins = "http://localhost:5000")]; global configuration is also possible (the [addCorsMappings] method of the [WebMvcConfigurer] interface). Clients that are not browsers ([curl], our console clients) are not affected by CORS.

For richer web interfaces generated by the server, Spring MVC also provides controllers that return HTML pages built from templates (such as Thymeleaf), which are beyond the scope of this course.

13.6. A web service is independent of its client’s programming language

With a SOAP service, the client had to have a tool capable of reading the WSDL and generating the proxy, and differences in how standards were interpreted across platforms often caused interoperability issues. With a REST/JSON service, any language that has a HTTP client and a JSON library, that is, all current languages, can query the service without any special tools. We’ve already seen this with [curl], which isn’t written in Java, and with the browser-based JavaScript. Here is a client written for Node.js, the runtime environment for JavaScript outside the browser:

// a JavaScript (Node.js) client of the tax calculation web service
// call: node client-tax.mjs [service url]
const serviceUrl = process.argv[2] ?? "http://localhost:5120/";

// the requests to send: the last one is incorrect
const requests = [
  { married: true, children: 2, salary: 60000 },
  { married: false, children: 0, salary: 60000 },
  { married: true, children: -1, salary: 60000 },
];

for (const request of requests) {
  // POST /tax with the request in JSON
  const response = await fetch(new URL("tax", serviceUrl), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
  const body = await response.json();
  if (response.ok) {
    console.log(`${JSON.stringify(request)} -> tax = ${body.tax} euros`);
  } else {
    console.log(`${JSON.stringify(request)} -> error ${response.status}: ${JSON.stringify(body.errors)}`);
  }
}
  • line 3: the service’s URL, passed as an argument or set by default;
  • lines 6–9: three calculation requests, the last of which is incorrect;
  • lines 14–17: the same [fetch] function as in the browser;
  • lines 20–23: display of the tax amount or errors.

Once the service is running:

1
2
3
4
> node client-tax.mjs
{"married":true,"children":2,"salary":60000} -> tax = 4282 euros
{"married":false,"children":0,"salary":60000} -> tax = 11519 euros
{"married":true,"children":-1,"salary":60000} -> error 400: {"children":["The number of children must be a positive or zero integer"]}

In Python, you would similarly use the [requests] or [httpx] module; in C#, the [HttpClient] class. Finally, here is the log for the [WsTax] service during all the tests in this chapter ([curl], console client, browser, Node.js client), until it was terminated with Ctrl-C (the course has abbreviated the paths on line 2 to [...]):

> mvn spring-boot:run
19:03:44.598 INFO  [main] WsTaxApplication : Starting WsTaxApplication using Java 25.0.4.1 with PID 25879 (... in .../13-web-services/WsTax)
19:03:44.621 INFO  [main] WsTaxApplication : No active profile set, falling back to 1 default profile: "default"
19:03:50.050 INFO  [main] TomcatWebServer : Tomcat initialized with port 5120 (http)
19:03:50.140 INFO  [main] StandardService : Starting service [Tomcat]
19:03:50.166 INFO  [main] StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
19:03:50.314 INFO  [main] WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 4921 ms
19:03:51.065 INFO  [main] WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
19:03:53.070 INFO  [main] TomcatWebServer : Tomcat started on port 5120 (http) with context path '/'
19:03:53.142 INFO  [main] WsTaxApplication : Started WsTaxApplication in 10.178 seconds (process running for 11.043)
19:04:03.287 INFO  [http-nio-5120-exec-1] [/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
19:04:03.287 INFO  [http-nio-5120-exec-1] DispatcherServlet : Initializing Servlet 'dispatcherServlet'
19:04:03.336 INFO  [http-nio-5120-exec-1] DispatcherServlet : Completed initialization in 49 ms
19:04:03.533 INFO  [http-nio-5120-exec-1] TaxController : Tax(married=true, children=2, salary=60000) = 4282
19:04:03.813 WARN  [http-nio-5120-exec-3] TaxController : Incorrect request: married, children, salary
19:04:04.116 INFO  [http-nio-5120-exec-4] TaxController : Tax(married=true, children=2, salary=60000) = 4282
19:04:09.345 INFO  [http-nio-5120-exec-6] TaxController : Tax(married=true, children=2, salary=60000) = 4282
19:04:09.875 INFO  [http-nio-5120-exec-8] TaxController : Tax(married=true, children=3, salary=60000) = 2959
19:04:09.890 INFO  [http-nio-5120-exec-10] TaxController : Tax(married=false, children=0, salary=60000) = 11519
19:04:13.159 INFO  [http-nio-5120-exec-2] TaxController : Tax(married=true, children=2, salary=60000) = 4282
19:04:13.319 WARN  [http-nio-5120-exec-3] TaxController : Incorrect request: children, salary
19:04:13.811 INFO  [http-nio-5120-exec-4] TaxController : Tax(married=true, children=2, salary=60000) = 4282
19:04:13.828 INFO  [http-nio-5120-exec-5] TaxController : Tax(married=false, children=0, salary=60000) = 11519
19:04:13.838 WARN  [http-nio-5120-exec-6] TaxController : Incorrect request: children
19:04:16.301 INFO  [SpringApplicationShutdownHook] GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
19:04:16.314 INFO  [tomcat-shutdown] GracefulShutdown : Graceful shutdown complete
  • line 8: Spring Boot found the home page [static/index.html];
  • lines 14–24: messages from our controller: the [INFO] lines log the calculations, while the [WARN] lines log invalid requests, along with the names of the parameters involved. Lines 14–16 come from [curl], lines 17–19 from the console client, lines 20–21 from the browser, and lines 22–24 from the Node.js client;
  • the thread names ([http-nio-5120-exec-1] through [exec-10]) show that the requests are distributed among the threads in the Tomcat pool; successive requests from the same client are not necessarily handled by the same thread.

13.7. Additional Information

13.7.1. Response Types and Error Handling

The [WsOperations] service returns [ResponseEntity<?>] objects: based on the signatures of its methods alone, it is unclear what responses they might produce, and the compiler does not perform any checks. The [WsTax] service returns the specific type of its response upon success, [TaxResponse], and signals errors via exceptions, which are converted into responses by a [@ExceptionHandler] handler. This is the most common approach with Spring: the method code handles only the normal case, and the tools (the OpenAPI document generator in particular) know the JSON response schema without being explicitly told. A unit test can also directly call a controller method and examine its result, without going through HTTP. [ResponseEntity<T>], with a specific type, remains useful when you need to select the status code or add headers, for example, for a 201 (Created) response accompanied by its [Location] header: [ResponseEntity.created(uri).body(client)].

A [@ExceptionHandler] handler placed in a controller handles only that controller’s exceptions. To handle exceptions from all controllers in an application, place the handlers in a class annotated with [@RestControllerAdvice]. Spring also provides the [ErrorResponseException] class, an exception that directly carries a [ProblemDetail] and produces the corresponding response without a handler, and the [ResponseEntityExceptionHandler] class, which can be extended to customize responses to errors detected by Spring MVC itself (unreadable JSON document, invalid document type, etc.).

13.7.2. A common prefix for routes

When multiple routes share a prefix, it is specified once and for all using the [@RequestMapping] annotation placed on the controller class. For example, with [@RequestMapping("/api/operations")] on the class [OperationsController], the routes become [GET /api/operations/add?a=1&b=2] and [POST /api/operations/calculation], with no other changes. This is also a simple way to manage service versions ([/api/v1/...], [/api/v2/...]).

13.7.3. Interceptors and Validation

Our services validate their own parameters. Spring provides two mechanisms for moving this code out of the controllers:

  • interceptors: a class that implements the [HandlerInterceptor] interface, registered via the [addInterceptors] method of [WebMvcConfigurer], is executed before and after controller methods; it can examine the request and respond on its own, without calling the controller. They are used for logging, measuring response times, authentication, and more. Servlet filters ([jakarta.servlet.Filter]) serve the same purpose at a lower level;
  • validation: with the [spring-boot-starter-validation] starter (which provides Hibernate Validator, the reference implementation of the API Jakarta Validation), components of a DTO can be annotated with constraints: [record TaxRequest(@NotNull Boolean married, @NotNull @Min(0) Integer children, @NotNull @Min(0) Integer salary)]. A [@Valid @RequestBody TaxRequest request] parameter is then validated before the method is called, and an invalid request is rejected with a 400 “Problem Details” response, such as the ones we have constructed.

13.7.4. Hosting

Our services are run by [mvn spring-boot:run], which is suitable for development. In production:

  • the application is first packaged by [mvn package]: the Spring Boot plugin generates a single “executable” [.jar] file, which contains the application, its dependencies, and the Tomcat server. Simply copy it to the server and run it using [java -jar ws-tax-9.0.jar]; the configuration can be modified using command-line arguments ([--server.port=80]), environment variables, or a [application.properties] file placed alongside the [.jar] file;
  • Tomcat can be exposed directly to the Internet, but it is often placed behind a reverse proxy (nginx or Apache on Linux, IIS on Windows) that receives requests HTTPS, manages certificates, and distributes the load across multiple instances of the application;
  • The application can also be deployed as a container image (Docker), which the Spring Boot plugin can generate without a [Dockerfile] file: [mvn spring-boot:build-image]. The image then runs on any container platform, whether on your own server or with a hosting provider;
  • HTTPS is essential: the Spring Boot properties [server.ssl.*] configure the Tomcat server certificate when the server is exposed directly.

13.8. Conclusion

This chapter introduced the REST/JSON web services, the successors to the SOAP web services, written using Spring Boot and Spring MVC. We have seen:

  • that a web service maps URL and HTTP methods to Java methods using [@RestController] annotations, [@GetMapping] and [@PostMapping], and that the parameters for these methods are drawn from the route ([@PathVariable]), the query string ([@RequestParam]), the body (JSON, [@RequestBody]), or the Spring container (constructor injection);
  • that the results are sent as JSON by Jackson, and errors are reported via a status code and a “Problem Details” document ([ProblemDetail], [ResponseEntity], [@ExceptionHandler]);
  • that a Java client uses [java.net.http.HttpClient] and Jackson, and that a client written in any other language can do the same;
  • that the layered architecture delivered on its promises: Version 9 of the tax calculation reuses, without modification, the server-side layers [dao] and [business] from Version 5, as well as its client-side layer [ui]; All it took was writing the web service (about 100 lines) and a new implementation of the [ITaxBusiness] interface (about 60 lines).