Avaje Nima
| Discord | Source | API Docs | Issues | Releases |
|---|---|---|---|---|
| Discord | GitHub | Javadoc | GitHub |
avaje-nima is a combination of Helidon SE Webserver and Avaje libraries, including:
- 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
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:
- 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>
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:
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
Testing Controllers
avaje-nima-test provides support for testing controllers which includes:
- Generating test clients for each controller
- Injecting Http Clients and Webserver into tests
- Starting webserver on a random port for any test with an injected Http Client
Test Injection
avaje-nima-test can inject dependencies into a test including
Http Clients and the Helidon Webserver.
io.avaje.http.client.HttpClientio.avaje.http.client.HttpClient.Builder- A type annotated with
io.avaje.http.api.@Clientorio.avaje.http.api.@Path
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:
- Takes an Exception type
- Optionally take a
ServerRequest - Optionally take a
ServerResponse
In terms of return type / error response body, the handler method can either:
- Return void, meaning the response should be explicitly set to the ServerResponse
- Return String, where the response is a
text/plain - Return another type which is deemed a
@JsonBean
@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();
}
}
@Header
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
...
}
@Cookie
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>