REST API

Build JSON APIs with JWeb's annotations — the whole controller is jweb.* and java.*, no Spring import.

Basic Controller

import jweb.Request;
import jweb.api.*;

record User(long id, String name) {}

@REST("/api/v1/users")
public class UserApi {

    private final List<User> users = new ArrayList<>(List.of(new User(1, "Ada")));

    @GET
    public List<User> list() {
        return users;
    }

    @GET("/{id}")
    public User get(@Param long id) {
        return users.stream().filter(u -> u.id() == id).findFirst().orElseThrow();
    }

    @GET("/search")
    public List<User> search(@Query String q,
                             @Query(value = "limit", defaultValue = "10") int limit) {
        return users.stream().filter(u -> u.name().contains(q)).limit(limit).toList();
    }

    @POST
    public User create(@Body User user) {
        users.add(user);
        return user;
    }

    @POST("/{id}/avatar")
    public Map<String, Object> avatar(@Param long id, @Upload("file") UploadedFile file) {
        return Map.of("saved", file.getFilename());
    }

    @GET("/me")
    public Map<String, Object> me(Request req, @Cookie("session") String session) {
        return Map.of("ip", req.ip(), "session", session);
    }
}

Annotations

@REST("/api/v1/x")   // Controller with base path (must start with /api/v)
@GET  @POST  @UPDATE  @PATCH  @DEL     // one per method; @GET("/{id}") adds a path

// Parameters
@Param       // path placeholder {id} → int, long, String, UUID, enum...
@Query       // query or form parameter; defaultValue, required; Optional<T>, List<T>
@Body        // JSON body → record, POJO, Map, List (or the raw String)
@Header      // request header
@Cookie      // cookie value
@Upload      // multipart file → jweb.UploadedFile (never null: isEmpty())
Request      // a bare jweb.Request parameter is injected

// Wiring
@Component   // a class the container creates once and injects (jweb.api.Component)
@Value       // @Value("${app.setting:default}") on a field or constructor parameter

Missing or malformed input answers 400 with a message naming the parameter. Conversion follows the record-binding rules: numbers, booleans (true/1/yes/on), UUID, enums by name.

Response Types

@GET("/users")
public List<User> users() { ... }              // Auto-JSON

@POST("/users")
public Object create(@Body User user) {
    if (!valid(user)) return Response.badRequest("name is required");
    return Response.created("/api/v1/users/" + id, saved);   // 201 + Location + JSON
}

@GET("/download")
public Object file() {
    return Response.ok().contentType("application/pdf").body(bytes);
}
A guard registered under the same prefix covers the controller too: app.guard("/api/v1/admin/**", Auth.requireRole("admin")). Middleware does not.
Mount OpenApi.create().addApi(UserApi.class).mount(app, "/api") to serve interactive API docs at /api/docs.

Server-Sent Events

Push real-time updates to clients with SSE.

import jweb.SseEmitter;

String getData() { return "tick"; }

// Create SSE endpoint — return the emitter straight from the route
app.get("/events", req -> {
    SseEmitter emitter = SseEmitter.create();

    Jobs.run(() -> {
        while (!emitter.isCompleted()) {
            emitter.send(getData());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    });

    return emitter;
});

Event Types

SseEmitter emitter = SseEmitter.create();
String jsonData = "{count: 1}";
String data = "hello";

// Named event (client listens with addEventListener)
emitter.send(SseEvent.of("notification", jsonData));

// Default event (client listens with onmessage)
emitter.send(jsonData);

// With event ID (for reconnection)
emitter.send(SseEvent.create()
    .id("msg-123")
    .name("message")
    .data(data)
    .build());

Client JavaScript

The sse(...) builder is the EventSource client — on(name, ...) for named events, onMessage(...) for unnamed ones.

import static jweb.Js.*;
import static jweb.js.JSJson.parse;

inlineScript(actions().does(
    sse("/events")
        .on("notification", callback("e")
            .var_("data", parse(v("e").dot("data")))
            .call("showNotification", v("data")))
        .onError(callback().log("Connection lost"))
        .toVal()
).build())

Broadcasting

String jsonData = "{count: 1}";
Map<String, Object> orderData = Map.of("id", 42);

// Shared broadcaster for all connected clients
SseBroadcaster broadcaster = new SseBroadcaster();

// Broadcast to everyone
broadcaster.broadcast("New message!");
broadcaster.broadcast(SseEvent.of("notification", jsonData));

// Broadcast to a channel
broadcaster.broadcast("orders", SseEvent.json("orderUpdate", orderData));

// Client subscribes to a channel
app.get("/orders/updates", req -> {
    SseEmitter emitter = SseEmitter.create();
    broadcaster.subscribe("orders-" + req.query("id"), emitter);
    return emitter;
});

Complete Example

Map<String, Object> getDashboardData() { return Map.of(); }
Map<String, Object> getStats() { return Map.of(); }

// Live dashboard updates
app.get("/dashboard/updates", req -> {
    SseEmitter emitter = SseEmitter.create();

    Jobs.run(() -> {
        emitter.send(SseEvent.json("init", getDashboardData()));

        while (!emitter.isCompleted()) {
            emitter.send(SseEvent.json("stats", getStats()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                break;
            }
        }
    });

    return emitter;
});
SSE auto-reconnects on connection loss. Use event IDs for message replay.

Background Jobs

Run tasks asynchronously without blocking the request.

import jweb.Jobs;

record User(String email) {}
User createUser(Request req) { return new User("new@example.com"); }
void sendWelcomeEmail(User user) {}
void logAnalytics(String event) {}

User user = new User("a@example.com");

// Fire and forget
Jobs.run(() -> {
    sendWelcomeEmail(user);
    logAnalytics("signup");
});

// Return immediately, email sends in background
app.post("/register", req -> {
    User created = createUser(req);
    Jobs.run(() -> sendWelcomeEmail(created));
    return Response.redirect("/welcome");
});

Scheduled Jobs

void cleanupExpiredSessions() {}
void generateDailyReport() {}
void runNightlyBackup() {}

// Run every 5 minutes (named, so it can be cancelled)
Jobs.schedule("session-cleanup", Duration.ofMinutes(5), () -> {
    cleanupExpiredSessions();
});

// Run every hour
Jobs.schedule("hourly-report", Duration.ofHours(1), () -> {
    generateDailyReport();
});

// Initial delay, then repeat every 24 hours
Jobs.schedule("nightly-backup", Duration.ofHours(2), Duration.ofHours(24), () -> {
    runNightlyBackup();
});

Delayed Execution

record User(String email) {}
void sendReminderEmail(User user) {}
void expireTemporaryLink(String linkId) {}
User user = new User("a@example.com");
String linkId = "link-42";

// Run after delay
Jobs.delay(Duration.ofSeconds(30), () -> {
    sendReminderEmail(user);
});

// Run after 1 hour
Jobs.delay(Duration.ofHours(1), () -> {
    expireTemporaryLink(linkId);
});

With Result

import java.util.concurrent.CompletableFuture;

record Report(String title) {}
Report generateReport(String params) { return new Report(params); }
void emailReport(Report report) {}
String params = "q4-2026";

// Get future result
CompletableFuture<Report> future = Jobs.submit(() -> {
    return generateReport(params);
});

// Use when ready
future.thenAccept(report -> {
    emailReport(report);
});

// Or block and wait
Report report = future.get();

Error Handling

record Order(long id) {}
void processOrder(Order order) {}
void notifyAdmin(Exception e) {}
void logError(String message, Exception e) {}
Order order = new Order(1);

Jobs.run(() -> {
    try {
        processOrder(order);
    } catch (Exception e) {
        logError("Order processing failed", e);
        notifyAdmin(e);
    }
});

// Cancel or inspect scheduled jobs
Jobs.cancel("session-cleanup");
Jobs.isScheduled("session-cleanup");
Use Jobs for anything that doesn't need to block the HTTP response.