Skip to content

Latest commit

 

History

153 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

turismo

A lightweight Sinatra/Express-style Java web framework.

CI Javadocs Maven Central

Maven

<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 to io.github.ghosthack starting with 2.0.0.

Quick start

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.

Routing

Exact paths

get("/hello", "Hello!");
post("/submit", () -> print("Submitted"));

Named parameters

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.

Wildcards

get("/files/*/download", () -> print("Downloading"));

Like a named parameter, * matches exactly one non-empty segment.

Query parameters

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

Form bodies

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.

HTTP methods

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. Add status(201) to handlers that relied on it. See Upgrading to 5.0.

Response helpers

// 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.

Redirects and open redirects

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 : "/");
});

Streaming large responses

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.

Custom not-found handler

Runs when no route matches the path (wrong-method requests get a 405 instead):

notFound(() -> {
    status(404);
    type("application/json");
    print("{\"error\":\"not found\"}");
});

Request access

get("/echo", () -> {
    String method = method();            // HTTP method
    String path = path();                // request path
    String auth = header("Authorization"); // request header
    InputStream body = body();           // request body
});

Controller mode

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.

Method arguments

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 (boolean and Boolean accept true/false in any case), BigInteger, BigDecimal, enums (by constant name) and UUID. A Context argument receives the request context and an InputStream argument the request body; the body is bound after the other arguments, so they can still come from a form body. double and float accept plain decimal numbers (-1.5, 2e10), not NaN, Infinity, hexadecimal or out-of-range values.
  • An array of any of those (String[], int[], Boolean[], BigDecimal[], ...), or a List, Collection, Iterable or Set of one (List<Integer>, Set<Size>), receives every value of a repeated parameter, in order; if the parameter is absent it's empty. A Set is a LinkedHashSet (request order, duplicates dropped), the others an ArrayList; each request gets a fresh, mutable one. The element type must be spelled out: a raw List, List<?> or List<Object> is rejected at registration.
  • A value that can't be converted (/items/abc for an int), or a missing value for a primitive, gets 400 Bad Request. A missing value for any other type is passed as null.
  • BigInteger and BigDecimal values are limited to 1000 characters, and a BigDecimal exponent to ±1000, since parsing or printing far larger numbers could tie up the server; longer values get 400.
  • 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 (plain javac, 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.

Multiple apps

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.

Concurrency

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.

Timeouts

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.

Stopping and errors

stop();                       // immediate: requests in progress are interrupted
stop(Duration.ofSeconds(10)); // graceful: in-flight requests get up to 10s

An exception thrown by a handler results in a 500 Internal Server Error and is logged through System.Logger (by default java.util.logging).

Servlet deployment

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 405 with an Allow header.
  • An exception thrown by an action is logged through System.Logger and answered with 500 (the container's error page never shows the stack trace); a request with no matching route gets 404.
  • Form and query parameters are decoded as UTF-8 when the request declares no charset.
  • An encoded slash is not a path separator: /admin%2Fsecret doesn't match an /admin/secret route, while /files/:name matches /files/a%2Fb with name = a/b. (Most containers reject %2F in paths by default anyway.)
  • Routes can be added at any time, including while requests are served.

RoutesMap — exact match (O(1) lookup)

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

RoutesList — wildcards and named parameters

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

ExtendedRoutesMap — forwarding aliases

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.

Embedded Jetty

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

web.xml

<?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 rendering

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.)

Multipart file uploads

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.

Upgrading to 5.0

5.0 changes these defaults and behaviors from 4.x:

  • POST status: post() and @POST routes respond 200 unless the handler sets a status. Add status(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 gets 405 Method Not Allowed with an Allow header, instead of 404 or the default route. HEAD requests are served by GET routes.
  • Encoded slashes: /admin%2Fsecret no longer matches an exact /admin/secret route (it could be used to get past path-based access rules in a proxy).
  • Repeated query parameters: param() and query() return the first value of a repeated name (?a=1&a=2 gives 1); 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.
    • MultipartFilter answers malformed bodies with 400 and oversized ones with 413 instead of throwing ServletException.
    • wrapAndParse throws ContentTooLargeException (a ParseException) for oversized bodies, and ParseException for a missing boundary or unsupported charset.
    • Requests without Content-Length are accepted.
    • More than 1000 parts is answered with 413 (MultipartParser.setMaxParts).
    • A backslash in a quoted file name is kept as is; only %22, %0D and %0A are decoded, as browsers send them.
    • Query-string parameters are merged with the body's fields.
  • Trailing slashes are significant: /users/:id no longer matches /users/42/ or /users/42//, the same as exact routes. A :param or * never matches an empty segment. This applies to RoutesList too.
  • OPTIONS: a path with routes for other methods answers OPTIONS with 204 and an Allow header, and 405 responses list OPTIONS in Allow.
  • 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/double arguments reject NaN, Infinity, hex floats and values that overflow, with 400.
  • 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 500 instead of reaching the container.
  • Form after body: reading form fields after taking the raw body answers 400 instead of throwing IllegalStateException.

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.

Releasing

  1. Set the release version in pom.xml (remove -SNAPSHOT) and merge it to master via PR
  2. Tag the merged commit on master and push the tag:
    git tag v5.0.0
    git push origin v5.0.0
  3. The Release workflow checks that the tag matches the pom.xml version and is on master, uploads the signed artifacts to Maven Central, and creates a draft GitHub release
  4. Publish the deployment at https://central.sonatype.com/publishing/deployments
  5. 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.

License

Apache License 2.0

About

A lightweight Sinatra/Express-style Java web framework -- zero-dependency embedded HTTP server or Jakarta Servlet deployment

Topics

Resources

Stars

35 stars

Watchers

8 watching

Forks

Releases

Packages

Used by

Contributors

Languages