GeezerVison is showing jooby.io — jooby: do more! more easily!!
Jooby
∞ do more, more easily
Jooby is a modular, high-performance web framework for Java and Kotlin. Designed for simplicity and speed, it gives you the freedom to build on your favorite server with a clean, modern API.
Welcome!
import io.jooby.Jooby;
public class App extends Jooby {
{
get("/", ctx -> "Welcome to Jooby!");
}
public static void main(String[] args) {
runApp(args, App::new);
}
}
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args) {
get("/") { "Welcome to Jooby!" }
}
}
Features
-
Lightweight and Fast: See the TechEmpower Benchmark.
-
High Productivity: Built-in hot-reload for development.
-
Flexible Routing: Use the fluent Script/Lambda API or MVC annotations (Jooby or JAX-RS).
-
Modern Tooling: Full OpenAPI 3 support.
-
Execution Options: Choose between Event Loop or worker threads.
-
Reactive Ready: Support for reactive responses (CompletableFuture, RxJava, Reactor, Mutiny, and Kotlin Coroutines).
-
Server Choice: Run on Jetty, Netty, Vert.x, or Undertow.
-
AI Ready: Seamlessly expose your application’s data and functions to Large Language Models (LLMs) using the first-class Model Context Protocol (MCP) module.
-
Deep Observability: Native, vendor-neutral distributed tracing, server metrics, and log correlation via OpenTelemetry module.
-
Extensible: Scale to a full-stack framework using extensions and modules.
|
Tip
|
Looking for a previous version?
|
The Script API (also known as script routes) provides a fluent, reflection-free DSL based on lambda functions.
We typically extend Jooby and define routes in the instance initializer:
Extending Jooby (Recommended for Java):
import io.jooby.Jooby;
public class App extends Jooby {
{
get("/", ctx -> "Hello Jooby!");
}
public static void main(String[] args) {
runApp(args, App::new);
}
}
import io.jooby.kt.Kooby
import io.jooby.kt.runApp
class App : Kooby({
get("/") { "Hello Jooby!" }
})
fun main(args: Array<String>) {
runApp(args, ::App)
}
For Java applications, we favor extending Jooby to keep the DSL clean (avoiding the need to prefix methods like get with a variable name). However, you can also define routes without subclassing:
Without extending Jooby:
import io.jooby.Jooby;
public class App {
public static void main(String[] args) {
Jooby.runApp(args, app -> {
app.get("/", ctx -> "Hello Jooby!");
});
}
}
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args) {
get("/") { "Hello Jooby!" }
}
}
(Note: For Kotlin, the DSL remains clean whether you extend Kooby or not).
The MVC API uses annotations to define routes and bytecode generation to execute them quickly.
MVC Routing:
import io.jooby.annotation.*;
public class MyController {
@GET
public String sayHi() {
return "Hello Jooby!";
}
}
public class App {
public static void main(String[] args) {
Jooby.runApp(args, app -> {
app.use(new MyController_());
});
}
}
import io.jooby.annotation.*
class MyController {
@GET
fun sayHi(): String {
return "Hello Jooby!"
}
}
fun main(args: Array<String>) {
runApp(args) {
use(MyController_())
}
}
Read more about MVC and JAX-RS support in the MVC API chapter.
The fastest way to start building is with the jooby console. This lightweight CLI generates configured Jooby projects instantly.
-
Scaffolds Maven or Gradle builds.
-
Generates Java or Kotlin applications.
-
Configures Script or MVC routes.
-
Selects Jetty, Netty, or Undertow as the underlying server.
-
Prepares Uber/Fat jars or Stork native launchers.
-
Generates a ready-to-use Dockerfile.
-
Download jooby-cli.zip.
-
Unzip the file into a preferred directory.
-
Locate the native launchers in the bin folder.
|
Tip
|
Add the launcher (bin/jooby on Linux/macOS, or bin/jooby.bat on Windows) to your system’s PATH to run it from any directory.
|
|
Note
|
The jooby CLI requires Java 17 or higher. Windows users should use jooby.bat.
|
First, set your workspace directory where new projects will be saved:
Next, open the console by typing jooby and pressing ENTER.
To see all available options, type help create:
jooby> help create
Usage: jooby create [-dgikms] [--server=<server>] <name>
Creates a new application
<name> Application name or coordinates (groupId:artifactId:version)
-d, --docker Generates a Dockerfile
-g, --gradle Generates a Gradle project
-i Start interactive mode
-k, --kotlin Generates a Kotlin application
-m, --mvc Generates an MVC application
-s, --stork Add Stork Maven plugin to build (Maven only)
--server=<server> Choose a server: jetty, netty, or undertow
Create a default Maven Java project:
Create a Kotlin project:
jooby> create myapp --kotlin
|
Note
|
Kotlin Dependency
Since version 3.x, Kotlin is no longer included in the core. The CLI will automatically add the required dependency:
|
Create a Gradle project (Java or Kotlin):
jooby> create myapp --gradle
jooby> create myapp --gradle --kotlin
Create an MVC-based project:
jooby> create myapp --mvc
Specify the server (default is Netty):
jooby> create myapp --server undertow
Include Dockerfile and Stork launcher configurations:
jooby> create myapp --docker --stork
For full control over the groupId, package structure, and versioning, use interactive mode:
Throughout this documentation, we prioritize brevity. Unless strictly necessary, code examples will omit the main method and class definitions.
When you see a snippet like this:
Snippet
{
get("/", ctx -> "Snippet");
}
{
get("/") { "Snippet" }
}
Assume it is taking place inside the Jooby initializer block or the runApp function.
The heart of the Jooby development experience. This section defines the Request-Response Pipeline, covering everything from expressive routing and path patterns to managing the Context and crafting fluid responses. It is the essential guide to building the logic that powers your web applications.
The Router is the heart of Jooby and consists of:
A Route consists of three parts:
Routes:
{
// (1) (2)
get("/foo", ctx -> {
return "foo"; // (3)
});
// Get example with path variable
get("/foo/{id}", ctx -> {
return ctx.path("id").value();
});
// Post example
post("/", ctx -> {
return ctx.body().value();
});
}
{
// (1) (2)
get("/foo") {
"foo" // (3)
}
// Get example with path variable
get("/foo/{id}") {
ctx.path("id").value()
}
// Post example
post("/") {
ctx.body().value()
}
}
-
HTTP method/verb (e.g., GET, POST)
-
Path pattern (e.g., /foo, /foo/{id})
-
Handler function
The handler function always produces a result, which is sent back to the client.
Attributes allow you to annotate a route at application bootstrap time. They function as static metadata available at runtime:
{
get("/foo", ctx -> "Foo")
.setAttribute("foo", "bar");
}
{
get("/foo") {
"Foo"
}.setAttribute("foo", "bar")
}
An attribute consists of a name and a value. Values can be any object. Attributes can be accessed during the request/response cycle. For example, a security module might check for a role attribute.
{
use(next -> ctx -> {
User user = ...;
String role = ctx.getRoute().getAttribute("Role");
if (user.hasRole(role)) {
return next.apply(ctx);
}
throw new StatusCodeException(StatusCode.FORBIDDEN);
});
}
{
use {
val user = ...
val role = ctx.route.getAttribute("Role")
if (user.hasRole(role)) {
next.apply(ctx)
} else {
throw StatusCodeException(StatusCode.FORBIDDEN)
}
}
}
In MVC routes, you can set attributes via annotations:
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Role {
String value();
}
@Path("/path")
public class AdminResource {
@Role("admin")
public Object doSomething() {
...
}
}
{
use(next -> ctx -> {
System.out.println(ctx.getRoute().getAttribute("Role"));
});
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Role (val value: String)
@Path("/path")
class AdminResource {
@Role("admin")
fun doSomething() : Any {
...
}
}
{
use {
println(ctx.route.getAttribute("Role"))
}
}
The previous example prints admin. You can retrieve all attributes of a route by calling ctx.getRoute().getAttributes().
Any runtime annotation is automatically added as a route attribute following these rules:
-
If the annotation has a value method, the annotation’s name becomes the attribute name.
-
Otherwise, the method name is used as the attribute name.
{
get("/foo", ctx -> "Foo");
}
{
get("/foo") { "Foo" }
}
Single path variable:
{
// (1)
get("/user/{id}", ctx -> {
int id = ctx.path("id").intValue(); // (2)
return id;
});
}
{
// (1)
get("/user/{id}") {
val id = ctx.path("id").intValue() // (2)
id
}
}
-
Defines a path variable id.
-
Retrieves the variable id as an int.
Multiple path variables:
{
// (1)
get("/file/{file}.{ext}", ctx -> {
String filename = ctx.path("file").value(); // (2)
String ext = ctx.path("ext").value(); // (3)
return filename + "." + ext;
});
}
{
// (1)
get("/file/{file}.{ext}") {
val filename = ctx.path("file").value() // (2)
val ext = ctx.path("ext").value() // (3)
filename + "." + ext
}
}
-
Defines two path variables: file and ext.
-
Retrieves the string variable file.
-
Retrieves the string variable ext.
Optional path variable:
{
// (1)
get("/profile/{id}?", ctx -> {
String id = ctx.path("id").value("self"); // (2)
return id;
});
}
{
// (1)
get("/profile/{id}?") {
val id = ctx.path("id").value("self") // (2)
id
}
}
-
Defines an optional path variable id. The trailing ? makes it optional.
-
Retrieves the variable id as a String if present, or uses the default value: self.
The trailing ? makes the path variable optional. The route matches both:
-
/profile
-
/profile/eespina
Regex path variable:
{
// (1)
get("/user/{id:[0-9]+}", ctx -> {
int id = ctx.path("id").intValue(); // (2)
return id;
});
}
{
// (1)
get("/user/{id:[0-9]+}") {
val id = ctx.path("id").intValue() // (2)
id
}
}`
-
Defines a path variable id. The regex expression is everything after the first : (e.g., [0-9]+).
-
Retrieves the int value.
The optional syntax is also supported for regex path variables (e.g., /user/{id:[0-9]+}?). This matches:
Catchall:
{
// (1)
get("/articles/*", ctx -> {
String catchall = ctx.path("*").value(); // (2)
return catchall;
});
get("/articles/*path", ctx -> {
String path = ctx.path("path").value(); // (3)
return path;
});
}
{
// (1)
get("/articles/*") {
val catchall = ctx.path("*").value() // (2)
catchall
}
get("/articles/*path") {
val path = ctx.path("path").value() // (3)
path
}
}
-
The trailing * defines a catchall pattern.
-
We access the catchall value using the * character.
-
In this example, we named the catchall pattern and access it using the path variable name.
|
Note
|
A catchall pattern must be defined at the end of the path pattern.
|
Application logic belongs inside a handler. A handler is a function that accepts a context object and produces a result.
A context allows you to interact with the HTTP request and manipulate the HTTP response.
|
Note
|
An incoming request matches exactly ONE route handler. If no handler matches, it produces a 404 response.
|
{
get("/user/{id}", ctx -> ctx.path("id").value()); // (1)
get("/user/me", ctx -> "my profile"); // (2)
get("/users", ctx -> "users"); // (3)
get("/users", ctx -> "new users"); // (4)
}
{
get("/user/{id}") { ctx.path("id").value() } // (1)
get("/user/me") { "my profile" } // (2)
get("/users") { "users" } // (3)
get("/users") { "new users" } // (4)
}
-
GET /user/ppicapiedra ⇒ ppicapiedra
-
GET /user/me ⇒ my profile
-
Unreachable ⇒ overridden by the next route.
-
GET /users ⇒ new users (not users).
Routes with a more specific path pattern (2 vs 1) have higher precedence. Also, if multiple routes share the same path pattern (like 3 and 4), the last registered route overrides the previous ones.
Cross-cutting concerns such as response modification, verification, security, and tracing are available via Route.Filter.
A filter takes the next handler in the pipeline and produces a new handler:
interface Filter {
Handler apply(Handler next);
}
Timing filter example:
{
use(next -> ctx -> {
long start = System.currentTimeMillis(); // (1)
Object response = next.apply(ctx); // (2)
long end = System.currentTimeMillis();
long took = end - start;
System.out.println("Took: " + took + "ms"); // (3)
return response; // (4)
});
get("/", ctx -> {
return "filter";
});
}
{
/** Kotlin uses implicit variables: `ctx` and `next` */
use {
val start = System.currentTimeMillis() // (1)
val response = next.apply(ctx) // (2)
val end = System.currentTimeMillis()
val took = end - start
println("Took: " + took + "ms") // (3)
response // (4)
}
get("/") {
"filter"
}
}
-
Saves the start time.
-
Proceeds with execution (the pipeline).
-
Computes and prints latency.
-
Returns a response.
|
Note
|
One or more filters applied on top of a handler produce a new handler.
|
The before filter runs before a handler.
A before filter takes a context as an argument and does not produce a response. It is expected to operate via side effects (usually modifying the HTTP request/response).
interface Before {
void apply(Context ctx);
}
Example
{
before(ctx -> {
ctx.setResponseHeader("Server", "Jooby");
});
get("/", ctx -> {
return "...";
});
}
{
before {
ctx.setResponseHeader("Server", "Jooby")
}
get("/") {
"..."
}
}
The after filter runs after a handler.
An after filter takes three arguments. The first is the HTTP context, the second is the result from a functional handler (or null for a side-effect handler), and the third is any exception generated by the handler.
It is expected to operate via side effects, usually modifying the HTTP response (if possible) or cleaning up/tracing execution.
interface After {
void apply(Context ctx, Object result, Throwable failure);
}
Functional Handler:
{
after((ctx, result, failure) -> {
System.out.println(result); // (1)
ctx.setResponseHeader("foo", "bar"); // (2)
});
get("/", ctx -> {
return "Jooby";
});
}
{
after {
println("Hello $result") // (1)
ctx.setResponseHeader("foo", "bar") // (2)
}
get("/") {
"Jooby"
}
}
-
Prints Jooby.
-
Adds a response header (modifies the HTTP response).
If the target handler is a functional handler, modifying the HTTP response is allowed.
For side-effect handlers, the after filter is invoked with a null result and is not allowed to modify the HTTP response.
Side-Effect Handler:
{
after((ctx, result, failure) -> {
System.out.println(result); // (1)
ctx.setResponseHeader("foo", "bar"); // (2)
});
get("/", ctx -> {
return ctx.send("Jooby");
});
}
{
after {
println("Hello $result") // (1)
ctx.setResponseHeader("foo", "bar") // (2)
}
get("/") {
ctx.send("Jooby")
}
}
-
Prints null (no value).
-
Produces an error/exception.
An exception occurs here because the response was already started, and it is impossible to alter it. Side-effect handlers are those that use the send methods, responseOutputStream, or responseWriter.
You can check whether you can modify the response by checking the state of isResponseStarted():
Safe After:
{
after((ctx, result, failure) -> {
if (ctx.isResponseStarted()) {
// Do not modify response
} else {
// Safe to modify response
}
});
}
{
after {
if (ctx.isResponseStarted) {
// Do not modify response
} else {
// Safe to modify response
}
}
}
|
Note
|
An after handler is always invoked.
|
The next examples demonstrate some use cases for dealing with errored responses. Keep in mind that an after handler is not a mechanism for handling and reporting exceptions; that is the task of an Error Handler.
Run code depending on success or failure responses:
{
after((ctx, result, failure) -> {
if (failure == null) {
db.commit(); // (1)
} else {
db.rollback(); // (2)
}
});
}
{
after {
if (failure == null) {
db.commit() // (1)
} else {
db.rollback() // (2)
}
}
}
Here, the exception is still propagated, giving the Error Handler a chance to jump in.
Recover from an exception and produce an alternative output:
{
after((ctx, result, failure) -> {
if (failure instanceof MyBusinessException) {
ctx.send("Recovering from something"); // (1)
}
});
}
{
after {
if (failure is MyBusinessException) {
ctx.send("Recovering from something") // (1)
}
}
}
-
Recovers and produces an alternative output.
Here, the exception won’t be propagated because we produce a response, meaning the error handler will not execute.
If the after handler produces a new exception, that exception will be added to the original exception as a suppressed exception.
Suppressed exceptions:
{
after((ctx, result, failure) -> {
// ...
throw new AnotherException();
});
get("/", ctx -> {
// ...
throw new OriginalException();
});
error((ctx, failure, code) -> {
Throwable originalException = failure; // (1)
Throwable anotherException = failure.getSuppressed()[0]; // (2)
});
}
{
after {
// ...
throw AnotherException()
}
get("/") { ctx ->
// ...
throw OriginalException()
}
error { ctx, failure, code ->
val originalException = failure // (1)
val anotherException = failure.suppressed[0] // (2)
}
}
-
Will be OriginalException.
-
Will be AnotherException.
The complete listener runs at the completion of a request/response cycle (i.e., when the request has been completely read and the response fully written).
At this point, it is too late to modify the exchange. They are attached to a running context (unlike before/after filters).
Example
{
use(next -> ctx -> {
long start = System.currentTimeMillis();
ctx.onComplete(context -> { // (1)
long end = System.currentTimeMillis(); // (2)
System.out.println("Took: " + (end - start));
});
return next.apply(ctx);
});
}
{
use {
val start = System.currentTimeMillis()
ctx.onComplete { // (1)
val end = System.currentTimeMillis() // (2)
println("Took: " + (end - start))
}
next.apply(ctx)
}
}
-
Attaches a completion listener.
-
Runs after the response has been fully written.
Completion listeners are invoked in reverse order.
The route pipeline (a.k.a. route stack) is a composition of one or more use statements tied to a single handler:
{
// Increment +1
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
// Increment +1
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
get("/1", ctx -> 1); // (1)
get("/2", ctx -> 2); // (2)
}
{
// Increment +1
use {
val n = next.apply(ctx) as Int
1 + n
}
// Increment +1
use {
val n = next.apply(ctx) as Int
1 + n
}
get("/1") { 1 } // (1)
get("/2") { 2 } // (2)
}
Behind the scenes, Jooby builds something like this:
{
// Increment +1
var increment = use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
Handler one = ctx -> 1;
Handler two = ctx -> 2;
Handler handler1 = increment.then(increment).then(one);
Handler handler2 = increment.then(increment).then(two);
get("/1", handler1);
get("/2", handler2);
}
Any filter defined on top of the handler will be chained into a new handler.
|
Note
|
Filter without path pattern
This was a hard decision, but it is the right one. Jooby 1.x used a path pattern to define a filter.
In Jooby 1.x, the pipeline consisted of multiple filters and handlers matched sequentially. The following filter was always executed:
Jooby 1.x
{
use("/*", (req, rsp, chain) -> {
// remote call, db call
});
// ...
}
If a bot tried to access missing pages (causing `404`s), Jooby 1.x executed the filter for every single request before realizing there was no matching route.
In Jooby 2.x+, this no longer happens. The pipeline is only executed if there is a matching handler. Otherwise, nothing is executed!
|
Order follows a what you see is what you get approach. Routes are stacked in the order they are defined.
Order example:
{
// Increment +1
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
get("/1", ctx -> 1); // (1)
// Increment +1
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
get("/2", ctx -> 2); // (2)
}
{
// Increment +1
use {
val n = next.apply(ctx) as Int
1 + n
}
get("/1") { 1 } // (1)
// Increment +1
use {
val n = next.apply(ctx) as Int
1 + n
}
get("/2") { 2 } // (2)
}
A scoped filter looks like this:
Scoped filter:
{
// Increment +1
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 1 + n.intValue();
});
routes(() -> { // (1)
// Multiply by 2
use(next -> ctx -> {
Number n = (Number) next.apply(ctx);
return 2 * n.intValue();
});
get("/4", ctx -> 4); // (2)
});
get("/1", ctx -> 1); // (3)
}
{
// Increment +1
use {
val n = next.apply(ctx) as Int
return 1 + n
}
routes { // (1)
// Multiply by 2
use {
val n = next.apply(ctx) as Int
2 * n
}
get("/4") { 4 } // (2)
}
get("/1") { 1 } // (3)
}
-
Introduces a new scope via the routes operator.
-
/4 ⇒ 9
-
/1 ⇒ 2
It functions as a normal filter inside a group operator.
As shown previously, the routes(Runnable) operator pushes a new route scope and allows you to selectively apply logic to one or more routes.
Route operator
{
routes(() -> {
get("/", ctx -> "Hello");
});
}
{
routes {
get("/") {
"Hello"
}
}
}
The routes operator is for grouping routes and applying cross-cutting concerns to all of them.
Routes with path prefix:
{
path("/api/user", () -> { // (1)
get("/{id}", ctx -> ...); // (2)
get("/", ctx -> ...); // (3)
post("/", ctx -> ...); // (4)
// ...
});
}
{
path("/api/user") { // (1)
get("/{id}") { ...} // (2)
get("/") { ...} // (3)
post("/") { ...} // (4)
// ...
});
}
-
Sets the common prefix /api/user.
-
GET /api/user/{id}
-
GET /api/user
-
POST /api/user
Composition is a technique for building modular applications. You can compose one or more routers into a new one using the mount(Router) operator:
Composing
public class Foo extends Jooby {
{
get("/foo", Context::getRequestPath);
}
}
public class Bar extends Jooby {
{
get("/bar", Context::getRequestPath);
}
}
public class App extends Jooby {
{
mount(new Foo()); // (1)
mount(new Bar()); // (2)
get("/app", Context::getRequestPath); // (3)
}
}
class Foo: Kooby({
get("/foo") { ctx.getRequestPath() }
})
class Bar: Kooby({
get("/bar") { ctx.getRequestPath() }
})
class App: Kooby({
mount(Foo()) // (1)
mount(Bar()) // (2)
get("/app") { ctx.getRequestPath() } // (3)
})
-
Imports all routes from Foo. Output: /foo ⇒ /foo
-
Imports all routes from Bar. Output: /bar ⇒ /bar
-
Adds more routes. Output: /app ⇒ /app
Composing with path prefix
public class Foo extends Jooby {
{
get("/foo", Context::getRequestPath);
}
}
public class App extends Jooby {
{
mount("/prefix", new Foo()); // (1)
}
}
class Foo: Kooby({
get("/foo") { ctx.getRequestPath() }
})
class App: Kooby({
mount("/prefix", Foo()) // (1)
})
-
Now all routes from Foo will be prefixed with /prefix. Output: /prefix/foo ⇒ /prefix/foo
The mount operator only imports routes. Services and callbacks are not imported. The main application is responsible for assembling all resources and services required by the imported applications.
Alternatively, you can install a standalone application into another one using the install(Supplier) operator:
Installing
public class Foo extends Jooby {
{
get("/foo", ctx -> ...);
}
}
public class Bar extends Jooby {
{
get("/bar", ctx -> ...);
}
}
public class App extends Jooby {
{
install(Foo::new); // (1)
install(Bar::new); // (2)
}
}
class Foo: Kooby({
get("/foo") { ... }
})
class Bar: Kooby({
get("/bar") { ... }
})
class App: Kooby({
install(::Foo) // (1)
install(::Bar) // (2)
})
-
Imports all routes, services, callbacks, etc. from Foo. Output: /foo ⇒ /foo
-
Imports all routes, services, callbacks, etc. from Bar. Output: /bar ⇒ /bar
This operator lets you, for example, deploy Foo as a standalone application or integrate it into a main application.
The install operator shares the state of the main application, so lazy initialization (and therefore instantiation) of any child applications is mandatory.
For example, this won’t work:
Java
{
Foo foo = new Foo();
install(() -> foo); // Won't work
}
The Foo application must be lazily initialized:
Java
{
install(() -> new Foo()); // Works!
}
Dynamic routing looks similar to composition but enables/disables routes at runtime using a predicate.
Suppose you own two versions of an API and need to support both the old and new versions concurrently based on a header:
Dynamic Routing
public class V1 extends Jooby {
{
get("/api", ctx -> "v1");
}
}
public class V2 extends Jooby {
{
get("/api", ctx -> "v2");
}
}
public class App extends Jooby {
{
mount(ctx -> ctx.header("version").value().equals("v1"), new V1()); // (1)
mount(ctx -> ctx.header("version").value().equals("v2"), new V2()); // (2)
}
}
class V1: Kooby({
get("/api") { "v1" }
})
class V2: Kooby({
get("/api") { "v2" }
})
class App: Kooby({
mount(ctx -> ctx.header("version").value().equals("v1"), V1()); // (1)
mount(ctx -> ctx.header("version").value().equals("v2"), V2()); // (2)
})
-
/api ⇒ v1; when version header is v1
-
/api ⇒ v2; when version header is v2
This model lets you run multiple applications on a single server instance. Each application works like a standalone application; they do not share any services.
Multiple routers
public class Foo extends Jooby {
{
setContextPath("/foo");
get("/hello", ctx -> ...);
}
}
public class Bar extends Jooby {
{
setContextPath("/bar");
get("/hello", ctx -> ...);
}
}
import static io.jooby.Jooby.runApp;
public class MultiApp {
public static void main(String[] args) {
runApp(args, List.of(Foo::new, Bar::new));
}
}
import io.jooby.kt.Kooby.runApp
fun main(args: Array<String>) {
runApp(args, ::Foo, ::Bar)
}
You write your application as usual and then deploy them using the runApp method.
|
Important
|
Due to the nature of logging frameworks (static loading and initialization), logging bootstrap might not work as expected. It is recommended to just use the logback.xml or log4j2.xml file.
|
Usage
import io.jooby.Jooby;
...
{
setRouterOptions(new RouterOptions()
.setIgnoreCase(true)
.setFailOnDuplicateRoutes(true)
.setIgnoreTrailingSlash(true)
.setNormalizeSlash(true)
.setResetHeadersOnError(false)
.setTrustProxy(true)
.setContextAsService(true)
);
}
import io.jooby.Jooby
...
{
routerOptions = RouterOptions().apply {
ignoreCase = true
failOnDuplicateRoutes = true
ignoreTrailingSlash = true
normalizeSlash = true
resetHeadersOnError = false
trustProxy = true
contextAsService = true
}
}
| Option |
Type |
Default |
Description |
contextAsService
|
boolean |
false |
If enabled, allows you to retrieve the Context object associated with the current request via the service registry while the request is being processed. |
ignoreCase
|
boolean |
false |
Indicates whether the routing algorithm uses case-sensitive matching for incoming request paths. |
ignoreTrailingSlash
|
boolean |
false |
Indicates whether trailing slashes are ignored on incoming request paths. |
failOnDuplicateRoutes
|
boolean |
false |
Throws an exception if multiple routes are registered with the same HTTP method and path pattern. |
normalizeSlash
|
boolean |
false |
Normalizes incoming request paths by replacing multiple consecutive slashes with a single slash. |
resetHeadersOnError
|
boolean |
true |
Indicates whether response headers should be cleared/reset when an exception occurs. |
trustProxy
|
boolean |
false |
When true, parses X-Forwarded-* headers and updates the current context to reflect the values sent by the proxy. |
|
Important
|
trustProxy: This should only be enabled if your application is running behind a reverse proxy configured to send X-Forwarded-* headers. Otherwise, remote users can spoof their IP addresses and protocols by sending malicious headers.
|
The setHiddenMethod(String) option allows clients to override the HTTP method. This is especially useful for HTML forms, which natively only support GET and POST.
Client
<form method="post" action="/form">
<input type="hidden" name="_method" value="put">
</form>
Server
import io.jooby.Jooby;
...
{
setHiddenMethod("_method"); // (1)
put("/form", ctx -> { // (2)
return "Updated!";
});
}
import io.jooby.Jooby
...
{
setHiddenMethod("_method") // (1)
put("/form") { // (2)
"Updated!"
}
}
-
Configures the router to look for a form field named _method.
-
Executes the PUT handler for /form instead of the standard POST.
(Note: I fixed a small bug in your Kotlin snippet where you were using Java lambda syntax inside the put route).
The default implementation looks for the specified hidden field in POST forms or multipart requests. Alternatively, you can provide a custom strategy, such as reading an HTTP header:
HTTP Header Strategy
import io.jooby.Jooby;
...
{
setHiddenMethod(ctx -> ctx.header("X-HTTP-Method-Override").toOptional()); // (1)
}
import io.jooby.Jooby
...
{
setHiddenMethod { ctx -> ctx.header("X-HTTP-Method-Override").toOptional() } // (1)
}
-
Overrides the HTTP method by extracting it from the X-HTTP-Method-Override request header.
A Context allows you to interact with the HTTP request and manipulate the HTTP response.
In most cases, you access the context object as a parameter of your route handler:
{
get("/", ctx -> { /* do important stuff with the 'ctx' variable */ });
}
{
get("/") { /* the 'it' variable (or implicit ctx) holds the context */ }
}
Context also provides derived information about the current request, such as matching locales based on the Accept-Language header. You can use locale() or locales() to present content matching the user’s language preference.
These methods use Locale.lookup(…) and Locale.filter(…) to perform language tag matching. (See their overloads if you need to plug in a custom matching strategy).
To leverage language matching, you must tell Jooby which languages your application supports. Set the application.lang configuration property to a value compatible with the Accept-Language header:
application.conf
application.lang = en, en-GB, de
{
setLocales(Locale.GERMAN, new Locale("hu", "HU"));
}
{
setLocales(Locale.GERMAN, Locale("hu", "HU"))
}
If you don’t explicitly set the supported locales, Jooby falls back to a single locale provided by Locale.getDefault().
There are several parameter types: header, cookie, path, query, form, multipart, session, and flash. They all share a unified, type-safe API for accessing and manipulating their values.
This section covers how to extract raw parameters. The next section covers how to convert them into complex objects using the Value API.
HTTP headers allow the client and server to pass additional information.
{
get("/", ctx -> {
String token = ctx.header("token").value(); // (1)
Value headers = ctx.headers(); // (2)
Map<String, String> headerMap = ctx.headerMap(); // (3)
// ...
});
}
{
get("/") { ctx ->
val token = ctx.header("token").value() // (1)
val headers = ctx.headers() // (2)
val headerMap = ctx.headerMap() // (3)
// ...
}
}
-
Retrieves the header token.
-
Retrieves all headers as a Value.
-
Retrieves all headers as a Map.
Request cookies are sent to the server via the Cookie header. Jooby provides simple key/value access:
Cookies
{
get("/", ctx -> {
String token = ctx.cookie("token").value(); // (1)
Map<String, String> cookieMap = ctx.cookieMap(); // (2)
// ...
});
}
{
get("/") { ctx ->
val token = ctx.cookie("token").value() // (1)
val cookieMap = ctx.cookieMap() // (2)
// ...
}
}
-
Retrieves the cookie named token.
-
Retrieves all cookies as a Map.
Path parameters are part of the URI. Use the {identifier} notation to define a path variable.
Syntax:
{
get("/{id}", ctx -> ctx.path("id").value()); // (1)
get("/@{id}", ctx -> ctx.path("id").value()); // (2)
get("/file/{name}.{ext}", ctx -> ctx.path("name") + "." + ctx.path("ext")); // (3)
get("/file/*", ctx -> ctx.path("*")); // (4)
get("/{id:[0-9]+}", ctx -> ctx.path("id")); // (5)
}
{
get("/{id}") { ctx -> ctx.path("id").value() } // (1)
get("/@{id}") { ctx -> ctx.path("id").value() } // (2)
get("/file/{name}.{ext}") { ctx -> ctx.path("name") + "." + ctx.path("ext") } // (3)
get("/file/*") { ctx -> ctx.path("*") } // (4)
get("/{id:[0-9]+}") { ctx -> ctx.path("id") } // (5)
}
-
Standard path variable id.
-
Path variable id prefixed with @.
-
Multiple variables: name and ext.
-
Unnamed catchall path variable.
-
Path variable strictly matching a regular expression.
Accessing Path Variables
{
get("/{name}", ctx -> {
String pathString = ctx.getRequestPath(); // (1)
Value path = ctx.path(); // (2)
Map<String, String> pathMap = ctx.pathMap(); // (3)
String name = ctx.path("name").value(); // (4)
// ...
});
}
{
get("/{name}") { ctx ->
val pathString = ctx.getRequestPath() // (1)
val path = ctx.path() // (2)
val pathMap = ctx.pathMap() // (3)
val name = ctx.path("name").value() // (4)
// ...
}
}
-
Access the raw path string (e.g., /a b returns /a%20b).
-
Path as a Value object (decoded).
-
Path as a Map<String, String> (decoded).
-
Specific path variable name as a String (decoded).
The query string is the part of the URI that starts after the ? character.
{
get("/search", ctx -> {
String queryString = ctx.queryString(); // (1)
QueryString query = ctx.query(); // (2)
Map<String, List<String>> queryMap = ctx.queryMultimap(); // (3)
String q = ctx.query("q").value(); // (4)
SearchQuery searchQuery = ctx.query(SearchQuery.class); // (5)
// ...
});
}
class SearchQuery {
public final String q;
public SearchQuery(String q) { this.q = q; }
}
{
get("/search") { ctx ->
val queryString = ctx.queryString() // (1)
val query = ctx.query() // (2)
val queryMap = ctx.queryMultimap() // (3)
val q = ctx.query("q").value() // (4)
val searchQuery = ctx.query<SearchQuery>() // (5)
// ...
}
}
data class SearchQuery(val q: String)
-
Access the raw query string (e.g., ?q=a%20b).
-
Query string as a QueryString object (e.g., {q=a b}).
-
Query string as a multi-value map (e.g., {q=[a b]}).
-
Access decoded variable q. Throws a 400 Bad Request if missing.
-
Binds the query string directly to a SearchQuery object.
Form data is sent in the HTTP body (or as part of the URI for GET requests) and is encoded as application/x-www-form-urlencoded.
{
post("/user", ctx -> {
Formdata form = ctx.form(); // (1)
Map<String, List<String>> formMap = ctx.formMultimap(); // (2)
String userId = ctx.form("id").value(); // (3)
String pass = ctx.form("pass").value(); // (4)
User user = ctx.form(User.class); // (5)
// ...
});
}
class User {
public final String id;
public final String pass;
public User(String id, String pass) {
this.id = id;
this.pass = pass;
}
}
{
post("/user") { ctx ->
val form = ctx.form() // (1)
val formMap = ctx.formMultimap() // (2)
val userId = ctx.form("id").value() // (3)
val pass = ctx.form("pass").value() // (4)
val user = ctx.form<User>() // (5)
// ...
}
}
data class User(val id: String, val pass: String)
-
Form as Formdata.
-
Form as a multi-value map.
-
Specific form variable id.
-
Specific form variable pass.
-
Form automatically bound to a User object.
Multipart data is sent in the HTTP body and encoded as multipart/form-data. It is required for file uploads.
{
post("/user", ctx -> {
Multipart multipart = ctx.multipart(); // (1)
Map<String, List<String>> multipartMap = ctx.multipartMultimap(); // (2)
String userId = ctx.multipart("id").value(); // (3)
String pass = ctx.multipart("pass").value(); // (4)
FileUpload pic = ctx.file("pic"); // (5)
User user = ctx.multipart(User.class); // (6)
// ...
});
}
class User {
public final String id;
public final String pass;
public final FileUpload pic;
public User(String id, String pass, FileUpload pic) {
this.id = id;
this.pass = pass;
this.pic = pic;
}
}
{
post("/user") { ctx ->
val multipart = ctx.multipart() // (1)
val multipartMap = ctx.multipartMultimap() // (2)
val userId = ctx.multipart("id").value() // (3)
val pass = ctx.multipart("pass").value() // (4)
val pic = ctx.file("pic") // (5)
val user = ctx.multipart<User>() // (6)
// ...
}
}
data class User(val id: String, val pass: String, val pic: FileUpload)
-
Form as Multipart.
-
Form as a multi-value map.
-
Specific multipart text variable id.
-
Specific multipart text variable pass.
-
Single file upload named pic.
-
Multipart form bound to a User object (including the file).
|
Note
|
File Upload
File uploads are only available for multipart requests.
FileUpload pic = ctx.file("pic"); // (1)
List<FileUpload> pics = ctx.files("pic"); // (2)
List<FileUpload> files = ctx.files(); // (3)
val pic = ctx.file("pic") // (1)
val pics = ctx.files("pic") // (2)
val files = ctx.files() // (3)
-
Single file upload named pic
-
Multiple file uploads named pic
-
All file uploads
|
Session session = ctx.session(); // (1)
String attribute = ctx.session("attribute").value(); // (2)
val session = ctx.session() // (1)
val attribute = session.attribute("attribute").value() // (2)
-
Finds an existing Session or creates a new one.
-
Gets a specific session attribute.
Flash parameters transport success/error messages between requests. They are similar to a session, but their lifecycle is shorter: data is kept for only one request.
get("/", ctx -> {
return ctx.flash("success").value("Welcome!"); // (3)
});
post("/save", ctx -> {
ctx.flash().put("success", "Item created"); // (1)
return ctx.sendRedirect("/"); // (2)
});
get("/") { ctx ->
ctx.flash("success").value("Welcome!") // (3)
}
post("/save") { ctx ->
ctx.flash().put("success", "Item created") // (1)
ctx.sendRedirect("/") // (2)
}
-
Sets a flash attribute: success.
-
Redirects to the home page.
-
Displays the flash attribute success (if it exists) or defaults to Welcome!.
Flash attributes are implemented using an HTTP Cookie. To customize the cookie (the default name is jooby.flash), use setFlashCookie(Cookie).
{
setFlashCookie(new Cookie("myflash").setHttpOnly(true));
// or if you're fine with the default name
getFlashCookie().setHttpOnly(true);
}
{
flashCookie = Cookie("myflash").setHttpOnly(true)
// or if you're fine with the default name
flashCookie.isHttpOnly = true
}
You can search for parameters across multiple sources with an explicitly defined priority using lookup().
get("/{foo}", ctx -> {
String foo = ctx.lookup("foo", ParamSource.QUERY, ParamSource.PATH).value();
return "foo is: " + foo;
});
get("/{foo}", ctx -> {
String foo = ctx.lookup().inQuery().inPath().get("foo").value();
return "foo is: " + foo;
});
get("/{foo}") { ctx ->
val foo = ctx.lookup("foo", ParamSource.QUERY, ParamSource.PATH).value()
"foo is: $foo"
}
get("/{foo}") { ctx ->
val foo = ctx.lookup().inQuery().inPath()["foo"].value()
"foo is: $foo"
}
If a request is made to /bar?foo=baz, the result will be foo is: baz because the query parameter takes precedence over the path parameter.
If mutual TLS is enabled, you can access the client’s certificates from the context. The first certificate in the list is the peer certificate, followed by the CA certificates in the chain.
get("/{foo}", ctx -> {
List<Certificate> certificates = ctx.getClientCertificates(); // (1)
Certificate peerCertificate = certificates.get(0); // (2)
});
get("/{foo}") { ctx ->
val certificates = ctx.clientCertificates // (1)
val peerCertificate = certificates.first() // (2)
}
-
Get all certificates presented by the client during the SSL handshake.
-
Get only the peer certificate.
The Value is a unified, type-safe API for accessing all parameter types:
-
Header
-
Path
-
Query
-
Formdata/Multipart
For learning purposes, we will demonstrate the Value features using query parameters, but keep in mind that these features apply to all parameter types.
Single values are retrieved via the value() or [type]Value() functions:
{
get("/", ctx -> {
String name = ctx.query("name").value(); // (1)
float score = ctx.query("score").floatValue(); // (2)
boolean enabled = ctx.query("enabled").booleanValue(); // (3)
BigDecimal decimal = ctx.query("decimal").value(BigDecimal::new); // (4)
// ...
});
}
{
get("/") {
val name = ctx.query("name").value() // (1)
val score = ctx.query("score").floatValue() // (2)
val enabled = ctx.query("enabled").booleanValue() // (3)
val decimal = ctx.query("decimal").value(::BigDecimal) // (4)
// ...
}
}
The value() family of methods always expects a value to exist. If the value is missing or cannot be converted to the requested type, a 400 Bad Request response is generated. Therefore, single-value parameters are implicitly required:
-
Access parameter name as String:
-
Access parameter score as float:
-
Access parameter enabled as boolean:
-
Access parameter decimal and convert it to a custom type (BigDecimal):
You can handle optional parameters by providing a default value or requesting an Optional object:
{
get("/search", ctx -> {
String q1 = ctx.query("q").value("*:*"); // (1)
Optional<String> q2 = ctx.query("q").toOptional(); // (2)
return q1;
});
}
{
get("/search") {
val q1 = ctx.query("q").value("*:*") // (1)
val q2 = ctx.query("q").toOptional() // (2)
q1
}
}
-
Retrieve variable q as a String with a fallback default value of :.
-
/search?q=foo ⇒ foo
-
/search ⇒ :
-
Retrieve variable q wrapped in an Optional<String>:
Multiple values for a single parameter key can be retrieved as Collections:
{
get("/", ctx -> {
List<String> q = ctx.query("q").toList(); // (1)
List<Integer> n = ctx.query("n").toList(Integer.class); // (2)
List<BigDecimal> d = ctx.query("d").toList(BigDecimal::new); // (3)
// ...
});
}
{
get("/") {
val q = ctx.query("q").toList() // (1)
val n = ctx.query("n").toList(Integer::class.java) // (2)
val d = ctx.query("d").toList(::BigDecimal) // (3)
// ...
}
}
-
Parameter q as List<String>:
-
Parameter n as List<Integer>:
-
/ ⇒ []
-
/?n=1&n=2 ⇒ [1, 2]
-
Parameter d mapped to List<BigDecimal>:
The Value API allows you to traverse and parse deeply nested structured data from a request.
Given the query: /?user.name=root&user.pass=pass
Traversal
{
get("/", ctx -> {
Value user = ctx.query("user"); // (1)
String name = user.get("name").value(); // (2)
String pass = user.get("pass").value(); // (3)
String email = user.get("email").value("none"); // (4)
// ...
});
}
{
get("/") {
val user = ctx.query("user") // (1)
val name = user["name"].value() // (2)
val pass = user["pass"].value() // (3)
val email = user["email"].value("none") // (4)
// ...
}
}
-
Retrieves the user node.
-
Extracts name from the user node.
-
Extracts pass from the user node.
-
Safely extracts email with a fallback default.
The get(String) method takes a path and returns a nested Value node, which may or may not exist.
The structured data decoder supports both dot and bracket notation:
Dot notation
?member.firstname=Pedro&member.lastname=Picapiedra
Bracket object notation
?member[firstname]=Pedro&member[lastname]=Picapiedra
Bracket array notation for tabular data
?members[0]firstname=Pedro&members[0]lastname=Picapiedra
The data decoder can automatically reconstruct POJOs (Plain Old Java Objects) from:
Example Models
class Member {
public final String firstname;
public final String lastname;
public Member(String firstname, String lastname) {
this.firstname = firstname;
this.lastname = lastname;
}
}
class Group {
public final String id;
public final List<Member> members;
public Group(String id, List<Member> members) {
this.id = id;
this.members = members;
}
}
class Member(val firstname: String, val lastname: String)
class Group(val id: String, val members: List<Member>)
Binding a single Member:
/?firstname=Pedro&lastname=Picapiedra
{
get("/", ctx -> {
Member member = ctx.query(Member.class);
// ...
});
}
{
get("/") {
val member = ctx.query<Member>()
// ...
}
}
Binding a nested Member from a root node:
/?member.firstname=Pedro&member.lastname=Picapiedra
{
get("/", ctx -> {
Member member = ctx.query("member").to(Member.class);
// ...
});
}
{
get("/") {
val member = ctx.query("member").to<Member>()
// ...
}
}
Binding tabular/array data:
/?[0]firstname=Pedro&[0]lastname=Picapiedra&[1]firstname=Pablo&[1]lastname=Marmol
{
get("/", ctx -> {
List<Member> members = ctx.query().toList(Member.class);
// ...
});
}
{
get("/") {
val members = ctx.query<List<Member>>()
// ...
}
}
Binding complex nested hierarchies:
/?id=flintstones&members[0]firstname=Pedro&members[0]lastname=Picapiedra
{
get("/", ctx -> {
Group group = ctx.query(Group.class);
// ...
});
}
{
get("/") {
val group = ctx.query<Group>()
// ...
}
}
POJO Binding Rules:
The target POJO must follow one of these rules:
-
Have a zero-argument (default) constructor.
-
Have exactly one constructor.
-
Have multiple constructors, but only one is annotated with @Inject.
The decoder maps HTTP parameters in the following order:
1. Constructor arguments
2. Setter methods
If an HTTP parameter name is not a valid Java identifier (e.g., first-name), you must map it using the @Named annotation:
class Member {
public final String firstname;
public final String lastname;
public Member(@Named("first-name") String firstname, @Named("last-name") String lastname) {
this.firstname = firstname;
this.lastname = lastname;
}
}
class Member(@Named("first-name") val firstname: String, @Named("last-name") val lastname: String)
The ValueFactory allows you to register new type conversions or override existing ones globally.
{
var valueFactory = getValueFactory();
valueFactory.put(MyBean.class, new MyBeanConverter());
}
import io.jooby.value.Converter;
class MyBeanConverter implements Converter {
@Override
public Object convert(Type type, Value node, ConversionHint hint) {
// Logic to convert the 'node' into MyBean.class
return new MyBean();
}
}
{
valueFactory.put(MyBean::class.java, MyBeanConverter())
}
import io.jooby.value.Converter
class MyBeanConverter : Converter {
override fun convert(type: Type, node: Value, hint: ConversionHint): Any {
// Logic to convert the 'node' into MyBean::class.java
return MyBean()
}
}
The raw request body is available via the body() method:
{
post("/string", ctx -> {
String body = ctx.body().value(); // (1)
// ...
});
post("/bytes", ctx -> {
byte[] body = ctx.body().bytes(); // (2)
// ...
});
post("/stream", ctx -> {
InputStream body = ctx.body().stream(); // (3)
// ...
});
}
{
post("/string") {
val body = ctx.body().value() // (1)
// ...
}
post("/bytes") {
val body = ctx.body().bytes() // (2)
// ...
}
post("/stream") {
val body = ctx.body().stream() // (3)
// ...
}
}
-
Reads the HTTP body as a String.
-
Reads the HTTP body as a byte array.
-
Reads the HTTP body as an InputStream.
Request body parsing (converting the raw body into a specific object) is handled by the MessageDecoder functional interface.
public interface MessageDecoder {
<T> T decode(Context ctx, Type type) throws Exception;
}
The MessageDecoder has a single decode method that takes the request context and the target type, returning the parsed result.
JSON Decoder Example:
{
FavoriteJson lib = new FavoriteJson(); // (1)
decoder(MediaType.json, (ctx, type) -> { // (2)
byte[] body = ctx.body().bytes(); // (3)
return lib.fromJson(body, type); // (4)
});
post("/", ctx -> {
MyObject myObject = ctx.body(MyObject.class); // (5)
return myObject;
});
}
{
val lib = FavoriteJson() // (1)
decoder(MediaType.json) { ctx, type -> // (2)
val body = ctx.body().bytes() // (3)
lib.fromJson(body, type) // (4)
}
post("/") {
val myObject = ctx.body<MyObject>() // (5)
myObject
}
}
-
Initialize your favorite JSON library.
-
Register the decoder to trigger when the Content-Type header matches application/json.
-
Read the raw body as a byte[].
-
Parse the payload into the requested type.
-
Inside the route, calling ctx.body(Type) automatically triggers the registered decoder.
The response body is generated by the route handler.
Response Body Example
{
get("/", ctx -> {
ctx.setResponseCode(200); // (1)
ctx.setResponseType(MediaType.text); // (2)
ctx.setResponseHeader("Date", new Date()); // (3)
return "Response"; // (4)
});
}
{
get("/") {
ctx.responseCode = 200 // (1)
ctx.responseType = MediaType.text // (2)
ctx.setResponseHeader("Date", Date()) // (3)
"Response" // (4)
}
}
-
Set the status code to 200 OK (this is the default).
-
Set the Content-Type to text/plain (this is the default for strings).
-
Set a custom response header.
-
Return the response body to the client.
Response encoding (converting an object into a raw HTTP response) is handled by the MessageEncoder functional interface.
public interface MessageEncoder {
Output encode(@NonNull Context ctx, @NonNull Object value) throws Exception;
}
The MessageEncoder has a single encode method that accepts the context and the value returned by the handler, producing an output. (Internally, Output works like a java.nio.ByteBuffer for performance reasons).
JSON Encoder Example:
{
FavoriteJson lib = new FavoriteJson(); // (1)
encoder(MediaType.json, (ctx, result) -> { // (2)
String json = lib.toJson(result); // (3)
ctx.setDefaultResponseType(MediaType.json); // (4)
return json; // (5)
});
get("/item", ctx -> {
MyObject myObject = new MyObject();
return myObject; // (6)
});
}
{
val lib = FavoriteJson() // (1)
encoder(MediaType.json) { ctx, result -> // (2)
val json = lib.toJson(result) // (3)
ctx.defaultResponseType = MediaType.json // (4)
json // (5)
}
get("/item") {
val myObject = MyObject()
myObject // (6)
}
}
-
Initialize your favorite JSON library.
-
Register the encoder to trigger when the client’s Accept header matches application/json.
-
Convert the route’s result into JSON.
-
Set the Content-Type header to application/json.
-
Return the encoded JSON payload.
-
The route handler returns a user-defined POJO, which is automatically intercepted and encoded.
This chapter covers special response types, including raw responses, streaming, file downloads, and non-blocking responses.
Raw responses are not processed by a message encoder. The following types are considered raw:
Generate a JSON String from a handler
{
get("/json", ctx -> {
ctx.setContentType(MediaType.json);
return "{\"message\": \"Hello Raw Response\"}";
});
}
{
get("/json") {
ctx.contentType = MediaType.json
"""
"message": "Hello Raw Response"
"""
}
}
Even if a JSON encoder is installed, a raw response is always sent directly to the client bypassing the encoder.
Projections allow API consumers to request a partial representation of a resource. This feature is a more flexible, dynamic, and powerful alternative to the standard Jackson @JsonView annotation.
While inspired by the "selection set" philosophy of GraphQL, it is important to note that this is not a GraphQL implementation. Instead, it provides a "Light GraphQL" experience for RESTful endpoints, allowing you to define exactly which fields should be serialized in the JSON response without the overhead of a full GraphQL engine.
To enable a projection, you wrap your response object using the Projected.wrap utility. The projection syntax is parsed and applied to the underlying object or collection.
get("/users/{id}", ctx -> {
User user = repository.findById(ctx.path("id").value());
return Projected.wrap(user)
.include("(id, name, email)");
});
If you have used Jackson’s @JsonView, you will find Projections far more capable:
-
Dynamic: Unlike @JsonView, which requires static class markers defined at compile-time, Projections are defined at runtime.
-
Ad-hoc: You can create any combination of fields on the fly without adding new Java interfaces or classes.
-
Deep Nesting: Projections easily handle deeply nested object graphs, whereas @JsonView can become difficult to manage with complex relationships.
The include method accepts a string using a simple, nested syntax:
-
Field Selection: (id, name) returns only those two fields.
-
Nested Selection: (id, address(city, country)) selects the id and specific fields from the nested address object.
-
Wildcards: (id, address(*)) selects the id and all available fields within the address object.
-
Deep Nesting: (id, orders(id, items(name, price))) allows for recursion into the object graph.
By default, the projection engine does not validate that requested fields exist on the target class (failOnMissingProperty is false). This allows for maximum flexibility, especially when working with polymorphic types or dynamic data where certain fields may only exist on specific subclasses.
If you prefer strict enforcement to prevent API consumers from requesting non-existent fields, you can enable validation:
return Projected.wrap(data)
.failOnMissingProperty(true)
.include("(id, name, strictFieldOnly)");
Support for Projections extends beyond core scripting to include high-level annotations and documentation generation.
-
MVC Support: Projections can be applied to controller methods using the @Project annotation. See the MVC documentation for details.
-
OpenAPI Support: Jooby automatically generates pruned schemas for your Swagger documentation. See the OpenAPI documentation for details.
|
Note
|
Implementation Note:
The Projection core API defines the structure and the DSL. The actual runtime filtering is performed by your chosen JSON module:
|
The Streaming/Chunked API is available via:
You can only call one of these methods per request. When you call one of them, Jooby automatically adds the Transfer-Encoding: chunked header if the Content-Length is missing.
All three APIs have a close method, which you must call when finished.
Writer example
{
get("/chunk", ctx -> {
try(Writer writer = ctx.responseWriter()) { // (1)
writer.write("chunk1"); // (2)
// ...
writer.write("chunkN");
}
return ctx; // (3)
});
}
{
get("/chunk") {
ctx.responseWriter().use { // (1)
writer.write("chunk1") // (2)
// ...
writer.write("chunkN")
}
ctx // (3)
}
}
-
Get the Writer inside a try-with-resources (or use in Kotlin) block so it closes automatically.
-
Write chunks of data.
-
Return the Context.
There is an overloaded version (primarily for Java) that lets you skip the try-with-resources block and automatically closes the writer/stream for you:
Auto-closing Writer example
{
get("/chunk", ctx -> {
return ctx.responseWriter(writer -> { // (1)
writer.write("chunk1"); // (2)
// ...
writer.write("chunkN");
});
});
}
{
get("/chunk") {
ctx.responseWriter { // (1)
writer.write("chunk1") // (2)
// ...
writer.write("chunkN")
}
}
}
Use FileDownload to generate file downloads (responses with a Content-Disposition header). You can use the convenience subclasses AttachedFile or InlineFile to set the header value to attachment or inline, respectively.
File download example
{
get("/download-file", ctx -> {
Path source = Paths.get("logo.png");
return new AttachedFile(source); // (1)
});
get("/view-stream", ctx -> {
InputStream source = ...;
return new InlineFile("myfile.txt", source); // (2)
});
}
{
get("/download-file") {
val source = Paths.get("logo.png")
AttachedFile(source) // (1)
}
get("/view-stream") {
val source = ...
InlineFile("myfile.txt", source) // (2)
}
}
-
Send a download from a Path as an attachment.
-
Send a download from an InputStream inline.
Alternatively, you can use the static builder methods on FileDownload and specify the download type later.
File download with builder method
FileDownload.Builder produceDownload(Context ctx) {
return FileDownload.build(...);
}
{
get("/view", ctx -> produceDownload(ctx).inline());
get("/download", ctx -> produceDownload(ctx).attachment());
}
fun produceDownload(ctx: Context) = FileDownload.build(...)
{
get("/view") {
produceDownload(it).inline()
}
get("/download") {
produceDownload(it).attachment()
}
}
From the user’s perspective, there is nothing special about writing non-blocking responses—you write your route handler the same way you usually do. However, it’s important to understand how they execute in the pipeline based on your application’s mode.
In event loop mode
{
mode(EVENT_LOOP); // (1)
use(ReactiveSupport.concurrent()); // (2)
get("/non-blocking", ctx -> {
return CompletableFuture // (3)
.supplyAsync(() -> {
// ... // (4)
});
});
}
{
mode(EVENT_LOOP) // (1)
use(ReactiveSupport.concurrent()) // (2)
get("/non-blocking") {
CompletableFuture // (3)
.supplyAsync {
// ... // (4)
}
}
}
-
The application runs in the event loop.
-
Indicates we want to go non-blocking and handle CompletableFuture responses.
-
The value is provided from the event loop. No blocking code is permitted.
-
The value is computed asynchronously.
Running your application in worker mode works identically, except you are allowed to make blocking calls:
In worker mode
{
mode(WORKER); // (1)
use(ReactiveSupport.concurrent()); // (2)
get("/blocking", ctx -> {
return CompletableFuture // (3)
.supplyAsync(() -> {
// ... // (4)
});
});
}
{
mode(WORKER) // (1)
use(ReactiveSupport.concurrent()) // (2)
get("/blocking") {
CompletableFuture // (3)
.supplyAsync {
// ... // (4)
}
}
}
-
The application runs in worker mode.
-
Indicates we want to go non-blocking.
-
The value is provided from the worker thread. Blocking code is permitted.
-
The value is computed asynchronously.
The default mode mimics the event loop mode when a route produces a non-blocking type:
In default mode
{
mode(DEFAULT); // (1)
use(ReactiveSupport.concurrent()); // (2)
get("/non-blocking", ctx -> {
return CompletableFuture // (3)
.supplyAsync(() -> {
// ... // (4)
});
});
}
{
mode(DEFAULT) // (1)
get("/non-blocking") {
// ... // (2)
CompletableFuture // (3)
.supplyAsync {
// ... // (4)
}
}
}
|
Note
|
For all reactive frameworks below, explicit handler setup (e.g., use(Reactivex.rx())) is only required for Script/Lambda routes. For MVC routes, Jooby automatically configures the handler based on the route’s return type.
|
CompletableFuture is a non-blocking type that produces a single result:
{
use(ReactiveSupport.concurrent());
get("/non-blocking", ctx -> {
return CompletableFuture
.supplyAsync(() -> "Completable Future!")
.thenApply(it -> "Hello " + it);
});
}
{
use(ReactiveSupport.concurrent())
get("/non-blocking") {
CompletableFuture
.supplyAsync { "Completable Future!" }
.thenApply { "Hello $it" }
}
}
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-mutiny</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-mutiny:4.6.0'
Uni (Single Value)
import io.jooby.mutiny;
import io.smallrye.mutiny.Uni;
{
use(Mutiny.mutiny());
get("/non-blocking", ctx -> {
return Uni.createFrom()
.completionStage(supplyAsync(() -> "Uni"))
.map(it -> "Hello " + it);
});
}
import io.jooby.mutiny
import io.smallrye.mutiny.Uni
{
use(Mutiny.mutiny())
get("/non-blocking") {
Uni.createFrom()
.completionStage { supplyAsync { "Uni" } }
.map { "Hello $it" }
}
}
Multi (Chunked Stream)
import io.jooby.mutiny;
import io.smallrye.mutiny.Multi;
{
use(Mutiny.mutiny());
get("/non-blocking", ctx -> {
return Multi.createFrom().range(1, 11)
.map(it -> it + ", ");
});
}
import io.jooby.mutiny
import io.smallrye.mutiny.Multi
{
use(Mutiny.mutiny())
get("/non-blocking") {
Multi.createFrom().range(1, 11)
.map { "$it, " }
}
}
For Multi, Jooby automatically builds a chunked response. Each item in the stream is sent to the client as a new HTTP chunk.
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-rxjava3</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-rxjava3:4.6.0'
Single
import io.jooby.rxjava3.Reactivex;
{
use(Reactivex.rx());
get("/non-blocking", ctx -> {
return Single
.fromCallable(() -> "Single")
.map(it -> "Hello " + it);
});
}
import io.jooby.rxjava3.Reactivex
{
use(Reactivex.rx())
get("/non-blocking") {
Single
.fromCallable { "Single" }
.map { "Hello $it" }
}
}
Flowable (Chunked Stream)
import io.jooby.rxjava3.Reactivex;
{
use(Reactivex.rx());
get("/non-blocking", ctx -> {
return Flowable.range(1, 10)
.map(it -> it + ", ");
});
}
import io.jooby.rxjava3.Reactivex
{
use(Reactivex.rx());
get("/non-blocking") {
Flowable.range(1, 10)
.map { "$it, " }
}
}
For Flowable, Jooby builds a chunked response, sending each item as a separate chunk.
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-reactor</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-reactor:4.6.0'
Mono (Single Value)
import io.jooby.Reactor;
{
use(Reactor.reactor());
get("/non-blocking", ctx -> {
return Mono
.fromCallable(() -> "Mono")
.map(it -> "Hello " + it);
});
}
import io.jooby.Reactor
{
use(Reactor.reactor())
get("/non-blocking") {
Mono
.fromCallable { "Mono" }
.map { "Hello $it" }
}
}
Flux (Chunked Stream)
import io.jooby.Reactor;
{
use(Reactor.reactor());
get("/non-blocking", ctx -> {
return Flux.range(1, 10)
.map(it -> it + ", ");
});
}
import io.jooby.Reactor
{
use(Reactor.reactor())
get("/non-blocking") {
Flux.range(1, 10)
.map { "$it, " }
}
}
For Flux, Jooby builds a chunked response, sending each item as a separate chunk.
(Note: Coroutines are exclusive to Kotlin, so there is no Java equivalent for this section).
Coroutine handler:
{
coroutine {
get("/") {
delay(100) // (1)
"Hello Coroutines!" // (2)
}
}
}
-
Call a suspending function.
-
Send the response to the client.
Using an extension and suspending function:
{
coroutine {
get("/") {
ctx.doSomething() // (1)
}
}
}
suspend fun Context.doSomething(): String {
delay(100) // (2)
return "Hello Coroutines!" // (3)
}
-
Call an extension suspending function.
-
Safely perform a suspending or blocking call.
-
Send the response to the client.
Coroutines work like any other non-blocking type. If you start Jooby using the event loop or default mode, Jooby creates a coroutine context to execute it.
Jooby uses the worker executor to create a coroutine context. This executor is provided by the web server implementation, unless you provide a custom one:
Coroutines with a custom executor:
{
worker(Executors.newCachedThreadPool())
coroutine {
get("/") {
val n = 5 * 5 // (1)
delay(100) // (2)
"Hello Coroutines!" // (3)
}
}
}
-
Statement runs in the worker executor (cached thread pool).
-
Calls a suspending function.
-
Produces a response.
By default, Coroutines always run in the worker executor. However, Jooby provides an experimental API where coroutines run in the caller thread (the event loop) until a suspending function is found. You can enable this by setting the coroutineStart option:
UNDISPATCHED Start
{
coroutine(CoroutineStart.UNDISPATCHED) {
get("/") {
val n = 5 * 5 // (1)
delay(100) // (2)
"Hello Coroutines!" // (3)
}
}
}
-
Statement runs in the event loop (caller thread).
-
Calls a suspending function and dispatches to the worker executor.
-
Produces a response from the worker executor.
You can also extend the CoroutineContext in which the routes run:
Customizing the Context
{
coroutine {
launchContext { MDCContext() } // (1)
get("/") {
// ...
}
}
}
-
The launchContext lambda runs before launching each coroutine. It allows you to customize the CoroutineContext for the request (e.g., to store/restore MDC, transactions, or other request-scoped data).
Jooby provides a family of send() methods that produce a response via side-effects.
send text
{
get("/", ctx -> {
return ctx.send("Hello World!");
});
}
{
get("/") {
ctx.send("Hello World!")
}
}
Although these methods operate via side-effects, the route handler must still return a value. All send methods return the current Context. Returning the context signals to Jooby that the response was already handled and the standard route output should be ignored.
The family of send methods includes:
This section describes some built-in handlers provided by Jooby.
Usage
import io.jooby.handler.AccessLogHandler;
{
use(new AccessLogHandler()); // (1)
get("/", ctx -> "Logging...");
}
import io.jooby.handler.AccessLogHandler
{
use(AccessLogHandler()) // (1)
get("/") { "Logging..." }
}
-
Install the AccessLogHandler as a global decorator.
Once installed, it prints a message to your log similar to this:
127.0.0.1 - - [04/Oct/2016:17:51:42 +0000] "GET / HTTP/1.1" 200 2 3
The message components are:
-
Remote Address: The IP address of the client.
-
User ID: Usually a dash (-) unless specified.
-
Date and Time: The timestamp of the request.
-
Request Line: The HTTP method, request path, and protocol.
-
Status Code: The HTTP response status code.
-
Content-Length: The size of the response in bytes (or - if missing).
-
Latency: The total time taken to process the request in milliseconds.
You can append specific request or response headers to the log entry using the following methods:
|
Tip
|
If your application runs behind a reverse proxy (like Nginx or AWS ALB) that sends X-Forwarded-* headers, remember to enable the trust proxy option to ensure the correct remote IP address is logged.
|
Cross-Origin Resource Sharing (CORS) is a mechanism that uses HTTP headers to grant a web application running at one origin permission to access resources from a server at a different origin.
By default, Jooby rejects all cross-origin requests. To enable them, you must install the CorsHandler.
CORS Usage
import io.jooby.handler.CorsHandler;
{
use(new CorsHandler()); // (1)
path("/api", () -> {
// API methods
});
}
import io.jooby.handler.CorsHandler
{
use(CorsHandler()) // (1)
path("/api") {
// API methods
}
}
-
Install the CorsHandler with default options.
The default configuration is:
To customize these settings, use the Cors class:
CORS Options
import io.jooby.handler.Cors;
import io.jooby.handler.CorsHandler;
{
Cors cors = new Cors()
.setMethods("GET", "POST", "PUT", "DELETE"); // (1)
use(new CorsHandler(cors)); // (2)
}
import io.jooby.handler.CorsHandler
import io.jooby.cors
{
val cors = cors {
methods = listOf("GET", "POST", "PUT", "DELETE") // (1)
}
use(CorsHandler(cors)) // (2)
}
-
Specify the allowed HTTP methods.
-
Pass the custom configuration to the CorsHandler.
You can also define your CORS settings directly in your application.conf file:
application.conf
cors {
origin: "https://example.com"
credentials: true
methods: [GET, POST, PUT]
headers: [Content-Type, X-App-Version]
maxAge: 30m
exposedHeaders: [X-Custom-Header]
}
Then, load the configuration into the handler:
Loading from Config
import io.jooby.handler.Cors;
import io.jooby.handler.CorsHandler;
{
Cors cors = Cors.from(getConfig()); // (1)
use(new CorsHandler(cors));
}
import io.jooby.handler.Cors
import io.jooby.handler.CorsHandler
{
val cors = Cors.from(config) // (1)
use(CorsHandler(cors))
}
-
Loads the CORS options defined in the cors namespace of your configuration file.
Jooby generates a unique CSRF token for each active user session. This token is used to verify that the authenticated user is the one actually making the requests to the application.
Anytime you define an HTML form that performs a state-changing operation (like POST), you must include the CSRF token.
HTML Form with CSRF
<form method="POST" action="/update-profile">
<input name="csrf" value="{{csrf}}" type="hidden" />
<button type="submit">Update</button>
</form>
In the example above, {{csrf}} is a request attribute created by the handler. When the form is submitted, the CsrfHandler automatically verifies that the token in the request matches the token stored in the user’s session.
CSRF protection requires an active Session Store to be configured first.
CSRF Usage
import io.jooby.handler.CsrfHandler;
{
// 1. Session store is required
setSessionStore(SessionStore.memory());
// 2. Install CSRF handler
use(new CsrfHandler());
get("/form", ctx -> {
// Token is available as a request attribute
return new ModelAndView("form.hbs", Map.of("csrf", ctx.getAttribute("csrf")));
});
}
import io.jooby.handler.CsrfHandler
{
// 1. Session store is required
sessionStore = SessionStore.memory()
// 2. Install CSRF handler
use(CsrfHandler())
get("/form") { ctx ->
// Token is available as a request attribute
ModelAndView("form.hbs", mapOf("csrf" to ctx.getAttribute("csrf")))
}
}
By default, the handler looks for a token named csrf in the following order:
-
HTTP Header: X-CSRF-Token or csrf
-
Cookie: csrf
-
Form Parameter: csrf
You can customize the behavior of the handler using the following methods:
|
Tip
|
If you are building a Single Page Application (SPA), you can configure the handler to read the token from a custom header and have your frontend (e.g., React or Vue) send it back on every request.
|
The GracefulShutdown extension allows the application to finish processing active requests before the JVM exits.
Once a shutdown is initiated, the extension interceptor ensures that all new incoming requests are rejected with a Service Unavailable (503) status code, while existing requests are allowed to complete within a specified grace period.
Usage
import io.jooby.GracefulShutdown;
{
install(new GracefulShutdown()); // (1)
get("/", ctx -> "Hello!");
}
import io.jooby.GracefulShutdown
{
install(GracefulShutdown()) // (1)
get("/") { "Hello!" }
}
-
Install the GracefulShutdown extension.
By default, the extension waits for all requests to finish. You can optionally specify a maximum duration to wait before forcing the application to stop:
install(new GracefulShutdown(Duration.ofMinutes(1)));
|
Important
|
This extension must be installed at the very beginning of your route pipeline to ensure it can intercept and manage all incoming traffic during the shutdown phase.
|
By default, Jooby does not automatically handle HTTP HEAD requests. To support them without manually defining head(…) routes for every resource, you can use the HeadHandler.
The HeadHandler automatically routes HEAD requests to your existing GET handlers.
Usage
import io.jooby.handler.HeadHandler;
{
use(new HeadHandler()); // (1)
get("/", ctx -> "Full response body");
}
import io.jooby.handler.HeadHandler
{
use(HeadHandler()) // (1)
get("/") { "Full response body" }
}
When a HEAD request is received, the corresponding GET handler is executed to compute response metadata (like Content-Length and other headers), but the actual response body is stripped before being sent to the client.
|
Note
|
This handler is a convenient way to support HEAD requests globally, but keep in mind that the GET handler logic still runs. If your GET handler performs expensive operations to generate a body, you might prefer a custom implementation that skips body generation for HEAD requests.
|
To use this handler, add the following dependency to your project:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.21.0</version>
</dependency>
implementation 'com.bucket4j:bucket4j_jdk17-core:8.21.0'
The simplest configuration applies a global limit to all incoming requests.
10 requests per minute (Global)
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
import io.jooby.handler.RateLimitHandler;
{
Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build(); // (1)
before(new RateLimitHandler(bucket)); // (2)
}
import io.github.bucket4j.Bandwidth
import io.github.bucket4j.Bucket4j
import io.jooby.handler.RateLimitHandler
import java.time.Duration
{
val limit = Bandwidth.simple(10, Duration.ofMinutes(1))
val bucket = Bucket4j.builder().addLimit(limit).build() // (1)
before(RateLimitHandler(bucket)) // (2)
}
-
Create a bucket with the desired capacity and refill rate.
-
Install the RateLimitHandler as a before filter.
Often, you want to limit requests per user, API key, or IP address rather than globally.
Throttling by Remote IP
{
before(new RateLimitHandler(remoteAddress -> {
Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1));
return Bucket4j.builder().addLimit(limit).build();
}));
}
{
before(RateLimitHandler { remoteAddress ->
val limit = Bandwidth.simple(10, Duration.ofMinutes(1))
Bucket4j.builder().addLimit(limit).build()
})
}
Throttling by API Key Header
{
before(new RateLimitHandler(key -> {
Bandwidth limit = Bandwidth.simple(50, Duration.ofHours(1));
return Bucket4j.builder().addLimit(limit).build();
}, "X-API-Key"));
}
{
before(RateLimitHandler({ key ->
val limit = Bandwidth.simple(50, Duration.ofHours(1))
Bucket4j.builder().addLimit(limit).build()
}, "X-API-Key"))
}
If you are running multiple Jooby instances, you can use a distributed bucket using Bucket4j’s ProxyManager. This allows the rate limit state to be shared across the cluster via a backend like Redis or Hazelcast.
Cluster Support
{
ProxyManager<String> buckets = ...; // Configure your backend (Redis, etc.)
before(RateLimitHandler.cluster(key -> {
return buckets.getProxy(key, () -> {
return Bucket4j.configurationBuilder()
.addLimit(Bandwidth.simple(100, Duration.ofMinutes(1)))
.build();
});
}));
}
{
val buckets: ProxyManager<String> = ... // Configure backend
before(RateLimitHandler.cluster { key ->
buckets.getProxy(key) {
Bucket4j.configurationBuilder()
.addLimit(Bandwidth.simple(100, Duration.ofMinutes(1)))
.build()
}
})
}
For more details on setting up backends like Redis, Hazelcast, or Infinispan, refer to the Bucket4j documentation.
The SSLHandler forces clients to use HTTPS by automatically redirecting all non-secure (HTTP) requests to their secure (HTTPS) version.
Before using this handler, ensure you have followed the instructions in the HTTPS Support section to enable SSL on your server.
Usage
import io.jooby.handler.SSLHandler;
{
before(new SSLHandler()); // (1)
get("/", ctx -> "You are on: " + ctx.getScheme());
}
import io.jooby.handler.SSLHandler
{
before(SSLHandler()) // (1)
get("/") { "You are on: ${ctx.scheme}" }
}
-
Install the SSLHandler as a before filter.
The handler reconstructs the HTTPS URL using the Host header. If your application is running behind a load balancer or reverse proxy, you must enable the trust proxy option to ensure the handler correctly identifies the client’s original protocol and host.
By default, the handler redirects to the host provided in the request. You can explicitly specify a destination host if needed:
Explicit Host Redirect
{
// Redirects all traffic to https://myhost.org
before(new SSLHandler("myhost.org"));
}
{
// Redirects all traffic to https://myhost.org
before(SSLHandler("myhost.org"))
}
|
Tip
|
For production environments behind a reverse proxy (like Nginx, HAProxy, or an AWS ALB) that terminates SSL, the SSLHandler relies on the X-Forwarded-Proto header. Make sure your proxy is configured to send it and that you have enabled setTrustProxy(true) in your Jooby configuration.
|
By default, Jooby does not support HTTP TRACE requests. To enable them for debugging or diagnostic purposes, you can use the TraceHandler.
The TraceHandler implements a loop-back test by returning the received request message back to the client as the entity body of a 200 (OK) response.
Usage
import io.jooby.handler.TraceHandler;
{
use(new TraceHandler()); // (1)
get("/", ctx -> "Hello World");
}
import io.jooby.handler.TraceHandler
{
use(TraceHandler()) // (1)
get("/") { "Hello World" }
}
-
Install the TraceHandler as a decorator.
When a TRACE request is received, the handler bypasses the normal route logic and echoes the request headers and body back to the client. This is useful for seeing what transformations are being applied to the request by intermediate proxies or firewalls.
|
Warning
|
HTTP TRACE can potentially expose sensitive information (like cookies or authentication headers) to malicious scripts (Cross-Site Tracing). It is generally recommended to enable this handler only in development or strictly controlled diagnostic environments.
|
Jooby catches application exceptions using the ErrorHandler class. By default, the DefaultErrorHandler produces a simple HTML page or a JSON response based on the request’s Accept header, and logs the exception.
HTML output:
Not Found
message: Page not found
status code: 404
JSON output:
{
"message": "Page not found",
"status": 404,
"reason": "Not Found"
}
Log output:
GET /xx 404 Not Found
io.jooby.exception.StatusCodeException: Not found
at ...
The StatusCodeException is a generic exception that lets you explicitly specify an HTTP status code:
throw new StatusCodeException(StatusCode.FORBIDDEN);
throw new StatusCodeException(StatusCode.NOT_FOUND);
Several standard Java exceptions are automatically mapped to default status codes:
-
IllegalArgumentException (and subclasses): 400 BAD_REQUEST
-
NoSuchElementException (and subclasses): 400 BAD_REQUEST
-
FileNotFoundException (and subclasses): 404 NOT_FOUND
-
Exception (and all other subclasses): 500 SERVER_ERROR
To map a custom exception to a specific status code, register it using the errorCode method:
{
errorCode(MyException.class, StatusCode.UNPROCESSABLE_ENTITY);
}
You can override the default behavior and provide a custom global error handler using the error(ErrorHandler) method:
Global Error Handler
{
error((ctx, cause, statusCode) -> { // (1)
Router router = ctx.getRouter();
router.getLog().error("Found {} error", statusCode.value(), cause); // (2)
ctx.setResponseCode(statusCode);
ctx.send("Encountered a " + statusCode.value() + " error"); // (3)
});
}
{
error { ctx, cause, statusCode -> // (1)
val router = ctx.router
router.log.error("Found {} error", statusCode.value(), cause) // (2)
ctx.responseCode = statusCode
ctx.send("Encountered a ${statusCode.value()} error") // (3)
}
}
-
Register a global (catch-all) exception handler.
-
Log the error.
-
Send a custom error response to the client.
The next example produces an HTML or JSON response based on the client’s Accept header using content negotiation:
Error Handling with Content Negotiation
import static io.jooby.MediaType.json;
{
install(new MyTemplateEngineModule()); // (1)
install(new MyJsonModule()); // (2)
error((ctx, cause, statusCode) -> {
ctx.getRouter().getLog().error("Error: {}", statusCode.value(), cause);
Map<String, Object> errorData = Map.of("message", cause.getMessage());
if (ctx.accept(json)) { // (3)
ctx.render(errorData); // (4)
} else {
ctx.render(new ModelAndView("error.template", errorData)); // (5)
}
});
}
import io.jooby.MediaType.json
{
install(MyTemplateEngineModule()) // (1)
install(MyJsonModule()) // (2)
error { ctx, cause, statusCode ->
ctx.router.log.error("Error: {}", statusCode.value(), cause)
val errorData = mapOf("message" to cause.message)
if (ctx.accept(json)) { // (3)
ctx.render(errorData) // (4)
} else {
ctx.render(ModelAndView("error.template", errorData)) // (5)
}
}
}
-
Install a template engine module.
-
Install a JSON module.
-
Check if the Accept header prefers application/json.
-
Render the JSON response if matched.
-
Fallback to rendering an HTML template.
In addition to the global error handler, you can register handlers for specific HTTP status codes:
Status Code Handler
import static io.jooby.StatusCode.NOT_FOUND;
{
error(NOT_FOUND, (ctx, cause, statusCode) -> {
ctx.send(statusCode); // (1)
});
}
import io.jooby.StatusCode.NOT_FOUND
{
error(NOT_FOUND) { ctx, cause, statusCode ->
ctx.send(statusCode) // (1)
}
}
-
Send a silent 404 response to the client.
In this example, we silence all 404 responses by bypassing the logging system and sending an empty response body.
|
Tip
|
The send(StatusCode) method sends an empty HTTP response with the specified status code.
|
You can also register handlers for specific exception types. This is useful for intercepting business-logic exceptions before they hit the global handler:
Exception Type Handler
{
error(MyBusinessException.class, (ctx, cause, statusCode) -> {
// Log and handle MyBusinessException specifically
});
}
{
error(MyBusinessException::class) { ctx, cause, statusCode ->
// Log and handle MyBusinessException specifically
}
}
Most APIs need a structured way to report errors, helping users understand exactly what went wrong. While you could invent a custom error-reporting format, it requires effort to design and forces your clients to learn a non-standard schema.
Instead, you can adopt the standard defined in IETF RFC 7807 (later refined by RFC 9457). Adopting this standard saves you time and benefits your users by providing a familiar, widely supported error format.
Jooby provides built-in, native support for RFC 7807 Problem Details.
To enable Problem Details, simply add the following line to your configuration:
application.conf
problem.details.enabled = true
This minimal configuration enables a global error handler that catches all exceptions, transforms them into the Problem Details format, and renders the response based on the Accept header. It also sets the appropriate content type (e.g., application/problem+json).
You can customize the behavior using these additional properties:
application.conf
problem.details {
enabled = true
log4xxErrors = true // (1)
muteCodes = [401, 403] // (2)
muteTypes = ["com.example.MyMutedException"] // (3)
}
-
By default, only server errors (5xx) are logged. You can enable logging for client errors (4xx) as well. (If your logger is set to DEBUG, the log will also include the stack trace).
-
Mute logging entirely for specific HTTP status codes.
-
Mute logging entirely for specific Exception classes.
The HttpProblem class represents the RFC 7807 model. Because it extends RuntimeException, you can throw it naturally just like any other exception.
There are several static methods to quickly produce an HttpProblem:
There are also shorthands for common HTTP errors:
-
HttpProblem.badRequest(…) (400)
-
HttpProblem.notFound(…) (404)
-
HttpProblem.unprocessableEntity(…) (422)
-
HttpProblem.internalServerError() (500)
Throwing an HttpProblem
import io.jooby.problem.HttpProblem;
import io.jooby.StatusCode;
{
get("/users/{userId}", ctx -> {
String userId = ctx.path("userId").value();
User user = userRepository.findUser(userId);
if (user == null) {
throw HttpProblem.valueOf(
StatusCode.NOT_FOUND,
"User Not Found",
"User with ID " + userId + " was not found in the system."
);
}
// ...
});
}
import io.jooby.problem.HttpProblem
import io.jooby.StatusCode
{
get("/users/{userId}") { ctx ->
val userId = ctx.path("userId").value()
val user = userRepository.findUser(userId)
if (user == null) {
throw HttpProblem.valueOf(
StatusCode.NOT_FOUND,
"User Not Found",
"User with ID $userId was not found in the system."
)
}
// ...
}
}
{
"timestamp": "2024-10-05T14:10:41.648933100Z",
"type": "about:blank",
"title": "User Not Found",
"status": 404,
"detail": "User with ID 123 was not found in the system.",
"instance": null
}
For complex errors, use the builder to construct a rich problem instance with all standard properties:
Using the Builder
throw HttpProblem.builder()
.type(URI.create("http://example.com/invalid-params"))
.title("Invalid input parameters")
.status(StatusCode.UNPROCESSABLE_ENTITY)
.detail("'Name' may not be empty")
.instance(URI.create("http://example.com/invalid-params/3325"))
.build();
throw HttpProblem.builder()
.type(URI.create("http://example.com/invalid-params"))
.title("Invalid input parameters")
.status(StatusCode.UNPROCESSABLE_ENTITY)
.detail("'Name' may not be empty")
.instance(URI.create("http://example.com/invalid-params/3325"))
.build()
RFC 7807 allows you to add custom properties to the problem details object. To make serialization easier (especially in statically typed languages), Jooby groups all extra properties under a single root field called parameters.
You can add parameters via the builder:
Adding Parameters
throw HttpProblem.builder()
.title("Order not found")
.status(StatusCode.NOT_FOUND)
.detail("Order with ID " + orderId + " could not be processed.")
.param("reason", "Order ID format incorrect or order does not exist.")
.param("suggestion", "Please check the order ID and try again.")
.param("supportReference", "/support")
.build();
throw HttpProblem.builder()
.title("Order not found")
.status(StatusCode.NOT_FOUND)
.detail("Order with ID $orderId could not be processed.")
.param("reason", "Order ID format incorrect or order does not exist.")
.param("suggestion", "Please check the order ID and try again.")
.param("supportReference", "/support")
.build()
{
"title": "Order not found",
"status": 404,
"detail": "Order with ID 123 could not be processed.",
"parameters": {
"reason": "Order ID format incorrect or order does not exist.",
"suggestion": "Please check the order ID and try again.",
"supportReference": "/support"
}
}
Some HTTP responses (like 413 Payload Too Large or 426 Upgrade Required) require specific response headers. You can append headers directly to your HttpProblem:
Adding Headers
throw HttpProblem.builder()
.title("Invalid input parameters")
.status(StatusCode.UNPROCESSABLE_ENTITY)
.header("my-string-header", "string-value")
.header("my-int-header", 100)
.build();
throw HttpProblem.builder()
.title("Invalid input parameters")
.status(StatusCode.UNPROCESSABLE_ENTITY)
.header("my-string-header", "string-value")
.header("my-int-header", 100)
.build()
RFC 9457 introduced a standard way to deliver bulk validation errors via an errors array. You can add these using the error() or errors() methods in the builder:
Adding Validation Errors
throw HttpProblem.builder()
.title("Validation Failed")
.status(StatusCode.BAD_REQUEST)
.error(new HttpProblem.Error("First name cannot be blank", "/firstName"))
.error(new HttpProblem.Error("Last name is required", "/lastName"))
.build();
throw HttpProblem.builder()
.title("Validation Failed")
.status(StatusCode.BAD_REQUEST)
.error(HttpProblem.Error("First name cannot be blank", "/firstName"))
.error(HttpProblem.Error("Last name is required", "/lastName"))
.build()
{
"title": "Validation Failed",
"status": 400,
"errors": [
{
"detail": "First name cannot be blank",
"pointer": "/firstName"
},
{
"detail": "Last name is required",
"pointer": "/lastName"
}
]
}
|
Tip
|
If you need to enrich validation errors with more information, you can extend HttpProblem.Error to create your own custom error model.
|
If your application already uses a suite of custom exception classes, you don’t need to rewrite them. Make them Problem Details-compliant by implementing the HttpProblemMappable interface:
Mapping Existing Exceptions
import io.jooby.problem.HttpProblemMappable;
public class MyBusinessException extends RuntimeException implements HttpProblemMappable {
@Override
public HttpProblem toHttpProblem() {
return HttpProblem.builder()
.title("Business Logic Violation")
.status(StatusCode.CONFLICT)
.detail(this.getMessage())
.build();
}
}
import io.jooby.problem.HttpProblemMappable
class MyBusinessException(message: String) : RuntimeException(message), HttpProblemMappable {
override fun toHttpProblem(): HttpProblem {
return HttpProblem.builder()
.title("Business Logic Violation")
.status(StatusCode.CONFLICT)
.detail(this.message)
.build()
}
}
You can easily define domain-specific problem types by extending HttpProblem and utilizing the builder:
Custom Problem Class
public class OutOfStockProblem extends HttpProblem {
private static final URI TYPE = URI.create("https://example.org/out-of-stock");
public OutOfStockProblem(String product) {
super(builder()
.type(TYPE)
.title("Out of Stock")
.status(StatusCode.BAD_REQUEST)
.detail("The product '" + product + "' is no longer available.")
.param("suggestions", List.of("Grinder MX-17", "Grinder MX-25"))
.build()
);
}
}
import java.net.URI
class OutOfStockProblem(product: String) : HttpProblem(
builder()
.type(URI.create("https://example.org/out-of-stock"))
.title("Out of Stock")
.status(StatusCode.BAD_REQUEST)
.detail("The product '$product' is no longer available.")
.param("suggestions", listOf("Grinder MX-17", "Grinder MX-25"))
.build()
)
The features above allow you to rely entirely on Jooby’s built-in global error handler. However, if you have a niche use case that requires a custom exception handler, you can still catch the exception and manually delegate it to the Problem Details renderer:
Custom Handler Delegation
{
error(MyCustomException.class, (ctx, cause, code) -> {
MyCustomException ex = (MyCustomException) cause;
HttpProblem problem = HttpProblem.valueOf(code, ex.getMessage()); // (1)
ctx.getRouter().getErrorHandler().apply(ctx, problem, code); // (2)
});
}
{
error(MyCustomException::class) { ctx, cause, code ->
val ex = cause as MyCustomException
val problem = HttpProblem.valueOf(code, ex.message) // (1)
ctx.router.errorHandler.apply(ctx, problem, code) // (2)
}
}
-
Transform the custom exception into an HttpProblem.
-
Propagate the problem back to the global ProblemDetailsHandler to ensure standard rendering.
|
Important
|
Do not attempt to render HttpProblem manually. Because HttpProblem derives from RuntimeException, it contains a stack trace. If you render it manually without passing it to the global error handler, you risk accidentally exposing the raw stack trace to the client. Always propagate the problem back to the router’s error handler so it can be safely sanitized and formatted.
|
Jooby is a flexible, performant micro-framework that provides both blocking and non-blocking APIs for building web applications in Java and Kotlin.
In this chapter, we will cover the Jooby execution model, specifically:
-
Executing code on the event loop.
-
Safely executing blocking code.
-
Working with non-blocking types like CompletableFuture, Reactive Streams, and Kotlin Coroutines.
The EVENT_LOOP mode allows you to run route handlers directly on the event loop (a.k.a. non-blocking mode).
import static io.jooby.ExecutionMode.EVENT_LOOP;
import static io.jooby.Jooby.runApp;
public class App extends Jooby {
{
get("/", ctx -> "I'm non-blocking!");
}
public static void main(String[] args) {
runApp(args, EVENT_LOOP, App::new);
}
}
import io.jooby.ExecutionMode.EVENT_LOOP
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args, EVENT_LOOP) {
get("/") { "I'm non-blocking!" }
}
}
The EVENT_LOOP mode is an advanced execution model that requires careful application design, because BLOCKING IS STRICTLY FORBIDDEN on the event loop thread.
What if you need to block?
The dispatch(Runnable) operator shifts execution to a worker executor, which safely allows blocking calls (like database queries or remote service calls):
import static io.jooby.ExecutionMode.EVENT_LOOP;
import static io.jooby.Jooby.runApp;
public class App extends Jooby {
{
get("/", ctx -> "I'm non-blocking!");
dispatch(() -> {
// All routes defined inside this block are allowed to block:
get("/db-list", ctx -> {
// Safe to block!
Object result = fetchFromDatabase();
return result;
});
});
}
public static void main(String[] args) {
runApp(args, EVENT_LOOP, App::new);
}
}
import io.jooby.ExecutionMode.EVENT_LOOP
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args, EVENT_LOOP) {
get("/") { "I'm non-blocking!" }
dispatch {
// All routes defined inside this block are allowed to block:
get("/db-list") {
// Safe to block!
val result = fetchFromDatabase()
result
}
}
}
}
By default, the dispatch operator moves execution to the default worker executor provided by the underlying web server.
However, you can provide your own custom worker executor at the application level or specifically for a single dispatch block:
import static io.jooby.ExecutionMode.EVENT_LOOP;
import static io.jooby.Jooby.runApp;
import java.util.concurrent.Executors;
public class App extends Jooby {
{
// Application-level executor
worker(Executors.newCachedThreadPool());
// Dispatches to the application-level executor (the cached thread pool)
dispatch(() -> {
// ...
});
// Dispatches to an explicit, custom executor
var cpuIntensive = Executors.newSingleThreadExecutor();
dispatch(cpuIntensive, () -> {
// ...
});
}
public static void main(String[] args) {
runApp(args, EVENT_LOOP, App::new);
}
}
import io.jooby.ExecutionMode.EVENT_LOOP
import io.jooby.kt.runApp
import java.util.concurrent.Executors
fun main(args: Array<String>) {
runApp(args, EVENT_LOOP) {
// Application-level executor
worker(Executors.newCachedThreadPool())
// Dispatches to the application-level executor (the cached thread pool)
dispatch {
// ...
}
// Dispatches to an explicit, custom executor
val cpuIntensive = Executors.newSingleThreadExecutor()
dispatch(cpuIntensive) {
// ...
}
}
}
The WORKER mode allows you to make blocking calls from any route handler (a.k.a. blocking mode). You can write code sequentially without worrying about blocking the server.
import static io.jooby.ExecutionMode.WORKER;
import static io.jooby.Jooby.runApp;
public class App extends Jooby {
{
get("/", ctx -> {
// Safe to block!
Object result = fetchFromDatabase();
return result;
});
}
public static void main(String[] args) {
runApp(args, WORKER, App::new);
}
}
import io.jooby.ExecutionMode.WORKER
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args, WORKER) {
get("/") {
// Safe to block!
val result = fetchFromDatabase()
result
}
}
}
Just like in EVENT_LOOP mode, you can override the default server worker and provide your own custom executor:
import static io.jooby.ExecutionMode.WORKER;
import static io.jooby.Jooby.runApp;
import java.util.concurrent.Executors;
public class App extends Jooby {
{
worker(Executors.newCachedThreadPool());
get("/", ctx -> {
// Safe to block! Handled by the cached thread pool.
Object result = fetchFromDatabase();
return result;
});
}
public static void main(String[] args) {
runApp(args, WORKER, App::new);
}
}
import io.jooby.ExecutionMode.WORKER
import io.jooby.kt.runApp
import java.util.concurrent.Executors
fun main(args: Array<String>) {
runApp(args, WORKER) {
worker(Executors.newCachedThreadPool())
get("/") {
// Safe to block! Handled by the cached thread pool.
val result = fetchFromDatabase()
result
}
}
}
|
Note
|
When running in WORKER mode, Jooby implicitly applies a dispatch call to the worker executor for every route.
|
The DEFAULT execution mode is a smart hybrid between the WORKER and EVENT_LOOP modes. As the name implies, this is the default execution mode in Jooby.
Jooby analyzes the return type of your route handler to determine which execution mode fits best.
If the response type is non-blocking, it executes on the event loop. Otherwise, it dispatches to the worker executor.
A response type is considered non-blocking if the route handler produces:
-
A CompletableFuture
-
An RxJava type (e.g., Single, Flowable)
-
A Reactor type (e.g., Mono, Flux)
-
A Kotlin Coroutine
import static io.jooby.Jooby.runApp;
import java.util.concurrent.CompletableFuture;
public class App extends Jooby {
{
get("/non-blocking", ctx -> {
return CompletableFuture
.supplyAsync(() -> "I'm non-blocking!"); // (1)
});
get("/blocking", ctx -> {
return "I'm blocking"; // (2)
});
}
public static void main(String[] args) {
runApp(args, App::new);
}
}
import io.jooby.kt.runApp
import java.util.concurrent.CompletableFuture
fun main(args: Array<String>) {
runApp(args) {
get("/non-blocking") {
CompletableFuture
.supplyAsync { "I'm non-blocking!" } // (1)
}
get("/blocking") {
"I'm blocking" // (2)
}
}
}
-
CompletableFuture is a non-blocking type; this route executes directly on the event loop.
-
String is a blocking type; this route is dispatched to the worker executor.
|
Tip
|
You are free to return non-blocking types while running in explicit WORKER or EVENT_LOOP modes. Non-blocking response types are not exclusive to DEFAULT mode; all DEFAULT mode does is dynamically decide whether to dispatch to a worker based on that return type.
|
This section details the default worker executors provided by the underlying web servers. The worker executor is used when:
Each web server provides its own default worker executor tuning:
workerThreads = Math.max(Runtime.getRuntime().availableProcessors(), 2) * 8
workerThreads = Runtime.getRuntime().availableProcessors() * 8
These are sensible defaults provided by the server implementations. If you need to increase or decrease the number of worker threads globally, you can configure the server:
{
configureServer(server -> {
server.setWorkerThreads(100);
});
}
{
configureServer { server ->
server.setWorkerThreads(100)
}
}
Everything you need to handle HTTP traffic and build robust APIs or web applications. Explore Jooby’s expressive routing paradigms, request and response handling, content negotiation, and advanced web features like WebSockets and file uploads.
Static files are served using the assets(String,Path) method. The assets route supports serving resources from both the classpath and the file system.
Serving a directory:
{
assets("/static/*", "/source"); // (1)
}
{
assets("/static/*", "/source") // (1)
}
-
Maps all incoming requests starting with /static/ to the /source location.
-
GET /static/index.html ⇒ /source/index.html
-
GET /static/js/file.js ⇒ /source/js/file.js
-
GET /static/css/styles.css ⇒ /source/css/styles.css
When passing a string as the source ("/source"), Jooby attempts to resolve it against the file system first (by prepending the user.dir path). If the directory exists on the file system, it uses it; otherwise, it falls back to looking for the /source directory on the classpath.
To explicitly serve from the file system, pass a Path object:
Explicit File System Resources:
{
assets("/static/*", Paths.get("www")); // (1)
}
{
assets("/static/*", Paths.get("www")) // (1)
}
-
Maps all incoming requests starting with /static/ directly to the www directory on the file system.
You can also map individual files rather than entire directories:
Single File Mapping
{
// Classpath
assets("/myfile.js", "/static/myfile.js");
// File System
Path basedir = Paths.get("www");
assets("/myfile.js", basedir.resolve("myfile.js"));
}
{
// Classpath
assets("/myfile.js", "/static/myfile.js")
// File System
val basedir = Paths.get("www")
assets("/myfile.js", basedir.resolve("myfile.js"))
}
The assets route can also serve full static websites (like generated documentation). To enable automatic resolution of index.html files, use the special /?* path mapping:
Static Site Directory
{
Path docs = Paths.get("docs"); // (1)
assets("/docs/?*", docs); // (2)
}
{
val docs = Paths.get("docs") // (1)
assets("/docs/?*", docs) // (2)
}
-
Serve from the docs directory.
-
Use the /?* mapping to enable automatic root resolution.
The /?* syntax automatically resolves root paths to their underlying index.html:
-
GET /docs ⇒ /docs/index.html
-
GET /docs/index.html ⇒ /docs/index.html
-
GET /docs/about.html ⇒ /docs/about.html
-
GET /docs/note ⇒ /docs/note/index.html
The assets route also supports Single Page Applications (SPAs) built with React, Vue, or Angular.
For an SPA to handle client-side routing properly, the server must return the main index.html file whenever a requested asset or route is not found. You can configure this using the AssetHandler:
SPA Routing
{
AssetSource buildDir = AssetSource.create(Paths.get("build")); // (1)
assets("/?*", new AssetHandler("index.html", buildDir)); // (2)
}
{
val buildDir = AssetSource.create(Paths.get("build")) // (1)
assets("/?*", AssetHandler("index.html", buildDir)) // (2)
}
-
Define the source directory containing the built SPA.
-
Map the root using /?* and provide index.html as the fallback asset.
In SPA mode, the server never generates a 404 NOT FOUND response for missing files; it simply falls back to serving the index.html so the client-side router can take over.
The AssetHandler automatically generates and handles ETag and Last-Modified headers. You can control this behavior programmatically:
Disable ETag and Last-Modified:
{
assets("/static/*", Paths.get("www"))
.setLastModified(false)
.setEtag(false);
}
{
assets("/static/*", Paths.get("www"))
.setLastModified(false)
.setEtag(false)
}
You can also set a Cache-Control header using the maxAge option:
Cache Control:
{
assets("/static/*", Paths.get("www"))
.setMaxAge(Duration.ofDays(365));
}
{
assets("/static/*", Paths.get("www"))
.setMaxAge(Duration.ofDays(365))
}
To explicitly forbid web browsers from caching assets, use the setNoCache() method.
If you need different cache configurations for different files, you can provide a function via cacheControl(Function):
Per-Asset Cache Control:
{
assets("/static/*", Paths.get("www"))
.cacheControl(path -> {
if (path.endsWith("dont-cache-me.html")) {
return CacheControl.noCache(); // Disable caching
} else if (path.equals("foo.js")) {
return CacheControl.defaults()
.setETag(false)
.setMaxAge(Duration.ofDays(365));
} else {
return CacheControl.defaults(); // Use default AssetHandler caching
}
});
}
{
assets("/static/*", Paths.get("www"))
.cacheControl { path ->
when {
path.endsWith("dont-cache-me.html") -> CacheControl.noCache() // Disable caching
path == "foo.js" -> CacheControl.defaults()
.setETag(false)
.setMaxAge(Duration.ofDays(365))
else -> CacheControl.defaults() // Use default AssetHandler caching
}
}
}
By default, the asset handler generates a 404 response if the requested file is not found (unless you are using the SPA fallback). You can override this behavior by throwing a custom exception or generating alternative content:
Custom Not Found Behavior:
{
assets("/static/*", Paths.get("www"))
.notFound(ctx -> {
throw new MyAssetException();
});
error(MyAssetException.class, (ctx, cause, code) -> {
// Render MyAssetException the way you want
});
}
{
assets("/static/*", Paths.get("www"))
.notFound { _ ->
throw MyAssetException()
}
error(MyAssetException::class) { ctx, cause, code ->
// Render MyAssetException the way you want
}
}
The MVC API provides an annotation-driven alternative to the Script API for defining routes. Jooby uses an annotation processor to generate source code that defines and executes these routes. By default, the generated classes are suffixed with an underscore (_).
If you use Gradle 6.0 or later, or a modern Maven setup, Jooby leverages incremental annotation processing. This means the compiler only processes classes that have changed since the last build, significantly speeding up compilation times.
You can control incremental processing via compiler arguments:
build.gradle Kotlin (kapt)
tasks.withType(JavaCompile) {
options.compilerArgs += [
'-parameters',
'-Ajooby.incremental=true'
]
}
kapt {
arguments {
arg('jooby.incremental', true)
}
}
By setting jooby.incremental=false, you disable incremental processing entirely, forcing a full recompilation of the project every time. (Defaults to true).
The io.jooby.annotation package contains all the annotations available for MVC routes.
MVC API Example:
import io.jooby.annotation.*;
@Path("/mvc") // (1)
public class Controller {
@GET // (2)
public String sayHi() {
return "Hello Mvc!";
}
}
public class App extends Jooby {
{
mvc(new Controller_()); // (3)
}
public static void main(String[] args) {
runApp(args, App::new);
}
}
import io.jooby.annotation.*
import io.jooby.kt.runApp
@Path("/mvc") // (1)
class Controller {
@GET // (2)
fun sayHi(): String {
return "Hello Mvc!"
}
}
fun main(args: Array<String>) {
runApp(args) {
mvc(Controller_()) // (3)
}
}
-
Set a base path pattern. The @Path annotation can be applied at the class or method level.
-
Define the HTTP method.
-
Register the generated controller (Controller_) in the main application.
To quickly create a new MVC project, use the jooby console:
The Jooby CLI automatically configures the Maven/Gradle build and sets up the annotation processor for you.
Unlike some frameworks, Jooby does not use classpath scanning. MVC routes must be explicitly registered in your application configuration:
Simple MVC Route Registration
public class App extends Jooby {
{
mvc(new MyController_());
}
public static void main(String[] args) {
runApp(args, App::new);
}
}
import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args) {
mvc(MyController_())
}
}
The mvc(Extension) method installs the MVC routes. You can pass an instance directly, or if the controller constructor is annotated with @Inject (e.g., jakarta.inject.Inject), the generated code will attempt to resolve the dependencies from the registry.
HTTP parameter extraction is handled via @*Param annotations.
You can also use the generic Param annotation to extract a parameter from multiple sources with a specific fallback order.
Headers
public class MyController {
@GET
public String handle(@HeaderParam String token) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@HeaderParam token: String): String { // (1)
// ...
}
}
-
Accesses the HTTP header named token.
Unlike JAX-RS, specifying the parameter name inside the annotation is optional. Jooby infers it from the variable name. However, you must provide the name explicitly if the HTTP header is not a valid Java identifier:
Invalid Java Identifier
public class MyController {
@GET
public String handle(@HeaderParam("Last-Modified-Since") long lastModifiedSince) {
// ...
}
}
class MyController {
@GET
fun handle(@HeaderParam("Last-Modified-Since") lastModifiedSince: Long): String {
// ...
}
}
Cookies
public class MyController {
@GET
public String handle(@CookieParam String token) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@CookieParam token: String): String { // (1)
// ...
}
}
-
Accesses the cookie named token.
As with headers, provide the explicit name if the cookie key contains dashes or invalid Java characters (e.g., @CookieParam("token-id") String tokenId).
Extract path variables using the PathParam annotation:
PathParam
public class MyController {
@Path("/{id}")
public String handle(@PathParam String id) {
// ...
}
}
class MyController {
@Path("/{id}")
fun handle(@PathParam id: String): String {
// ...
}
}
Extract query string variables using the QueryParam annotation:
QueryParam
public class MyController {
@Path("/")
public String handle(@QueryParam String q) {
// ...
}
}
class MyController {
@Path("/")
fun handle(@QueryParam q: String): String {
// ...
}
}
Extract form-data or multipart parameters using the FormParam annotation:
FormParam
public class MyController {
@POST("/")
public String handle(@FormParam String username) {
// ...
}
}
class MyController {
@POST("/")
fun handle(@FormParam username: String): String {
// ...
}
}
The HTTP request body does not require an explicit annotation. Simply define the POJO in the method signature:
HTTP Body
public class MyController {
@POST("/")
public String handle(MyObject body) {
// ...
}
}
class MyController {
@POST("/")
fun handle(body: MyObject): String {
// ...
}
}
The BindParam annotation allows for custom data binding from the HTTP request directly into an object.
Using the annotation
public class Controller {
@GET("/{foo}")
public String bind(@BindParam MyBean bean) {
return "with custom mapping: " + bean;
}
}
class Controller {
@GET("/{foo}")
fun bind(@BindParam bean: MyBean) = "with custom mapping: $bean"
}
Writing the mapping function
public record MyBean(String value) {
public static MyBean of(Context ctx) {
// Build MyBean entirely from the Context
return new MyBean(ctx.path("foo").value());
}
}
class MyBean(val value: String) {
companion object {
@JvmStatic
fun of(ctx: Context): MyBean {
// Build MyBean entirely from the Context
return MyBean(ctx.path("foo").value())
}
}
}
-
It looks for a public method/function on the target class that accepts a Context and returns the target type.
-
By default, it looks for this factory method on the parameter type itself (MyBean), but will fall back to searching the Controller class.
Alternatively, you can specify a distinct factory class and/or method name:
@BindParam(MyFactoryClass.class)
@BindParam(value = MyFactoryClass.class, fn = "fromContext")
Extract flash attributes using the FlashParam annotation:
Flash
public class MyController {
@GET
public String handle(@FlashParam String success) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@FlashParam success: String): String { // (1)
// ...
}
}
-
Accesses the flash attribute named success.
Extract specific session attributes using the SessionParam annotation:
Session Attribute
public class MyController {
@GET
public String handle(@SessionParam String userId) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@SessionParam userId: String): String { // (1)
// ...
}
}
-
Accesses the session attribute named userId.
You can also request the entire Session object:
Session Object
public class MyController {
@GET
public String handle(Session session) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(session: Session): String { // (1)
// ...
}
}
-
If no session exists yet, a new session will be created. To avoid this and only retrieve an existing session, use Optional<Session> as the parameter type.
Extract specific context attributes using the ContextParam annotation:
Context Attribute
public class MyController {
@GET
public String handle(@ContextParam String userId) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@ContextParam userId: String): String { // (1)
// ...
}
}
-
Accesses the context attribute named userId.
All Context Attributes
public class MyController {
@GET
public String handle(@ContextParam Map<String, Object> attributes) { // (1)
// ...
}
}
class MyController {
@GET
fun handle(@ContextParam attributes: Map<String, Any>): String { // (1)
// ...
}
}
-
To retrieve all context attributes, the parameter must be typed as a Map<String, Object> (or Map<String, Any> in Kotlin).
Use the Param annotation to search for a parameter across multiple sources with an explicitly defined fallback order:
Multiple Sources
import static io.jooby.annotation.ParamSource.QUERY;
import static io.jooby.annotation.ParamSource.PATH;
public class FooController {
@GET("/{foo}")
public String multipleSources(@Param({ QUERY, PATH }) String foo) {
return "foo is: " + foo;
}
}
import io.jooby.annotation.ParamSource.QUERY
import io.jooby.annotation.ParamSource.PATH
class FooController {
@GET("/{foo}")
fun multipleSources(@Param(QUERY, PATH) foo: String) = "foo is: $foo"
}
If a request is made to /bar?foo=baz, the result will be foo is: baz because the QUERY parameter takes precedence over the PATH parameter in the annotation array.
The MVC module provides first-class support for Projections via annotations. This allows you to define the response view declaratively, keeping your controller logic clean and focused on data retrieval.
There are two ways to define a projection in an MVC controller.
You can annotate your method with @Project and provide the selection DSL:
Via @Project Annotation
@GET
@Project("(id, name)")
public List<User> listUsers() {
return service.findUsers();
}
Alternatively, you can define the projection directly within the HTTP method annotation (e.g., @GET, @POST) using the projection attribute:
Via HTTP Method Attribute
@GET(value = "/users", projection = "(id, name, email)")
public List<User> listUsers() {
return service.findUsers();
}
The Jooby Annotation Processor automatically handles the conversion of your method’s return type. You are not forced to return a Projected instance; you can simply return your POJO or Collection, and Jooby will wrap it for you at compile-time.
However, if you need manual control (for example, to dynamically toggle validation), you can still return a Projected instance explicitly:
@GET
public Projected<User> getUser(String id) {
User user = service.findById(id);
return Projected.wrap(user)
.failOnMissingProperty(true)
.include("(id, status)");
}
The default HTTP status code returned by an MVC route is 200 OK, except for void methods annotated with @DELETE, which automatically return 204 No Content.
Any MVC method returning a non-blocking type (CompletableFuture, Single, Maybe, Flowable, Mono, Flux) is automatically handled as a non-blocking route.
Kotlin suspend functions are also supported natively:
Kotlin Coroutines
class SuspendMvc {
@GET
@Path("/delay")
suspend fun delayed(ctx: Context): String {
delay(100)
return ctx.getRequestPath()
}
}
fun main(args: Array<String>) {
runApp(args) {
mvc(SuspendMvc_())
}
}
A non-blocking route runs on the event loop by default, where blocking is NOT allowed. For more details, see the NonBlocking Responses section.
By default, if your route returns a blocking type (like a String or a POJO), Jooby automatically dispatches the execution to the worker executor. If it returns a non-blocking type (or is a suspend function), it runs on the event loop.
If you need explicit control over where a specific blocking MVC route executes, use the Dispatch annotation:
Dispatch Annotation
public class MyController {
@GET("/blocking")
@Dispatch // (1)
public String blocking() {
return "I'm blocking";
}
}
import io.jooby.annotation.*
class MyController {
@GET("/blocking")
@Dispatch // (1)
fun blocking(): String {
return "I'm blocking"
}
}
-
Forces the route to run in the WORKER executor, safely allowing blocking calls.
The Dispatch annotation also supports routing execution to a named, custom executor:
Dispatch to custom executor
public class MyController {
@GET("/blocking")
@Dispatch("single") // (1)
public String blocking() {
return "I'm blocking";
}
}
import io.jooby.annotation.*
class MyController {
@GET("/blocking")
@Dispatch("single") // (1)
fun blocking(): String {
return "I'm blocking"
}
}
-
Dispatches execution to the executor registered under the name single.
The custom executor must be registered in the application before the MVC route is registered:
Custom executor registration
{
executor("single", Executors.newSingleThreadExecutor());
mvc(new MyController_());
}
{
executor("single", Executors.newSingleThreadExecutor())
mvc(MyController_())
}
Alternatively, you can use JAX-RS annotations to define MVC routes.
Resource
import javax.ws.rs.GET;
import javax.ws.rs.Path;
@Path("/jaxrs")
public class Resource {
@GET
public String getIt() {
return "Got it!";
}
}
import javax.ws.rs.GET
import javax.ws.rs.Path
@Path("/jaxrs")
class Resource {
@GET
fun getIt(): String {
return "Got it!"
}
}
These annotations work exactly like the Jooby native MVC annotations.
(Note: Jooby does not implement the full JAX-RS specification, nor is there a plan to do so. Support for these annotations exists primarily to allow integration with third-party tools, like Swagger/OpenAPI generators, that rely on them).
For each MVC controller class, a new class is generated ending with an underscore (_). This generated class mimics the constructors of the source class. (If the constructor is annotated with @Inject, a default constructor is automatically generated).
Any annotations found on the controller methods will be persisted as route attributes, unless explicitly excluded by the jooby.skipAttributeAnnotations compiler option.
You can access the generated routes at runtime:
{
var routes = mvc(new MyController_());
routes.forEach(route -> {
// Modify or inspect the route
});
}
| Option |
Type |
Default |
Description |
jooby.debug
|
boolean |
true |
Runs the annotation processor in debug mode. |
jooby.incremental
|
boolean |
true |
Hints to Maven/Gradle to perform incremental compilation. Essential for fast development iteration. |
jooby.skipAttributeAnnotations
|
array |
[] |
A comma-separated list of annotations to skip during bytecode generation (i.e., do not attach them as route attributes). |
jooby.mvcMethod
|
boolean |
false |
Sets the Route.mvcMethod property on the generated route when true. |
jooby.routerPrefix
|
string |
|
Adds a prefix to the generated class name. |
jooby.routerSuffix
|
string |
_ |
Sets the suffix for the generated class name. |
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.jooby</groupId>
<artifactId>jooby-apt</artifactId>
<version>${jooby.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>-Ajooby.debug=false</compilerArg>
<compilerArg>-Ajooby.incremental=true</compilerArg>
<compilerArg>-Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation</compilerArg>
</compilerArgs>
</configuration>
</plugin>
tasks.withType(JavaCompile) {
options.compilerArgs += [
'-parameters',
'-Ajooby.debug=false',
'-Ajooby.incremental=true',
'-Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation'
]
}
|
Important
|
The execution order of annotation processors is critical. If you are using Lombok and Avaje Inject alongside Jooby, the configuration order must be: lombok → avaje-inject → jooby-apt.
|
Basic Rendering
{
install(new MyTemplateEngineModule()); // (1)
get("/", ctx -> {
MyModel model = new MyModel(); // (2)
return new ModelAndView("index.html", model); // (3)
});
}
{
install(MyTemplateEngineModule()) // (1)
get("/") { ctx ->
val model = MyModel() // (2)
ModelAndView("index.html", model) // (3)
}
}
-
Install a template engine module.
-
Build the view model.
-
Return a ModelAndView instance containing the template path and the model data.
You can explicitly set the desired locale for template rendering on the ModelAndView object:
Setting the Locale
{
install(new MyTemplateEngineModule());
get("/", ctx -> {
MyModel model = new MyModel();
return new ModelAndView("index.html", model)
.setLocale(Locale.GERMAN); // (1)
});
}
{
install(MyTemplateEngineModule())
get("/") { ctx ->
val model = MyModel()
ModelAndView("index.html", model)
.setLocale(Locale.GERMAN) // (1)
}
}
-
Explicitly set the preferred locale.
If no locale is specified explicitly, Jooby falls back to the locale matched by the Accept-Language header of the current request.
|
Note
|
Not all template engines support localized rendering. If you use a template engine that doesn’t support it, setting the locale will have no effect.
|
A template engine handles the actual view rendering. A template engine extends the MessageEncoder interface, accepting a ModelAndView instance and producing a String result.
The extensions() method lists the file extensions that the template engine supports. The default file extension is .html.
Jooby uses the file extension of the requested template to locate the correct template engine. If a template engine for the specified file extension isn’t found, an IllegalArgumentException is thrown.
This file-extension routing allows you to easily use multiple template engines side-by-side in the same application:
Multiple Template Engines
{
install(new HandlebarsModule()); // (1)
install(new FreemarkerModule()); // (2)
get("/first", ctx -> {
return new ModelAndView("index.hbs", model); // (3)
});
get("/second", ctx -> {
return new ModelAndView("index.ftl", model); // (4)
});
}
{
install(HandlebarsModule()) // (1)
install(FreemarkerModule()) // (2)
get("/first") { ctx ->
ModelAndView("index.hbs", model) // (3)
}
get("/second") { ctx ->
ModelAndView("index.ftl", model) // (4)
}
}
-
Install Handlebars.
-
Install Freemarker.
-
Renders using Handlebars due to the .hbs extension.
-
Renders using Freemarker due to the .ftl extension.
Check out the Modules section for a full list of supported template engines.
Since Jooby 3.1.x, the view model can be any custom Java object/POJO. (Previous versions strictly required the model to be a Map).
There are two primary ways to instantiate a ModelAndView:
-
new ModelAndView(String view, Object model) (For POJOs)
-
new MapModelAndView(String view, Map<String, Object> model) (For Maps)
Sessions are accessible via:
Sessions are commonly used for authentication, storing user preferences, or tracking user state.
A session attribute must be a String or a primitive. The session API does not allow storing arbitrary Java objects or complex object graphs. It is intended as a simple, lightweight mechanism to store basic data.
-
In-Memory Sessions: Suitable for single-instance applications (or multi-instance if combined with a sticky-session proxy).
-
Signed Cookie Sessions: Stateless sessions signed with a secret key.
-
JWT (JSON Web Token) Sessions: Stateless, token-based sessions.
Note: Since Jooby 4.0.0, no session store is configured by default. Attempting to access a session at runtime without first configuring a store will result in an exception.
The in-memory session store saves session data directly in the server’s RAM. It uses a cookie or HTTP header solely to track the session ID.
In-Memory Session
{
setSessionStore(SessionStore.memory(Cookie.session("myappid")));
get("/", ctx -> {
Session session = ctx.session(); // (1)
session.put("foo", "bar"); // (2)
return session.get("foo").value(); // (3)
});
}
{
sessionStore = SessionStore.memory(Cookie.session("myappid"))
get("/") {
val session = ctx.session() // (1)
session.put("foo", "bar") // (2)
session.get("foo").value() // (3)
}
}
-
Finds an existing session or creates a new one.
-
Sets a session attribute.
-
Gets a session attribute.
By default, the session ID is retrieved from a request cookie. The default session cookie never expires and is set to HttpOnly under the root / path.
To customize the cookie details:
In-Memory Session with Custom Cookie
{
setSessionStore(SessionStore.memory(new Cookie("SESSION"))); // (1)
get("/", ctx -> {
Session session = ctx.session();
session.put("foo", "bar");
return session.get("foo").value();
});
}
{
sessionStore = SessionStore.memory(Cookie("SESSION")) // (1)
get("/") {
val session = ctx.session()
session.put("foo", "bar")
session.get("foo").value()
}
}
-
Configures an in-memory session store using a custom cookie named SESSION.
Alternatively, you can use an HTTP header to transmit the session token/ID instead of a cookie:
In-Memory Session with HTTP Header
{
setSessionStore(SessionStore.memory(SessionToken.header("TOKEN"))); // (1)
get("/", ctx -> {
Session session = ctx.session();
session.put("foo", "bar");
return session.get("foo").value();
});
}
{
sessionStore = SessionStore.memory(SessionToken.header("TOKEN")) // (1)
get("/") {
val session = ctx.session()
session.put("foo", "bar")
session.get("foo").value()
}
}
-
The Session Token/ID is read from the TOKEN HTTP header.
You can also combine both methods, telling Jooby to check the cookie first, and then fall back to the header:
Mixed Tokens
{
setSessionStore(SessionStore.memory(
SessionToken.combine(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN")) // (1)
));
get("/", ctx -> {
Session session = ctx.session();
session.put("foo", "bar");
return session.get("foo").value();
});
}
{
sessionStore = SessionStore.memory(
SessionToken.combine(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN")) // (1)
)
get("/") {
val session = ctx.session()
session.put("foo", "bar")
session.get("foo").value()
}
}
-
The Session Token/ID is read from the SESSION cookie or the TOKEN header (in that order).
This is a stateless session store. The server does not keep any session state in memory. Instead, the entire session payload is serialized, cryptographically signed, and sent back and forth between the client and server on every request.
Usage
{
String secret = "super-secret-key-must-be-32-bytes"; // (1)
setSessionStore(SessionStore.signed(Cookie.session("myappid"), secret)); // (2)
get("/", ctx -> {
Session session = ctx.session();
session.put("foo", "bar");
return session.get("foo").value();
});
}
{
val secret = "super-secret-key-must-be-32-bytes" // (1)
sessionStore = SessionStore.signed(Cookie.session("myappid"), secret) // (2)
get("/") {
val session = ctx.session()
session.put("foo", "bar")
session.get("foo").value()
}
}
-
A secure, 32-byte secret key is required to sign the data.
-
Creates a signed session store using a cookie and the secret key.
Just like the in-memory store, the signed session store also supports HTTP headers:
Signed Session with HTTP Headers
{
String secret = "super-secret-key-must-be-32-bytes"; // (1)
setSessionStore(SessionStore.signed(SessionToken.header("TOKEN"), secret)); // (2)
get("/", ctx -> {
Session session = ctx.session();
session.put("foo", "bar");
return session.get("foo").value();
});
}
{
val secret = "super-secret-key-must-be-32-bytes" // (1)
sessionStore = SessionStore.signed(SessionToken.header("TOKEN"), secret) // (2)
get("/") {
val session = ctx.session()
session.put("foo", "bar")
session.get("foo").value()
}
}
In addition to the built-in memory and signed stores, Jooby provides external module integrations:
-
Caffeine: High-performance in-memory session store using the Caffeine cache library.
-
JWT: Stateless JSON Web Token session store.
-
Redis: Distributed session store using Redis.
Server-Sent Events (SSE) is a mechanism that allows the server to push data to the client once a connection is established. Unlike WebSockets, SSE is strictly unidirectional: the server can send data to the client, but not the other way around.
Server-Sent Events
{
sse("/sse", sse -> { // (1)
sse.send("Welcome jooby"); // (2)
});
}
{
sse("/sse") { // (1)
sse.send("Welcome jooby") // (2)
}
}
-
Connection established.
-
Send a message to the client.
Use @SSE to register an SSE route from a controller:
import io.jooby.ServerSentEmitter;
import io.jooby.annotation.SSE;
public class Events {
@SSE("/sse")
public void sse(ServerSentEmitter emitter) {
emitter.send("Welcome");
emitter.onClose(() -> {
// Clean up resources.
});
emitter.close();
}
}
import io.jooby.ServerSentEmitter
import io.jooby.annotation.SSE
class Events {
@SSE("/sse")
fun sse(emitter: ServerSentEmitter) {
emitter.send("Welcome")
emitter.onClose {
// Clean up resources.
}
emitter.close()
}
}
Install the controller with mvc(new Events()). The annotation processor generates an
app.sse("/sse", this::sse) registration. In Kotlin, use mvc(Events()) with KAPT enabled.
The controller method must return void in Java or Unit in Kotlin. Kotlin suspend
functions are not supported for @SSE methods.
The emitter is injected directly; regular MVC parameters and class-level @Path prefixes
are supported. Use @SSE(path = "/sse") (@SSE(path = ["/sse"]) in Kotlin) or multiple
path values as with other MVC routes.
Additional message properties (like custom events, IDs, and retry timeouts) are available via the ServerSentMessage class:
Server-Sent Message
{
sse("/sse", sse -> {
sse.send(
new ServerSentMessage("...")
.setEvent("myevent")
.setId("myId")
.setRetry(1000)
);
});
}
{
sse("/sse") {
sse.send(ServerSentMessage("...").apply {
event = "myevent"
id = "myId"
retry = 1000
})
}
}
For details on how these options are interpreted by the browser, see the MDN documentation on the Event stream format.
The sse.onClose(Runnable) callback allows you to clean up and release resources when the connection ends. A connection is considered closed when you explicitly call sse.close() or when the remote client disconnects.
Connection Lost
{
sse("/sse", sse -> {
sse.onClose(() -> {
// Clean up resources
});
});
}
{
sse("/sse") {
sse.onClose {
// Clean up resources
}
}
}
You can use the keep-alive feature to prevent idle connections from timing out or being dropped by intermediate proxies:
Keep Alive
{
sse("/sse", sse -> {
sse.keepAlive(15, TimeUnit.SECONDS);
});
}
{
sse("/sse") {
sse.keepAlive(15, TimeUnit.SECONDS)
}
}
This example sends a : message (an empty SSE comment) every 15 seconds to keep the connection active. If the client drops the connection, the sse.onClose event will be fired.
This feature is especially useful for quickly detecting closed connections without having to wait until your application tries to send a real event. (However, if your application already pushes data frequently—e.g., every few seconds—enabling keepAlive is generally unnecessary).
WebSockets are added using the ws() method:
WebSocket
{
ws("/ws", (ctx, configurer) -> { // (1)
configurer.onConnect(ws -> {
ws.send("Connected"); // (2)
});
configurer.onMessage((ws, message) -> {
ws.send("Got " + message.value()); // (3)
});
configurer.onClose((ws, statusCode) -> {
// Clean up resources (4)
});
configurer.onError((ws, cause) -> {
// Handle exceptions (5)
});
});
}
{
ws("/ws") { ctx, configurer -> // (1)
configurer.onConnect { ws ->
ws.send("Connected") // (2)
}
configurer.onMessage { ws, message ->
ws.send("Got " + message.value()) // (3)
}
configurer.onClose { ws, statusCode ->
// Clean up resources (4)
}
configurer.onError { ws, cause ->
// Handle exceptions (5)
}
}
}
-
Register a WebSocket handler.
-
On connection (open), send a message back to the client. This is also a good place to initialize resources.
-
On receiving a new message, send a response back to the client.
-
The WebSocket is about to close. You must free/release any acquired resources here.
-
The WebSocket encountered an exception. Useful for logging the error or providing an alternative response if the socket is still open.
You are free to access the HTTP context from the WebSocket configurer or callbacks, but it is forbidden to modify the HTTP context or produce an HTTP response from it.
Accessing Context
{
ws("/ws/{key}", (ctx, configurer) -> {
String key = ctx.path("key").value(); // (1)
String foo = ctx.session().get("foo").value(); // (2)
// ...
});
}
{
ws("/ws/{key}") { ctx, configurer ->
val key = ctx.path("key").value() // (1)
val foo = ctx.session().get("foo").value() // (2)
// ...
}
}
-
Access a path variable (key).
-
Access a session variable (foo).
Structured data (like JSON) is supported using the Value API and the render(Object) method.
To use structured messages, you need a registered MessageDecoder and MessageEncoder. In the following example, both are provided by the Jackson2Module.
JSON Example
import io.jooby.jackson.Jackson2Module;
{
install(new Jackson2Module()); // (1)
ws("/ws", (ctx, configurer) -> {
configurer.onMessage((ws, message) -> {
MyObject myobject = message.to(MyObject.class); // (2)
ws.render(myobject); // (3)
});
});
}
import io.jooby.jackson.Jackson2Module
{
install(Jackson2Module()) // (1)
ws("/ws") { ctx, configurer ->
configurer.onMessage { ws, message ->
val myobject = message.to<MyObject>() // (2)
ws.render(myobject) // (3)
}
}
}
-
Install the Jackson module (required for JSON decoding/encoding).
-
Parse and decode the incoming message to a MyObject.
-
Encode myobject as JSON and send it to the client.
Alternatively, you can explicitly tell the WebSocket which decoder/encoder to use by specifying the consumes and produces attributes:
Explicit Content Types
import io.jooby.jackson.Jackson2Module;
{
install(new Jackson2Module()); // (1)
ws("/ws", (ctx, configurer) -> {
configurer.onMessage((ws, message) -> {
MyObject myobject = message.to(MyObject.class); // (2)
ws.render(myobject); // (3)
});
})
.consumes(MediaType.json)
.produces(MediaType.json);
}
import io.jooby.jackson.Jackson2Module
{
install(Jackson2Module()) // (1)
ws("/ws") { ctx, configurer ->
configurer.onMessage { ws, message ->
val myobject = message.to<MyObject>() // (2)
ws.render(myobject) // (3)
}
}.consumes(MediaType.json)
.produces(MediaType.json)
}
You can implement the same WebSocket as above using annotated classes in declarative style.
Ensure that jooby-apt is in the annotation processor path, annotate the class with Path,
and mark methods with OnConnect, OnMessage, OnClose, and OnError. Compile code to generate an extension Extension and register it by calling ws(Extension).
When a lifecycle method returns a value, that value is written to the client automatically: plain text or binary for String, byte[], and ByteBuffer, and structured values (for example JSON) using the same encoders as in Structured Data. Alternatively, use a void method and send with ws.send(…) on the WebSocket argument.
@Path("/chat/{room}") // (1)
public class ChatSocket {
@OnConnect
public String onConnect(WebSocket ws, Context ctx) { // (2)
return "welcome";
}
@OnMessage
public Map<String, String> onMessage(WebSocket ws, Context ctx, WebSocketMessage message) { // (3)
return Map.of("echo", message.value());
// ws.send(message.value()); // (4)
}
@OnClose
public void onClose(WebSocket ws, Context ctx, WebSocketCloseStatus status) {}
@OnError
public void onError(WebSocket ws, Context ctx, Throwable cause) {}
}
// Application startup:
{
ws(new ChatSocketWs_()); // (5)
}
@Path("/chat/{room}") // (1)
class ChatSocket {
@OnConnect
fun onConnect(ws: WebSocket, ctx: Context): String { // (2)
return "welcome"
}
@OnMessage
fun onMessage(ws: WebSocket, ctx: Context, message: WebSocketMessage): Map<String, String> { // (3)
return mapOf("echo" to message.value())
// ws.send(message.value()) // (4)
}
@OnClose
fun onClose(ws: WebSocket, ctx: Context, status: WebSocketCloseStatus) {}
@OnError
fun onError(ws: WebSocket, ctx: Context, cause: Throwable) {}
}
// Application startup:
{
ws(ChatSocketWs_()) // (5)
}
-
WebSocket route patterns for this handler.
-
Returning a value sends it to the client without calling send.
-
Return a value for automatic encoding (see Structured Data)
-
You still can use ws.send(…) if method return type is void.
-
Register the generated extension with ws(Extension).
@OnMessage handlers also support parsing messages into structured data, similar to MVC methods:
@Path("/chat/{room}")
public class ChatSocket {
record ChatMessage(String username, String message, String type) {}
@OnMessage
public Map<String, ChatMessage> onMessage(ChatMessage message) { // (1)
return Map.of("echo", message);
}
...
}
@Path("/chat/{room}")
class ChatSocket {
data class ChatMessage(
val username: String,
val message: String,
val type: String
)
@OnMessage
fun onMessage(message: ChatMessage): Map<String, ChatMessage> { // (1)
return mapOf("echo" to message)
}
...
}
-
WebSocket message is automatically decoded into ChatMessage structure.
Jooby automatically times out idle connections that have no activity after 5 minutes. You can control this behavior by setting the websocket.idleTimeout property in your configuration file:
application.conf
websocket.idleTimeout = 1h
The maximum message size is set to 128K by default. You can override it using the websocket.maxSize property:
Extend the power of Jooby through its rich ecosystem of modules and standards. Learn how to seamlessly integrate with OpenAPI 3 to automatically generate interactive documentation and client SDKs, and explore a wide array of community and first-party modules that bring database access, security, and messaging to your application with minimal configuration.
The Jooby ecosystem is built on three core, interconnected concepts:
-
Services: The objects and dependencies your application needs to run.
-
Extensions: The mechanism for packaging and registering those services, along with routes and configuration.
-
Modules: Pre-built extensions provided by Jooby to integrate popular third-party libraries.
At its core, Jooby uses a simple, built-in map called the ServiceRegistry to manage application state and dependencies.
Services can be registered as immediate singletons, or their lifecycle can be customized by registering a jakarta.inject.Provider.
You can explicitly put and retrieve services from the registry:
Basic Service Registry
import jakarta.inject.Provider;
{
// 1. Put a singleton service into the registry
getServices().put(MyDatabase.class, new MyDatabase());
// 2. Put a provider to customize lifecycle (e.g., prototype/lazy creation)
getServices().put(MyService.class, (Provider<MyService>) () -> new MyService());
get("/", ctx -> {
// 3. Require the service at runtime
MyDatabase db = require(MyDatabase.class);
MyService service = require(MyService.class);
return db.query();
});
}
import jakarta.inject.Provider
{
// 1. Put a singleton service into the registry
services.put(MyDatabase::class.java, MyDatabase())
// 2. Put a provider to customize lifecycle (e.g., prototype/lazy creation)
services.put(MyService::class.java, Provider { MyService() })
get("/") { ctx ->
// 3. Require the service at runtime
val db = require(MyDatabase::class.java)
val service = require(MyService::class.java)
db.query()
}
}
The registry also supports grouping multiple services of the same type using Lists, Sets, or Maps.
Collections
import io.jooby.Reified;
{
// Add to a List
getServices().listOf(Animal.class).add(new Cat());
getServices().listOf(Animal.class).add(new Dog());
// Add to a Map
getServices().mapOf(String.class, Animal.class).put("cat", new Cat());
get("/list", ctx -> {
// Retrieve the List using the Reified type helper
List<Animal> animals = ctx.require(Reified.list(Animal.class));
return animals;
});
}
import io.jooby.Reified
{
// Add to a List
services.listOf(Animal::class.java).add(Cat())
services.listOf(Animal::class.java).add(Dog())
// Add to a Map
services.mapOf(String::class.java, Animal::class.java).put("cat", Cat())
get("/list") { ctx ->
// Retrieve the List using the Reified type helper
val animals = ctx.require(Reified.list(Animal::class.java))
animals
}
}
While the ServiceRegistry acts as a simple service locator out-of-the-box, its true power lies in its ability to bridge to full Dependency Injection frameworks.
When you install a DI module (like Guice, Dagger, or Avaje Inject), the require() method seamlessly delegates to the underlying DI container. This allows you to use standard jakarta.inject.Inject annotations on your controllers and services, while still falling back to the Jooby registry when needed.
The Extension API is how you package and distribute configuration, infrastructure, and services. It is a simple way of reusing code and decoupling technical features from your business logic.
Let’s develop a custom extension that configures a DataSource service, registers it, and ensures it closes when the application shuts down.
import io.jooby.Extension;
import io.jooby.Jooby;
public class MyExtension implements Extension {
@Override
public void install(Jooby app) {
DataSource dataSource = createDataSource(); // (1)
app.getServices().put(DataSource.class, dataSource);// (2)
app.onStop(dataSource::close); // (3)
}
private DataSource createDataSource() {
// Initialization logic
}
}
import io.jooby.Extension
import io.jooby.Jooby
class MyExtension : Extension {
override fun install(app: Jooby) {
val dataSource = createDataSource() // (1)
app.services.put(DataSource::class.java, dataSource) // (2)
app.onStop(dataSource::close) // (3)
}
private fun createDataSource(): DataSource {
// Initialization logic
}
}
-
Create the service.
-
Save the service into the application’s service registry.
-
Register a lifecycle hook to clean up the service when the application stops.
Now, you can install the extension in your main application and use the service:
public class App extends Jooby {
{
install(new MyExtension()); // (1)
get("/", ctx -> {
DataSource ds = require(DataSource.class); // (2)
// Use the datasource...
return "Success";
});
}
}
class App : Kooby({
install(MyExtension()) // (1)
get("/") {
val ds = require(DataSource::class.java) // (2)
// Use the datasource...
"Success"
}
})
-
Install the custom extension.
-
Retrieve the service that the extension registered.
Extensions are incredibly flexible. In addition to registering services, an extension can add standard routes, configure body decoders/encoders, or set up template engines.
Modules are simply built-in Extensions. They are thin layers that bootstrap and configure external third-party libraries (like HikariCP, Jackson, or Hibernate) using Jooby’s Extension API.
Unlike other frameworks, Jooby modules do not create new layers of abstraction or custom wrappers around the libraries they integrate. Instead, they expose the raw library components directly to your application via the Service Registry, allowing you to use the library’s native API exactly as its creators intended.
Modules are distributed as separate dependencies. Below is the catalog of officially supported Jooby modules:
-
Freemarker: Freemarker template engine.
-
Handlebars: Handlebars template engine.
-
HTMX: First-class HTMX support with declarative annotations and SPA layout management.
-
JStachio: JStachio template engine.
-
jte: jte template engine.
-
Pebble: Pebble template engine.
-
Rocker: Rocker template engine.
-
Thymeleaf: Thymeleaf template engine.
-
gRPC: A high-performance, strictly-typed protocol utilizing Protocol Buffers and HTTP/2 multiplexing. Ideal for backend-to-backend communication and polyglot microservices.
-
json-rpc: A lightweight, stateless, and simple JSON-encoded protocol. Excellent for rapid integrations and environments where standard JSON is preferred.
-
tRPC: A RPC implementation that provides end-to-end type safety directly between your Java backend and TypeScript clients, without requiring intermediate build steps or code generation.
-
Caffeine: In-memory session store using Caffeine cache.
-
JWT: JSON Web Token session store.
-
Redis: Save session data on redis.
Streamline your development workflow with Jooby’s productivity suite. This section covers essential utilities like Hot Reload for instantaneous code changes without restarting the server, and deep integration with build systems like Maven and Gradle to manage your project’s lifecycle from the first line of code to the final deployment.
Application configuration is built on the Typesafe Config library. By default, Jooby supports configuration provided in Java properties, JSON, or HOCON format.
Jooby allows you to override any property via system properties, environment variables, or program arguments.
The Environment class manages your application’s configuration and active environment names (e.g., dev, prod, test).
Environment names allow you to load different configuration files or toggle features (like caching or file reloading) depending on the deployment stage.
Accessing the Environment
{
Environment env = getEnvironment();
}
{
val env = environment
}
You can set active environment names in several ways:
-
Program Argument: java -jar myapp.jar prod,cloud (This works when using Jooby’s runApp methods).
-
System Property: java -Dapplication.env=prod -jar myapp.jar
-
Environment Variable: application.env=prod
When you call getEnvironment(), Jooby searches for an application.conf file in the following order of priority:
-
${user.dir}/conf/application.conf (External file system)
-
${user.dir}/application.conf (External file system)
-
classpath://application.conf (Internal jar resource)
|
Note
|
${user.dir} refers to the directory from which the JVM was launched. Jooby favors file system files over classpath files, allowing you to easily externalize configuration without rebuilding your jar.
|
Properties are resolved using the following precedence (highest priority first):
-
Program arguments (e.g., java -jar app.jar foo=bar)
-
System properties (e.g., -Dfoo=bar)
-
Environment variables (e.g., foo=bar java -jar app.jar)
-
Environment-specific property file (e.g., application.prod.conf)
-
Default property file (application.conf)
Accessing Properties
{
Environment env = getEnvironment(); // (1)
Config conf = env.getConfig(); // (2)
System.out.println(conf.getString("foo")); // (3)
}
{
val env = environment // (1)
val conf = env.config // (2)
println(conf.getString("foo")) // (3)
}
-
Retrieve the current environment.
-
Access the underlying Config object.
-
Extract the value for the key foo.
It is best practice to keep common settings in application.conf and override environment-specific values in separate files named application.[env].conf.
Example Structure
└── application.conf (foo = "default", bar = "base")
└── application.prod.conf (foo = "production")
Running with java -jar myapp.jar prod results in:
* foo: "production" (overridden)
* bar: "base" (inherited from default)
To activate multiple environments, separate them with commas: java -jar app.jar prod,cloud.
If you want to bypass Jooby’s default loading logic, you can provide custom options or instantiate the environment manually.
Using Environment Options
{
setEnvironmentOptions(new EnvironmentOptions().setFilename("myapp.conf")); // (1)
}
{
environmentOptions {
filename = "myapp.conf" // (1)
}
}
-
Loads myapp.conf instead of the default application.conf while maintaining standard precedence rules.
Direct Instantiation
{
Config conf = ConfigFactory.load("custom.conf"); // (1)
Environment env = new Environment(getClassLoader(), conf); // (2)
setEnvironment(env); // (3)
}
{
val conf = ConfigFactory.load("custom.conf") // (1)
val env = Environment(classLoader, conf) // (2)
environment = env // (3)
}
-
Manually load a configuration file.
-
Wrap it in a Jooby Environment.
-
Assign it to the application before startup.
Jooby uses SLF4J, allowing you to plug in your preferred logging framework.
-
Add Dependency: logback-classic.
-
Configure: Place logback.xml in your conf directory or classpath root.
-
Add Dependencies: log4j-slf4j-impl and log4j-core.
-
Configure: Place log4j2.xml in your conf directory or classpath root.
Logging is also environment-aware. Jooby will look for logback.[env].xml or log4j2.[env].xml and favor them over the default files.
|
Important
|
To ensure environment-specific logging works correctly, avoid using static loggers in your main App class before runApp is called. Static loggers force the logging framework to initialize before Jooby can apply the environment-specific configuration. Use an instance logger or Jooby’s getLog() method instead.
|
| Property |
Default |
Description |
application.charset
|
UTF-8
|
Charset for encoding/decoding and templates. |
application.env
|
dev
|
Active environment names. Jooby optimizes performance for non-dev environments. |
application.lang
|
Locale.getDefault()
|
Supported languages for Context.locale(). |
application.tmpdir
|
tmp
|
Temporary directory for the application. |
application.pid
|
System assigned |
The JVM process ID. |
The jooby run tool provides a "hot reload" experience by restarting your application automatically whenever code changes are detected, without exiting the JVM. This makes Java and Kotlin development feel as fast and iterative as a scripting language.
The tool leverages JBoss Modules to efficiently reload application classes. It is available as both a Maven and a Gradle plugin.
<plugins>
<plugin>
<groupId>io.jooby</groupId>
<artifactId>jooby-maven-plugin</artifactId>
<version>4.6.0</version>
</plugin>
</plugins>
plugins {
id "application"
id "io.jooby.run" version "4.6.0"
}
2) Configure the Main Class:
<properties>
<application.class>myapp.App</application.class>
</properties>
mainClassName = "myapp.App"
3) Launch the Application:
-
Source Files (.java, .kt): Changing a source file triggers an incremental compilation request. If the compilation succeeds, the application restarts automatically.
-
Configuration Files (.conf, .properties): Changes to these files trigger an immediate application restart without a compilation step.
-
Compilation Errors: Any errors during the build process are printed directly to the console by Maven or Gradle.
|
Note
|
For Eclipse users: The plugin detects the .classpath file in your project directory. If found, the plugin’s internal compiler is disabled, letting Eclipse handle the compilation while the plugin focuses on the restart logic.
|
Below are the available configuration options with their default values:
<configuration>
<mainClass>${application.class}</mainClass>
<restartExtensions>conf,properties,class</restartExtensions>
<compileExtensions>java,kt</compileExtensions>
<port>8080</port>
<waitTimeBeforeRestart>500</waitTimeBeforeRestart>
<useSingleClassLoader>false</useSingleClassLoader>
</configuration>
joobyRun {
mainClassName = "${mainClassName}" // (1)
restartExtensions = ["conf", "properties", "class"] // (2)
compileExtensions = ["java", "kt"] // (3)
port = 8080 // (4)
waitTimeBeforeRestart = 500 // (5)
useSingleClassLoader = false // (6)
}
-
The application’s entry point (Main class).
-
Extensions that trigger an immediate restart.
-
Extensions that trigger a compilation followed by a restart.
-
The local development port.
-
The delay (in milliseconds) to wait after the last file change before restarting. Default is 500ms.
-
If true, Jooby uses a single "fat" classloader. Set this to true if you encounter strange reflection or class-loading errors in complex projects. Since 3.x, Jooby uses a modular classloader by default for faster restarts and lower memory usage.
To run the application while including the test scope/source set in the classpath, use the following commands:
Jooby provides dedicated tools for both lightweight unit testing and full-blown integration testing.
Unit testing in Jooby is fast because it allows you to test your routes without starting a real HTTP server.
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-test</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-test:4.6.0'
2) Define your Application:
App
public class App extends Jooby {
{
get("/", ctx -> "Easy unit testing!");
}
}
class App : Kooby({
get("/") { "Easy unit testing!" }
})
Use the MockRouter to simulate requests and capture the return values of your handlers.
TestApp
import io.jooby.test.MockRouter;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class TestApp {
@Test
public void test() {
MockRouter router = new MockRouter(new App());
assertEquals("Easy unit testing!", router.get("/").value());
}
}
import io.jooby.test.MockRouter
import org.junit.jupiter.api.Assertions.assertEquals
class TestApp {
@Test
fun test() {
val router = MockRouter(App())
assertEquals("Easy unit testing!", router.get("/").value())
}
}
If your route modifies the context (like setting status codes or headers), you can verify the metadata using a callback:
Metadata Test
@Test
public void testMetadata() {
MockRouter router = new MockRouter(new App());
router.get("/", response -> {
assertEquals(StatusCode.OK, response.getStatusCode());
assertEquals("Easy unit testing", response.value(String.class));
});
}
@Test
fun testMetadata() {
val router = MockRouter(App())
router.get("/") { response ->
assertEquals(StatusCode.OK, response.statusCode)
assertEquals("Easy unit testing", response.value(String::class.java))
}
}
For complex routes that interact with forms, bodies, or headers, you can provide a MockContext or a mock object from a library like Mockito.
Using MockContext
@Test
public void testWithForm() {
MockRouter router = new MockRouter(new App());
MockContext context = new MockContext();
context.setForm(Formdata.create(context).put("name", "Jooby"));
assertEquals("Jooby", router.post("/", context).value());
}
@Test
fun testWithForm() {
val router = MockRouter(App())
val context = MockContext().apply {
form = Formdata.create(this).put("name", "Jooby")
}
assertEquals("Jooby", router.post("/", context).value())
}
Integration tests run a real web server and allow you to test your application using any HTTP client. Jooby provides a JUnit 5 extension to manage the application lifecycle automatically.
-
Add the Dependency:
-
Write the Test:
Annotate your test class with @JoobyTest.
Integration Test
import io.jooby.test.JoobyTest;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
@JoobyTest(App.class) // (1)
public class IntegrationTest {
static OkHttpClient client = new OkHttpClient();
@Test
public void testApp() throws IOException {
Request request = new Request.Builder()
.url("http://localhost:8911") // (2)
.build();
try (Response response = client.newCall(request).execute()) {
assertEquals("Easy testing!", response.body().string());
}
}
}
import io.jooby.test.JoobyTest
import okhttp3.OkHttpClient
import okhttp3.Request
@JoobyTest(App::class) // (1)
class IntegrationTest {
companion object {
val client = OkHttpClient()
}
@Test
fun testApp() {
val request = Request.Builder()
.url("http://localhost:8911") // (2)
.build()
client.newCall(request).execute().use { response ->
assertEquals("Easy testing!", response.body?.string())
}
}
}
-
Jooby starts the application before the test and stops it afterward.
-
The default integration test port is 8911.
If you use a random port (port = 0) or want to avoid hardcoding URLs, you can inject server details directly into your test methods or fields:
Injection Example
@JoobyTest(value = App.class, port = 0)
public void test(int serverPort, String serverPath) {
// serverPort: e.g. 54321
// serverPath: e.g. "http://localhost:54321"
}
@JoobyTest(value = App::class, port = 0)
fun test(serverPort: Int, serverPath: String) {
// serverPort: e.g. 54321
// serverPath: e.g. "http://localhost:54321"
}
Supported injectable types include:
* int serverPort: The port the application is listening on.
* String serverPath: The full base URL (e.g., http://localhost:port).
* io.jooby.Environment: Access to the test environment.
* com.typesafe.config.Config: Access to the application configuration.
* io.jooby.Jooby: Access to the application instance itself.
|
Tip
|
When running integration tests, Jooby automatically sets the environment name to test. You can create a conf/application.test.conf file to provide test-specific settings.
|
If your application requires constructor arguments, you can specify a factoryMethod to instantiate it:
Factory Method Test
@JoobyTest(value = App.class, factoryMethod = "createApp")
public class TestApp {
public static App createApp() {
return new App("custom-argument");
}
}
@JoobyTest(value = App::class, factoryMethod = "createApp")
class TestApp {
companion object {
@JvmStatic
fun createApp(): App = App("custom-argument")
}
}
Jooby supports multiple web server implementations. A server is automatically registered based on its presence on the project classpath.
Officially supported servers:
To use a specific server, add the corresponding dependency to your project:
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-netty</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-netty:4.6.0'
|
Important
|
Only one server dependency should be available on the classpath at a time.
|
While Jooby usually loads the server automatically via the ServiceLoader API, you can also instantiate and configure a server manually in your main method.
This is particularly useful if you need to access server-specific features, such as configuring Project Loom (Virtual Threads) in Jetty:
Jetty with Virtual Threads
import io.jooby.jetty.JettyServer;
import java.util.concurrent.Executors;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
public static void main(String[] args) {
var worker = new QueuedThreadPool();
worker.setReservedThreads(0);
worker.setVirtualThreadsExecutor(Executors.newVirtualThreadPerTaskExecutor());
runApp(args, new JettyServer(worker), App::new);
}
import io.jooby.jetty.JettyServer
import java.util.concurrent.Executors
import org.eclipse.jetty.util.thread.QueuedThreadPool
fun main(args: Array<String>) {
val worker = QueuedThreadPool().apply {
reservedThreads = 0
virtualThreadsExecutor = Executors.newVirtualThreadPerTaskExecutor()
}
runApp(args, JettyServer(worker), ::App)
}
Jooby servers can run multiple applications simultaneously.
Multi-App Server
import io.jooby.netty.NettyServer;
import java.util.List;
public static void main(String[] args) {
runApp(args, new NettyServer(), List.of(FooApp::new, BarApp::new));
}
import io.jooby.netty.NettyServer
fun main(args: Array<String>) {
runApp(args, NettyServer(), ::FooApp, ::BarApp)
}
|
Note
|
When running multiple apps, the server configuration (ports, threads, etc.) is determined by the first application setup that defines them.
|
Server behavior can be controlled via the ServerOptions class or through application.conf.
Server Options
{
var options = new ServerOptions()
.setPort(8080)
.setIoThreads(16)
.setWorkerThreads(64)
.setGzip(false)
.setMaxRequestSize(10485760) // 10MB
.setHttp2(true);
}
{
val options = ServerOptions().apply {
port = 8080
ioThreads = 16
workerThreads = 64
maxRequestSize = 10485760 // 10MB
isHttp2 = true
}
}
-
server.port: The HTTP port (default: 8080). Use 0 for a random port.
-
server.ioThreads: Number of IO threads (Netty/Undertow). Defaults to Processors * 2.
-
server.workerThreads: Number of worker threads. Defaults to ioThreads * 8.
-
server.maxRequestSize: Maximum request size in bytes. Exceeding this triggers a 413 Request Entity Too Large response.
-
server.defaultHeaders: Automatically sets Date, Content-Type, and Server headers.
-
server.expectContinue: Enables support for 100-Continue requests.
Jooby supports HTTPS out of the box using either PKCS12 (default) or X.509 certificates.
For development, you can enable a self-signed certificate with one line:
Self-Signed HTTPS
public static void main(String[] args) {
var options = new ServerOptions().setSecurePort(8443);
runApp(args, new NettyServer(options), App::new);
}
fun main(args: Array<String>) {
val options = ServerOptions().setSecurePort(8443)
runApp(args, NettyServer(options), ::App)
}
For production, you should use valid certificates (e.g., from Let’s Encrypt). You can configure these in your code or via application.conf.
X.509 Configuration
server.ssl.type = X509
server.ssl.cert = "path/to/server.crt"
server.ssl.key = "path/to/server.key"
PKCS12 Configuration
server.ssl.type = PKCS12
server.ssl.cert = "path/to/server.p12"
server.ssl.password = "password"
To require clients to present a certificate, set the clientAuth mode to REQUIRED. This usually requires a Trust Store containing the certificates you trust.
Mutual TLS
{
var ssl = SslOptions.pkcs12("server.p12", "password")
.setTrustCert(Paths.get("trust.crt"))
.setClientAuth(SslOptions.ClientAuth.REQUIRED);
var options = new ServerOptions().setSsl(ssl);
}
{
val ssl = SslOptions.pkcs12("server.p12", "password")
.setTrustCert(Paths.get("trust.crt"))
.setClientAuth(SslOptions.ClientAuth.REQUIRED)
val options = ServerOptions().setSsl(ssl)
}
HTTP/2 is supported across all servers. To use it in a browser, you must enable HTTPS.
Enable HTTP/2
{
var options = new ServerOptions()
.setHttp2(true)
.setSecurePort(8443);
}
{
val options = ServerOptions().apply {
isHttp2 = true
securePort = 8443
}
}
By default, Jooby uses the JDK’s built-in SSL engine. For better performance and features (like TLS v1.3 on older Java versions), you can use the OpenSSL-backed Conscrypt provider.
Simply add the dependency:
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-conscrypt</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-conscrypt:4.6.0'
This section describes the primary options for packaging and distributing your Jooby application.
The most common deployment option is creating a single executable "Fat Jar" that contains your application code along with all its dependencies.
|
Tip
|
The Jooby CLI automatically configures your project for single jar distribution. The examples below show how to configure it manually if needed.
|
Maven (Shade Plugin) Gradle (Shadow Plugin)
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>uber-jar</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${application.class}</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
plugins {
id "com.github.johnrengelman.shadow" version "8.1.1"
}
shadowJar {
// Required to merge ServiceLoader files from dependencies
mergeServiceFiles()
manifest {
attributes 'Main-Class': "${mainClassName}"
}
}
Stork is a specialized packaging, launch, and deployment tool for Java applications. It generates platform-specific native launchers (shell scripts or batch files) and organizes your dependencies in a clean directory structure.
|
Note
|
The Stork integration is currently only available for Maven projects.
|
-
Create the configuration: Define a src/etc/stork/stork.yml file.
stork.yml
# Application name (no spaces)
name: "${project.artifactId}"
display_name: "${project.name}"
# Type of launcher (CONSOLE or DAEMON)
type: DAEMON
main_class: "${application.class}"
# Platforms to generate (LINUX, WINDOWS, MAC_OSX)
platforms: [ LINUX ]
# Directory mode: RETAIN (current) or APP_HOME (switch to app root)
working_dir_mode: RETAIN
# Runtime Requirements
min_java_version: "17"
min_java_memory: 512
max_java_memory: 512
# Create a symbolic link to java as "<app_name>-java" for easier process tracking
symlink_java: true
-
Add the Maven Tiles plugin: Use the Jooby Stork tile to automate the build.
pom.xml
<build>
<plugins>
<plugin>
<groupId>io.repaint.maven</groupId>
<artifactId>tiles-maven-plugin</artifactId>
<version>2.47.0</version>
<extensions>true</extensions>
<configuration>
<tiles>
<tile>io.jooby:jooby-stork:4.6.0</tile>
</tiles>
</configuration>
</plugin>
</plugins>
</build>
-
Build the package: Run mvn package. The resulting Stork .zip file will be located in the target directory.
You will find here notes/tips about how to migrate from 3.x to 4.x.
Starting from 4.0.7 @XXXParam the default annotation value attribute is actually that: the default value
of the parameter. In previous version this was used it for invalid/special HTTP names.
@QuerParam("some-http-name") String name
@QuerParam(name = "some-http-name") String name
The value is now reserved for default values:
@QueryParam("20") int pageSize
The package io.jooby.buffer is gone. It was replaced by io.jooby.output these classes
are used mainly by the MessageEncoder API, the new API is easier to use and has better
performance.
The new package is now io.jooby.value. The API is now decoupled from Context
in future release will be the basis of a new configuration system.
Also, the io.jooby.ValueNode and io.jooby.ValueConverter are gone.
For security reasons, the default HTTP session was removed. You need to configure the session
explicitly and provide a cookie session name. The jooby.sid cookie name was removed from project.
The install(Server), setServerOptions, start() method are gone. With the new support for
multiple applications in a single server, these methods are useless.
New way to boot
runApp(args, new NettyServer(new ServerOptions()), App::new);
3.x |
4.x |
Module |
io.jooby.buffer |
io.jooby.output |
replacement jooby (core) |
3.x |
4.x |
Module |
Description |
io.jooby.buffer.* |
- |
jooby (core) |
removed |
|
io.jooby.output.* |
jooby (core) |
new output API |
io.jooby.MvcFactory |
- |
jooby (core) |
was deprecated and now removed |
io.jooby.annotation.ResultType |
- |
jooby (core) |
removed |
io.jooby.ValueNode |
io.jooby.value.Value |
jooby (core) |
replaced/merged |
io.jooby.ValueConverter |
io.jooby.value.Converter |
jooby (core) |
replaced/merged |
io.jooby.RouteSet |
io.jooby.Route.Set |
jooby (core) |
moved into Route and renamed to Set |
3.x |
4.x |
Description |
io.jooby.Jooby.setServerOptions() |
Server.setOptions() |
removed in favor of Server.setOptions() |
io.jooby.Router.mvc |
- |
it was deprecated and now removed |
io.jooby.Router.decorator |
- |
it was deprecated and now removed |
io.jooby.Router.getConverters |
io.jooby.Router.getValueFactory |
replaced |
io.jooby.Router.getBeanConverters |
io.jooby.Router.getValueFactory |
replaced |
io.jooby.Router.attribute(String) |
Router.getAttribute(String) |
Renamed |
io.jooby.Router.RouteOption |
io.jooby.RouterOptions |
Moved to RouterOptions |
io.jooby.Router.setTrustProxy |
RouterOptions.setTrustProxy |
Moved to RouterOptions |
You will find here notes/tips about how to migrate from 2.x to 3.x.
Jooby is now compatible with Java Module system.
Almost all Jooby components are now Java Modules, but not all them. For those where wasn’t
possible the Jooby module contains the Automatic-Module-Name manifest entry.
Kotlin was removed from core, you need to the jooby-kotlin dependency:
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-kotlin</artifactId>
<version>4.6.0</version>
</dependency>
implementation 'io.jooby:jooby-kotlin:4.6.0'
2.x |
3.x |
javax.servlet |
jakarta.servlet |
javax.inject |
jakarta.inject |
javax.persistence |
jakarta.persistence |
2.x |
3.x |
jooby-kotlin |
Added |
jooby-weld |
Removed |
jooby-archetype |
Removed |
jooby-utow |
Renamed: jooby-undertow |
jooby-commons-email |
Renamed: jooby-commons-mail |
jooby-http2-jetty |
Merged into: jooby-netty |
jooby-http2-netty |
Merged into: jooby-netty |
jooby-http2-undertow |
Merged into: jooby-undertow |
2.x |
3.x |
Module |
io.jooby.annotations |
io.jooby.annotation |
jooby (core) |
io.jooby |
io.jooby.test |
jooby-test |
io.jooby (Kotlin) |
io.jooby.kt |
removed from jooby, now in jooby-kotlin |
io.jooby.graphql |
io.jooby.graphiql |
jooby-graphiql |
io.jooby.graphql |
io.jooby.graphql.playground |
jooby-graphql-playground |
io.jooby.json |
io.jooby.gson |
jooby-gson |
io.jooby.json |
io.jooby.jackson |
jooby-jackson |
io.jooby.di |
io.jooby.guice |
jooby-guice |
io.jooby.di |
io.jooby.spring |
jooby-spring |
io.jooby.aws |
io.jooby.awssdkv1 |
jooby-awssdk-v1 |
io.jooby.email |
io.jooby.commons.mail |
jooby-commons-mail |
io.jooby.utow |
io.jooby.undertow |
jooby-undertow |
2.x |
3.x |
Module |
io.jooby.Route.Decorator |
jooby (core) |
io.jooby.Route.Filter |
io.jooby.Kooby |
io.jooby.kt.Kooby |
jooby-kotlin (new module) |
io.jooby.jetty.Jetty |
io.jooby.jetty.JettyServer |
jooby-jetty |
io.jooby.netty.Netty |
io.jooby.netty.NettyServer |
jooby-netty |
io.jooby.utow.Utow |
io.jooby.undertow.UndertowServer |
jooby-undertow |
io.jooby.AccessLogHandler |
io.jooby.handler.AccessLogHandler |
jooby (core) |
io.jooby.Cors |
io.jooby.handler.Cors |
jooby (core) |
io.jooby.CorsHandler |
io.jooby.handler.CorsHandler |
jooby (core) |
io.jooby.CsrfHandler |
io.jooby.handler.CsrfHandler |
jooby (core) |
io.jooby.HeadHandler |
io.jooby.handler.HeadHandler |
jooby (core) |
io.jooby.RateLimitHandler |
io.jooby.handler.RateLimitHandler |
jooby (core) |
io.jooby.SSLHandler |
io.jooby.handler.SSLHandler |
jooby (core) |
io.jooby.TraceHandler |
io.jooby.handler.TraceHandler |
jooby (core) |
io.jooby.WebVariables |
io.jooby.handler.WebVariables |
jooby (core) |
io.jooby.Asset |
io.jooby.handler.Asset |
jooby (core) |
io.jooby.AssetHandler |
io.jooby.handler.AssetHandler |
jooby (core) |
io.jooby.AssetSource |
io.jooby.handler.AssetSource |
jooby (core) |
io.jooby.CacheControl |
io.jooby.handler.CacheControl |
jooby (core) |
2.x |
3.x |
Description |
Router.decorator(Decorator) |
Router.use(Filter) |
decorator has been deprecated in favor of use
|
SslOptions.setCert(String) |
SslOptions.setCert(InputStream) |
Replaced the string argument with InputStream |
SslOptions.setTrustCert(String) |
SslOptions.setTrustCert(InputStream) |
Replaced the string argument with InputStream |
SslOptions.setPrivateKey(String) |
SslOptions.setPrivateKey(InputStream) |
Replaced the string argument with InputStream |
FileUpload.destroy |
FileUpload.close |
destroy has been replaced by close
|
Context.attribute(String) |
Context.getAttribute(String) |
|
Context.attribute(String, Object) |
Context.setAttribute(String, Object) |
|
Context.multipart* |
Context.form* |
All Context.multipart related methods where merged into Context.form |
Context.query<Type>() |
Context.query(Type::class) |
Kotlin |
Context.form<Type>() |
Context.form(Type::class) |
Kotlin |
Context.body<Type>() |
Context.body(Type::class) |
Kotlin |
2.x |
3.x |
Module |
Slf4j 1.x |
Slf4j 2.x |
jooby (core) |
Jetty 9.x |
Jetty 11.x |
jooby-jetty |
Guice 5.x |
Guice 7.x |
jooby-guice |
Reactive libraries has been removed from core to his own module.
2.x |
3.x |
rxjava |
jooby-rxjava3 |
reactor |
jooby-reactor |
All reactive libraries requires explicit handler while using script/lambda routes. More details on NonBlocking responses.
You will find here notes/tips about how to migrate from 1.x to 2.x.
org.jooby became io.jooby. Hence, use <groupId>org.jooby</groupId> for all dependencies.
1.x |
2.x |
jooby-apitool |
no real equivalent, use a combination of jooby-maven-plugin and jooby-swagger-ui |
jooby-hbv |
n/a |
jooby-lang-kotlin |
not needed anymore, part of core now |
jooby-servlet |
n/a |
API still similar/equivalent in 2.x. Except for the one listed below:
Table 1. Classes
1.x |
2.x |
org.jooby.Module |
io.jooby.Extension |
org.jooby.Env |
io.jooby.Environment |
org.jooby.Mutant |
io.jooby.Value |
org.jooby.Render |
io.jooby.MessageEncoder |
org.jooby.Parser |
io.jooby.MessageDecoder |
org.jooby.Err |
io.jooby.StatusCodeException |
org.jooby.Results |
- (removed) |
org.jooby.Result |
- (removed) |
The concept of route pipeline still applies for 2.x but works different.
In 1.x there is no difference between handler and filter (including before and after). The way to
chain multiple handler/filter was like:
Pipeline in 1.x
{
use("*", (req, rsp, chain) -> {
System.out.println("first");
// Moves execution to next handler: second
chain.next(req, rsp);
});
use("*", (req, rsp, chain) -> {
System.out.println("second");
// Moves execution to next handler: third
chain.next(req, rsp);
});
get("/handler", req -> {
return "third";
});
}
A filter in 1.x requires a path pattern, here we use a wide matcher * for first and second filters.
Both of this filters are going to be executed before the real handler.
Pipeline in 2.x
{
use(next -> ctx -> {
System.out.println("first");
// Moves execution to next handler: second
return next.apply(ctx);
});
use(next -> ctx -> {
System.out.println("second");
// Moves execution to next handler: third
return next.apply(ctx);
});
get("/handler", ctx -> {
return "third";
});
}
Execution is identical to 1.x. The first and second decorators are executed before the handler.
Differences with 1.x are:
-
Route.Decorator doesn’t support a path pattern. In 1.x the path pattern is required for a filter.
-
Only the handler supports a path pattern and HTTP-method.
-
A handler might have zero or more decorator.
-
In 2.x we chain all the decorator defined before the handler.
The routing matching algorithm in 2.x is more efficient and fast, because:
-
Matches a single path pattern (due decorator lacks of path pattern)
-
Uses a radix tree, not regular expression like in 1.x
-
It never executes a decorator if there isn’t a matching handler
|