Library that generates extremely fast adapter code
for Javalin and Helidon SE/Nima APIs via Annotation Processing.
This library enables your service to be fast and light at runtime by using source code generation
(java annotation processors) to adapt annotated rest controllers with (@Path, @Get, @Post etc
)
to Javalin, Helidon SE
and similar web routing http servers.
Effectively we are replacing Jersey or RestEasy with source code generation and the capabilities
of Javalin or Helidon SE
(web routing).
The generated source code is very simple and readable, so developers can navigate to it and add break points and debug just as if we wrote it all
manually ourselves.
What we lose in doing this is automatic
Content negotiation. For example, if
we need endpoints that serve response content as either JSON or XML content based on request headers
then we would need to handle this ourselves.
Summary
- Provides a similar programming style to JAX-RS and Spring MVC
- Light weight by using code generation – no reflection, no extra overhead
- Automatically generates Swagger/OpenAPI documentation
- Allows use of underlying Javalin/Helidon request/response constructs as needed
- Supports request scope injection of Javalin Context, Helidon request and response
- Supports using Bean validation on request payloads
- Requires Fewer annotations than typical JAX-RS – avoid annotation overload
JAX-RS Annotations
As we are using Java annotation processing our generators are exposed to more information
than is obtained via reflection at runtime. This means we can reduce annotation verbosity
making nicer cleaner API’s.
A design decision has been to not use JAX-RS annotations. This is because
the JAX-RS annotations are a lot more verbose than we desire and because they are not provided
as a nice clean separate dependency. The JAX-RS API has a lot of extra weight that we do not need.
HTTP Client
Avaje http client is a lightweight wrapper of JDK HttpClient
that also supports Client API with annotation processing to generate source code that implements
the API.
Quick Start
1. Add avaje-http-api dependency.
io.avaje avaje-http-api ${avaje-http.version}
2. Add the generator module for your desired microframework as a annotation processor.
io.avaje avaje-http-{helidon/javalin}-generator ${avaje-http.version} provided true
2a. JDK 22+
In JDK 22+, annotation processors are disabled by default, so we need to add a flag to re-enable.
org.apache.maven.plugins maven-compiler-plugin -proc:full
3. Define a Controller (These APT processors work with both Java and Kotlin.)
package org.example.hello; import io.avaje.http.api.Controller; import io.avaje.http.api.Get; import io.avaje.http.api.Path; import java.util.List; @Path("/widgets") @Controller public class WidgetController { private final HelloComponent hello; public WidgetController(HelloComponent hello) { this.hello = hello; } @Get("/{id}") Widget getById(int id) { return new Widget(id, "you got it"+ hello.hello()); } @Get() List<Widget> getAll() { return List.of(new Widget(1, "Rob"), new Widget(2, "Fi")); } record Widget(int id, String name){}; }
Java Module Setup
In the module-info.java
we need to define the avaje modules:
Example module-info
module org.example { requires io.avaje.http.api; // if using javalin specific actions like @Before/@After //requires static io.avaje.http.api.javalin; }
Generated Adapter
Given the above controller and the corresponding framework generator, the below class will be generated
Helidon 4.x
@Generated("avaje-helidon-generator") @Singleton public class WidgetController$Route implements HttpFeature { private final WidgetController controller; public WidgetController$Route(WidgetController controller) { this.controller = controller; } @Override public void setup(HttpRouting.Builder routing) { routing.get("/widgets/{id}", this::_getById); routing.get("/widgets", this::_getAll); } private void _getById(ServerRequest req, ServerResponse res) throws Exception { res.status(OK_200); var pathParams = req.path().pathParameters(); var id = asInt(pathParams.first("id").get()); var result = controller.getById(id); res.send(result); } private void _getAll(ServerRequest req, ServerResponse res) throws Exception { res.status(OK_200); var result = controller.getAll(); res.send(result); } }
Javalin
@Generated("avaje-javalin-generator") @Singleton public class WidgetController$Route implements Plugin { private final WidgetController controller; public WidgetController$Route(WidgetController controller) { this.controller = controller; } @Override public void apply(Javalin app) { app.get("/widgets/{id}", ctx -> { ctx.status(200); var id = asInt(ctx.pathParam("id")); var result = controller.getById(id); ctx.json(result); }); app.get("/widgets", ctx -> { ctx.status(200); var result = controller.getAll(); ctx.json(result); }); } }
Helidon 4.x (Avaje-Jsonb on classpath)
@Generated("avaje-helidon-generator") @Singleton public class WidgetController$Route implements HttpFeature { private final WidgetController controller; private final JsonType<WidgetController.Widget> widgetController$WidgetJsonType; private final JsonType<List<WidgetController.Widget>> listWidgetController$WidgetJsonType; public WidgetController$Route(WidgetController controller, Jsonb jsonb) { this.controller = controller; this.widgetController$WidgetJsonType = jsonb.type(WidgetController.Widget.class); this.listWidgetController$WidgetJsonType = jsonb.type(WidgetController.Widget.class).list(); } @Override public void setup(HttpRouting.Builder routing) { routing.get("/widgets/{id}", this::_getById); routing.get("/widgets", this::_getAll); } private void _getById(ServerRequest req, ServerResponse res) throws Exception { res.status(OK_200); var pathParams = req.path().pathParameters(); var id = asInt(pathParams.first("id").get()); var result = controller.getById(id); res.headers().contentType(MediaTypes.APPLICATION_JSON); //jsonb has a special accommodation for helidon to improve performance widgetController$WidgetJsonType.toJson(result, JsonOutput.of(res)); } private void _getAll(ServerRequest req, ServerResponse res) throws Exception { res.status(OK_200); var result = controller.getAll(); res.headers().contentType(MediaTypes.APPLICATION_JSON); listWidgetController$WidgetJsonType.toJson(result, JsonOutput.of(res)); } }
Javalin (Avaje-Jsonb on classpath)
@Generated("avaje-javalin-generator") @Singleton public class WidgetController$Route implements Plugin { private final WidgetController controller; private final JsonType<List<Widget>> listWidgetJsonType; private final JsonType<Widget> widgetJsonType; public WidgetController$Route(WidgetController controller, Jsonb jsonB) { this.controller = controller; this.listWidgetJsonType = jsonB.type(Widget.class).list(); this.widgetJsonType = jsonB.type(Widget.class); } @Override public void apply(Javalin app) { app.get("/widgets/{id}", ctx -> { ctx.status(200); var id = asInt(ctx.pathParam("id")); var result = controller.getById(id); widgetJsonType.toJson(result, ctx.contentType("application/json").outputStream()); }); app.get("/widgets", ctx -> { ctx.status(200); var result = controller.getAll(); listWidgetJsonType.toJson(result, ctx.contentType("application/json").outputStream()); }); } }
Usage
The natural way to use the generated adapters is to
get a DI library to find and wire them.
Note that there isn’t a requirement to use Avaje for dependency injection.
Any DI library that can find and wire the generated @Singleton beans can
be used.
Usage with Javalin
The annotation processor will generate controller classes implementing the javalin Plugin
interface, which means we can register them using:
List<Plugin> routes = ...; //retrieve using a DI framework Javalin.create(cfg -> routes.forEach(cfg.plugins::register)).start();
Usage with Helidon SE (4.x)
The annotation processor will generate controller classes implementing the Helidon HttpFeature
interface, which we can register with the Helidon HttpRouting
.
List<HttpFeature> routes = ... //retrieve using a DI framework final var builder = HttpRouting.builder(); routes.forEach(builder::addFeature); WebServer.builder() .addRouting(builder) .build() .start();
Dependencies
Maven
See the quick start example
Gradle
See the example at:
examples/javalin-gradle-java-basic/build.gradle
Use Gradle version 5.2 or greater which has better support for annotation processing.
Also review the IntelliJ IDEA Gradle settings – see below.
Optional: OpenAPI plugin
Optionally add the io.avaje.openapi
plugin to have the openapi.json
(swagger) to be generated into src/main/resources/public.
plugins { ... id('io.avaje.openapi') version('1.2') }
Dependencies
Add avaje-inject and avaje-http-api as compile dependencies.
Add avaje-http-javalin-generator and javalin-generator as annotation processors.
dependencies { ... compile('io.avaje:avaje-inject:8.10') compile('io.avaje:avaje-http-api:1.20') annotationProcessor('io.avaje:avaje-inject-generator:8.10') annotationProcessor('io.avaje:avaje-http-javalin-generator:1.20') }
Kotlin KAPT
For use with Kotlin we change the annotationProcessor to be kapt
for the Kotlin compiler.
dependencies { ... kapt('io.avaje:avaje-inject-generator:8.10') kapt('io.avaje:avaje-http-javalin-generator:1.20') }
OpenAPI Plugin configuration
We can change the location of the generated openapi file by adding an openapi
configuration
section in build.gradle.
openapi { destination = 'other/my-api.json' }
IntelliJ IDEA with Gradle
We want to delegate the build
to Gradle (to properly include the annotation processing)
so check our IDEA settings.
Settings / Build / Compiler / Annotation processors
Ensure that Enable annotation processing
is disabled so
that the build is delegated to Gradle (including the annotation processing):
Settings / Build / Build tools / Gradle
Make sure Build and run
is delegated to Gradle.
Optionally set Run tests using to Gradle
but leaving it to IntelliJ IDEA should be ok.
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 Javalin context, 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("http://avaje.io/")
is used for the root context path.
Example
The URI’s for the RootController below would be:
@Controller @Path("http://avaje.io/") 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:
@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 ... }
This means that unlike JAX-RS we do not need a @PathParam
annotation
and this makes our code less verbose and nicer to read.
Note that the JAX-RS equivalent to the above is below. The method declaration starts
to get long and harder to read quite quickly.
// 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 effectively an alternative to using query parameters where we 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 that they relate by convention to
method parameters of the same name and we do not need explicit @MatrixParam
.
The JAX-RS equivalent to the above is below. The method declaration starts
to get long and harder to read quite quickly.
// 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
We 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, if a method parameter does not match a path parameter then 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 ... }
Note that we must explicitly use @QueryParam
when the query parameter
is not a valid java identifier. For example, if the query parameter includes a hyphen
then we must use @QueryParam
explicitly.
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 we can 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
We can create a bean and annotate it in a controller method with @BeanParam
.
The properties on the bean default to being query parameters
.
We typically do this when we have a set of query parameters/headers that are
common / shared 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 }
We annotate the bean with @BeanParam
@Get("search/{type}") List<Cat> findCats(String type, @BeanParam CommonParams params) { ... }
The generated Javalin code for the above is:
ApiBuilder.get("/cats/search/{type}", ctx -> { ctx.status(200); String type = ctx.pathParam("type"); CommonParams params = new CommonParams(); params.setFirstRow(toLong(ctx.queryParam("firstRow"))); params.setMaxRows(toLong(ctx.queryParam("maxRows"))); params.setSortBy(ctx.queryParam("sortBy")); params.setfilter(list(Objects::toString, ctx.queryParams("filter"))); ctx.json(controller.findCats(type, params)); });
@Form
@BeanParam and @Form are very 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) { ... }
Generated for Javalin
The code generated code for Javalin for save() above is:
ApiBuilder.post("/customers", ctx -> { ctx.status(201); Customer customer = ctx.bodyStreamAsClass(Customer.class); controller.save(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.
Generated for Javalin
The generated Javalin code for both cases above is the same:
ApiBuilder.post("/customers/register", ctx -> { ctx.status(201); String name = ctx.formParam("name"); String email = ctx.formParam("email"); String url = ctx.formParam("url"); controller.register(name, email, url); });
@Default
We can 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”
In the case where we are posting a form with a lot of parameters we can define a bean with
properties for each of the form parameters rather than have a 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 nicer and gives us a nicer 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) { ... }
The generated Javalin code for the above is.
ApiBuilder.post("/contacts/register", ctx -> { ctx.status(201); MyForm myForm = new MyForm(ctx.formParam("name"), ctx.formParam("name"), ctx.formParam("email")); controller.register(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) { ... }
The generated code for the above controller method is:
ApiBuilder.post("http://avaje.io/", ctx -> { ctx.status(201); SaveForm form = new SaveForm( asLong(checkNull(ctx.formParam("id"), "id")), // non-nullable type checkNull(ctx.formParam("name"), "name"), // non-nullable type toLocalDate(ctx.formParam("someDate")) ); controller.saveIt(form); });
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; }
The generated code populates from query params, headers and cookies. The generated code is:
ApiBuilder.post("/contacts/register", ctx -> { ctx.status(201); MyForm myForm = new MyForm(); myForm.name = ctx.formParam("name"); myForm.email = ctx.formParam("email"); myForm.url = ctx.formParam("url"); myForm.overrideFlag = toBoolean(ctx.queryParam("overrideFlag")); // queryParam !! myForm.ifModifiedSince = ctx.header("If-Modified-Since"); // header !! myForm.myState = ctx.cookie("myState"); // cookie !! controller.register(myForm); });
@Produces
Use @Produces
to modify the response content type and generated OpenAPI definition.
When not specified, we default to application/json
. We can set the default http status code for the method as well.
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("http://avaje.io/") @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 Javalin Context for our response // in this case Produces is only needed for the OpenAPI generation @Get("ctx") @Produces(MediaType.IMAGE_PNG) void helloCTX(Context ctx) { service.writeResponseDirectly(ctx.outputStream()); } }
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
We can 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) { ... }
@Filter
Annotate methods with @Filter
for HTTP filter web routes. Filter web routes behave similarly to void @Get
methods (They can use header/query/cookie parameters with type conversion)
Helidon
Helidon filters must have a FilterChain
parameter, and optionally can add RoutingRequest
and RoutingResponse
.
@Filter void filter(FilterChain chain, RoutingRequest req, RoutingResponse res) { //... filter logic }
Javalin
Javalin filters correspond to before
handlers, and we can add a Context
parameter.
@Filter void filter(Context ctx) { //... filter logic }
@ExceptionHandler
As the name implies, this annotation marks a handler method for handling exceptions that are thrown by other handlers.
Exception handler methods may have parameters of the following types:
- An exception argument: declared as a general Exception or as a more specific exception.
This also serves as a mapping hint if the annotation itself does not specify the exception
types. - Request and/or response objects (typically from the microframework). We can choose any
specific request/response type. e.g. Javalin’sContext
or Helidon’s
ServerRequest
/ServerResponse
.
Handler methods may be void or return an object for serialization. When returning an object, we can combine the @ExceptionHandler
annotation with @Produces
for a
specific HTTP error status and media type.
Helidon
@E
!-->