Avaje Nima
| Discord | Source | API Docs | Issues | Releases |
|---|---|---|---|---|
| Discord | GitHub | Javadoc | GitHub |
A combination of Helidon SE Webserver and Avaje libraries, includes:
- Helidon SE - High performance webserver
- avaje-inject - Dependency injection
- avaje-http - JAX-RS style controller generation
- avaje-jsonb - JSON adapter generation
- avaje-validator - Bean validation
- avaje-config - External configuration
Dependencies
1. Add avaje-nima dependencies
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-nima</artifactId>
<version>${avaje.nima.version}</version>
</dependency>
<!-- test dependency -->
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-nima-test</artifactId>
<version>${avaje.nima.version}</version>
<scope>test</scope>
</dependency>
Annotation processor
2. Add the annotation processor
avaje-nima-generator is a composite of:
- avaje-http-helidon-generator - for the route adapter
- avaje-inject-generator - for dependency injection
- avaje-jsonb-generator - for json adapters
- avaje-validator-generator - for bean validation
- avaje-record-builder - for record builders
- avaje-spi-service - for automatic service generation
- jstachio-apt - for mustache template rendering
- avaje-http-client-generator - for test client generation
<!-- Annotation processors -->
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-nima-generator</artifactId>
<version>${avaje.nima.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
2a. JDK 23+
In JDK 23+, annotation processors added as a provided dependency are disabled by default, so we need to add a compiler property to re-enable via:
<properties>
<maven.compiler.proc>full</maven.compiler.proc>
</properties>
Controller
Create a controller and annotate it with http-api annotations.
package org.example.web;
import io.avaje.http.api.*;
import io.avaje.inject.*;
import io.avaje.jsonb.Json;
@Controller
public class HelloController {
@Produces("text/plain")
@Get("/")
String hello() {
return "hello world";
}
@Get("/json")
HelloBean one() {
return new HelloBean(97, "Hello JSON");
}
@Json
public record HelloBean(int id, String name) {
}
}
Nima Class
The Nima class will start a BeanScope, register generated controller routes, and start the helidon webserver.
The Nima class will search your BeanScope for a WebServerConfig.Builder, if you provide one in your
BeanScope it will be used to configure the webserver.
package org.example;
import io.avaje.nima.Nima;
public class Main {
public static void main(String[] args) {
var webServer = Nima.builder()
.port(8080)
.build();
webServer.start();
}
}
Now you can run the application main method,
21:23:37.951 [main] INFO io.avaje.inject - Wired beans in 77ms
21:23:38.001 [features-thread] INFO i.h.common.features.HelidonFeatures - Helidon SE 4.2.2 features: [Config, Encoding, Media, Metrics, Registry, WebServer]
21:23:38.005 [start @default (/0.0.0.0:8080)] INFO io.helidon.webserver.ServerListener - [0x2132b530] http://0.0.0.0:8080 bound for socket '@default'
21:23:38.008 [main] INFO io.helidon.webserver.LoomServer - Started all channels in 6 milliseconds. 369 milliseconds since JVM startup. Java 21.0.5+9-LTS-239
... and perform a quick test using curl.
curl http://localhost:8080
hello world
curl http://localhost:8080/json
{"id":97,"name":"Hello JSON"}
Generated code
After compiling, we should see in target/generated-sources/annotations:
HelloController$DI- which performs the dependency injection wiring of the controllerHelloController$Route- which registers the controller routes with Helidon and adapts the Helidon request and response to the controller codeHelloController$Route$DI- the dependency injection for the routesHelloController$HelloBeanJsonAdapter- the JSON adapter for HelloBean
Generated test code
In target/generated-test-sources/test-annotations
useful testing classes are generated for us:
HelloControllerTestAPI- a test client inteface to test the controllerHelloControllerTestAPIHttpClient- the test client implementation
Controller component test
Use the generated test client to create a component test that tests our controller.
The name of the generated test clients is: {controllerName} + TestAPI.
package org.example.web;
import io.avaje.inject.test.InjectTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.net.http.HttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
@InjectTest
class HelloControllerTest {
@Inject HelloControllerTestAPI client;
@Test
void hello() {
HttpResponse<String> res = client.hello();
assertThat(res.statusCode()).isEqualTo(200);
assertThat(res.body()).isEqualTo("hello world");
}
}
- Use
@InjectTestto allow the test which to wire dependencies - Use
@Inject HelloControllerTestAPI clientto wire the test http client. The webserver will start on a random port and be available for this test.
Error handlers
Commonly exception handling is done in a dedicated exception handling class.
This can be done by creating a controller that has @ExceptionHandler methods.
Example error controller:
package org.example.web;
import io.avaje.http.api.Controller;
import io.avaje.http.api.ExceptionHandler;
import io.avaje.http.api.Produces;
import io.helidon.http.BadRequestException;
import io.helidon.webserver.http.ServerRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.UUID;
@Controller
final class ErrorHandlers {
private static final Logger log = LoggerFactory.getLogger(ErrorHandlers.class);
@Produces(statusCode = 500)
@ExceptionHandler
ErrorResponse defaultError(Exception ex, ServerRequest req) {
var path = path(req);
var traceId = log(ex, path);
return ErrorResponse.builder()
.statusCode(500)
.path(path)
.traceId(traceId)
.message("Unhandled server error")
.build();
}
@Produces(statusCode = 400)
@ExceptionHandler
ErrorResponse badRequest(BadRequestException ex, ServerRequest req) {
var path = path(req);
var traceId = log(ex, path);
return ErrorResponse.builder()
.statusCode(400)
.path(path)
.traceId(traceId)
.message("Unhandled server error")
.build();
}
private static String path(ServerRequest req) {
return req != null && req.path() != null ? req.path().path() : null;
}
private static UUID log(Throwable ex, String path) {
UUID traceId = UUID.randomUUID();
log.error("Unhandled server error path:{} trace:{}", path, traceId, ex);
return traceId;
}
}
With the above example the error response has a JSON payload. The code for the
ErrorResponse is:
import io.avaje.jsonb.Json;
import io.avaje.recordbuilder.RecordBuilder;
import java.util.UUID;
@Json
@RecordBuilder
public record ErrorResponse(
int statusCode,
String path,
UUID traceId,
String message) {
public static ErrorResponseBuilder builder() {
return ErrorResponseBuilder.builder();
}
}
Generated Code: (click to expand)
@Generated("avaje-helidon-generator")
@Component
public final class ErrorHandlers$Route implements HttpFeature {
private final ErrorHandlers controller;
private final JsonType<ErrorResponse> errorResponseJsonType;
public ErrorHandlers$Route(ErrorHandlers controller, Jsonb jsonb) {
this.controller = controller;
this.errorResponseJsonType = jsonb.type(ErrorResponse.class);
}
@Override
public void setup(HttpRouting.Builder routing) {
routing.error(Exception.class, this::_defaultError);
routing.error(BadRequestException.class, this::_badRequest);
}
private void _defaultError(ServerRequest req, ServerResponse res, Exception ex) {
res.status(INTERNAL_SERVER_ERROR_500);
var result = controller.defaultError(ex, req);
if (result == null) {
res.status(NO_CONTENT_204).send();
} else {
res.headers().contentType(MediaTypes.APPLICATION_JSON);
errorResponseJsonType.toJson(result, JsonOutput.of(res));
}
}
private void _badRequest(ServerRequest req, ServerResponse res, BadRequestException ex) {
res.status(BAD_REQUEST_400);
var result = controller.badRequest(ex, req);
if (result == null) {
res.status(NO_CONTENT_204).send();
} else {
res.headers().contentType(MediaTypes.APPLICATION_JSON);
errorResponseJsonType.toJson(result, JsonOutput.of(res));
}
}
}
Filters
To add a Filter we can add a controller that has a @Filter method
that takes a FilterChain, RoutingRequest and
optionally a RoutingResponse.
Example filter that reads a "Caller-Id" request header and rejects the request if one isn't provided.
import io.avaje.http.api.Controller;
import io.avaje.http.api.Filter;
import io.helidon.http.BadRequestException;
import io.helidon.http.HeaderName;
import io.helidon.http.HeaderNames;
import io.helidon.webserver.http.FilterChain;
import io.helidon.webserver.http.RoutingRequest;
import io.helidon.webserver.http.ServerRequest;
import java.util.Optional;
import java.util.Set;
@Controller
final class CallerIdFilter {
private static final HeaderName CALLER_ID = HeaderNames.create("Caller-Id");
private static final Set<!String> BYPASS = Set.of("/ping");
@Filter
void filter(FilterChain chain, RoutingRequest req) {
var path = path(req);
if (BYPASS.contains(path)) {
chain.proceed();
} else {
String callerId = callerId(req).orElseThrow(() -> new BadRequestException("Caller-Id required"));
handleCallerMetrics(path, callerId);
chain.proceed();
}
}
private void handleCallerMetrics(String path, String callerId) {
// capture metrics
}
private Optional<!String> callerId(RoutingRequest request) {
return request.headers().first(CALLER_ID);
}
private static String path(ServerRequest req) {
return req != null && req.path() != null ? req.path().path() : null;
}
}
GraalVM
We can build GraalVM native images with Avaje Nima and the associated dependencies including Helidon SE webserver.
Native profile build plugin
<profile>
<id>native</id>
<build>
<plugins>
<plugin> <!-- build native executable -->
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.11.3</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
<configuration>
<mainClass>nz.co.eroad.central.access.Main</mainClass>
</configuration>
</execution>
</executions>
<configuration>
<buildArgs>
<buildArg>--gc=G1</buildArg>
<buildArg>-R:MaxGCPauseMillis=50</buildArg>
<buildArg>-R:MaxHeapSize=400m</buildArg>
<buildArg>--emit build-report</buildArg>
<buildArg>--no-fallback</buildArg>
<buildArg>-march=compatibility</buildArg>
<buildArg>--allow-incomplete-classpath</buildArg>
<buildArg>--static-nolibc</buildArg>
</buildArgs>
</configuration>
</plugin>
<plugin> <!-- build docker image for native executable -->
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.6</version>
<executions>
<execution>
<goals>
<goal>dockerBuild</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-native-image-extension-maven</artifactId>
<version>0.1.0</version>
</dependency>
</dependencies>
<configuration>
<pluginExtensions>
<pluginExtension>
<implementation>
com.google.cloud.tools.jib.maven.extension.nativeimage.JibNativeImageExtension
</implementation>
<properties> <!-- name of executable produced -->
<imageName>central-access-service</imageName>
</properties>
</pluginExtension>
</pluginExtensions>
<container>
<mainClass>nz.co.eroad.central.access.Main</mainClass>
<ports>8080</ports>
</container>
<from> <!-- UBI micro base image with glibc -->
<image>redhat/ubi10-micro:10.1-1762215812</image>
</from>
<to>
<image>${project.artifactId}-native:${project.version}</image>
</to>
</configuration>
</plugin>
</plugins>
</build>
</profile>
MacOS specific profile
MacOS does not support G1 garbage collector in native images, so we need to create a specific profile for MacOS builds that omits the G1 related build args.
<profile>
<id>mac</id>
<activation>
<os>
<family>mac</family>
</os>
</activation>
<build>
<plugins>
<plugin> <!-- native build on MacOS -->
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs> <!-- No G1 on MacOS native image -->
<buildArg>-R:MaxHeapSize=400m</buildArg>
<buildArg>--emit build-report</buildArg>
<buildArg>--no-fallback</buildArg>
<buildArg>-march=compatibility</buildArg>
<buildArg>--static-nolibc</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>