A lightweight Sinatra/Express-style Java web framework.
<dependency>
<groupId>io.github.ghosthack</groupId>
<artifactId>turismo</artifactId>
<version>5.0.0</version>
</dependency>Gradle:
implementation 'io.github.ghosthack:turismo:5.0.0'Requires Java 21+. (3.x supports Java 17.) See Upgrading to 5.0 for breaking changes.
Note: Versions 1.x were published under
com.ghosthack:turismo. The groupId changed toio.github.ghosthackstarting with 2.0.0.
Zero dependencies -- uses the JDK's built-in HTTP server:
import static io.github.ghosthack.turismo.Turismo.*;
public class Main {
public static void main(String[] args) {
get("/hello", "Hello World!");
get("/users/:id", () -> print("User ", param("id")));
post("/users", () -> json(Map.of("created", true)));
start(8080);
}
}For a runnable starter project, including a servlet deployment example, see turismo-bootstrap.
get("/hello", "Hello!");
post("/submit", () -> print("Submitted"));get("/users/:id", () -> {
String id = param("id");
print("User " + id);
});
get("/users/:userId/posts/:postId", () -> {
print(param("userId") + "/" + param("postId"));
});Parameter values are percent-decoded, and an encoded slash stays inside its
segment: /files/a%2Fb matches /files/:name with name = a/b. For the
same reason /admin%2Fsecret does not match an exact /admin/secret route.
Paths must start with /.
Every path segment counts, empty ones included: a trailing slash or a
doubled slash is part of the path, for exact and pattern routes alike.
/users/:id matches /users/42 but not /users/42/, /users/42// or
/users/ (a named parameter never matches an empty segment), and an exact
/exact/ route doesn't match /exact. Register both forms if you want to
serve both.
Registering the same method and path again replaces the earlier route.
get("/files/*/download", () -> print("Downloading"));Like a named parameter, * matches exactly one non-empty segment.
get("/search", () -> {
String q = param("q"); // from ?q=turismo
print("Search: " + q);
});For a name that repeats (?tag=a&tag=b), param() returns the first value
and paramValues() returns all of them (queryValues() and formValues()
look in one place only):
get("/filter", () -> print(String.join(",", paramValues("tag"))));Fields of an application/x-www-form-urlencoded body (what an HTML form
posts) are read with form(), and param() falls back to them after path
and query parameters:
post("/login", () -> {
String user = param("user"); // path, then query, then form field
String pass = form("pass"); // form field only
Map<String, String> all = formFields();
});The body is read on first use and decoded with the request's charset (UTF-8
by default); if a name repeats, form() returns the first value and
formValues() all of them. body() still returns the full body afterwards.
The reverse order doesn't work: once a handler has taken the raw body()
stream of a form request, form() and a param() that falls through to the
form fail, and the request is answered with 400 Bad Request. Bodies over 2 MB get 413 Content Too Large (change the limit with app().setMaxFormSize(bytes)), and malformed
ones 400 Bad Request. Other content types are left alone: form() returns
null and the body isn't read. For multipart/form-data (servlet
deployment), see Multipart file uploads.
All standard methods: get, post, put, delete, patch, head, options.
HEAD requests without a head route are served by the matching GET route,
with the body discarded. A request whose path matches a route registered only
for other methods gets 405 Method Not Allowed with an Allow header. OPTIONS
requests without an options route are answered with 204 No Content and
the same Allow header (OPTIONS, and HEAD when there is a GET route, are
always listed).
Every route responds 200 OK unless the handler sets a status, POST
included:
post("/users", () -> {
status(201); // Created
json(Map.of("id", 42));
});
delete("/users/:id", () -> print("Deleted ", param("id")));Upgrading from 4.x: POST routes used to default to
201. Addstatus(201)to handlers that relied on it. See Upgrading to 5.0.
// Set status code
status(201);
// Set headers
header("X-Custom", "value");
type("application/json");
// Write body
print("Hello World");
print("Hello ", name, "!"); // varargs — avoids concatenation
// JSON response (built-in serializer, no dependencies)
json(Map.of("ok", true, "count", 42));
json(List.of("a", "b", "c"));
// Also: records (as objects), enums (by name), Character, all array types.
// NaN and Infinity are written as null, as in JavaScript
String s = toJson(Map.of("key", "value")); // serialize without writing
// Redirects
redirect("/new-location"); // 302
movedPermanently("/new-url"); // 301
redirect(307, "/temporary"); // custom code
redirectLocal(param("next")); // 302, only to a path on this site
// 404
notFound();The embedded server accepts status codes 200-599; others throw
IllegalArgumentException. HEAD requests get the Content-Length their GET
would have, without the body.
redirect() rejects control characters (CR/LF header injection, TAB) but
otherwise goes wherever it is told. Never pass it a target from the request:
redirect(param("next")) lets anyone craft a link to your site that sends
users to https://evil.com or //evil.com. Use redirectLocal() instead,
which accepts only a path on the same site (a single leading /; not
//host, /\host, a scheme or control characters) and answers anything
else with 400. To fall back to a default instead, check the target with
Validation.isLocalPath(String) first:
post("/login", () -> {
// ... authenticate ...
String next = param("next");
redirect(303, Validation.isLocalPath(next) ? next : "/");
});The embedded server buffers each response in memory so it can send
Content-Length, and so an error can still become a clean 500. For large
downloads or incremental output, call stream(): it sends the status and
headers at once and returns a stream that writes straight to the client with
chunked transfer encoding.
get("/export.csv", () -> {
type("text/csv"); // status and headers first
try (OutputStream out = stream()) {
for (Row row : rows()) {
out.write(row.toCsv()); // print() and output() also stream now
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});After stream(), status() and header() throw IllegalStateException. An
exception thrown after streaming has started can't be turned into a 500: it is
logged and the response ends, so the client may receive a truncated body.
stream() is only available on the embedded server; with another transport
(a custom Context passed to App.handle) it throws
UnsupportedOperationException.
Runs when no route matches the path (wrong-method requests get a 405 instead):
notFound(() -> {
status(404);
type("application/json");
print("{\"error\":\"not found\"}");
});get("/echo", () -> {
String method = method(); // HTTP method
String path = path(); // request path
String auth = header("Authorization"); // request header
InputStream body = body(); // request body
});Routes can also be defined as annotated methods on a controller class:
import static io.github.ghosthack.turismo.Turismo.*;
import io.github.ghosthack.turismo.annotation.*;
public class UserController {
@GET("/hello")
void hello() {
print("Hello World!");
}
@GET("/users/:id")
void getUser() {
print("User ", param("id"));
}
@POST("/users")
void createUser() {
json(Map.of("created", true));
}
@DELETE("/users/:id")
void deleteUser() {
print("Deleted ", param("id"));
}
}Register the controller and start the server:
controller(new UserController());
start(8080);Annotated methods inherited from a superclass are registered too, and an
annotated override in a subclass replaces the superclass's route (an
overload with other parameter types doesn't). If any method of the
controller is rejected, controller() throws and registers none of its
routes.
Instead of calling param(), a route method can take the parameters as
arguments. Each is looked up like param() (path parameter, query string,
then form body) and converted to the argument type:
@GET("/items/:id")
void getItem(int id) { // bound by its name, "id"
print("item: " + id);
}
@GET("/users/:id")
void getUser(@Param("id") long userId) { // or named explicitly
print("user: " + userId);
}
@GET("/search") // /search?q=shoes&page=2
void search(@Param("q") String q, @Param("page") Integer page) {
print(q + " page " + (page != null ? page : 1));
}
@POST("/signup") // form: email=a%40b.c&age=30
void signup(@Param("email") String email, @Param("age") int age) {
print(email + " is " + age);
}
@GET("/cart") // ?sku=A1&sku=B2&qty=1&qty=3
void cart(String[] sku, int[] qty, BigDecimal discount, Boolean gift) {
...
}
@POST("/subscribe") // form: topic=java&topic=http
void subscribe(Set<String> topic, List<Integer> day) {
...
}- Supported types:
String, primitives and their wrappers (booleanandBooleanaccepttrue/falsein any case),BigInteger,BigDecimal, enums (by constant name) andUUID. AContextargument receives the request context and anInputStreamargument the request body; the body is bound after the other arguments, so they can still come from a form body.doubleandfloataccept plain decimal numbers (-1.5,2e10), notNaN,Infinity, hexadecimal or out-of-range values. - An array of any of those (
String[],int[],Boolean[],BigDecimal[], ...), or aList,Collection,IterableorSetof one (List<Integer>,Set<Size>), receives every value of a repeated parameter, in order; if the parameter is absent it's empty. ASetis aLinkedHashSet(request order, duplicates dropped), the others anArrayList; each request gets a fresh, mutable one. The element type must be spelled out: a rawList,List<?>orList<Object>is rejected at registration. - A value that can't be converted (
/items/abcfor anint), or a missing value for a primitive, gets400 Bad Request. A missing value for any other type is passed asnull. BigIntegerandBigDecimalvalues are limited to 1000 characters, and aBigDecimalexponent to ±1000, since parsing or printing far larger numbers could tie up the server; longer values get400.- Without
@Param, the Java parameter name is used. turismo reads it from the class file, which records it when the controller is compiled with debug information (-g, the default in Maven, Gradle and IDEs) or with-parameters. Only a class compiled with neither (plainjavac, or-g:none) needs@Param. For an annotated abstract method the names come from its implementation in the controller's class;controller()rejects its methods with a message saying so, as it does an argument of an unsupported type.
Controller routes use the same routing engine as lambda routes and can be
freely mixed. All request/response methods (param(), print(), json(),
etc.) work the same way inside annotated methods.
The static get()/start()/... methods register routes on one shared default
app. Create App instances for independent route sets, for example two
servers in one JVM, or a fresh app per test instead of calling reset():
import static io.github.ghosthack.turismo.Turismo.*;
App api = new App();
api.get("/users/:id", () -> json(Map.of("id", param("id"))));
api.start(8080);
App admin = new App();
admin.get("/health", "ok");
admin.start(9090);App has the same registration and server methods (get, post, ...,
route, controller, notFound, start, stop, port, handle,
reset). Handlers use the usual static helpers (param(), print(),
json(), ...), which work for whichever app is serving the request.
Turismo.app() returns the default app.
The embedded server handles each request on its own virtual thread, so
handlers can block (database calls, outbound HTTP, Thread.sleep) without
holding up other requests.
turismo sets no timeouts, and by default the JDK server lets a client take
forever to send its request or read the response, so slow clients can hold
connections open indefinitely. The JDK server reads its limits from system
properties once per JVM (they apply to every HttpServer in the process), so
turismo leaves them to you. Set them on the command line when exposing the
server directly:
java -Dsun.net.httpserver.maxReqTime=30 \
-Dsun.net.httpserver.maxRspTime=120 \
-Dsun.net.httpserver.idleInterval=30 \
-Djdk.httpserver.maxConnections=1000 \
-jar app.jar| Property | Unit | Default | Meaning |
|---|---|---|---|
sun.net.httpserver.maxReqTime |
seconds | none | time to receive a request |
sun.net.httpserver.maxRspTime |
seconds | none | time to send a response; raise it for large or streamed downloads |
sun.net.httpserver.idleInterval |
seconds | 30 | how long an idle keep-alive connection stays open |
jdk.httpserver.maxConnections |
count | unlimited | concurrent connections |
Set them before the first server starts (setting them later with
System.setProperty has no effect). Behind a reverse proxy (nginx, a load
balancer), the proxy's own timeouts usually cover this.
stop(); // immediate: requests in progress are interrupted
stop(Duration.ofSeconds(10)); // graceful: in-flight requests get up to 10sAn exception thrown by a handler results in a 500 Internal Server Error and
is logged through System.Logger (by default java.util.logging).
turismo also supports deployment in any Jakarta EE 10 servlet container
(Tomcat 10.1+, Jetty 12+, etc.) via the Servlet class and
RoutesMap/RoutesList API. As with the embedded server:
- HEAD requests are served by GET routes, and wrong-method requests get
405with anAllowheader. - An exception thrown by an action is logged through
System.Loggerand answered with500(the container's error page never shows the stack trace); a request with no matching route gets404. - Form and query parameters are decoded as UTF-8 when the request declares no charset.
- An encoded slash is not a path separator:
/admin%2Fsecretdoesn't match an/admin/secretroute, while/files/:namematches/files/a%2Fbwithname=a/b. (Most containers reject%2Fin paths by default anyway.) - Routes can be added at any time, including while requests are served.
import io.github.ghosthack.turismo.action.Action;
import io.github.ghosthack.turismo.routes.RoutesMap;
public class AppRoutes extends RoutesMap {
@Override
protected void map() {
get("/hello", new Action() {
@Override
public void run() {
print("Hello!");
}
});
}
}import io.github.ghosthack.turismo.action.Action;
import io.github.ghosthack.turismo.routes.RoutesList;
public class AppRoutes extends RoutesList {
@Override
protected void map() {
get("/users/:id", new Action() {
@Override
public void run() {
print("User " + params("id"));
}
});
// Route aliases: /u/:id runs the /users/:id action
get("/u/:id", "/users/:id");
}
}RoutesMap has no string alias; ExtendedRoutesMap adds
get(path, target), which forwards the request to another resource of the
web application (another route, a JSP, a static file) with a
RequestDispatcher:
import io.github.ghosthack.turismo.action.Action;
import io.github.ghosthack.turismo.routes.ExtendedRoutesMap;
public class AppRoutes extends ExtendedRoutesMap {
@Override
protected void map() {
get("/hello", new Action() {
@Override
public void run() {
print("Hello!");
}
});
get("/hi", "/hello");
}
}The target is a context-relative path starting with / and goes through the
container's servlet mappings, so /hello only reaches the route if the
servlet is mapped to / or /* (with a prefix mapping such as /app/*,
forward to /app/hello). Forward targets, like forward() and jsp()
paths, can name anything in the web application, including /WEB-INF, so
don't build them from request input.
import io.github.ghosthack.turismo.servlet.Servlet;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.ee10.servlet.ServletHolder;
import org.eclipse.jetty.server.Server;
public class Main {
public static void main(String[] args) throws Exception {
Server server = new Server(8080);
ServletContextHandler ctx = new ServletContextHandler();
ctx.setContextPath("/");
ServletHolder holder = new ServletHolder(new Servlet());
holder.setInitParameter("routes", "com.example.AppRoutes");
ctx.addServlet(holder, "/*");
server.setHandler(ctx);
server.start();
server.join();
}
}<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" version="6.0">
<servlet>
<servlet-name>app</servlet-name>
<servlet-class>io.github.ghosthack.turismo.servlet.Servlet</servlet-class>
<init-param>
<param-name>routes</param-name>
<param-value>com.example.AppRoutes</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>app</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>Mapped to /, turismo replaces the container's default servlet and gets
every request that no other mapping claims, while *.jsp requests and
forwards still reach the container's JSP servlet. A route's path is the
request path within the context (/hello). To serve routes under a prefix
instead, map to /app/*; route paths are then relative to it (/hello is
served at /app/hello).
jsp() forwards to a JSP, which the container's JSP servlet renders. Map
turismo to / or a prefix such as /app/* as shown above, not to /*: a
/* mapping also matches the JSP's path, so the forward comes back to
turismo (a 404 for a path with no route) instead of rendering the page.
import io.github.ghosthack.turismo.action.Action;
import io.github.ghosthack.turismo.routes.RoutesMap;
public class AppRoutes extends RoutesMap {
@Override
protected void map() {
get("/render", new Action() {
@Override
public void run() {
req().setAttribute("message", "Hello World!");
jsp("/WEB-INF/views/render.jsp");
}
});
}
}/WEB-INF/views/render.jsp:
<%@ page contentType="text/html; charset=UTF-8" %>
<p>${message}</p>(Use ${fn:escapeXml(message)} or <c:out> from JSTL for values that come
from the request.)
import io.github.ghosthack.turismo.multipart.MultipartRequest;
post("/upload", new Action() {
@Override
public void run() {
try {
MultipartRequest multipart = MultipartRequest.wrapAndParse(req());
String[] meta = multipart.getParameterValues("image");
String contentType = meta[0];
String fileName = meta[1];
byte[] bytes = (byte[]) multipart.getAttribute("image");
print("Uploaded " + fileName + " (" + bytes.length + " bytes)");
} catch (Exception e) {
throw new ActionException(e);
}
}
});Uploads are limited to 10 MB by default (MultipartParser.setMaxContentSize);
memory is used as the body arrives, not reserved from the declared
Content-Length, and chunked uploads are accepted. wrapAndParse throws
ContentTooLargeException for bodies over the limit and ParseException for
malformed ones; MultipartFilter answers those with 413 and 400. Text is
decoded with the request's charset, defaulting to UTF-8. A file part without
a Content-Type is reported as application/octet-stream. Bodies with more
than 1000 parts are rejected like oversized ones
(MultipartParser.setMaxParts).
getParameterValues(name) and the other parameter methods return the query
string values first, then the body's text fields, so /upload?id=5 keeps
id. Files are kept apart from text fields. The [contentType, fileName]
array and byte[] attribute above describe the first file of a field (the
array is only reported when the name has no query or text values); use
getFile and getFiles for everything, including several files under one
name:
for (FilePart file : multipart.getFiles("attachments")) {
save(file.fileName(), file.contentType(), file.content());
}fileName is exactly what the client sent: it may contain /, \ or ..,
so never use it as a path without sanitizing it. Backslashes are kept
literally, and %22, %0D and %0A are decoded to ", CR and LF, as
browsers encode them.
5.0 changes these defaults and behaviors from 4.x:
- POST status:
post()and@POSTroutes respond200unless the handler sets a status. Addstatus(201)where you relied on the old default. - Servlet routes answer 405: with
RoutesMap/RoutesList, a request whose path only matches routes for other methods gets405 Method Not Allowedwith anAllowheader, instead of 404 or the default route. HEAD requests are served by GET routes. - Encoded slashes:
/admin%2Fsecretno longer matches an exact/admin/secretroute (it could be used to get past path-based access rules in a proxy). - Repeated query parameters:
param()andquery()return the first value of a repeated name (?a=1&a=2gives1); 4.x returned the last.paramValues()/queryValues()return all of them. - Route validation:
route(),get(), ... reject a null method, path or action, and paths that don't start with/;notFound(null)is rejected too. - Multipart:
- Text defaults to UTF-8 instead of ISO-8859-1 when the request has no charset.
MultipartFilteranswers malformed bodies with400and oversized ones with413instead of throwingServletException.wrapAndParsethrowsContentTooLargeException(aParseException) for oversized bodies, andParseExceptionfor a missing boundary or unsupported charset.- Requests without
Content-Lengthare accepted. - More than 1000 parts is answered with
413(MultipartParser.setMaxParts). - A backslash in a quoted file name is kept as is; only
%22,%0Dand%0Aare decoded, as browsers send them. - Query-string parameters are merged with the body's fields.
- Trailing slashes are significant:
/users/:idno longer matches/users/42/or/users/42//, the same as exact routes. A:paramor*never matches an empty segment. This applies toRoutesListtoo. - OPTIONS: a path with routes for other methods answers
OPTIONSwith204and anAllowheader, and405responses listOPTIONSinAllow. - Re-registering a route replaces it for pattern routes too (exact routes already did); the last registration wins.
controller()is all-or-nothing: if a method can't be registered, no route of that controller is.- Number arguments:
float/doublearguments rejectNaN,Infinity, hex floats and values that overflow, with400. - Status codes: on the embedded server
status()rejects codes outside 200-599. - Redirects:
redirect()rejects every control character (TAB too), not only CR/LF. - Servlet backend: requests without a charset are decoded as UTF-8, and
an exception from an action is logged and answered with
500instead of reaching the container. - Form after body: reading form fields after taking the raw body
answers
400instead of throwingIllegalStateException.
New in 5.0: App instances (Multiple apps), controller
method arguments (Method arguments), form bodies
(Form bodies), graceful
stop(Duration), logging of handler errors, and JSON support for records,
enums, Character and all array types, stream() responses,
redirectLocal(), multiple files per multipart field (getFiles) and
automatic OPTIONS.
- Set the release version in
pom.xml(remove-SNAPSHOT) and merge it tomastervia PR - Tag the merged commit on
masterand push the tag:git tag v5.0.0 git push origin v5.0.0
- The Release workflow checks that the tag matches the
pom.xmlversion and is onmaster, uploads the signed artifacts to Maven Central, and creates a draft GitHub release - Publish the deployment at https://central.sonatype.com/publishing/deployments
- Once the artifacts are live on Central, publish the draft release on GitHub
The workflow uses the MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD,
GPG_PRIVATE_KEY and GPG_PASSPHRASE repository secrets; the passphrase
reaches maven-gpg-plugin through the MAVEN_GPG_PASSPHRASE environment
variable.