Avaje Nima
Discord | Source | API Docs | Issues | Releases |
---|---|---|---|---|
Discord | GitHub | Javadoc | GitHub |
A combination of Helidon SE Webserver and Avaje libraries, includes:
- Helidon 4 SE WebServer (Uses Virtual Threads)
- avaje-inject - Dependency injection using source code generation via annotation processing
- avaje-http - Controllers generated as source code using annotation processing
- avaje-jsonb - JSON adapters generated as source code using annotation processing
- avaje-validator - JSR validation generated as source code using annotation processing
- avaje-config - External configuration
Maven 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>
2. Add the annotation processor
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<!-- Annotation processors -->
<annotationProcessorPaths>
<path>
<groupId>io.avaje</groupId>
<artifactId>avaje-nima-generator</artifactId>
<version>${avaje-nima.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Note the avaje-nima-generator
is actually 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
Controller
Create a package (e.g. org.example.web
) and create you first
controller in the package.
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) {
}
}
Generated code
After compiling HelloController 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 our controller codeHelloController$Route$DI
- the dependency injection for the routesHelloController$HelloBeanJsonAdapter
- the JSON adapter for HelloBean
Generated test code
And for test purposes in target/generated-test-sources/test-annotations
a couple of things are generated for us:
HelloControllerTestAPI
- a test client we can use to test the controllerHelloControllerTestAPIHttpClient
- the test client implementation
Test client - HelloControllerTestAPI
We can use the generated test client to create a component test, and test our controller.
The name of the generated test clients is: {controllerName}
+ TestAPI
.
Controller component test
Create a test class called HelloControllerTest
to test the controller.
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
@InjectTest
for a DI component test which can wire dependencies - Use
@Inject HelloControllerTestAPI client
to wire the test http client. The webserver will start on a random port and be available for this test.
Main method
Create a main method to run the application.
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"}
Error handlers
Commonly we want define all the global exception handling in a single exception handler.
We can do this by creating a controller than just has @ExceptionHandler
methods.
An example ErrorHandlers controller is:
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
.
An 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;
}
}