Nima

Discord Source API Docs Issues Releases
Discord GitHub Javadoc GitHub



avaje-nima is a combination of Helidon SE Webserver and Avaje libraries, including:

Avaje Nima uses annotation processors to generate the necessary code at compile time to wire up controllers, dependency injection, JSON adapters and validation. This results in a very lightweight runtime with minimal dependencies and no reflection, making it well suited for GraalVM native image applications.

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>
Alternative: maven-compiler-plugin

Alternatively we can explicitly configure the maven-compiler-plugin to specify the annotation processor paths:

<build>
  <plugins>

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.1</version>
      <configuration>
        <annotationProcessorPaths>
          <path>
            <groupId>io.avaje</groupId>
            <artifactId>avaje-nima-generator</artifactId>
            <version>${avaje-nima.version}</version>
          </path>
          <path>
            <groupId>io.ebean</groupId>
            <artifactId>querybean-generator</artifactId>
            <version>${ebean.version}</version>
          </path>
        </annotationProcessorPaths>
      </configuration>
    </plugin>

  </plugins>
</build>

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 helloJson() {
    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:

Testing Controllers

avaje-nima-test provides support for testing controllers which includes:

Test Injection

avaje-nima-test can inject dependencies into a test including Http Clients and the Helidon Webserver.

When a test includes any of the above Http Client types to be injected, avaje-nima-test will automatically start a Helidon webserver on a random port for the test to use, and inject the Http Client(s) configured into the test (with the correct port to connect to the started webserver).

@Inject generated client

Given we have a @Controller called HelloController then there will automatically be a generated test client called HelloControllerTestAPI and we can inject that into our test. This allows us to use a type safe client to perform requests.

The generated test client uses the same method names and parameters as the controller but it returns HttpResponse<T> where T is the response body type. This allows us to easily check the response status code, headers and body in our test code.

The generated test client can be found in target/generated-test-sources/test-annotations and is only available in test scope.

example: @Inject generated client
package org.example.web;

import io.avaje.http.client.HttpClient;
import io.avaje.inject.test.InjectTest;
import io.helidon.webserver.WebServer;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@InjectTest
class MyControllerTest {

  @Inject HelloControllerTestAPI myClient;

  @Test
  void testUsingGeneratedClient() {

    // perform test using generated client API
    HttpResponse<String> response = myClient.hello();
    assertThat(response.statusCode()).isEqualTo(200);
    assertThat(response.body()).isEqualTo("hello world");

    HttpResponse<HelloBean> response = myClient.helloJson();
    assertThat(response.statusCode()).isEqualTo(200);

    HelloBean body = response.body();
    assertThat(body.id()).isEqualTo(97);
    assertThat(body.name()).isEqualTo("Hello JSON");
  }
}

@Inject HttpClient

We can inject the HttpClient to perform requests against the started webserver.

example: @Inject HttpClient
package org.example.web;

import io.avaje.http.client.HttpClient;
import io.avaje.inject.test.InjectTest;
import io.helidon.webserver.WebServer;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@InjectTest
class MyControllerTest {

  @Inject HttpClient httpClient;

  @Test
  void testSomething() {

    // perform test using httpClient
    HttpResponse<String> res = httpClient.request().GET().asString();
    assertThat(res.statusCode()).isEqualTo(200);
    assertThat(res.body()).isEqualTo("hello world");
  }
}

@Inject HttpClient.Builder

We can inject the HttpClient.Builder to perform requests against the started webserver. With the Builder we can customise the HttpClient as needed including things like setting the interceptors (that might automatically set http headers etc).

example: @Inject HttpClient

For example lets say that we have a filter that requires a "Caller-Id" header to be set on each request, and we have a test configuration that sets a default Caller-Id for tests like:

@TestScope
@Factory
class TestConfiguration {

    /** Test clients by default will use this interceptor. */
    @Bean
    HttpClient.Builder httpClientBuilder() {
        return HttpClient.builder()
            .requestIntercept(new RequestIntercept() {
                @Override
                public void beforeRequest(HttpClientRequest request) {
                    request.header("my-callerid", "local-test");
                }
            });
    }
}

We can inject the HttpClient.Builder so that we can change the interceptor for a specific test.

package org.example.web;

import io.avaje.http.client.HttpClient;
import io.avaje.inject.test.InjectTest;
import io.helidon.webserver.WebServer;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@InjectTest
class MyControllerTest {

  @Inject HttpClient.Builder httpClientBuilder;

  @Test
  void testSomething() {

    // create a custom http client for this test
    HttpClient httpClient = httpClientBuilder
      .requestIntercept(request -> request.header("my-callerid", "special-test"))
      .build();

    // perform test using httpClient
    HttpResponse<String> res = httpClient.request().GET().asString();
    assertThat(res.statusCode()).isEqualTo(200);
    assertThat(res.body()).isEqualTo("hello world");
  }
}

@Inject WebServer

We typically don't need to inject the WebServer unless we want to check the port or stop/start the server manually.

example: @Inject WebServer
package org.example.web;

import io.avaje.http.client.HttpClient;
import io.avaje.inject.test.InjectTest;
import io.helidon.webserver.WebServer;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@InjectTest
class MyControllerTest {

  @Inject WebServer webServer;
  @Inject HttpClient httpClient;

  @Test
  void testSomething() {

    // webserver started on random port
    assertThat(webServer.port()).isNotEqualTo(0);

    // perform test using httpClient
    HttpResponse<String> res = httpClient.request().GET().asString();
    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.

The handler method:

In terms of return type / error response body, the handler method can either:

@Controller
final class ErrorHandlers {

  @Produces(statusCode = ...)
  @ExceptionHandler
  SomeErrorResponse handler(SomeException ex, ServerRequest req, ServerResponse res) {
    ...
  }

}

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

}

Testing error handlers

We can test error handlers by injecting a test double that will invoke the error.

With the test below, we use a test double for the HelloController that simulates a failure when the hello() method is called. This will cause the defaultError(Exception, ServerRequest) method in the ErrorHandlers controller to be invoked.

@InjectTest
class HelloControllerErrorTest {

    @Inject HelloControllerTestAPI api;

    // use test double in place of the real HelloController used by the server
    @Mock HelloController failingController = Mockito.mock(HelloController.class);


    @Test
    void testHelloError_expect500() {
        // simulate failure in the service method
        when(failingController.hello()).thenThrow(new RuntimeException("Simulated failure"));
        try {
            api.hiMaybeError();
            fail("Should have thrown HttpException...");

        } catch (HttpException e) {
            // error response status code 500
            assertThat(e.statusCode()).isEqualTo(500);

            // if we know the error response body type we can convert to that
            ErrorResponse errorResponse = e.bean(ErrorResponse.class);
            assertThat(errorResponse.path()).isEqualTo("/hello");

            // or just get the error body contents
            String errorBodyContent = e.bodyAsString();
            assertThat(errorBodyContent).contains("...");
        }
    }
}

Filters

To add a Filter we can add a controller that has a @Filter method that takes a FilterChain, RoutingRequest and optionally a RoutingResponse.

@Controller
final class MyFilter {

  @Filter
  void filter(FilterChain chain, RoutingRequest req) {
    ...
  }

}

Example filter

The following is 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;
  }

}

Controllers


@Controller
@Path("/contacts")
class ContactController {

  @Get("/{id}")
  Contact getById(long id) {
    ...
  }

  @Post
  void save(Contact contact) {
    ...
  }

  @Delete("/{id}")
  void deleteById(long id) {
    ...
  }
  ...
}

@Controller

Create controllers @Controller. You can provide a path segment that is prepended to any path segments defined by on methods using @Get, @Post, @Put etc. There are three ways to prepend a path.

1. Directly put the path in the controller annotation.

@Controller("/customers")
class CustomerController {
  ...
}

2. Use @Path and @Controller

@Controller
@Path("/customers")
class CustomerController {
  ...
}

3. Use @Path on an Interface and @Controller on an implementing class

@Path("/customers")
interface CustomerController {
  ...
}
@Controller
class CustomerControllerImpl implements CustomerController {
  ...
}

Web Methods on a controller are annotated with HTTP annotations like @Get, @Post, @Put, @Delete.

@Controller("/contacts")
class ContactController {

  private final ContactService contactService;

  @Inject
  ContactController(ContactService contactService) {
    this.contactService = contactService;
  }

  @Get("/{id}")
  Contact getById(long id) {
    ...
  }

  @Get("/find/{type}")
  List<Contact> findByType(String type, @QueryParam String lastName) {
    ...
  }

  @Post
  void save(Contact contact) {
    ...
  }

  ...
}

The controllers can have dependencies injected. The ContactController above can easily have the ContactService dependency injected by avaje-inject.

Controllers are singleton scoped by default

By default controllers are singleton scoped. If the controllers have a dependency on Helidon ServerRequest or ServerResponse then they automatically become request scoped.

@Path


@Path is put on the controller class. The path is prepended to the paths specified by @Get, @Post etc.

@Path("/") is used for the root context path.

Example

The URI's for the RootController below would be:

GET /
GET /foo
@Controller
@Path("/")
class RootController {

  @Get
  @Produces(MediaType.TEXT_PLAIN)
  String hello() {
    return "Hello world";
  }

  @Get("foo")
  @Produces(MediaType.TEXT_PLAIN)
  String helloFoo() {
    return "Hello Foo";
  }

}

The URI's for the CustomerController below are:

GET /customer
GET /customer/active
GET /customer/active/{customerType}
@Controller
@Path("/customer")
class CustomerController {

  @Get
  List<Customer> findAll() {
    ...
  }

  @Get("/active")
  List<Customer> findActive() {
    ...
  }

  @Get("/active/{customerType}")
  List<Customer> findByType(String customerType) {
    ...
  }
}

Module/Package Wide Root Paths

When a @Path annotation is placed on a module-info or package-info file, that path wil be prepended to all controllers contained within the packages and sub-packages.

@Path("/module")
module example.module {
//contents...
}

The URI's for the CustomerController below are:

GET /module/customer
@Controller("/customer")
class CustomerController {

  @Get
  List<Customer> findAll() {
    ...
  }
}

Path parameters


Path parameters start with { and end with }.

For example {id}, {name}, {startDate}.

The path parameter names need to be matched by method parameter names on the controller. For example:

@Get("/{id}/{startDate}/{type}")
List<Bazz> findBazz(long id, LocalDate startDate, String type) {

  // id, startDate, type all match method parameter names
  ...
}

Unlike JAX-RS avaje-http does not need a @PathParam annotation. Making the code less verbose and nicer to read.

Compare and contrast the above with the following JAX-RS equivalent.

// JAX-RS "annotation noise" with @PathParam

@GET
@Path("/{id}/{startDate}/{sort}")
List<Bazz> findBazz(@PathParam("id") long id, @PathParam("startDate") LocalDate startDate, @PathParam("sort") String sort) {

  // we start getting "annotation noise" ...
  // making the code hard to read

}

Matrix parameters


Matrix parameters are optional sub-parameters that relate to a specific segment of the path. They are an alternative to using query parameters when you have optional parameters that relate to a specific path segment.

// 'type' path segment has matrix parameters 'category' and 'vendor'

@Get("/products/{type;category;vendor}/available")
List<Product> products(String type, String category, String vendor) {
  ...
}
// example URI's

GET /products/chair/available
GET /products/chair;category=kitchen/available
GET /products/chair;category=kitchen;vendor=jfk/available
// 'type' has matrix parameters 'category' and 'vendor'
// 'range' has matrix parameter 'style'

@Get("/products/{type;category;vendor}/{range;style}")
List<Product> products(String type, String category, String vendor, String range, String style) {
  ...
}
// example URI's

GET /products/chair/commercial
GET /products/chair;category=kitchen/domestic
GET /products/chair;category=kitchen/commercial;style=contemporary
GET /products/chair/commercial;style=classical

JAX-RS @MatrixParam

Our matrix parameters are equivalent to JAX-RS except they relate by convention to method parameters of the same name and do not need explicit @MatrixParam.

Compare and contrast the above with the following JAX-RS equivalent.

// JAX-RS "annotation noise" with @MatrixParam and @PathParam

@GET
@Path("/products/{type;category;vendor}/{range;style}")
List<Product> products(@PathParam("type") String type, @MatrixParam("category") String category, @MatrixParam("vendor") String vendor, @PathParam("type") String range, @MatrixParam("style") String style) {

  // we start getting "annotation noise" ...
  // making the code hard to read
  ...
}

@QueryParam


Explicitly specify query parameters using @QueryParam.

// Explicit query parameter order-by

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam("order-by") String orderBy) {
  ...
}

Implied query parameters

Query parameters can be implied by not being a path parameter. That is, when a method parameter does not match a path parameter, it is implied to be a query parameter.

The following 3 declarations are exactly the same with all 3 having a query parameters for orderBy

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam("orderBy") String orderBy) {
  ...
}

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam String orderBy) {
  ...
}

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, String orderBy) {  // orderBy implied as query parameter
  ...
}

When the query parameter is not a valid java identifier, the explicit @QueryParam is required.

Example

We must use an explicit @QueryParam when the parameter name includes a hyphen like order-by.

// order-by is not a valid java identifier
// ... so we must use explicit @QueryParam here

@Get
List<Cat> findCats(@QueryParam("order-by") String orderBy) {
  ...
}

Query parameter types

Query parameters can be one of the following types: String, Integer, Long, Short, Float, Double, Boolean, BigDecimal, UUID, LocalDate, LocalTime, LocalDateTime, or Enums(Will use Enum.valueOf(EnumType, parameter) ). To get multivalue parameters, use List<T> or Set<T> where T is any of the previously mentioned types. To get all query parameters define a parameter of type Map<List<T>>.

Query parameters are considered optional / nullable.

@BeanParam


Annotate a bean parameter in a controller method with @BeanParam to map various request values into a class. The properties on the bean default to being query parameters.

This is typically done when there are a set of query parameters/headers/etc that are common across a number of endpoints.

public class CommonParams {

  private Long firstRow;
  private Long maxRows;
  private String sortBy;
  private Set<String> filter;
  //you can use ignore to mark a field as not a request parameter
  @Ignore
  private String ignored;

  //getters/setters or a constructor
}

Annotate the bean with @BeanParam

@Get("search/{type}")
List<Cat> findCats(String type, @BeanParam CommonParams params) {

  ...
}

@Form

@BeanParam and @Form are similar except with @Form beans the properties default to form parameters instead of query parameters.

JAX-RS @BeanParam

Our @BeanParam is virtually the same as JAX-RS @BeanParam except the properties default to being query parameters, whereas with JAX-RS we need to annotate each of the properties. We can do this because we have @Form and "Form beans".

BeanParam beans with @Header, @Cookie properties

The properties on a "bean" default to being query parameters. We put @Header or @Cookie on properties that are instead headers or cookies.

public class CommonParams {

  private Long firstRow;
  private Long maxRows;
  private String sortBy;
  private String filter

  @Header
  private String ifModifiedSince;

  @Cookie
  private String myState;

  //getters/setters or a constructor
}

Request Body


Avaje auto detects that a parameter is a request body if the type is a POJO/byte[]/InputStream and not marked with a @BeanParam annotation. To mark a string parameter as a body, use the @BodyString annotation.

@Post
void save(Customer customer) {
  ...
}

@Form


If a method has both @Post and @Form then the method parameters default to be form parameters.

In the following example name, email and url all default to be form parameters.

@Form @Post("register")
void register(String name, String email, String url) {
  ...
}

@FormParam


For the example above we could alternatively use explicit @FormParam on each of the form parameters rather than @Form. We then get:

@Post("register")
void register(@FormParam String name, @FormParam String email, @FormParam String url) {
  ...
}

The expectation is that we most often would use @Form because it reduces "annotation noise" and that we will very rarely use @FormParam. Potentially we only use @FormParam if the parameter name includes hyphen or similar characters that are not valid Java/Kotlin identifiers.

@Default

Use @Default to specify a default value for form parameters.

@Form @Post("register")
void register(String name, String email, @Default("http://localhost") String url) {
  ...
}

@Form "Form Beans"


When posting a form with a lot of parameters, try defining a bean with properties for each of the form parameters rather than a controller method with lots of arguments.

"Form beans" can have a constructor with arguments. They do not require a no-arg constructor.

Using a form bean can make the code more readable and gives the option to use validation annotations on the "form bean" properties.

public class MyForm {

  @Size(min=2, max=100)
  private String name;
  private String email;
  //getters/setters/constructors
}
@Form
@Post("register")
void register(MyForm myForm) {
  ...
}

"Form beans" are nice with forms with lots of properties because they de-clutter our code and the generated code takes care of putting the values into our bean properties so that we don't have to write that code.

This use of @Form is very similar to JAX-RS @BeanParam except that the bean properties default be being form parameters. With JAX-RS we would put a @FormParam on every property that is a form parameter which becomes a lot of annotation noise on a large form.

Kotlin data class

Kotlin data classes are a natural fit for form beans.

data class SaveForm(var id: Long, var name: String, var someDate: LocalDate?)


@Form @Post
fun saveIt(form: SaveForm) {

  ...
}

If the form bean has Kotlin non-nullable types (id and name above) then the generated code includes a null check when populating the bean (the checkNull() method).

If there is not a value for a non-nullable Kotlin property then a validation error will be thrown at that point (this validation exception is thrown relatively early compared to using bean validation on Java form beans).

Form beans with @QueryParam, @Header, @Cookie properties

The properties on a "form bean" default to being form parameters. We put @QueryParam, @Header or @Cookie on properties that are instead query params, headers or cookies.

public class MyForm {

  @Size(min=2, max=100)
  public String name;
  public String email;
  public String url;

  @QueryParam
  public Boolean overrideFlag;

  @Header
  public String ifModifiedSince;

  @Cookie
  public String myState;
}

@Produces


Use @Produces to modify the response content type, default status code and generated OpenAPI definition. When not specified, it defaults to application/json. If not specified, the default status codes for the different http verbs are as follows:
GET(200)
POST(201)
PUT(200, void methods 204)
PATCH(200, void methods 204)
DELETE(200, void methods 204)

@Path("/")
@Controller
class RootController {

  private Service service;

  //send plain text
  @Get
  @Produces(MediaType.TEXT_PLAIN)
  String hello() {
    return "Hello world";
  }

  // default json
  @Get("obj")
  Example helloObj() {
    return new Example();
  }

  // we can also send our data as a byte array
  @Get("png")
  @Produces(MediaType.IMAGE_PNG)
  byte[] helloByte() {
    return service.getPNG();
  }

}

Use @Header for a header parameter. It the header parameter name is not explicitly specified then it is the init caps snake case of the parameter name.

userAgent -> User-Agent

lastModified -> Last-Modified

@Post
Bar postIt(Foo payload, @Header("User-Agent") String userAgent) { // explicit
  ...
}

@Post
Bar postIt(Foo payload, @Header String userAgent) { // User-Agent
  ...
}

@Get
Bazz find(@Header String lastModified) { // Last-Modified
  ...
}

Use @Cookie for a Cookie parameter.

@Post("bar/{name}")
Bar bar(String name, @Cookie("my-cookie") String myCookie) {
  ...
}

@Post("foo/{name}")
Foo foo(String name, @Cookie String myCookie) {
  ...
}

The generated Helidon code for the method above is:

private void _foo(ServerRequest req, ServerResponse res) {
  String name = req.path().param("name");
  String myCookie = req.headers().cookies().first("myCookie").orElse(null);
  res.send(controller.fooMe(name, myCookie));
}

@Default

Use @Default to specify a default value for a Query Parameter/Header/Cookie/Form Parameter.

@Get("/catty")
List<Cat> findCats(@Header @Default("age") String orderBy, @Default({"1", "2"}) List<Integer> numbersOfLimbs) {
  ...
}

ServerRequest ServerResponse

Helidon ServerRequest and ServerResponse can be used in controller methods to access request details and manipulate the response.

They can either be method parameters or injected.

Method parameters

@Get
Response save(HelloDto dto, ServerRequest request, ServerResponse response) {
  // use Helidon server request or response as desired
  ...
  return new Response();
}
@Post
void save(HelloDto dto, ServerRequest request, ServerResponse response) {
  // use Helidon server request or response as desired
  ...
}

Injecting ServerRequest and ServerResponse

When ServerRequest and/or ServerRequest are injected into the controller, we say that this controller is request scoped, meaning a new instance of the controller is created for each request.

In this example ProductController has the Helidon ServerRequest and ServerResponse injected using field injection rather than constructor injection. Note that when using field injection they can not be final and can not be private.

@Controller
@Path("/products")
class ProductController {

  private final MyService myService;

  @Inject
  ServerRequest request; // Helidon request field injected

  @Inject
  ServerRequest response; // Helidon response field injected

  @Inject
  ProductController(MyService myService) {
    this.myService = myService;
  }

  @Get("/{id}")
  Contact getById(long id) {
    // use the helidon request ...
    var fooCookie = request.headers().cookies().first("foo");
    ...
  }
}

GraalVM native image

We can build GraalVM native images with Avaje Nima and the associated dependencies including Helidon SE webserver.

Have a look at Why GraalVM native image and Comparing GraalVM with JVM.

native-maven-plugin

Add the native-maven-plugin to the maven build / plugins to build the native executable.

The configuration below uses G1 garbage collector with max pause of 50ms and max heap size of 400mb. It also emits a build report and uses static nolibc linking.

<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>org.example.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>

jib-maven-plugin

We can use the jib-maven-plugin to build a docker image that includes the native executable built by the native-maven-plugin.

The configuration below uses the UBI micro base image with glibc (redhat/ubi10-micro:10.1-1762215812) and exposes port 8080.

<plugin> <!-- build docker image for native executable -->
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>3.5.0</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>my-service</imageName>
        </properties>
      </pluginExtension>
    </pluginExtensions>
    <container>
      <mainClass>org.example.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>
example: combined native maven profile

A complete maven profile that includes both plugins is as follows:

With mvn package -Pnative, the native executable and docker image will be built.

<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>org.example.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>my-service</imageName>
              </properties>
            </pluginExtension>
          </pluginExtensions>
          <container>
            <mainClass>org.example.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>

Platform specific maven profile

MacOS does not support G1 garbage collector in native images, so often we need to create a platform specific profile for MacOS builds that omits the G1 related build args.

example: platform specific maven profile
<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>