Reactive applications often use HTTP because it is such a versatile protocol, and Vert.x offers comprehensive support for web technologies. The Vert.x web stack provides many tools for building web application backends. These include advanced routing, authentication, an HTTP client, and more. This chapter will guide you through exposing an HTTP API with JSON web tokens (JWT) for access control, making HTTP requests to other services, and building a reactive single-page application that connects to the HTTP API.
Note This book does not cover the following noteworthy elements from the Vert.x web stack that are not needed to build the application in this part of the book: routing with regular expressions, cookies, server-side sessions, server-side template rendering, and cross-site request forgery protection. You can get more details about those topics in the official documentation at https://vertx.io/.
Let’s start with a reminder of what the public API service does, as illustrated in figure 8.1. This service is an edge service (or service gateway, depending on how you prefer to name it) as it exposes an HTTP API, but it essentially composes functionality found in other services. In this case, the user profile and activity services are being used. These two services are internal to the application and are not publicly exposed. They also lack any form of authentication and access control, which is something the public API cannot afford for most operations.
The following Vert.x modules are needed to implement the public API:
vertx-web
, to provide advanced HTTP request-processing functionality
vertx-web-client
, to issue HTTP requests to the user profile and activity services
vertx-auth-jwt
, to generate and process JSON web tokens and perform access control
The complete source code of the public API service can be found in the part2-steps-challenge/public-api folder of the book’s source code repository.
We’ll start with the Vert.x web router.
Vert.x core provides a very low-level HTTP server API, where you need to pass a request handler for all types of HTTP requests. If you just use Vert.x core, you need to manually check the requested path and method. This is fine for simple cases, and it’s what we did in some earlier chapters, but it can quickly become complicated.
The vertx-web
module provides a router that can act as a Vert.x HTTP server request handler, and that manages the dispatch of HTTP requests to suitable handlers based on request paths (e.g., /foo
) and HTTP methods (e.g., POST
). This is illustrated in figure 8.2.
The following listing shows how to initialize and then set up a router as an HTTP request handler.
Router router = Router.router(vertx); // (...) ❶ vertx.createHttpServer() .requestHandler(router) ❷ .listen(8080);
❷ A router is just another HTTP request handler.
The Router
class provides a fluent API to describe routes based on HTTP methods and paths, as shown in the following listing.
BodyHandler bodyHandler = BodyHandler.create(); ❶ router.post().handler(bodyHandler); ❷ router.put().handler(bodyHandler); String prefix = "/api/v1"; router.post(prefix + "/register").handler(this::register); ❸ router.post(prefix + "/token").handler(this::token); // (...) defines jwtHandler, more later router.get(prefix + "/:username/:year/:month") ❹ .handler(jwtHandler) ❺ .handler(this::checkUser) .handler(this::monthlySteps); // (...)
❶ BodyHandler is a predefined handler that extracts HTTP request body payloads.
❷ Here bodyHandler is called for all HTTP POST and PUT requests.
❸ The register method handles /api/v1/register POST requests.
❹ We can extract path parameters by prefixing elements with ":".
An interesting property of the Vert.x router is that handlers can be chained. With the definitions from listing 8.2, a POST
request to /api/v1/register
first goes through a BodyHandler
instance. This handler is useful for easily decoding an HTTP request body payload. The next handler is the register
method.
Listing 8.2 also defines the route for GET
requests to monthlySteps
, where the request first goes through jwtHandler
, and then checkUser
, as illustrated in figure 8.3. This is useful for decomposing an HTTP request, processing concerns in several steps: jwtHandler
checks that a valid JWT token is in the request, checkUser
checks that the JWT token grants permissions to access the resource, and monthlySteps
checks how many steps a user has taken in a month.
Note that both checkUser
and jwtHandler
will be discussed in section 8.2.
tip The io.vertx.ext.web.handler
package contains useful utility handlers including BodyHandler
. It especially provides handlers for HTTP authentication, CORS, CSRF, favicon, HTTP sessions, static files serving, virtual hosts, and template rendering.
Let’s now dive into the implementation of a handler. Since the public API service forwards requests to the user profile and activity services, we need to use the Vert.x web client to make HTTP requests. As noted previously, the Vert.x core APIs offer a low-level HTTP client, whereas the WebClient
class from the vertx-web-client
module offers a richer API.
Creating a web client instance is as simple as this:
WebClient webClient = WebClient.create(vertx);
A WebClient
instance is typically stored in a private field of a verticle class, as it can be used to perform multiple concurrent HTTP requests. The whole application uses the RxJava 2 bindings, so we can take advantage of them to compose asynchronous operations. As you will see in later examples, the RxJava bindings sometimes bring additional functionality for dealing with error management.
The following listing shows the implementation of the register
route handler.
private void register(RoutingContext ctx) { webClient .post(3000, "localhost", "/register") ❶ .putHeader("Content-Type", "application/json") ❷ .rxSendJson(ctx.getBodyAsJson()) ❸ .subscribe( response -> sendStatusCode(ctx, response.statusCode()), ❹ err -> sendBadGateway(ctx, err)); } private void sendStatusCode(RoutingContext ctx, int code) { ctx.response().setStatusCode(code).end(); } private void sendBadGateway(RoutingContext ctx, Throwable err) { logger.error("Woops", err); ctx.fail(502); }
❶ Methods match the HTTP methods (GET, POST, etc.).
❸ This converts the request from a Vert.x Buffer to a JsonObject.
❹ Subscription in RxJava triggers the request.
This example demonstrates both how to handle an HTTP request with a router, and how to use the web client. The RoutingContext
class encapsulates details about the HTTP request and provides the HTTP response object via the response
method. HTTP headers can be set in both requests and responses, and the response is sent once the end
method has been called. A status code can be specified, although by default it will be 200
(OK).
You can see that getBodyAsJson
transforms the HTTP request body to a JsonObject
, while rxSendJson
sends an HTTP request with a JsonObject
as the body. By default, Vert.x Buffer
objects carry bodies in both requests and responses, but there are helper methods to convert from or to String
, JsonObject
, and JsonArray
.
The next listing offers another router handler method for HTTP GET
requests to /api/v1/:username
, where :username
is a path parameter.
private void fetchUser(RoutingContext ctx) { webClient .get(3000, "localhost", "/" + ctx.pathParam("username")) ❶ .as(BodyCodec.jsonObject()) ❷ .rxSend() .subscribe( resp -> forwardJsonOrStatusCode(ctx, resp), err -> sendBadGateway(ctx, err)); } private void forwardJsonOrStatusCode(RoutingContext ctx, ➥ HttpResponse<JsonObject> resp) { if (resp.statusCode() != 200) { sendStatusCode(ctx, resp.statusCode()); } else { ctx.response() .putHeader("Content-Type", "application/json") .end(resp.body().encode()); ❸ } }
❷ Converts the response to a JsonObject
❸ Ends the response with some content
This example shows the as
method that converts HTTP responses to a type other than Buffer
using a BodyCodec
. You can also see that the HTTP response’s end
method can take an argument that is the response content. It can be a String
or a Buffer
. While it is often the case that the response is sent in a single end
method call, you can send intermediary fragments using the write
method until a final end
call closes the HTTP response, as shown here:
response.write("hello").write(" world").end();
JSON Web Token (JWT) is an open specification for securely transmitting JSON-encoded data between parties (https://tools.ietf.org/html/rfc7519). JWT tokens are signed with either a symmetric shared secret or an asymmetric public/private key pair, so it is always possible to verify that the information that they contain has not been modified. This is very interesting, as a JWT token can be used to hold claims such as identity and authorization grants. JWT tokens can be exchanged as part of HTTP requests using the Authorization
HTTP header.
Let’s look at how to use JWT tokens, what data they contain, and how to both validate and issue them with Vert.x.
tip JWT is only one protocol supported by Vert.x. Vert.x offers the vertx-auth-oauth2
module for OAuth2, which is a popular protocol among public service providers like Google, GitHub, and Twitter. You will be interested in using it if your application needs to integrate with such services (such as when accessing a user’s Gmail account data), or when your application wants to grant third-party access through OAuth2.
To illustrate using JWT tokens, let’s interact with the public API and authenticate as user foo
with password 123
, and get a JWT token. The following listing shows the HTTP response.
$ http :4000/api/v1/token username=foo password=123 ❶ HTTP/1.1 200 OK Content-Type: application/jwt content-length: 496 eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx ➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb ➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1bg ➥ tDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935aV ➥ lxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F7rO ➥ ZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsWJKow ➥ c1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg
❶ Authenticating as user foo with password 123
A JWT token has the MIME type application/jwt
, which is plain text. We can pass the token to make a request as follows.
http :4000/api/v1/foo Authorization:'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb ➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1bg ➥ tDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935a ➥ VlxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F7 ➥ rOZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsWJK ➥ owc1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg' HTTP/1.1 200 OK ❶ Content-Type: application/json content-length: 90 { "city": "Lyon", "deviceId": "a1b2", "email": "[email protected]", "makePublic": true, "username": "foo" }
❶ We can access the resource, because we have a valid token for user foo.
tip The token value fits on a single line, and there is only a single space between Bearer
and the token.
The token is passed with the Authorization
HTTP header, and the value is prefixed with Bearer
. Here the token allows us to access the resource /api/v1/foo
, since the token was generated for user foo
. If we try to do the same thing without a token, or if we try to access the resource of another user, as in the following listing, we get denied.
http :4000/api/v1/abc Authorization:'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb ➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1b ➥ gtDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935 ➥ aVlxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F ➥ 7rOZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsW ➥ JKowc1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg' HTTP/1.1 403 Forbidden ❶ content-length: 0
❶ We are denied access to a resource of user abc because we passed a (valid) token for user foo.
So far so good, but what is in the token string?
If you look closely, you will see that a JWT token string is a big line with three parts, each separated by a dot. The three parts are of the form header.payload.signature
:
header
is a JSON document specifying the type of token and the signature algorithm being used.
payload
is a JSON document containing claims, which are JSON entries where some are part of the specification and some can be free-form.
signature
is the signature of the header and payload with either a shared secret or a private key, depending on what algorithm you chose.
The header and payload are encoded with the Base64 algorithm. If you decode the JWT token obtained in listing 8.5, the header contains the following:
{ "typ": "JWT", "alg": "RS256" }
This is what the payload contains:
{ "deviceId": "a1b2", "iat": 1565167475, "exp": 1565772275, "iss": "10k-steps-api", "sub": "foo" }
Here, deviceId
is the device identifier for user foo
, sub
is the subject (user foo
), iat
is the date when the token was issued, exp
is the token expiration date, and iss
is the token issuer (our service).
The signature allows you to check that the content of both the header and payload have been signed by the issuer and have not been modified, as long as you know the public key. This makes JWT tokens a great option for authorization and access control in APIs; a token with all needed claims is self-contained and does not require you to make checks for each request against an identity management service like an LDAP/ OAuth server.
It is important to understand that anyone with a JWT token can decode its content, because Base64 is not an encryption algorithm. You must never put sensitive data like passwords in tokens, even if they are transmitted over secure channels like HTTPS. It is also important to set token expiration dates, so that a compromised token cannot be used indefinitely. There are various strategies for dealing with JWT token expiration, like maintaining a list of compromised tokens in the backend, and combining short expiration deadlines with frequent validity extension requests from clients, where the issuer resends the token, but with an extended exp
claim.
The first thing we need in order to issue and check tokens is a pair of public and private RSA keys, so we can sign JWT tokens. You can generate these using the shell script in the following listing.
#!/bin/bash
openssl genrsa -out private.pem 2048
openssl pkcs8 -topk8 -inform PEM -in private.pem -out
➥ private_key.pem -nocrypt
openssl rsa -in private.pem -outform PEM -pubout -out public_key.pem
The next listing shows a helper class to read the PEM files as a string.
class CryptoHelper { static String publicKey() throws IOException { return read("public_key.pem"); } static String privateKey() throws IOException { return read("private_key.pem"); } private static String read(String file) throws IOException { Path path = Paths.get("public-api", file); if (!path.toFile().exists()) { ❶ path = Paths.get("..", "public-api", file); } return String.join(" ", Files.readAllLines(path, StandardCharsets.UTF_8));❷ } }
❶ This allows us to run the service from either the service folder or the application project root.
❷ Joins all lines, separating them with a newline character
Note that the code in CryptoHelper
uses blocking APIs. Since this code is run once at initialization, and PEM files are small, we can afford a possible yet negligible blocking of the event loop.
We can then create a Vert.x JWT handler as follows.
String publicKey = CryptoHelper.publicKey(); String privateKey = CryptoHelper.privateKey(); jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions() ❶ .addPubSecKey(new PubSecKeyOptions() .setAlgorithm("RS256") .setBuffer(publicKey)) .addPubSecKey(new PubSecKeyOptions() .setAlgorithm("RS256") .setBuffer(privateKey))); JWTAuthHandler jwtHandler = JWTAuthHandler.create(jwtAuth); ❷
❶ jwtAuth is a private field of type JWTAuth.
❷ Vert.x router handler for JWT authentication
The JWT handler can be used for routes that require JWT authentication, as it decodes the Authorization
header to extract JWT data.
The following listing recalls a route with the handler in its handlers chain.
router.get(prefix + "/:username/:year/:month")
.handler(jwtHandler) ❶
.handler(this::checkUser)
.handler(this::monthlySteps);
The JWT handler supports the common authentication API from the vertx-auth-common
module, which offers a unified view across different types of authentication mechanisms like databases, OAuth, or Apache .htdigest
files. The handler puts authentication data in the routing context.
The following listing shows the implementation of the checkUser
method where we check that the user in the JWT token is the same as the one in the HTTP request path.
private void checkUser(RoutingContext ctx) { String subject = ctx.user().principal().getString("sub"); ❶ if (!ctx.pathParam("username").equals(subject)) { ❷ sendStatusCode(ctx, 403); } else { ctx.next(); ❸ } }
❶ User name from the JWT token
❷ User name specified in the HTTP request path
This provides a simple separation of concerns, as the checkUser
handler focuses on access control and delegates to the next handler in the chain by calling next
if access is granted, or ends the request with a 403 status code if the wrong user is trying to access a resource.
Knowing that access control is correct, the monthlySteps
method in the following listing can focus on making the request to the activity service.
private void monthlySteps(RoutingContext ctx) {
String deviceId = ctx.user().principal().getString("deviceId"); ❶
String year = ctx.pathParam("year");
String month = ctx.pathParam("month");
webClient
.get(3001, "localhost", "/" + deviceId + "/" + year + "/" + month)
.as(BodyCodec.jsonObject())
.rxSend()
.subscribe(
resp -> forwardJsonOrStatusCode(ctx, resp),
err -> sendBadGateway(ctx, err));
}
The device identifier is extracted from the JWT token data and passed along to the web client request.
Last, but not least, we need to generate JWT tokens. To do that, we need to make two requests to the user profile service: first we need to check the credentials, and then we gather profile data to prepare a token.
The following listing shows the handler for the /api/v1/token
route.
private void token(RoutingContext ctx) { JsonObject payload = ctx.getBodyAsJson(); ❶ String username = payload.getString("username"); webClient .post(3000, "localhost", "/authenticate") ❷ .expect(ResponsePredicate.SC_SUCCESS) .rxSendJson(payload) .flatMap(resp -> fetchUserDetails(username)) ❸ .map(resp -> resp.body().getString("deviceId")) .map(deviceId -> makeJwtToken(username, deviceId)) ❹ .subscribe( token -> sendToken(ctx, token), err -> handleAuthError(ctx, err)); } private void sendToken(RoutingContext ctx, String token) { ctx.response().putHeader("Content-Type", "application/jwt").end(token); } private void handleAuthError(RoutingContext ctx, Throwable err) { logger.error("Authentication error", err); ctx.fail(401); }
❶ We extract the credentials from the request to /api/v1/token.
❷ We first issue an authentication request.
❸ On success, we make another request to get the profile data.
This is a typical RxJava composition of asynchronous operations with flatMap
to chain requests. You can also see the declarative API of the Vert.x router, where we can specify that we expect the first request to be a success.
The following listing shows the implementation of fetchUserDetails
, which gets the user profile data after the authentication request has succeeded.
private Single<HttpResponse<JsonObject>> fetchUserDetails(String username) {
return webClient
.get(3000, "localhost", "/" + username)
.expect(ResponsePredicate.SC_OK) ❶
.as(BodyCodec.jsonObject())
.rxSend();
}
Finally, the next listing shows how to prepare a JWT token.
private String makeJwtToken(String username, String deviceId) { JsonObject claims = new JsonObject() ❶ .put("deviceId", deviceId); JWTOptions jwtOptions = new JWTOptions() .setAlgorithm("RS256") .setExpiresInMinutes(10_080) // 7 days .setIssuer("10k-steps-api") ❷ .setSubject(username); return jwtAuth.generateToken(claims, jwtOptions); }
❷ A claim that is in the JWT specification
The JWTOptions
class offers methods for the common claims from the JWT RFC, such as the issuer, expiration date, and subject. You can see that we did not specify when the token was issued, although there is a method for that in JWTOptions
. The jwtAuth
object does the right thing here and adds it on our behalf.
We have a public API that forwards requests to internal services, and this API uses JWT tokens for authentication and access control. I also demonstrated on the command line that we can interact with the API. In fact, any third-party application can talk to our API over HTTP: a mobile phone application, another service, a desktop application, and so on. You might think that web applications could also talk to the API from JavaScript code running in web browsers, but it is (fortunately!) not that simple.
Web browsers enforce security policies, and among them is the same-origin policy. Suppose we load app.js from https://my.tld:4000/js/app.js:
app.js is allowed to make requests to https://my.tld:4000/api/foo/bar.
app.js is not allowed to make requests to https://my.tld:4001/a/b/c because a different port is not the same origin.
app.js is not allowed to make requests to https://other.tld/123 because a different host is not the same origin.
Cross-origin resource sharing (CORS) is a mechanism by which a service can allow incoming requests from other origins (https://fetch.spec.whatwg.org/). For instance, the service exposing https://other.tld/123 can specify that cross-origin requests are allowed from code served from https://my.tld:4000, or even from any origin. This allows web browsers to proceed with a cross-origin request when the request origin allows it; otherwise it will deny it, which is the default behavior.
When a cross-origin request is triggered, such as to load some JSON data, an image, or a web font, the web browser sends a request to the server with the requested resource, and passes an Origin
HTTP header. The server then responds with an Access-Control-Allow-Origin
HTTP header with the allowed origin, as illustrated in figure 8.4.
A value of "*"
means that any origin can access the resource, whereas a value like https://my.tld
means that only cross-origin requests from https://my.tld are allowed. In figure 8.4, the request succeeds with the JSON payload, but if the CORS policy forbids the call, the app.js code would get an error while attempting to make a cross-origin request.
Depending on the type of cross-origin HTTP request, web browsers do simple or preflighted requests. The request in figure 8.4 is a simple one. By contrast, a PUT
request would need a preflighted request, as it can potentially have side effects (PUT
implies modifying a resource), so a preflight OPTIONS
HTTP request to the resource must be made to check what the CORS policy is, followed by the actual PUT
request when allowed. Preflighted requests provide more detail, such as the allowed HTTP headers and methods, because a server can, for example, have a CORS policy of forbidding doing DELETE
requests or having an ABC
header in the HTTP request. I recommend reading Mozilla’s “Cross-Origin Resource Sharing (CORS)” document (http://mng .bz/X0Z6), as it provides a detailed and approachable explanation of the interactions between browsers and servers with CORS.
Vert.x comes with a ready-to-use CORS handler with the CorsHandler
class. Creating a CorsHandler
instance requires three settings:
The following listing shows how to install CORS support in a Vert.x router.
Set<String> allowedHeaders = new HashSet<>(); ❶ allowedHeaders.add("x-requested-with"); allowedHeaders.add("Access-Control-Allow-Origin"); allowedHeaders.add("origin"); allowedHeaders.add("Content-Type"); allowedHeaders.add("accept"); allowedHeaders.add("Authorization"); Set<HttpMethod> allowedMethods = new HashSet<>(); ❷ allowedMethods.add(HttpMethod.GET); allowedMethods.add(HttpMethod.POST); allowedMethods.add(HttpMethod.OPTIONS); allowedMethods.add(HttpMethod.PUT); router.route().handler(CorsHandler ❸ .create("*") .allowedHeaders(allowedHeaders) .allowedMethods(allowedMethods));
❶ The set of allowed HTTP headers
❷ The set of allowed HTTP methods
❸ A CORS handler for all origins
The HTTP methods are those supported in our API. You can see that we don’t support DELETE
, for instance. The CORS handler has been installed for all routes, since they are all part of the API and should be accessible from any kind of application, including web browsers. The allowed headers should match what your API needs, and also what clients may pass, like specifying a content type, or headers that could be injected by proxies and for distributed tracing purposes.
We can check that CORS is properly supported by making an HTTP OPTIONS
preflight request to one of the routes supported by the API.
$ http OPTIONS :4000/api/v1/token Origin:'http://foo.tld' HTTP/1.1 405 Method Not Allowed access-control-allow-origin: * content-length: 0
By specifying an origin
HTTP header, the CORS handler inserts an access-control-allow-origin
HTTP header in the response. The HTTP status code is 405, since the OPTION
HTTP method is not supported by the specific route, but this is not an issue as web browsers are only interested in the CORS-related headers when they do a preflight request.
We have discussed the interesting points in the public API: how to make HTTP requests with the Vert.x web client, how to use JWT tokens, and how to enable CORS support. It is now time to see how we can expose the user web application (defined in chapter 7), and how that application can connect to the public API.
The application is written with the Vue.js JavaScript framework. Vert.x is used to serve the application’s compiled assets: HTML, CSS, and JavaScript.
The corresponding source code is located in the part2-steps-challenge/user-webapp folder of the book’s source code repository.
Vue.js deserves a book by itself, and we recommend that you read Erik Hanchett and Benjamin Listwon’s Vue.js in Action (Manning, 2018) if you are interested in learning this framework. I’ll provide a quick overview here, since we’re using Vue.js as the JavaScript framework for the two web applications developed as part of the larger 10k steps application.
Vue.js is a modern JavaScript frontend framework, like React or Angular, for building modern web applications, including single-page applications. It is reactive as changes in a component model trigger changes in the user interface. Suppose that we display a temperature in a web page. When the corresponding data changes, the temperature is updated, and Vue.js takes care of (most) of the plumbing for doing that.
Vue.js supports components, where an HTML template, CSS styling, and JavaScript code can be grouped together, as in the following listing.
<template> <div id="app"> {{ hello }} ❶ </div> </template> <style scoped> ❷ div { border: solid 1px black; } </style> <script> export default { data() { return { hello: "Hello, world!" ❸ } } } </script>
❶ Replaced by the value of the hello property
❷ CSS rules local to the component
❸ The initial definition of the hello property
A Vue.js project can be created using the Vue.js command-line interface (https://cli .vuejs.org/):
$ vue create user-webapp
The yarn
build tool can then be used to install dependencies (yarn install
), serve the project for development with automatic live-reload (yarn run serve
), and build a production version of the project HTML, CSS, and JavaScript assets (yarn run build
).
The user web application is a single-page application with three different screens: a login form, a page with user details, and a registration form.
The key Vue.js files are the following:
src/router.js--The Vue.js router that dispatches to the components of the three different screens
src/DataStore.js--An object to hold the application store using the web browser local storage API, shared among all screens
src/App.vue--The main component that mounts the Vue.js router
src/views--Contains the three screen components: Home.vue
, Login.vue
, and Register.vue
The Vue.js router configuration is shown in the following listing.
import Vue from 'vue' import Router from 'vue-router' import Home from './views/Home.vue' import Login from './views/Login.vue' import Register from './views/Register.vue' Vue.use(Router) export default new Router({ routes: [ { path: '/', ❶ name: 'home', ❷ component: Home ❸ }, { path: '/login', name: 'login', component: Login }, { path: '/register', name: 'register', component: Register }, ] })
The application code is colocated in the same module as the Vert.x application that serves the user web application, so you will find the usual Java source files under src/main/java and a Gradle build.gradle.kts file. The Vue.js compiled assets (yarn build
) must be copied to src/main/resources/webroot/assets for the Vert.x-based service to serve them.
This makes for two build tools in a single project, and fortunately they can coexist peacefully. In fact, it is very easy to call yarn
from Gradle, as the com.moowork.node Gradle plugin provides a self-contained Node environment. The following listing shows the Node-related configuration of the user web application Gradle build file.
import com.moowork.gradle.node.yarn.YarnTask apply(plugin = "com.moowork.node") ❶ tasks.register<YarnTask>("buildVueApp") { ❷ dependsOn("yarn_install") ❸ // (...) ❹ args = listOf("build") ❺ } tasks.register<Copy>("copyVueDist") { ❻ dependsOn("buildVueApp") from("$projectDir/dist") into("$projectDir/src/main/resources/webroot/assets") } val processResources by tasks.named("processResources") { ❼ dependsOn("copyVueDist") } val clean by tasks.named<Delete>("clean") { ❽ delete("$projectDir/dist") delete("$projectDir/src/main/resources/webroot/assets") } // (...)
❸ Adds a dependency on running yarn install first
❹ Gradle caching instructions that you can find in the full source code
❻ Task to copy the compiled assets
❼ Make sure building the project also builds the Vue.js application.
❽ Extra clean task to be done for the Vue.js compiled assets
The buildVueApp
and copyVueDist
tasks are inserted as part of the regular project build tasks, so the project builds both the Java Vert.x code and the Vue.js code. We also customize the clean
task to remove the generated assets.
Let’s look at one of the Vue.js components: the login screen shown in figure 8.5.
The file for this component is src/views/Login.vue. The component shows the login form, and when submitted it must call the public API to get a JWT token. On success, it must store the JWT token locally and then switch the view to the home
component. On error, it must stay on the login form and display an error message.
The HTML template part of the component is shown in the following listing.
<template> <div> <div class="alert alert-danger" role="alert" ➥ v-if="alertMessage.length > 0"> ❶ {{ alertMessage }} ❷ </div> <form v-on:submit="login"> ❸ <div class="form-group"> <label for="username">User name</label> <input type="username" class="form-control" id="username" ➥ placeholder="somebody123" v-model="username"> ❹ </div> <div class="form-group"> <label for="password">Password</label> <input type="password" class="form-control" id="password" placeholder="abc123" v-model="password"> </div> <button type="submit" class="btn btn-primary">Submit</button> </form> <div> <p>...or <router-link to="/register">register</router-link></p> ❺ </div> </div> </template>
❶ Conditionally display the div block depending on the value of the alertMessage component data.
❷ Template syntax to render the value of alertMessage
❸ Call the login method on form submit.
❹ v-model binds the field value to the username component data.
❺ <router-link> allows linking to another component.
The JavaScript part of the component provides the component data declaration as well as the login
method implementation. We use the Axios JavaScript library to make HTTP client calls to the public API. The following listing provides the component JavaScript code.
import DataStore from '../DataStore' import axios from 'axios' export default { data() { ❶ return { username: '', password: '', alertMessage: '' } }, methods: { ❷ login: function () { if (this.username.length === 0 || this.password.length === 0) { ❸ return } axios .post("http://localhost:4000/api/v1/token", { ❹ username: this.username, password: this.password }) .then(response => { DataStore.setToken(response.data) ❺ DataStore.setUsername(this.username) this.$router.push({name: 'home'}) ❻ }) .catch(err => this.alertMessage = err.message) ❼ } } }
❷ Component methods declaration
❸ If either of the fields is empty, there is no point in trying to authenticate against the public API.
❹ Issue an authentication request with the credentials as a JSON payload.
❺ In case of success, store the token and username from the response.
❻ Tell the router to change component.
❼ Triggers the error message to be reactively displayed when the value of alertMessage changes
The component data properties are updated as the user types text in the username and password fields, and the login
method is called on form submit. If the call succeeds, the application moves to the home
component.
The next listing is from the code of the Home.vue component, and it shows how you can use the JWT token to fetch the user’s total number of steps.
axios .get(`http://localhost:4000/api/v1/${DataStore.username()}/total`, { headers: { 'Authorization': `Bearer ${DataStore.token()}` ❶ } }) .then(response => this.totalSteps = response.data.count) ❷ .catch(err => { if (err.response.status === 404) { this.totalSteps = 0 } else { this.alertMessage = err.message } })
❶ Pass the token from the value fetched by the login component.
❷ Update the component data, triggering a view refresh.
Let’s now see how we can serve the web application assets with Vert.x.
The Vert.x code does not have much to do beyond starting an HTTP server and serving static content. The following listing shows the content of the rxStart
method of the UserWebAppVerticle
class.
@Override public Completable rxStart() { Router router = Router.router(vertx); router.route().handler(StaticHandler.create("webroot/assets")); ❶ router.get("/*").handler(ctx -> ctx.reroute("/index.html")); ❷ return vertx.createHttpServer() .requestHandler(router) .rxListen(HTTP_PORT) .ignoreElement(); }
❶ Resolve static content against webroot/assets in the classpath.
The StaticHandler
caches files in memory, unless configured otherwise in the call to the create
method. Disabling caching is useful in development mode, because you can modify static assets’ content and see changes by reloading in a web browser without having to restart the Vert.x server. By default, static files are resolved from the webroot folder in the classpath, but you can override it as we did by specifying webroot/assets.
Now that we’ve discussed how to use the Vert.x web stack, it is time to focus on testing the services that compose the reactive application.
Testing is a very important concern, especially as there are multiple services involved in the making of the 10k steps challenge reactive application. There is no point in testing that the user web application service delivers static content properly, but it is crucial to have tests covering interactions with the public API service. Let’s discuss how to write integration tests for this service.
The public API source code reveals an IntegrationTest
class. It contains several ordered test methods that check the API behavior:
Since the public API service depends on the activity and user profile services, we either need to mock them with fake services that we run during the tests’ execution, or deploy them along with all their dependencies, like databases. Either approach is fine. In the chapters in this part we will sometimes create a fake service for running our integration tests, and sometimes we will just deploy the real services.
In this case, we are going to deploy the real services, and we need to make this from JUnit 5 in a self-contained and reproducible manner. We first need to add the project dependencies, as in the following listing.
testImplementation(project(":user-profile-service")) ❶ testImplementation(project(":activity-service")) testImplementation("io.vertx:vertx-pg-client:$vertxVersion") ❷ testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion")❸ testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") testImplementation("io.vertx:vertx-junit5:$vertxVersion") testImplementation("io.vertx:vertx-junit5-rx-java2:$vertxVersion") testImplementation("io.rest-assured:rest-assured:$restAssuredVersion") ❹ testImplementation("org.assertj:assertj-core:$assertjVersion")
❶ Dependency on another project module
❷ This is used to insert data in PostgreSQL. More on that later.
❸ This is to run Docker containers.
❹ A nice DSL library for testing HTTP services
These dependencies bring us two useful tools for writing tests:
Testcontainers is a project for running Docker containers in JUnit tests, so we will be able to use infrastructure services like PostgreSQL or Kafka (www.test containers.org).
REST Assured is a library focusing on testing HTTP services, providing a convenient fluent API for describing requests and response assertions (http://rest-assured.io).
The preamble of the test class is shown in the following listing.
@ExtendWith(VertxExtension.class) ❶ @TestMethodOrder(OrderAnnotation.class) ❷ @TestInstance(TestInstance.Lifecycle.PER_CLASS) @DisplayName("Integration tests for the public API") @Testcontainers ❸ class IntegrationTest { @Container private static final DockerComposeContainer CONTAINERS = new DockerComposeContainer(new File("../docker-compose.yml")); ❹ // (...) }
❶ Use the Vert.x JUnit 5 support.
❷ Test methods must be run in order.
❹ Start containers from a Docker Compose file.
Testcontainers gives lots of choices for starting one or many containers. It supports generic Docker images, specialized classes for common infrastructure (PostgreSQL, Apache Kafka, etc.), and Docker Compose. Here we reuse the Docker Compose descriptor for running the whole application (docker-compose.yml
), and the containers described in the file are started before the first test is run. The containers are destroyed when all tests have executed. This is very interesting--we get to write integration tests against the real infrastructure services that would be used in production.
The prepareSpec
method is annotated with @BeforeAll
and is used to prepare the tests. It inserts some data in the PostgreSQL database for the activity service and then deploys the user profile and activity verticles. It also prepares a RequestSpecification
object from REST Assured, as follows.
requestSpecification = new RequestSpecBuilder() .addFilters(asList(new ResponseLoggingFilter(), new RequestLoggingFilter()))❶ .setBaseUri("http://localhost:4000/") .setBasePath("/api/v1") ❷ .build();
❶ All requests and responses will be logged, which is useful for tracking errors.
❷ This avoids repeating the base path of all URLs in requests.
This object is shared among all tests methods, as they all have to make requests to the API. We enable logging of all requests and responses for easier debugging, and we set /api/v1
as the base path for all requests.
The test class maintains a hash map of users to register and later use in calls, as well as a hash map of JWT tokens.
private final HashMap<String, JsonObject> registrations = new ➥ HashMap<String, JsonObject>() { ❶ { put("Foo", new JsonObject() .put("username", "Foo") .put("password", "foo-123") .put("email", "[email protected]") .put("city", "Lyon") .put("deviceId", "a1b2c3") .put("makePublic", true)); // (...) }; private final HashMap<String, String> tokens = new HashMap<>(); ❷
The following listing is the first test, where the users from the registrations
hash map are registered.
@Test @Order(1) @DisplayName("Register some users") void registerUsers() { registrations.forEach((key, registration) -> { given(requestSpecification) .contentType(ContentType.JSON) .body(registration.encode()) ❶ .post("/register") ❷ .then() .assertThat() .statusCode(200); ❸ }); }
❶ We encode the JSON data to a string.
❷ HTTP POST to /api/v1/register
❸ Assert that the status code is a 200.
The REST Assured fluent API allows us to express our request and then do an assertion on the response. It is possible to extract a response as text or JSON to perform further assertions, as in the next listing, which is extracted from the test method that retrieves JWT tokens.
JsonObject login = new JsonObject() .put("username", key) .put("password", registration.getString("password")); String token = given(requestSpecification) .contentType(ContentType.JSON) .body(login.encode()) .post("/token") .then() .assertThat() .statusCode(200) .contentType("application/jwt") ❶ .extract() ❷ .asString(); assertThat(token) ❸ .isNotNull() .isNotBlank(); tokens.put(key, token);
❶ Assert that the content-type header is in the response and matches that of JWT tokens.
❸ AssertJ assertions on a String
The test fetches a token and then asserts that the token is neither a null
value or a blank string (empty or with spaces). Extracting JSON data is similar, as shown next.
JsonPath jsonPath = given(requestSpecification) .headers("Authorization", "Bearer " + tokens.get("Foo")) ❶ .get("/Foo/total") .then() .assertThat() .statusCode(200) .contentType(ContentType.JSON) .extract() .jsonPath(); assertThat(jsonPath.getInt("count")).isNotNull().isEqualTo(6255); ❷
❷ Work with a JSON representation.
The test fetches the total number of steps for user Foo
, extracts the JSON response, and then checks that the step count (the count
key in the JSON response) is equal to 6255.
The integration test can be run with Gradle (./gradlew :public-api:test
) or from a development environment, as shown in figure 8.6.
You now have a good understanding of using the Vert.x web stack both for exposing endpoints and consuming other services. The next chapter focuses on the messaging and event streaming stack of Vert.x.
The Vert.x web module makes it easy to build an edge service with CORS support and HTTP calls to other services.
JSON web tokens are useful for authorization and access control in a public API.
Vert.x does not have a preference regarding frontend application frameworks, but it is easy to integrate a Vue.js frontend application.
By combining Docker containers managed from Testcontainers and the Rest Assured library, you can write integration tests for HTTP APIs.
3.16.15.149