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