Nima

Discord Source API Docs Issues Releases
Discord GitHub Javadoc GitHub



A combination of Helidon SE Webserver and Avaje libraries, includes:

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:

<!-- 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:

Generated test code

In target/generated-test-sources/test-annotations useful testing classes are generated for us:

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");
  }
}

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>