Almaraz is a set of components to build up a production-ready service with Spring WebFlux.
It provides the following functionality:
- Context. It uses the reactive context to store orthogonal information. The context information is available to any consumer of the reactive stream. It can be used to write contextual logs.
- Logging. Using the reactive context, it provides a library to log contextual information in JSON with SLF4J logger.
- Validation. Validate requests and documents against a JSON schema.
- Middlewares. Set of WebFlux webfilters to serve a REST API with production quality.
- Exceptions. Hierarchy of exceptions with support to build an error response. These exceptions are used by all the features of the Almaraz library.
Almaraz is built on top of Reactor and Spring WebFlux.
See the example to know how to use Almaraz in a real WebFlux application.
Almaraz receives the name from a Spanish nuclear power plant. This is a reference of the reactive nature of this library.
Reactor provides a context where it is possible to store orthogonal information. For example, Spring Security uses the context to store the user identifier after the authentication process.
The context is relevant to share information all along the reactive stream to avoid passing this information to every method. Typically, this information was stored at thread level, but this is not possible in reactive programming because the same thread can be used by multiple streams at the same time.
The Reactor context is immutable. Every time the context is modified, it returns a new instance. According to reactor information, it is recommended to use a dedicated mutable structure, instead of storing the information directly to the Reactor context.
The class com.elevenpaths.almaraz.context.RequestContext
is designed according to MDC constraints to log contextual information with SLF4J. This class includes a Map<String, String>
and it is possible to put/get the following types: String
, Boolean
, Long
. Any value that is stored in the map is converted to String
. Apart from a general map to store any contextual information, it states the following context elements:
Name | Key | Type | Description |
---|---|---|---|
transactionId | trans | String | Unique identifier of a request/response flow. |
correlator | corr | String | Correlator to track logs corresponding to a HTTP flow. |
operation | op | String | Name of the operation (e.g. createUser). |
service | svc | String | Service name. |
component | comp | String | Component name. |
user | user | String | User identifier. |
realm | realm | String | Realm. |
alarm | alarm | String | Alarm identifier to track the start/stop of an alarm. |
RequestContext
also provides a method to retrieve it (associated to the current reactive stream):
public static Mono<RequestContext> context() {
return Mono.subscriberContext()
.map(ctxt -> ctxt.getOrDefault(RequestContext.class, new RequestContext()));
}
Note that it is assumed that RequestContext
is always stored in the Reactor context under the key RequestContext.class
.
It is not possible to identify the operation in a web filter when using a Spring Controller
because the controller is responsible for the routing of the request to the appropriate method. It would be very repetitive to include the operation in each controller method. Apart from that, updating the context is a bit tricky due to its reactive nature.
Almaraz provides the Java annotation @OperationRequestContext
to decorate a method and update the RequestContext
with the operation name. By default, it takes the method name as operation, but it could be customized with a value
parameter.
It is recommended to annotate the methods of the Spring controller so that the RequestContext
is enriched with the operation and the log records can identify which operation is being served.
@OperationRequestContext
@DeleteMapping(value = "/{userId}")
public Mono<ResponseEntity<Void>> deleteUser(@PathVariable String userId) {
return usersService.deleteUser(userId)
.then(Mono.just(new ResponseEntity<Void>(HttpStatus.NO_CONTENT)));
}
Logging is based on the ideas provided by Simon Basle in Contextual Logging with Reactor Context and MDC. The implementation uses Reactor doOnEach
:
Mono<T> doOnEach(Consumer<? super Signal<T>> signalConsumer)
This function is executed whenever an item is emitted, fails with an error or completes successfully for both Mono
and Flux
. The consumer receives a Signal
that provides access to the reactive context with signal.getContext()
.
The class com.elevenpaths.almaraz.logging.ReactiveLogger
provides the low-level method logOnSignal
:
public static <T> Consumer<Signal<T>> logOnSignal(Predicate<Signal<T>> isSignal, Consumer<Signal<T>> log) {
return signal -> {
if (!isSignal.test(signal)) {
return;
}
try {
RequestContext logContext = signal.getContext().getOrDefault(RequestContext.class, new RequestContext());
MDC.setContextMap(logContext.getContextMap());
log.accept(signal);
} finally {
MDC.clear();
}
};
}
This function executes the predicate isSignal
to filter which signals are relevant for logging. If the predicate is true, then it gets the RequestContext
from the reactive context (obtained from the signal), configures the contextual information with MDC, and executes the log consumer. Finally, the MDC is reset, so that the log context is not available for following log records.
ReactiveLogger
also provides 3 high-level functions based on the signal type:
Function | Signal | Description |
---|---|---|
logOnNext(Consumer<T> log) |
ON_NEXT | Log when an item is emitted passing the item to the log consumer. |
logOnComplete(Runnable log) |
ON_COMPLETE | Log when a reactive stream is completed successfully. |
logOnError(Consumer<Throwable> log) |
ON_ERROR | Log when an error is thrown passing the exception to the log consumer. |
The following example initializes the {@link RequestContext} in the reactive context and configures the logger for signals: next, complete, and error. The method logOnNext is invoked twice (one per item in the reactive stream), the method logOnComplete is invoked only once, and the method logOnError is not invoked because there is no error.
Flux.just("test 1", "test 2")
.doOnEach(ReactiveLogger.logOnNext(next -> log.info("Next: {}", next)))
.doOnEach(ReactiveLogger.logOnComplete(() -> log.info("Complete")))
.doOnEach(ReactiveLogger.logOnError(error -> log.error("Error", error)))
.subscriberContext(Context.of(RequestContext.class, new RequestContext().setCorrelator("test-corr")))
.subscribe();
ReactiveLogger
also provides a helper function Mono<Void> log(Runnable log)
to start a reactive stream when you need to write a log as a first step. For example, in onErrorResume
step.
Mono.just("test")
.onErrorResume(MyException.class, e -> {
return ReactiveLogger.log(() -> log.error("Error raised", e))
.thenReturn("test failed");
});
Finally, it is required to configure the logger to generate contextual information in JSON. This is really convenient to process this information with a log aggregator. The following file configures the logback logger to write to console and include the MDC parameters:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE"
class="ch.qos.logback.core.ConsoleAppender">
<encoder
class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<fieldName>time</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>lvl</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<mdc>
<excludeMdcKeyName>status</excludeMdcKeyName>
<excludeMdcKeyName>latency</excludeMdcKeyName>
</mdc>
<pattern>
<omitEmptyFields>true</omitEmptyFields>
<pattern>
{
"svc": "almaraz-example",
"status": "#asLong{%mdc{status}}",
"latency": "#asLong{%mdc{latency}}"
}
</pattern>
</pattern>
<message>
<fieldName>msg</fieldName>
</message>
<stackTrace>
<fieldName>exception</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>1</maxDepthPerThrowable>
<rootCauseFirst>true</rootCauseFirst>
<exclude>sun\.reflect\..*\.invoke.*</exclude>
</throwableConverter>
</stackTrace>
</providers>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
Almaraz recommends using JSON schema validation to validate inputs (e.g. request body or request query parameters).
It is provided the following classes:
com.elevenpaths.almaraz.validation.JsonSchemaRepository
loads the JSON schemas available in directory/schemas
under classpath. Each JSON schema is loaded with an schema name that results from removing the.json
extension from the file name. The goal of this class is to cache the JSON schemas to speed up multiple validations against the same JSON schema.com.elevenpaths.almaraz.validation.JsonSchemaValidator
provides a method to validate:void validate(String schemaName, JsonNode node)
. This method receives a schema name to retrieve the JSON schema from the repository, and a JacksonJsonNode
with the document to be validated.
The JsonSchemaValidator
can be used directly to validate any JsonNode
. However, Almaraz also provides an annotation @ValidRequestBody
to decorate an argument of a controller method. This annotation will retrieve the request body, validates it against a JSON schema, and binds it to the type of the method argument. Note that it is equivalent to standard @RequestBody
but with additional JSON schema validation. The reason to merge validation and binding in a single annotation is to validate it against the original data, because the binding to a Java type could lose information (e.g. elements available in the document but absent in the Java type).
The following controller uses this annotation to validate the request body against the JSON schema stored at /schemas/json-schema.json
and bind it to the TestType
argument:
@RestController
public class DemoController {
@PostMapping(value = "/demo")
public String demo(@ValidRequestBody("json-schema") TestType value) {
...
}
}
The annotation supports the following arguments:
Argument | Default | Description |
---|---|---|
value | "" | Name of the JSON schema to perform the validation |
multi | false | Multivalue maps (only considered for urlencoded media type). If set to false (default), it is checked that MultiValueMap does not include multiple values for the same element. It is converted the MultiValueMap<String, String> into Map<String, String>. |
query | false | Process the query parameters of the request, instead of the request body. |
Almaraz provides a set of Spring WebFlux WebFilters to comply with frequent requirements. These webfilters are located in the package com.elevenpaths.almaraz.webfilters
.
Middleware | Order | Description |
---|---|---|
VersionWebFilter | 5 | It listens to a GET request to /version path (the path is configurable) to reply a JSON body with the version of the application. The version is passed with a Spring BuildProperties object. This webfilter has the highest order to avoid logging this request, especially if it is used as a keep-alive check. |
RequestContextWebFilter | 10 | It initializes the RequestContext with the correlator and transactionId. This instance is stored in the reactive context. |
LoggerWebFilter | 20 | It logs the request and response with contextual log information. The request context contains: address (remote IP address; it supports the X-Forwarded-For header), method (HTTP method), and path (HTTP resource path). The response context contains: status (HTTP response status code) and latency (time, in milliseconds, required to process the request). |
ErrorWebFilter | 30 | It handles any exception to build up an error response. |
CompleteLocationHeaderWebFilter | 40 | If the response contains a location header with a relative path, then it modifies the header to make it absolute. This webfilter simplifies the controllers so that they only need to add the resource identifier in the location header when the resource is created. |
BasePathWebFilter | 50 | It supports the configuration of a base path (aka context path). The controllers would process the request path without the base path. |
These WebFilters can be executed in a chain (pipeline). The class com.elevenpaths.almaraz.AlmarazConfiguration
is a pragmatic WebFlux configuration that provides a pipeline of Almaraz middlewares which is suitable for most REST servers. Note that AlmarazConfiguration
also provides the validation beans: JsonSchemaRepository
and JsonSchemaValidator
.
The following picture represents the order of execution for each webfilter. It is possible to insert additional webfilters in the pipeline:
The way to create all the Almaraz beans is to create a configuration class that extends AlmarazConfiguration
in your application. In the following example, it is achieved passing the basePath using the configuration property server.basePath
:
@Configuration
public class WebConfig extends AlmarazConfiguration {
public WebConfig(@Value("${server.basePath}") String basePath) {
super(basePath);
}
}
The AlmarazConfiguration
supports 2 additional arguments in the constructor: ObjectMapper
to serialize JSON documents with custom settings (e.g. Spring settings), and BuildProperties
to enable VersionWebFilter
:
@Configuration
public class WebConfig extends AlmarazConfiguration {
public WebConfig(
@Value("${server.basePath}") String basePath,
ObjectMapper objectMapper,
BuildProperties buildProperties) {
super(basePath, objectMapper, buildProperties);
}
}
It is also possible to customize which beans are instantiated without using AlmarazConfiguration
. The following example configures only two webfilters:
@Configuration
public class WebConfig {
@Order(10)
@Bean
public RequestContextWebFilter getContextWebFilter() {
return new RequestContextWebFilter();
}
@Order(20)
@Bean
public LoggerWebFilter getLoggerWebFilter() {
return new LoggerWebFilter();
}
}
Almaraz provides a set of Spring WebFlux ExchangeFilterFunctions to comply with frequent requirements in a WebClient. These middlewares are located in the package com.elevenpaths.almaraz.webclientfilters
.
Middleware | Description |
---|---|
CorrelatorWebClientFilter | It adds a correlator header in the request of the WebClient. |
LoggerWebClientFilter | It logs the request and the response of the WebClient. |
The following code configures a WebClient with both middlewares:
WebClient.builder()
.baseUrl("http://example.com")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(new CorrelatorWebClientFilter())
.filter(new LoggerWebClientFilter())
.build();
Almaraz provides a hierarchy of exceptions to build custom error responses. Every Almaraz exception is a RuntimeException
. This is convenient because it is possible to throw the exception and it will be automatically encapsulated as a reactive error.
The base exception is com.elevenpaths.almaraz.exceptions.ResponseException
. This exception contains the following attributes:
Attribute | Type | Optional | Description |
---|---|---|---|
status | HttpStatus | no | HTTP status of the error response. |
error | String | yes | Error identifier. See the list of available error identifiers. |
reason | String | yes | Error description. |
headers | Map<String, String> | yes | Map of HTTP headers. Some error responses may require to include HTTP headers (e.g. www-authenticate header). |
The ErrorWebFilter
is responsible for converting an exception into an error response. If the exception extends ResponseException
, then the error response will contain all the relevant information to generate the response. The error response may contain a JSON body (if error is not null), but some errors are self-descriptive with the status code (e.g. 404 - not found).
The format of the response body complies with the error format defined by OAuth2 standard:
{
"error": "invalid_request",
"error_description": "$.invalid: is not defined in the schema and the schema does not allow additional properties"
}
The list of predefined error identifiers are:
error |
---|
invalid_grant |
invalid_request |
invalid_scope |
unauthorized_client |
unsupported_grant_type |
invalid_client |
forbidden |
conflict |
server_error |
Almaraz provides a hierarchy of exceptions that implements these errors:
exception | error | status | description |
---|---|---|---|
InvalidGrantException | invalid_grant | 400 | The authorization code (or user’s password for the password grant type) is invalid or expired. This is also the error you would return if the redirect URL given in the authorization grant does not match the URL provided in this access token request. |
InvalidRequestException | invalid_request | 400 | The request is invalid or malformed. For example, if the request body does not comply with JSON schema. |
InvalidScopeException | invalid_scope | 400 | For access token requests that include a scope (password or client_credentials grants), this error indicates an invalid scope value in the request. |
UnauthorizedClientException | unauthorized_client | 400 | The client is unauthorized. For example, when the client requests a scope which is not allowed. |
ExpiredTokenException | unauthorized_client | 400 | The client is unauthorized because the access token is expired. |
UnsupportedGrantTypeException | unsupported_grant_type | 400 | If a grant type is requested that the authorization server doesn’t recognize. |
InvalidClientException | invalid_client | 401 | Client authentication failed, such as if the request contains invalid credentials. |
InvalidTokenException | unauthorized_client | 401 | Client/Application requested a protected OAuth resource with an invalid token. |
ForbiddenException | forbidden | 403 | The access to a resource is forbidden. |
InsufficientScopesException | unauthorized_client | 403 | The client is unauthorized because, although the access token is valid, it does not fulfil all the required scopes to access the protected resource. |
NotFoundException | - | 404 | Resource not found. |
ConflictException | conflict | 409 | Error due to a conflict. For example, when it is not possible to create a resource in database due to a violation of the uniqueness of a field. |
UnsupportedMediaTypeException | - | 415 | Unsupported media type. |
ServerException | server_error | 500 | Internal error due to unhandled exception or bad integration with external systems. |
This library is published in maven central repository. To publish a new version, it requires to upgrade the version in pom.xml. However, to coordinate the version of the library and the example, the makefile provides the set-version
target.
Follow these steps to upgrade the version (e.g. to version 0.2.2):
export VERSION=0.2.2
make set-version
Then you need to push the change to master branch via a pull request.
Finally, create a new tag to trigger the publication of the new version in maven central repository. This is automated with circleci.
This library has been developed by Telefónica as a building block of our internal projects. Future versions of this project will be released according to our own needs. Due to our limited resources, we cannot commit ourselves to attend new features, issues, or pull requests that are not aligned to our roadmap.
Copyright 2019 Telefónica Investigación y Desarrollo, S.A.U
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.