Getting started — Install
Panaragan targetsJDK. Build with Maven or Gradle. The runtime reads .env
from resources/
or working dir.
./gradlew build
java -jar build/libs/your-app.jar
mvn package
java -jar target/your-app.jar
.env
app.name=Panaragan
app.port=8005
app.base_url=http://127.0.0.1:8005/
You can also set Database (Mysql, Postgresql, SQLServer or Oracle), SMTP, Session, Cookies, etc. in .env
as used by the sample Web/CMS.
Quick Start
Start Panaragan quickly via three starter packs (Vanilla, CMS, API) or follow the minimal “Vanilla” walkthrough below (environment, Core, routes, controllers, views, run & test).
A) Download a Starter Pack
- Open theDownloadpage and choose one:
- Vanilla (empty)— bare minimum skeleton; you wire everything yourself (covered below).
- Web/CMS Project— ready-to-run Web/CMS example (routes/controllers/views already set).
- API Project— ready-to-run API example (JSON endpoints, JWT, etc.).
- Click the project to download, then unzip to a working folder.
B) Run the CMS / API Packs (Instant)
- Open the unzipped folder in IntelliJ IDEA (or your IDE). Let it import Gradle/Maven.
- Run the app (use the mainAppclass or Gradle/Maven run tasks).
- Visit
http://127.0.0.1:8005/
(or the configured port).
Tip: If a .env
is included, update app.port
& credentials as needed.
C) Vanilla Pack — Step-by-Step
We’ll add a minimal .env
, implement Core
, register routes, create two controllers (View + API), add a base layout (Tailwind CDN) + home view, then run and test.
Step 0 — Requirements & Structure
- JDK, Gradle or Maven.
- Recommended structure:
your-app/ ├─ src/main/java/app/ │ ├─ App.java (implements Core) │ └─ controllers/ │ ├─ Home.java (renders view) │ └─ Api.java (returns JSON) └─ src/main/resources/ ├─ .env ├─ public/ (optional static assets) └─ views/ ├─ layouts/ │ └─ frontend.html (base layout - Tailwind CDN) └─ frontend/ └─ home.html (page)
Step 1 — Minimal .env
Create src/main/resources/.env
:
app.name=Panaragan Vanilla
app.port=8005
app.base_url=http://127.0.0.1:8005/
# Templates
template.path=views
template.minify=true
# Sessions & CSRF
session.mode=MEMORY
csrf.name=csrf-token
csrf.expired=3600
# (Optional DB) enable if you need Database.* helpers
# db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
# db.username=postgres
# db.password=postgres
Step 2 — Implement Core
& Register Routes
Create src/main/java/app/App.java
:
package app;
public class App implements Core {
public static void main(String[] args) {
Server.run(new App());
}
public void boot(final Routers r) {
// Routes: one view page, one JSON endpoint
r.get("/", app.controllers.Home::index);
r.get("/api/health", app.controllers.Api::health);
}
public void accept(final Request req, final Response res) {
// Default content type; handlers may override.
res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");
}
public boolean authorize(final Request req, final Response res, final String authority) {
// No guards in this quickstart. If you call res.redirect/empty/send/renderView here,
// it's terminal (no further code runs).
return true;
}
public void respond(final Request req, final Response res) {
res.addHeader("X-Frame-Options","DENY");
res.addHeader("X-Content-Type-Options","nosniff");
res.addHeader("Referrer-Policy","strict-origin-when-cross-origin");
}
public void error(final Request req, final Response res, final String reason) {
ParamsObject ctx = new ParamsObject()
.set("error_code", res.getCode())
.set("error_message", res.getMessage())
.set("error_reason", reason);
res.renderView("error", ctx);
}
}
Step 3 — First Controllers (View & API)
Create src/main/java/app/controllers/Home.java
:
package app.controllers;
public class Home {
public static void index(Request request, Response response) {
// Provide only what the layout/page needs
ParamsObject data = new ParamsObject()
.set("page_title", "Welcome") // used in <title>
.set("intro", "Build fast. Ship faster.");
response.renderView("frontend.home", data);
}
}
Create src/main/java/app/controllers/Api.java
:
package app.controllers;
public class Api {
public static void health(Request req, Response res) {
PJsonObject json = new PJsonObject()
.set("ok", true)
.set("time", System.currentTimeMillis());
res.send(json); // Content-Type: application/json
}
}
Step 4 — Base Layout (Tailwind CDN) & Home View
Use Tailwind via CDN to avoid missing local assets. Add both files below.
4.1Create src/main/resources/views/layouts/frontend.html
:
<!-- Minimal base layout using Tailwind CDN -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ config('app.name') }} — {{ page_title }}</title>
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
@yield('style')
</head>
<body class="antialiased text-slate-900 bg-slate-50">
<header class="border-b bg-white/80 backdrop-blur-sm">
@yield('menus')
</header>
@yield('body')
<footer class="mt-10 text-center text-xs text-slate-500 py-8">
© <span id="year"></span> Panaragan
</footer>
<script>document.getElementById('year').textContent = new Date().getFullYear();</script>
@yield('script')
</body>
</html>
4.2Create src/main/resources/views/frontend/home.html
:
@extends('layouts.frontend')
@section('menus')
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<a href="{{ url('/') }}" class="font-semibold">Panaragan</a>
<nav class="text-sm text-slate-600">
<a class="px-3 py-2 hover:underline" href="{{ url('/') }}">Home</a>
<a class="px-3 py-2 hover:underline" href="{{ url('api/health') }}">API</a>
</nav>
</div>
@endsection
@section('body')
<main class="max-w-6xl mx-auto px-6 py-10">
<h1 class="text-3xl font-bold">Welcome to Panaragan</h1>
<p class="mt-2 text-slate-600">{{ intro }}</p>
<div class="mt-6 flex gap-3">
<a class="px-3 py-2 rounded bg-slate-900 text-white" href="{{ url('api/health') }}">Check API</a>
<a class="px-3 py-2 rounded border" href="{{ url('docs') }}">Read Docs</a>
</div>
</main>
@endsection
Step 5 — Build & Run
From the project root:
# Gradle
./gradlew clean build -x test
java -jar build/libs/*-all.jar # or the produced fat jar name
# Maven
mvn -q -DskipTests package
java -jar target/*.jar
Step 6 — Test
http://127.0.0.1:8005/
→ rendersfrontend.home
using the Tailwind CDN layout.http://127.0.0.1:8005/api/health
→ returns JSON{"ok":true,...}
.
Notes & Troubleshooting
- Blank page?Ensure you have
layouts/frontend.html
and your page uses@extends('layouts.frontend')
with a@section('body')
. - Template not found?Check
template.path=views
and file paths undersrc/main/resources/views/...
. - Port in use?Change
app.port
in.env
or free the port. - JSON shows as HTML?Ensure
Core.accept
sets JSON for/api/*
paths or callresponse.setContentType("application/json")
beforesend(...)
.
Features at a Glance
Core
: boot
, accept
, authorize
, respond
, error
./x/{id}
, GET/POST, mixed getOrPost
.QueryBuilder
for SELECT, Database
for CRUD.resources/languages/
.Url.to("/path")
, query helpers, safe encoding/decoding.Core
Core
is your application lifecycle contract. Implement it once to centralize routing bootstrap, per-request setup, authorization checks, cross-cutting headers, and unified error rendering. Mastering these hooks helps you keep controllers lean and your app consistent.
Lifecycle (execution order)
- boot(routers)— runs once at startup; register routes here.
- accept(request, response)— runs onevery requestbefore the controller; add defaults & context.
- authorize(request, response, authority)— runs if the matched route/group set an authority string.
- controller— your handler logic.
- respond(request, response)— last stop before flush; add final headers/content-type.
- error(request, response, reason)— centralized error rendering for exceptions/error codes.
response.renderView(...)
, response.send(...)
, response.redirect(...)
, response.redirectBack(...)
, response.redirectBackWithInput(...)
, or response.empty(...)
immediatelyterminatesthe pipeline. That means inside authorize()
you don’t need to return anything after a redirect/send/empty — the flow stops there.Minimal Core implementation
package app;
public class App implements Core {
public static void main(String[] args) {
Server.run(new App());
}
public void boot(final Routers r) {
// Basic routes: one HTML view and one JSON endpoint
r.get("/", app.controllers.Home::index);
r.get("/api/health", app.controllers.Api::health);
}
public void accept(final Request req, final Response res) {
// Set a sensible default content-type; controllers may override.
res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");
// Example: attach a per-request id for tracing
String rid = java.util.UUID.randomUUID().toString();
req.setPayload("req_id", rid);
res.addHeader("X-Request-ID", rid);
}
public boolean authorize(final Request req, final Response res, final String authority) {
// No guards here in the minimal example.
// NOTE: If you call res.redirect()/res.empty()/res.send()/res.renderView() here,
// the request terminates immediately; you don't need to return anything after that.
return true;
}
public void respond(final Request req, final Response res) {
// Security/compat headers (good defaults)
res.addHeader("X-Frame-Options", "DENY");
res.addHeader("X-Content-Type-Options", "nosniff");
res.addHeader("Referrer-Policy", "strict-origin-when-cross-origin");
}
public void error(final Request req, final Response res, final String reason) {
// Unified HTML error page
ParamsObject ctx = new ParamsObject()
.set("error_code", res.getCode())
.set("error_message", res.getMessage())
.set("error_reason", reason);
res.renderView("error", ctx); // terminal
}
}
Defining routes in boot()
(palette)
Register single-verb routes, multi-verb utilities, and groups with optional authority:
// Single verb
r.get("/", Home::index);
r.post("/login", Auth::submit, "guest");
r.put("/users/{id}", UsersUpdate::update, "users_update");
r.delete("/users/{id}", UsersDelete::remove, "users_delete");
r.patch("/profile", Profile::partialUpdate, "profile_edit");
r.head("/files/{id}", Files::headInfo);
r.options("/api/*", Cors::preflight);
// Multi-verb utilities
r.getOrPost("/search", Search::index); // GET and POST
r.all("/webhook/{source}", Webhook::receive);
// Grouping (prefix) + authority cascade to children (unless overridden)
r.group("/admin", "auth", g -> {
g.get("/", Admin::dashboard); // requires "auth"
g.get("roles", Roles::index, "roles_list"); // overrides to "roles_list"
});
Dynamic segments use {name}
; read with request.getParam("name")
. Wildcards like /api/*
match nested paths.
Patterns for accept()
(per-request setup)
// A) Default content-type, session user to payload, locale prep
public void accept(final Request req, final Response res) {
res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");
String userJson = req.getSession("user"); // set during login
if (userJson != null) {
req.setPayload("user", PJsonObject.parse(userJson));
}
// Optional: language detection/bootstrap (if your app uses Language)
req.getLanguage(); // resolves from Accept-Language or default
// Request id
String rid = java.util.UUID.randomUUID().toString();
req.setPayload("req_id", rid);
res.addHeader("X-Request-ID", rid);
}
// B) Bearer/JWT extraction (pseudo; implement with your JWT library)
public void accept(final Request req, final Response res) {
String auth = req.getHeader("Authorization", "");
if (auth.startsWith("Bearer ")) {
String token = auth.substring(7);
try {
PJsonObject claims = Jwt.verifyAndDecode(token); // your lib/impl
req.setPayload("jwt", claims);
} catch (Exception ignored) {
// invalid token → treat as guest
}
}
}
Recipes for authorize()
A route (or its group) may set an authority string (e.g., "guest"
, "auth"
, "users_list"
). In authorize()
you decide what that string means. Remember: any terminal response here stops the pipeline immediately.
// Guest-only vs Auth-only
public boolean authorize(final Request req, final Response res, final String a) {
boolean loggedIn = (req.getSession("user") != null);
if ("guest".equals(a)) {
if (loggedIn) { res.redirect("dashboard"); } // terminal; no return needed after this line
return true;
}
if ("auth".equals(a)) {
if (!loggedIn) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal
return true;
}
return true;
}
// RBAC permission strings stored in session
public boolean authorize(final Request req, final Response res, final String perm) {
if (perm == null || perm.isBlank()) return true;
String userJson = req.getSession("user");
if (userJson == null) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal
PJsonArray perms = (PJsonArray) PJsonObject.parse(userJson).get("permissions");
if (perms == null || !perms.contains(perm)) { res.empty(403, "Forbidden"); } // terminal
return true;
}
// CORS preflight guarded by a custom authority
// boot(): r.options("/api/*", Cors::preflight, "cors");
public boolean authorize(final Request req, final Response res, final String a) {
if ("cors".equals(a) && "OPTIONS".equals(req.getMethod())) {
res.addHeader("Access-Control-Allow-Origin", "*");
res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS");
res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.empty(204, "No Content"); // terminal
}
return true;
}
// IP allow-list / API key
public boolean authorize(final Request req, final Response res, final String a) {
if ("internal_api".equals(a)) {
String ip = req.getIP();
String key = req.getHeader("X-API-Key", "");
if (!"127.0.0.1".equals(ip) || !"supersecret".equals(key)) { res.empty(401, "Unauthorized"); } // terminal
}
return true;
}
Patterns for respond()
// Security & defaults (good baseline)
public void respond(final Request req, final Response res) {
res.addHeader("X-Frame-Options", "DENY");
res.addHeader("X-Content-Type-Options", "nosniff");
res.addHeader("Referrer-Policy", "strict-origin-when-cross-origin");
// Force JSON for /api/*
if (req.getPath().startsWith("/api/")) {
res.setContentType("application/json");
}
}
// Public cache for homepage, CORS headers for APIs
public void respond(final Request req, final Response res) {
if ("/".equals(req.getPath())) {
res.addHeader("Cache-Control", "public, max-age=60");
}
if (req.getPath().startsWith("/api/")) {
res.addHeader("Access-Control-Allow-Origin", "*");
res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS");
res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
}
Patterns for error()
// HTML error page (default)
public void error(final Request req, final Response res, final String reason) {
ParamsObject ctx = new ParamsObject()
.set("error_code", res.getCode())
.set("error_message", res.getMessage())
.set("error_reason", reason);
res.renderView("error", ctx); // terminal
}
// JSON errors for API paths
public void error(final Request req, final Response res, final String reason) {
if (req.getPath().startsWith("/api/")) {
res.send(new PJsonObject()
.set("error", true)
.set("code", res.getCode())
.set("message", res.getMessage())
.set("reason", reason));
return; // explicit only
}
ParamsObject ctx = new ParamsObject()
.set("error_code", res.getCode())
.set("error_message", res.getMessage())
.set("error_reason", reason);
res.renderView("error", ctx);
}
views/error.html
(escaped so it won’t render):@extends('layouts.frontend')
@section('body')
<section class="py-16">
<div class="max-w-xl mx-auto px-6">
<h1 class="text-2xl font-bold">Error {{ error_code }}</h1>
<p class="mt-2 text-slate-600">{{ error_message }}</p>
<pre class="mt-4 text-xs bg-slate-100 p-3 rounded">{{ error_reason }}</pre>
<a class="inline-block mt-6 px-3 py-2 bg-slate-900 text-white rounded" href="{{ url('/') }}">Back to home</a>
</div>
</section>
@endsection
End-to-end: Admin & Users
// boot()
r.group("/admin", "auth", g -> {
g.get("/", Admin::dashboard); // "auth" guard
g.group("users", "users_list", ug -> {
ug.get("/", UsersIndex::index); // needs "users_list"
ug.post("store", UsersAdd::store, "users_add"); // needs "users_add"
ug.get("edit/{id}", UsersEdit::index, "users_edit"); // needs "users_edit"
});
});
// authorize()
public boolean authorize(final Request req, final Response res, final String perm) {
if (perm == null || perm.isBlank()) return true;
String userJson = req.getSession("user");
if (userJson == null) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal
PJsonObject user = PJsonObject.parse(userJson);
if ("auth".equals(perm)) return true; // already authenticated
PJsonArray perms = (PJsonArray) user.get("permissions");
if (perms == null || !perms.contains(perm)) { res.empty(403, "Forbidden"); } // terminal
return true;
}
// respond(): add API headers if needed; see patterns above
// error(): same as the examples above
Troubleshooting (Core)
- Authorize seems ignored:ensure the route or its group sets an authority string.
- Unexpected HTML on API errors:switch on path prefix (or
Accept
header) inerror()
/respond()
to send JSON. - Redirect loops:verify how/when you set/destroy
session("user")
and double-check guards ("guest"
/"auth"
). - Headers missing:add them in
respond()
; after a terminal call the response is already flushed. - OPTIONS/HEAD issues:handle them explicitly via
r.options(...)
andr.head(...)
and terminate withempty(...)
.
Cache
A lightweight, thread-safe, in-memory cache for speeding up repeated computations, DB/API calls, or rendering pre-built results. Keys are strings (internally hashed with XXH64), and each entry has a per-item TTL (time-to-live).
Public API
Cache.set(String key, Object value)
— store using thedefault TTL.Cache.set(String key, Object value, int ttlMs)
— store with a custom TTL (ms).Cache.get(String key)
— get ifnot expired; returnsnull
if missing/expired.Cache.getFromExpired(String key)
— get even if expired (useful for “stale-while-revalidate”).Cache.delete(String key)
— remove a key manually.Cache.setMaxExpired(int ttlMs)
— set the global default TTL (non-negative).Cache.setMaxItems(int max)
— set global max entries (min threshold enforced). Auto-sweeps when near capacity.
10_000
ms; capacity guard ≈ 10_000_000
items (automatic sweep of expired entries when size ≥ ~80% of the limit). In-memory only (not distributed).Basics
// Set & get with default TTL
Cache.set("home:hero", "Welcome to Panaragan!");
String hero = (String) Cache.get("home:hero"); // "Welcome to Panaragan!" or null if expired
// Set with custom TTL (e.g., 30 seconds)
Cache.set("report:weekly", generateWeeklyReport(), 30_000);
// Delete manually
Cache.delete("home:hero");
// Adjust global defaults (optional)
Cache.setMaxExpired(15_000); // new default TTL for subsequent Cache.set(key, value)
Cache.setMaxItems(2_000_000); // adjust capacity guard
Avoid heavy recomputation
String key = "stats:dashboard:v1";
PJsonObject cached = (PJsonObject) Cache.get(key);
if (cached == null) {
long t0 = System.currentTimeMillis();
cached = StatsService.computeDashboard(); // expensive: DB + external API
Cache.set(key, cached, 60_000); // cache for 60s
System.out.println("fresh compute: " + (System.currentTimeMillis()-t0) + " ms");
} else {
System.out.println("served from cache");
}
response.send(cached); // terminal JSON
Key design (namespaces & parameters)
Build stable, descriptive keys. Include versions and parameters so you can invalidate them predictably.
// Good patterns
String key1 = "user:profile:v2:" + userId;
String key2 = String.format("search:v1:q=%s&page=%d", query, page);
// Controller sample
PJsonObject profile = (PJsonObject) Cache.get(key1);
if (profile == null) {
profile = UserRepo.fetchProfile(userId);
Cache.set(key1, profile, 300_000); // 5 minutes
}
response.renderView("users.profile", new ParamsObject()
.set("page_title","Profile")
.set("profile", profile)); // terminal
Stale-while-revalidate (SWR)
Serve a stale value immediately (better latency) while refreshing in the background. Use getFromExpired
to fall back to expired data if the fresh entry is missing.
String key = "catalog:homepage:v3";
PJsonObject fresh = (PJsonObject) Cache.get(key); // valid only if not expired
if (fresh != null) {
response.send(fresh); // terminal
}
// No fresh value; try stale (expired) to avoid blank states
PJsonObject stale = (PJsonObject) Cache.getFromExpired(key);
if (stale != null) {
response.send(stale); // terminal — quick response
}
// Nothing at all → compute synchronously, then cache
PJsonObject rebuilt = CatalogService.buildHomepage();
Cache.set(key, rebuilt, 45_000);
response.send(rebuilt); // terminal
QueryBuilder integration (auto-cache + DB invalidation)
QueryBuilder.results()
transparently caches SELECT results by query+params. When you later call Database.insert/update/delete(...)
on a table, Panaragan removes any related cached result sets for that table. You get correctness without manually wiring keys.
// Read side: SELECT is cached automatically by query+params
PJsonArray users = QueryBuilder
.table("users")
.select("id","username","email","status")
.where("status = ?", 1)
.orderBy("created_at DESC")
.results(); // served from Cache on subsequent identical requests
// Write side: these calls trigger invalidations for related tables
Database.insert("users", new ParamsObject().set("username","john").set("email","j@ex.com").set("status",1));
Database.update("users", new ParamsObject().set("status",0), "id = ?", 123);
Database.delete("users", "id = ?", 456);
// After writes, future .results() will compute fresh data again
Database
looks up those keys and calls Cache.delete(...)
for you. You can still use manual Cache.set/get
for non-SQL workloads.Per-user / per-scope caches
// Scope cache by user/session/role to avoid cross-user leakage
String uname = request.getSession("username"); // set at login
String key = "dash:v1:user=" + (uname==null?"guest":uname);
PJsonObject dto = (PJsonObject) Cache.get(key);
if (dto == null) {
dto = DashboardService.buildForUser(uname);
Cache.set(key, dto, 120_000);
}
response.send(dto); // terminal
Invalidation patterns
- Write-through (manual):when you update a resource,
Cache.delete("ns:id")
the related keys. - Versioned keys:bump a version suffix (e.g.,
v4
) when your data shape changes. - Time-based:pick TTLs aligned with data volatility (seconds for dashboards, minutes for catalogs).
- Auto-invalidation (SQL):rely on
QueryBuilder
+Database
coupling for SELECT cache keys.
Pitfalls & guidance
- Memory-only:cache resets on restart. For shared/distributed caches, wrap Panaragan’s cache with Redis or similar.
- Right TTL:too long → stale data; too short → little benefit. Start with 30–120s for dynamic dashboards; hours for static lists.
- Key hygiene:always namespace (e.g.,
user:profile:v2:{id}
). Avoid collisions. - Object size:store compact DTOs (e.g.,
PJsonObject
, small POJOs). Avoid huge blobs to prevent GC pressure. - Concurrency:the cache is thread-safe. Still, typicalcompute-if-absentpattern is fine (double-check & set).
- SWR correctness:if you serve stale data, ensure the UI marks it (e.g., “updating…”) and refresh soon after.
Mini reference (copy-paste)
// 1) Basic
Cache.set("foo", 123);
Integer v = (Integer) Cache.get("foo");
// 2) Custom TTL
Cache.set("foo", 123, 5_000); // 5s
// 3) SWR
Object fresh = Cache.get("foo");
if (fresh == null) {
Object stale = Cache.getFromExpired("foo");
if (stale != null) { /* use stale; revalidate */ }
}
// 4) Delete & globals
Cache.delete("foo");
Cache.setMaxExpired(20_000);
Cache.setMaxItems(500_000);
// 5) Heavy computation wrapper
String key = "calc:abc";
Object out = Cache.get(key);
if (out == null) { out = doHeavyThing(); Cache.set(key, out, 60_000); }
Troubleshooting
- Always null:check TTLs; maybe the default TTL (10s) is too short for your use case.
- Growing memory:lower
setMaxItems(...)
, reduce object sizes, or manuallydelete
coarse keys after batch jobs. - Stale after writes:if not using
QueryBuilder.results()
, delete affected keys after updates. - Wrong user’s data:scope keys by user/session/tenant.
Config
Config
is a thin wrapper around an in-memory java.util.Properties
map that is populated at server bootstrap from a .env
file. Once loaded, values are kept in memory and used across the application. There isno runtime reloadandno OS env overridein the core—edit .env
and restart the app to apply changes.
Where values are loaded from
- Classpath:
resources/.env
(if present). - Working directory:
./.env
(if present).
The server’s static initializer attempts classpath first, then the working directory. Missing/invalid keys may be normalized, set to defaults, or removed (see notes below).
API (typed getters)
// Strings
String name = Config.get("app.name", "Panaragan");
String baseUrl = Config.get("app.base_url", "http://127.0.0.1:8005/");
// Numbers & booleans (type inferred from fallback)
int port = Config.get("app.port", 8005);
boolean gzip = Config.get("response.gzip", true);
double rate = Config.get("payments.rate", 0.05D);
float ratio = Config.get("feature.ratio", 0.75f);
Minimal .env
example
# --- Application ---
app.name=Panaragan
app.environment=development # "development" or "production" (affects internal DEBUG flag)
app.port=8005
app.base_url=http://127.0.0.1:8005/
# Paths (optional; server validates existence and permissions)
app.path_public=public # folder to serve static files if enabled
app.path_upload=storage/upload # folder to store uploads
app.slug_upload=/uploads # public slug to expose upload folder (required if path_upload is set)
# Templates
template.path=views
template.minify=false # evaluated at bootstrap; not changeable at runtime
# HTTP behavior
response.gzip=true
request.max_size_on_mb=20
# Rate limiting
request.rate_limit_ip=yes
request.rate_limit_per_minute=120
# CSRF
csrf.name=_secure-csrf-token
csrf.expired=3600 # seconds
# Sessions
session.mode=MEMORY # MEMORY | STORAGE | DATABASE | CUSTOM
session.key=_secure_SESSION_ID
session.expired=3600 # seconds
session.path=/
session.domain=
session.http_only=true
session.secure=false
session.same_site=Lax
session.storage=storage/sessions # used by STORAGE/DATABASE/CUSTOM (see notes)
# Database (single)
# db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
# db.username=postgres
# db.password=postgres
# db.max_pool_size=5
# db.reconnect=5
# Database (multi: db1.*, db2.*, ...)
# db1.url=...
# db1.username=...
# db1.password=...
# db1.max_pool_size=5
# db1.reconnect=5
# SMTP (multi: smtp1.*, smtp2.*, ...)
# smtp1.host=smtp.example.com
# smtp1.port=587
# smtp1.username=user@example.com
# smtp1.password=secret
# smtp1.from=Panaragan
# smtp1.start_ssl=false
# Encryption (used by CIPHER and PJWT)
encrypt.algorithm=AES
encrypt.key=please-change-me-16+
Important keys & behavior (what the server does)
app.environment
sets an internalDEBUGflag (development
⇒ debug;production
⇒ non-debug).app.path_public
andapp.path_upload
are validated (existence, readability; writability for uploads). If invalid, they are disabled and removed from the runtime properties.app.slug_upload
is required to expose the upload folder publicly; if missing/invalid, the upload path won’t be routable.template.minify
is readonceat bootstrap. Minified templates are cached; this is not a toggle you can flip at runtime.response.gzip
enables gzip compression when sending responses (server-level).- Rate limiting uses
request.rate_limit_ip
andrequest.rate_limit_per_minute
(numeric cap per IP). csrf.name
defines the token field/header name;csrf.expired
controls token TTL (seconds).session.*
controls cookie attributes and the backing store:MEMORY
: in-memory store.STORAGE
: file-based store (usessession.storage
as folder path).DATABASE
: DB-backed store (usessession.storage
as DSN/class hint; see your setup).CUSTOM
: fully qualified class name insession.storage
implementingSession.Store
.
- Database configuration supports a single DSN (
db.*
) or multiple (db1.*
,db2.*
, ...). Each entry acceptsurl
,username
,password
,max_pool_size
,reconnect
. - SMTP supports multiple entries (
smtp1.*
,smtp2.*
, ...) withhost
,port
,username
,password
,from
,start_ssl
. encrypt.algorithm
andencrypt.key
are used to initialize symmetric CIPHERs and PJWT. If invalid, the server attempts to select a supported algorithm and key length.- Some values are normalized back into the properties (e.g., booleans as
true/false
, derived paths). You may see the server printSET/NOT SET/WARNINGmessages at startup.
Using config in controllers & views
// Controller → pass values to the view via ParamsObject
public final class HomeController {
public static void index(Request req, Response res) {
ParamsObject data = new ParamsObject()
.set("page_title", "Home")
.set("app_name", Config.get("app.name", "Panaragan"))
.set("base_url", Config.get("app.base_url", "http://127.0.0.1:8005/"));
res.renderView("frontend.home", data); // terminal
}
}
<!-- frontend layout snippet (escaped) -->
<title>{{ app_name }} — {{ page_title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@3.4.10/dist/tailwind.min.css">
Validation & normalization examples
- If
app.path_public
is invalid or unreadable, public-folder routing is disabled and the key is removed. - If
app.path_upload
is valid butapp.slug_upload
is blank/invalid, upload routing is disabled. - Session cookie attributes are normalized into
true/false
, andSameSite
is one ofLax
,Strict
, orNone
.
Copy-paste reference
// String with default
String app = Config.get("app.name", "Panaragan");
// Numbers & booleans
int port = Config.get("app.port", 8005);
long maxSz = 1024L * 1024L * Config.get("request.max_size_on_mb", 20);
boolean gz = Config.get("response.gzip", true);
// In a controller
ParamsObject data = new ParamsObject()
.set("page_title", "Settings")
.set("env", Config.get("app.environment", "development"))
.set("csrf_name", Config.get("csrf.name", "_secure-csrf-token"));
res.renderView("frontend.settings", data); // terminal
Troubleshooting
- Changes not applied:the core reads
.env
at startup—restart the app. - “NOT SET / WARNING” at startup:the server failed to validate a key (path not accessible, missing dependency, etc.). Fix the value and restart.
- Template minify seems ignored:remember it’s evaluated once at bootstrap; there is no runtime toggle.
- Upload routes don’t work:ensure both
app.path_upload
andapp.slug_upload
are set and valid.
CSRF
Panaragan menyediakan proteksi CSRF berbasisper-session tokenyangkedaluwarsa. Token disimpan di memori server, memiliki TTL dari .env
, dan dicek otomatis untuk form POST melalui utilitas Request.validatePost(...)
. Untuk endpoint JSON/API, gunakan CSRF.validateRequest(req)
secara manual atau kirim token pada header dengan nama yang dikonfigurasi.
Konfigurasi terkait
csrf.name
— nama parameter/HTTP header token (default:_secure-csrf-token
).csrf.expired
— TTL token dalam detik (default:3600
detik). Nilai ini dibaca saat bootstrap.
API yang tersedia
CSRF.HTML(Request request)
→ menghasilkan elemen<input type="hidden">
untuk form.CSRF.validateRequest(Request request)
→boolean; cek token dari POST paramatauheader.CSRF.getTokenName()
→ nama token (sama dengancsrf.name
yang aktif).CSRF.getTokenValue(Request request)
→ ambil/generate nilai token untuk session aktif.request.getCSRFTokenName()
,request.getCSRFTokenValue()
→ helper padaRequest
.request.validatePost(rules [, labels [, messages]])
→ otomatiscek CSRFsebelum validasi field.
CSRF.HTML(...)
atau saat validasi memerlukan token). Token disapu berkala setiap menit oleh scheduler internal. Jika token tidak valid, validatePost(...)
langsung menghasilkan false
(validasi field tidak dijalankan).Form HTML (direkomendasikan)
Sisipkan hidden input CSRF di dalam form menggunakan helper.(Escaped agar tidak ter-render di docs)
<form action="/profile/update" method="post">
@csrf
<input type="text" name="full_name" class="input">
<button type="submit" class="btn">Save</button>
</form>
Controller (POST) dengan validatePost(...)
validatePost(...)
akan mengecek CSRF lebih dulu. Jika token tidak valid atau field gagal validasi, method ini mengembalikan false
dan kamu bisa redirectBackWithInput(...)
.
public final class ProfileController {
public static void update(Request req, Response res) {
// Rules contoh (gunakan sesuai validator di project kamu)
ParamsString rules = new ParamsString()
.set("full_name", "required|min:3|max:100");
if (!req.validatePost(rules)) {
// Jika token CSRF invalid atau field tidak valid → kembali dengan input & flash
res.redirectBackWithInput("error", "Invalid CSRF token or invalid form fields");
return; // terminal di atas akan stop alur
}
// ... lakukan update ...
res.redirect("/profile", "success", "Updated");
}
}
Endpoint JSON / AJAX
Untuk request non-form (AJAX/JSON), kirim token padaheaderbernama csrf.name
aktif. Di server, panggil CSRF.validateRequest(req)
sebelum memproses.
public final class ApiProfile {
public static void update(Request req, Response res) {
if (!CSRF.validateRequest(req)) {
res.empty(403, "CSRF token mismatch"); // terminal
return;
}
// ... proses JSON ...
PJsonObject out = new PJsonObject().set("ok", true);
res.send(out); // terminal JSON
}
}
Mengambil nama & nilai token untuk front-end
Kamu bisa menyisipkan keduanya ke halaman supaya JS dapat mengirim header dengan benar.(Escaped)
<script>
const CSRF_NAME = "{{ request.getCSRFTokenName() }}";
const CSRF_TOKEN = "{{ request.getCSRFTokenValue() }}";
</script>
Contohfetchdi browser
async function saveProfile(data) {
const headers = {};
headers[CSRF_NAME] = CSRF_TOKEN; // penting: nama header mengikuti csrf.name
const res = await fetch('/api/profile/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(data)
});
if (!res.ok) { throw new Error('CSRF/validation error'); }
return await res.json();
}
Akses token di controller
public final class TokenController {
// Menyediakan endpoint untuk SPA mengambil token (opsional)
public static void show(Request req, Response res) {
PJsonObject out = new PJsonObject()
.set("name", req.getCSRFTokenName())
.set("token", req.getCSRFTokenValue());
res.send(out); // terminal
}
}
Perilaku & siklus hidup token
- Per session:satu token aktif per session id; dibuat saat dibutuhkan.
- TTL:ditentukan oleh
csrf.expired
(detik) → setelah lewat, token diputar/refresh. - Sumber token di request:diambil dariPOST parambernama
csrf.name
, atau jika kosong, darirequest headerbernama sama. - Pembersihan periodik:token yang kedaluwarsa dibersihkan oleh job internal setiap menit.
Copy-paste reference
// View
@csrf
// Controller: form POST
if (!req.validatePost(new ParamsString().set("title","required"))) {
res.redirectBackWithInput("error", "Invalid CSRF token or invalid form fields");
return;
}
// Controller: JSON/API
if (!CSRF.validateRequest(req)) { res.empty(403, "CSRF token mismatch"); return; }
// Ambil nama+token
String name = req.getCSRFTokenName();
String token = req.getCSRFTokenValue();
Troubleshooting
- Token selalu tidak valid:pastikan form menyertakan
@csrf
di dalamtag<form>
, dan method=POST. - AJAX gagal:kirim header bernama persis
csrf.name
aktif dan isinyarequest.getCSRFTokenValue()
. - Token berubah sering:cek nilai
csrf.expired
; TTL terlalu pendek akan memaksa rotasi cepat. - Multi-tab/login ulang:session id berbeda → token berbeda; refresh halaman agar JS mengambil token terbaru.
Database
Panaragan provides a thin database layer on top of JDBC with:connection pooling, safeprepared statements, and concise CRUD helpers. For reads, useQuery Builder(cached, convenient). For writes or custom SQL, use Database
helpers or raw PreparedStatement
.
Configuration recap (.env)
Database is configured at bootstrap and pooled. Single DB or multi-DB are supported:
# Single DB
db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
db.username=postgres
db.password=postgres
db.max_pool_size=5
db.reconnect=5
# Multi DB (db1, db2, ...)
# db1.url=...
# db1.username=...
# db1.password=...
# db1.max_pool_size=5
# db1.reconnect=5
getPrepareStatement(int,...)
) target the corresponding configured database by index.API at a glance
Database.getPrepareStatement(String sql)
,getPrepareStatement(int index, String sql)
,getPrepareStatement(String sql, int returnKey)
Database.getMetaData()
,getMetaData(int index)
Database.insert(String table, ParamsObject data)
→booleanDatabase.insertGetInt(String table, ParamsObject data)
→intgenerated idDatabase.insertGetString(String table, ParamsObject data)
→Stringgenerated idDatabase.update(String table, ParamsObject data, String conditionOrColumn, Object... values)
→booleanDatabase.delete(String table, String conditionOrColumn, Object... values)
→boolean
insert
/update
/delete
, Panaragan clears any cached QueryBuilder
results related to the touched table.Insert
// 1) Simple insert
ParamsObject user = new ParamsObject()
.set("username", "johndoe")
.set("email", "john@example.com")
.set("status", 1);
boolean ok = Database.insert("users", user);
// 2) Insert and get generated key (int or string)
int newId = Database.insertGetInt("users", user);
String newIdStr = Database.insertGetString("users", user);
Update
update
accepts aconditionstring. You can use: (A) standardplaceholders(?
), or (B) a smalloperator suffixform for IN
/NOT IN
without writing parentheses.
// A) Placeholders (recommended)
ParamsObject data = new ParamsObject().set("status", 1).set("email_verified_at", new Timestamp(System.currentTimeMillis()));
boolean updated = Database.update("users", data, "id = ?", 42);
// B) Operator suffix for IN/NOT IN (no parentheses in condition; values come from varargs)
// "col IN" ... expands to "col IN (?, ?, ...)"
// "col NOT IN" expands similarly
ParamsObject disable = new ParamsObject().set("status", 0);
Database.update("users", disable, "id IN", 10, 11, 12); // expands placeholders based on values
Database.update("users", disable, "role_id NOT IN", 3, 5, 7);
Delete
// A) Placeholders
Database.delete("token", "token = ?", token);
// B) Operator suffix for IN/NOT IN
Database.delete("users", "id IN", 101, 102, 103);
// C) No placeholders (literal condition — be careful and validate inputs)
Database.delete("token", "valid < CURRENT_TIMESTAMP");
Reads
PreferQuery Builderfor SELECTs (it returns PJsonArray
/PJsonObject
and caches results). Use low-level JDBC when you need full control.
A) Low-level JDBC with getPrepareStatement
try (PreparedStatement st = Database.getPrepareStatement(
"SELECT id, username, email FROM users WHERE status = ? ORDER BY id DESC LIMIT ?")) {
st.setInt(1, 1);
st.setInt(2, 20);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
int id = rs.getInt("id");
String name = rs.getString("username");
String email = rs.getString("email");
// ... use values
}
}
} // auto-close
B) Query Builder (recommended for SELECT)
PJsonObject one = new QueryBuilder()
.select("id","username","email","status")
.from("users")
.where("email", "john@example.com")
.row();
PJsonArray list = new QueryBuilder()
.select("id","username","email")
.from("users")
.where("status = ?", 1)
.orderBy("id", "DESC")
.limit(20)
.results();
Multi-database (by index)
If you configured multiple databases (db1.*
, db2.*
, …), you can prepare a statement for a specific DB index:
// Prepare on database index #1
try (PreparedStatement st = Database.getPrepareStatement(1, "SELECT now()")) {
try (ResultSet rs = st.executeQuery()) { /* ... */ }
}
Database metadata
DatabaseMetaData md = Database.getMetaData();
try (ResultSet tables = md.getTables(null, null, "%", new String[]{"TABLE"})) {
while (tables.next()) {
String name = tables.getString("TABLE_NAME");
// ...
}
}
Practical recipes (from the sample project)
Activation & reset flows
// Lookup activation/reset token
PJsonObject tok = new QueryBuilder()
.select("user_id")
.from("token")
.where("token", token)
.where("type", 2) // 1 forgot password, 2 activation
.where("valid >= CURRENT_TIMESTAMP")
.row();
if (tok != null) {
// Activate user
ParamsObject upd = new ParamsObject()
.set("email_verified_at", new Timestamp(System.currentTimeMillis()))
.set("status", 1);
if (Database.update("users", upd, "id = ?", tok.getInt("user_id"))) {
Database.delete("token", "token = ?", token);
// ... render success
}
}
Safety & tips
- Always bind parameterswith
?
to avoid SQL injection for dynamic values. - Use the operator suffixonly when it matches
IN
/NOT IN
semantics; values are taken from the varargs. - Literal conditions(no
?
) are passed as-is—validate inputs yourself. - Writes invalidate cachesof related
QueryBuilder
SELECTs automatically. - Auto-commit: there is no explicit transaction API in this layer—use JDBC directly if you need custom transactions.
Copy-paste reference
// INSERT
ParamsObject row = new ParamsObject().set("k","v");
Database.insert("table", row);
int id = Database.insertGetInt("table", row);
// UPDATE
Database.update("table", new ParamsObject().set("k","v"), "id = ?", id);
Database.update("table", new ParamsObject().set("flag", 0), "id IN", 10, 11, 12);
// DELETE
Database.delete("table", "id = ?", id);
Database.delete("table", "id IN", 10, 11, 12);
// SELECT (raw)
try (PreparedStatement st = Database.getPrepareStatement("SELECT * FROM table WHERE k = ?")) {
st.setString(1, "v");
try (ResultSet rs = st.executeQuery()) { /* ... */ }
}
// SELECT (QueryBuilder)
PJsonArray rows = new QueryBuilder().select("*").from("table").results();
Datatable
Datatable
buildsserver-side JSONfor jQuery DataTables from a single SELECT … FROM …
query (or a QueryBuilder
). It handles: pagination (start
, length
), ordering (order[0][column]
), per-column/global search, custom cell rendering, virtual columns, and optional per-columnsum. Use Response.send(datatable)
to return the JSON.
GET
or POST
using keys like search[value]
, columns[i][data]
, order[0][column]
, order[0][dir]
, start
, length
. JSON shape: { "recordsTotal", "recordsFiltered", "data", "sum?" }
.Minimal server endpoint
public final class UsersController {
// GET/POST /users/datatable
public static void datatable(Request req, Response res) {
// Base SELECT (must include the columns you will expose/edit/order)
Datatable dt = new Datatable(
"SELECT id, username, email, status, created_at FROM users"
);
// Optional: add an auto-numbering column (first column)
dt.addNumbering("no");
// Render existing columns (HTML-safe via Helper.escHtml if you inject markup)
dt.edit("status", (_, v, __) -> "1".equals(v)
? "<span class=\\"badge badge-success\\">Active</span>"
: "<span class=\\"badge badge-danger\\">Inactive</span>"
);
// Add a new virtual column (e.g., action URLs)
dt.add("edit_url", (_, __, row) -> Url.to("users/edit/"+Helper.encryptString(row.get("id"))));
dt.add("delete_url", (_, __, row) -> Url.to("users/delete/"+Helper.encryptString(row.get("id"))));
// Optional: fixed server-side filters applied before DataTables search/order
dt.where("status", 1); // becomes "status = ?"
// dt.orWhere("username LIKE ?", "%john%"); // standard placeholders
// Respond as application/json (Panaragan calls toJsonString(request) internally)
res.send(dt); // terminal
}
}
Construct from QueryBuilder
// Any QueryBuilder that yields a valid SELECT ... FROM ...
QueryBuilder qb = new QueryBuilder()
.select("id","username","email","status","created_at")
.from("users")
.where("status = ?", 1);
Datatable dt = new Datatable(qb);
res.send(dt); // terminal
Custom search (column & global)
Override how search terms are applied. The callback receives a Filter
you can chain with: where(...)
, orWhere(...)
, between(...)
, whereIn(...)
.
dt.onSearch("email", (filter, column, value) -> {
if (value != null && !value.isBlank()) {
filter.where(column + " LIKE ?", "%" + value.trim() + "%");
}
});
dt.onGlobalSearch("username", (filter, column, value) -> {
if (value != null && !value.isBlank()) {
filter.where(column + " LIKE ?", "%" + value.trim() + "%");
}
});
Filter helpers (mini-DSL)
You can add your own fixed filters before generating JSON. Placeholders ?
are supported, and operator suffixes auto-expand: "col IN"
, "col NOT IN"
, ">"
, ">="
, "<"
, "<="
, "LIKE"
.
// Standard placeholders
dt.where("created_at >= ?", "2024-01-01 00:00:00");
dt.orWhere("username LIKE ?", "%doe%");
// IN / NOT IN expansion (varargs become (?, ?, ...))
dt.where("id IN", 10, 11, 12);
dt.orWhere("role_id NOT IN", 3, 5, 7);
// Range
dt.between("created_at", "2024-01-01 00:00:00", "2024-12-31 23:59:59");
Render & virtual columns
// Format an existing column
dt.edit("created_at", (_, v, __) -> {
// keep it simple; DB returns a string/datetime — return any HTML-safe string
return Helper.escHtml(v);
});
// Add a new column not present in SELECT (e.g., buttons)
dt.add("actions", (_, __, row) -> {
String id = Helper.encryptString(row.get("id"));
return "<div class=\\"actions\\">"
+ "<a href=\\"" + Url.to("users/edit/"+id) + "\\" class=\\"btn btn-outline\\">Edit</a>"
+ "<button type=\\"button\\" class=\\"btn btn-danger\\" data-id=\\"" + id + "\\">Delete</button>"
+ "</div>";
});
Per-column sum (optional)
Include a numeric summary for one or more columns. The resulting JSON adds "sum": { ... }
.
dt.sum("sort", total -> "Total: " + total); // label/value is your string
dt.sum("id", total -> String.valueOf(total)); // raw total as string
Frontend (jQuery DataTables)
Basic initialization, matching the JSON keys you expose. Use POST or GET — server reads both.
<table id="users-table" class="display" style="width:100%"></table>
<script>
const table = $('#users-table').DataTable({
processing: true,
serverSide: true,
ajax: { url: '{{ url('users/datatable') }}', type: 'POST' },
order: [[0,'desc']], // example
columns: [
{ data: 'no', name: 'no' }, // from addNumbering("no")
{ data: 'id', name: 'id' },
{ data: 'username', name: 'username' },
{ data: 'email', name: 'email' },
{ data: 'status', name: 'status' }, // HTML badge rendered on server
{ data: 'created_at', name: 'created_at' },
{ data: null, name: 'actions', orderable:false, searchable:false,
render: row => (
'<div class="actions-group">'
+ '<a href="' + row.edit_url + '" class="btn btn-outline">Edit</a>'
+ '<button type="button" class="btn btn-danger js-del" data-url="' + row.delete_url + '">Delete</button>'
+ '</div>'
)
}
]
});
</script>
Practical patterns
// 1) Role-based scoping (fixed filter)
if (!req.isAdmin()) {
dt.where("organization_id", req.getUserOrganizationId());
}
// 2) Date range from request (when provided)
String start = req.getQuery("start_date", null);
String end = req.getQuery("end_date", null);
if (start != null && end != null) {
dt.between("created_at", start + " 00:00:00", end + " 23:59:59");
}
// 3) Custom global search behavior for a specific column only
dt.onGlobalSearch("email", (f, col, val) -> {
if (val != null && !val.isBlank()) f.where(col + " LIKE ?", "%" + val.trim() + "%");
});
Copy-paste reference
// Construct
Datatable dt = new Datatable("SELECT id, name, status FROM users");
// or: new Datatable(new QueryBuilder().select("id","name","status").from("users"));
// Numbering, render, virtual column, sum
dt.addNumbering("no");
dt.edit("status", (_, v, __) -> "1".equals(v) ? "<b>Active</b>" : "Inactive");
dt.add("edit_url", (_, __, row) -> Url.to("users/edit/"+Helper.encryptString(row.get("id"))));
dt.sum("id", total -> String.valueOf(total));
// Filters
dt.where("status", 1);
dt.orWhere("name LIKE ?", "%doe%");
dt.where("id IN", 10, 11, 12);
dt.between("created_at", "2024-01-01 00:00:00", "2024-12-31 23:59:59");
// Custom search hooks
dt.onSearch("email", (f, c, v) -> { if(v!=null&&!v.isBlank()) f.where(c+" LIKE ?", "%"+v.trim()+"%"); });
// Respond
res.send(dt); // terminal
Troubleshooting
- Empty table:ensure your base
SELECT … FROM …
is valid and accessible by the DB user. - Missing columns:any column referenced by
edit()
,add()
, ordering, or the frontendcolumns
must be present in the SELECT (or provided byadd(...)
). - Wrong order/search:DataTables sends
order[0][column]
andcolumns[i][data]
. Make sure your frontendcolumns
names match the JSON keys output on the server. - IN with empty list:the builder expands to
IN ('')
(no matches). Validate user input before applying anIN
filter. - HTML output:escape user data with
Helper.escHtml(...)
in youredit(...)
to avoid XSS when returning HTML fragments.
Panaragan ships a lightweight SMTP client that talks directly to SMTP servers (LOGIN auth) and can optionally upgrade with STARTTLS
. It sendsHTMLemails and supportsattachments. Configuration is read once at bootstrap from .env
and kept in memory. If SMTP is not configured, Email.send(...)
throws an error.
Configuration (.env)
Use either a single SMTP blockormultiple indexed blocks. For multiple servers, the first is smtp1.*
(index 0
), the second is smtp2.*
(index 1
), and so on.
# Single SMTP
smtp.host=smtp.example.com
smtp.port=587
smtp.username=mailer@example.com # authentication & return-path address
smtp.password=your-password
smtp.from=Panaragan # display name in "From: Panaragan <mailer@example.com>"
smtp.start_ssl=false # true => STARTTLS
# Multiple SMTP (smtp1.*, smtp2.*, ...)
# smtp1.host=...
# smtp1.port=587
# smtp1.username=...
# smtp1.password=...
# smtp1.from=Panaragan
# smtp1.start_ssl=false
# smtp2.host=...
# smtp2.port=465
# smtp2.username=...
# smtp2.password=...
# smtp2.from=No-Reply
# smtp2.start_ssl=true
smtp.from
is thedisplay name; the authenticated sender address is smtp.username
. Values are read at startup; restart to apply changes.API (overloads)
Email.send(String to, String subject, String body)
Email.send(String to, String subject, String body, List<File> attachments)
Email.send(String fromDisplayName, String to, String subject, String body, List<File> attachments)
Email.send(int index, String to, String subject, String body)
(index 0 = smtp1, 1 = smtp2, ...)Email.send(int index, String to, String subject, String body, List<File> attachments)
Email.send(int index, String fromDisplayName, String to, String subject, String body, List<File> attachments)
text/html; charset=UTF-8
. If attachments
is non-empty, the message becomes a multipart/mixed
email; content type of each file is guessed by extension.Send a simple HTML email
In the sample CMS, HTML bodies are rendered from a view and passed to Email.send
.
// Controller snippet (activation)
public static void sendActivation(Request req, Response res) {
String email = req.getPost("email", "");
String token = Helper.randomString(64);
// Prepare template data
ParamsObject data = new ParamsObject()
.set("app_name", Config.get("app.name", "Panaragan"))
.set("activation_url", Url.to("activation/" + token))
.set("user_email", email);
// Render HTML from a view and send
String html = Template.render("email.activation-account", data);
Email.send(email, "Activation Account", html);
res.redirect("login", "success",
"Registration successful. Please check your email to activate your account.");
}
Email view (escaped example)
<!-- views/email/activation-account.html -->
<!doctype html>
<html>
<body style="font-family:ui-sans-serif,system-ui">
<h1 style="font-size:18px;margin:0 0 12px">Activate your account</h1>
<p style="margin:0 0 8px">Hi, {{ user_email }}</p>
<p style="margin:0 0 16px">Click the button below to activate your account.</p>
<p>
<a href="{{ activation_url }}"
style="display:inline-block;background:#0ea5e9;color:#fff;padding:10px 14px;border-radius:8px;text-decoration:none">
Activate Now
</a>
</p>
</body>
</html>
Attachments
Pass a list of files. The client infers content types from file extensions and sends as multipart/mixed
.
List<File> files = List.of(
new File("storage/invoice-1234.pdf"),
new File("public/logo.png")
);
String html = Template.render("email.invoice", new ParamsObject()
.set("invoice_no", "INV-1234")
.set("issued_at", Helper.formatDateNow("yyyy-MM-dd")));
Email.send("customer@example.com", "Your Invoice INV-1234", html, files);
Choose SMTP by index
When you configured multiple SMTP blocks, select which one to use by index (0
for smtp1.*
, 1
for smtp2.*
, …).
// Use smtp2.* (index = 1)
String html = Template.render("email.system-notice",
new ParamsObject().set("message", "System will be under maintenance tonight."));
Email.send(1, "ops@example.com", "System Notice", html);
Custom “From” display name
Override the display name in the From:
header. The authenticated address remains smtp.username
.
String html = "<p>Please do not reply to this message.</p>";
Email.send("No-Reply", "user@example.com", "Password Reset", html, null);
Preview template without sending (optional)
Sometimes you want to preview the email HTML in the browser before sending—render the same view as a page.
// Preview route
public static void preview(Request req, Response res) {
ParamsObject data = new ParamsObject().set("reset_url", Url.to("reset-password/EXAMPLE"));
res.renderView("email.request-reset-password", data); // only renders HTML (no email)
}
Troubleshooting
- “No configuration email found”:SMTP is not configured or the list is empty—fill
smtp.*
orsmtp1.*
in.env
and restart. - Connection/auth errors:check host, port, username/password; set
smtp.start_ssl=true
when your server requires STARTTLS. - Attachments not received:ensure files exist and your process can read them; content type is inferred by extension.
- Display name not applied:remember the “From” address uses
smtp.username
; thefromDisplayName
only changes the name part.
Job (Scheduler)
Panaragan provides a lightweight job scheduler built on top of Java’s ScheduledThreadPoolExecutor
. Use the static Job.*
helpers to run tasks at fixed intervals (milliseconds, seconds, minutes, hours), at specific hours of day, or once every day (midnight, server time).
Available methods
Job.everyMilliSeconds(Runnable command, int intervalInMs)
Job.everySecond(Runnable command)
,Job.everySecond(Runnable command, int intervalInSecond)
Job.everyMinute(Runnable command)
,Job.everyMinute(Runnable command, int intervalInMinute)
Job.everyHour(Runnable command)
,Job.everyHour(Runnable command, int intervalInHour)
Job.everyHoursOfDay(Runnable command, int... hoursOfDay)
— e.g.9, 17
Job.everyDay(Runnable command)
,Job.everyDay(Runnable command, int intervalInDay)
(runs daily at midnight, server time)
Examples
1) Heartbeat every 5 seconds
// In Core.boot(...)
Job.everySecond(() -> {
System.out.println("[heartbeat] " + System.currentTimeMillis());
}, 5);
2) Clean expired tokens every minute
Example matches the CMS sample logic (activation/reset tokens).
// In Core.boot(...)
Job.everyMinute(() -> {
try {
// Remove expired tokens (valid < now)
Database.delete("token", "valid < CURRENT_TIMESTAMP");
} catch (Exception e) {
// Always catch inside jobs to avoid suppressing future runs
e.printStackTrace();
}
});
3) Run tasks at specific hours: 09:00 and 17:00
// In Core.boot(...)
Job.everyHoursOfDay(() -> {
try {
// Example: snapshot a lightweight report to DB
ParamsObject row = new ParamsObject()
.set("created_at", new java.sql.Timestamp(System.currentTimeMillis()))
.set("message", "twice-a-day snapshot");
Database.insert("system_log", row);
} catch (Exception e) {
e.printStackTrace();
}
}, 9, 17);
4) Daily task at midnight (server time)
// In Core.boot(...)
Job.everyDay(() -> {
try {
// Example: disable old sessions
Database.delete("sessions", "expired < CURRENT_TIMESTAMP");
} catch (Exception e) {
e.printStackTrace();
}
});
5) High-frequency sampling (every 200 ms)
// In Core.boot(...)
Job.everyMilliSeconds(() -> {
// Keep it fast & non-blocking
// For example, increment an in-memory counter
}, 200);
Scheduling in Core.boot(...)
public final class AppCore implements Core {
public void boot(final Routers r) throws Exception {
// ... register routes
// Jobs start here
Job.everySecond(() -> { System.out.println("tick"); }, 5);
Job.everyMinute(() -> {
try { Database.delete("token", "valid < CURRENT_TIMESTAMP"); }
catch (Exception e) { e.printStackTrace(); }
});
Job.everyHoursOfDay(() -> {
try {
ParamsObject row = new ParamsObject()
.set("created_at", new java.sql.Timestamp(System.currentTimeMillis()))
.set("message", "9am/5pm summary");
Database.insert("system_log", row);
} catch (Exception e) { e.printStackTrace(); }
}, 9, 17);
Job.everyDay(() -> {
try { Database.delete("sessions", "expired < CURRENT_TIMESTAMP"); }
catch (Exception e) { e.printStackTrace(); }
});
}
// ... accept(...), authorize(...), respond(...), error(...)
}
Best practices
- Keep jobs short & non-blocking.If a job is heavy, offload I/O and avoid long CPU spikes.
- Always handle exceptions.Wrap job bodies in
try/catch
so future executions continue. - Be idempotent.Write jobs so re-running doesn’t corrupt state (e.g., use
WHERE
conditions). - Use DB helpers safely.Prefer placeholders or the supported
IN/NOT IN
expansion style (seeDatabase). - Time zone.
everyHoursOfDay
andeveryDay
follow server time.
Copy-paste reference
// Seconds, minutes, hours
Job.everySecond(() -> { /* ... */ }); // default: 1s
Job.everySecond(() -> { /* ... */ }, 5); // every 5s
Job.everyMinute(() -> { /* ... */ }); // every minute
Job.everyMinute(() -> { /* ... */ }, 10); // every 10 minutes
Job.everyHour(() -> { /* ... */ }); // every hour
Job.everyHour(() -> { /* ... */ }, 6); // every 6 hours
// Milliseconds
Job.everyMilliSeconds(() -> { /* ... */ }, 200); // every 200 ms
// Specific hours (server time)
Job.everyHoursOfDay(() -> { /* ... */ }, 9, 17); // 09:00 and 17:00 daily
// Daily at midnight (server time)
Job.everyDay(() -> { /* ... */ }); // 00:00 daily
Language (i18n)
Panaragan provides simple, file-based internationalization using JSON dictionaries loaded at bootstrap. Each language file is a flat key–value map (no plural rules, no ICU), and is placed under resources/languages/
(packaged inside the JAR at build time).
Directory & file format
Put one JSON file per language code. The file name is the language code (e.g. en.json
, id.json
).
src/main/resources/languages/
├─ en.json
└─ id.json
Example en.json
:
{
"app.title": "Panaragan",
"app.subtitle": "Build your app to enterprise class",
"button.save": "Save",
"button.cancel": "Cancel",
"auth.login": "Sign in",
"auth.logout": "Sign out"
}
Example id.json
:
{
"app.title": "Panaragan",
"app.subtitle": "Bangun aplikasi kelas enterprise",
"button.save": "Simpan",
"button.cancel": "Batal",
"auth.login": "Masuk",
"auth.logout": "Keluar"
}
Initialization
Call Language.init()
once during application startup (before you use translations). It scans and loads all JSON files under languages/
from the classpath.
public final class Main {
public static void main(String[] args) throws Exception {
// Load dictionaries from resources/languages/*.json
Language.init();
// Continue with server/bootstrap…
Server.run(AppCore.class);
}
}
Basic usage in controllers
Pick a language code (e.g. from query/cookie/session), get a Language
instance, translate keys, and pass strings to the view.
public final class HomeController {
public static void index(Request req, Response res) {
// choose a language: query takes precedence, fallback to cookie, then "en"
String langCode = req.getQuery("lang", req.getCookie("lang", "en"));
// get the language dictionary (returns an instance for the code)
Language lang = Language.get(langCode);
// look up keys
String title = lang.translate("app.title");
String subtitle = lang.translate("app.subtitle");
ParamsObject data = new ParamsObject()
.set("page_title", title)
.set("subtitle", subtitle)
.set("lang", langCode);
res.renderView("frontend.home", data); // terminal
}
}
Using translations in views
Render values that you’ve already translated in the controller. (Escaped so it won’t render inside these docs.)
<!-- resources/views/frontend/home.html -->
<div class="prose max-w-none">
<h1>{{ page_title }}</h1>
<p class="text-slate-600">{{ subtitle }}</p>
<button class="btn">{{ button_save }}</button>
</div>
To populate button_save
(and friends), just translate in the controller and pass them:
Language lang = Language.get(langCode);
ParamsObject data = new ParamsObject()
.set("page_title", lang.translate("app.title"))
.set("subtitle", lang.translate("app.subtitle"))
.set("button_save", lang.translate("button.save"))
.set("button_cancel", lang.translate("button.cancel"));
res.renderView("frontend.home", data);
Switching language (via cookie)
Provide a small route to set the language preference and redirect back. Use a persistent cookie if you want it to stick across sessions.
public final class LangController {
public static void set(Request req, Response res) {
String code = req.getParam("code", "en");
// only accept available languages
if (!Language.isAvailable(code)) code = "en";
// persist for 1 year
res.setCookie("lang=" + code + "; Path=/; Max-Age=31536000; SameSite=Lax");
res.redirectBack(); // terminal
}
}
Language switcher (view example)
Render links for the languages you have. Build the list in the controller using Language.list()
.
// Controller fragment
ParamsObject data = new ParamsObject()
.set("codes", Language.list()); // e.g., ["en", "id"]
res.renderView("frontend.partials.lang-switch", data);
<!-- resources/views/frontend/partials/lang-switch.html (escaped) -->
<div class="flex items-center gap-2">
@if(codes != null)
@foreach(code in codes)
<a class="px-2 py-1 rounded hover:bg-slate-100"
href="/lang/{{ code }}">{{ code.toUpperCase() }}</a>
@endforeach
@endif
</div>
Inspecting availability
The loader exposes what’s been loaded so you can guard against invalid codes.
// returns a list like ["en", "id", ...]
System.out.println(Language.list());
// quick check
if (!Language.isAvailable("id")) {
// fallback to "en", or handle it accordingly
}
Parameter placeholders (simple)
If you store placeholders in strings (e.g., "Hello, {name}!"
), perform replacement in your controller before passing to the view.
// en.json: "greet": "Hello, {name}!"
String tmpl = Language.get(langCode).translate("greet");
String greet = tmpl.replace("{name}", req.getUser("name", "Guest"));
data.set("greet", greet);
Common patterns
- Keep keys stable:use dotted namespaces like
auth.*
,button.*
,menu.*
. - Translate in controllers:pass ready-to-render strings to your views.
- Validate user selection:
Language.isAvailable(code)
before saving to cookie/session. - Restart on changes:dictionaries are loaded at bootstrap; rebuild/restart to apply file updates.
Copy-paste reference
// Init once
Language.init();
// Get dictionary
Language lang = Language.get("en");
// Translate keys
String t = lang.translate("button.save");
// Availability & list
boolean ok = Language.isAvailable("id");
java.util.List<String> codes = Language.list();
Troubleshooting
- Null/empty translations:ensure the key exists in the JSON file and that
Language.init()
ran before use. - Language not switching:check the persisted cookie or query param and guard with
Language.isAvailable(code)
. - Changes not applied:JSON files are read at startup; rebuild/restart the app.
- Unexpected characters:save JSON files as UTF-8.
Query Builder
QueryBuilder
is a fluent SQL builder that outputs parameterized SQL and executes it via Panaragan’s pooled Database
connections. It supportsSELECT / JOIN / WHERE / GROUP BY / HAVING / ORDER BY / LIMIT-OFFSET, flexible conditions (includingIN,NOT IN,LIKE,IS,IS NOT), and built-inresult cachingwith automatic invalidation on writes.
results()
→ PJsonArray
, row()
→ PJsonObject
, count()
→ int
, sum()
→ double
. Force variants (*Force
) bypass the cache.Basic pattern
// SELECT id, username FROM users WHERE status = ? ORDER BY id DESC LIMIT 10 OFFSET 0
PJsonArray rows = new QueryBuilder()
.select("id","username")
.from("users")
.where("status = ?", 1)
.orderBy("id", "DESC")
.limit(10, 0)
.results();
for (int i = 0; i < rows.size(); i++) {
PJsonObject r = rows.getObject(i);
int id = r.getInt("id");
String name = r.getString("username");
}
Single row
PJsonObject user = new QueryBuilder()
.select("id","email","created_at")
.from("users")
.where("email = ?", "john@example.com")
.row();
if (user != null) {
int id = user.getInt("id");
String mail= user.getString("email");
}
Flexible WHERE (mini-DSL)
where(String conditionOrColumn, Object... values)
rewrites conditions based on: placeholders ?
, trailing operators (=, !=, >, <, LIKE, NOT LIKE, IS, IS NOT, IN, NOT IN), and null-handling.
QueryBuilder qb = new QueryBuilder().from("users");
// 1) With '?' placeholders (classic)
qb.where("status = ?", 1)
.where("created_at >= ?", "2025-01-01");
// 2) Trailing operator without '?' — builder adds placeholders for you
qb.where("role_id =", 3) // → "role_id = ?"
.where("deleted_at IS", (Object)null) // → "deleted_at IS NULL"
.where("deleted_at IS NOT", (Object)null); // → "deleted_at IS NOT NULL"
// 3) IN / NOT IN (values taken from varargs)
qb.where("id IN", 10, 11, 12); // → "id IN (?,?,?)"
qb.where("id NOT IN", 1, 2);
// 4) OR chaining
qb.where("status = ?", 1)
.orWhere("status = ?", 2); // last WHERE becomes "(status=? OR status=?)"
LIKE helpers (DB-aware casting)
like(column, value)
and orLike(column, value)
append a LIKE ?
clause, casting non-text columns appropriately depending on the configured database type.
new QueryBuilder()
.select("id","username")
.from("users")
.like("username", "%john%") // → "... WHERE (username)::text LIKE ?" (dialect-aware)
.results();
Group nested conditions
Use groupWhere("(status = ? OR role = ?)", 1, "admin")
to push a parenthesized condition.
new QueryBuilder()
.from("users")
.groupWhere("(status = ? OR role = ?)", 1, "admin")
.where("created_at >= ?", "2025-01-01")
.results();
whereIn / whereNotIn (list or CSV)
Besides the mini-DSL above, there are dedicated helpers that accept a comma-separated string or a ListObject
.
ListObject ids = new ListObject().add(5).add(6).add(7);
new QueryBuilder()
.from("users")
.whereIn("id", ids) // or: whereIn("id", "5,6,7")
.results();
new QueryBuilder()
.from("users")
.whereNotIn("email", "a@x.com,b@x.com")
.results();
JOINs
new QueryBuilder()
.select("u.id","u.username","p.full_name")
.from("users u")
.join("profiles p", "p.user_id = u.id") // INNER JOIN
.leftJoin("roles r", "r.id = u.role_id") // LEFT JOIN
.where("u.status = ?", 1)
.orderBy("u.id", "DESC")
.limit(20, 0)
.results();
GROUP BY / HAVING
PJsonArray stats = new QueryBuilder()
.select("status", "COUNT(id) AS total")
.from("users")
.groupBy("status")
.having("COUNT(id) > ?", 10)
.orderBy("total", "DESC")
.results();
ORDER BY / LIMIT-OFFSET
orderBy(column, direction)
normalizes direction to ASC
/DESC
. limit(limit, offset)
emits dialect-specific pagination (e.g., OFFSET/FETCH on Oracle 12c+).
new QueryBuilder()
.from("audit_log")
.orderBy("created_at", "DESC")
.limit(50, 100) // LIMIT 50 OFFSET 100 (or dialect equivalent)
.results();
Counting & Summation
int totalActive = new QueryBuilder()
.from("users")
.where("status = ?", 1)
.count(); // cached
double sumAmount = new QueryBuilder()
.select("amount")
.from("orders")
.where("paid = ?", 1)
.sum("amount"); // cached
// Force variants bypass cache:
int freshCount = new QueryBuilder().from("users").countForce();
double freshSum = new QueryBuilder().from("orders").sumForce("amount");
Numbered results
resultsWithNumber("no")
adds a running number column named no
.
PJsonArray rows = new QueryBuilder()
.select("id","username")
.from("users")
.orderBy("id","ASC")
.resultsWithNumber("no");
int firstNo = rows.getObject(0).getInt("no"); // 1
Inspect the generated SQL
For debugging, you can get the SQL and its bound parameters before execution.
QueryBuilder qb = new QueryBuilder()
.select("id","email")
.from("users")
.where("email LIKE", "%@example.com");
String sql = qb.getQuery();
String sqlRow = qb.getQueryRaw(); // raw form (no limit/offset rewrite for count/sum)
ListObject ps = qb.getParameters(); // bound parameters (in order)
Caching & invalidation (automatic)
- Read caches:
results()
,row()
,count()
, andsum()
cache by a key derived from SQL + parameters. - TTL:dynamically set to
max(queryTime, 1000 ms)
. - Coalescing:concurrent identical queries are coalesced (only one hits the DB).
- Invalidation:any
Database.insert/update/delete
on a table clears cached entries linked to that table. - Bypass:use
*Force
variants when you must skip cache.
Copy-paste reference
// Build basics
new QueryBuilder().select("*").from("t").results();
new QueryBuilder().from("t").where("id =", 10).row();
new QueryBuilder().from("t").orderBy("id","DESC").limit(20,0).results();
// WHERE mini-DSL
qb.where("id IN", 1,2,3);
qb.where("deleted_at IS", (Object)null);
qb.where("name LIKE", "%john%");
qb.orWhere("status =", 2);
qb.groupWhere("(status = ? OR role = ?)", 1, "admin");
// Lists
ListObject ids = new ListObject().add(1).add(2);
qb.whereIn("id", ids);
qb.whereNotIn("id", "3,4,5");
// Aggregates
int c = new QueryBuilder().from("t").count();
double s = new QueryBuilder().from("t").sum("amount");
// Joins
qb.select("u.id","p.full_name")
.from("users u")
.leftJoin("profiles p","p.user_id = u.id");
Request / Response
Request
gives you a clean API to read the HTTP method, path, headers, cookies, sessions, route params, query/form inputs, JSON body, and uploaded files. Response
lets you render views, send JSON/HTML/bytes/files, set headers/content type, and redirect (including back with old input).Important:terminal methods— renderView
, send
, redirect
, redirectBack
, redirectBackWithInput
, empty
— immediately stop further execution.
Essentials at a glance
// Method, path, IP, UA, headers
String method = request.getMethod();
String path = request.getPath();
String ip = request.getIP();
String agent = request.getUserAgent();
String referer = request.getHeader("Referer", "");
String traceId = request.getHeader("X-Request-ID", "n/a");
String fullUrl = request.getFullUrl(); // scheme + host + path + query
// Route params, query, form (typed overloads with defaults)
int id = request.getParam("id", 0);
String q = request.getQuery("q", "");
int page = request.getQuery("page", 1);
String email = request.getPost("email", "");
boolean agree = request.getPost("agree", false);
long stamp = request.getPostOrQuery("ts", 0L); // check POST first, then query
// Body & JSON
String rawBody = request.getBody();
PJsonObject jsonObj = request.getJsonObject(); // or null if not JSON
PJsonArray jsonArr = request.getJsonArray(); // for array payloads
// Sessions & cookies
String userJson = request.getSession("user");
String theme = request.getCookie("theme", "light");
int visits = request.getCookie("visits", 0);
// Respond
ParamsObject data = new ParamsObject().set("page_title","Dashboard");
response.renderView("frontend.dashboard", data); // terminal
// or JSON
response.send(new PJsonObject().set("ok", true)); // terminal
Reading inputs
All getters have typed variants with default values to avoid boilerplate casting/parsing.
// /orders/{id}?status=paid&page=2
r.get("/orders/{id}", Orders::show);
public final class Orders {
public static void show(Request req, Response res) {
int id = req.getParam("id", 0);
String status = req.getQuery("status", "all");
int page = req.getQuery("page", 1);
PJsonObject debug = new PJsonObject()
.set("id", id)
.set("status", status)
.set("page", page)
.set("agent", req.getUserAgent());
res.send(debug); // terminal JSON
}
}
Forms & validation
// Display + submit
r.get("/contact", Contact::form);
r.post("/contact-submit", Contact::submit);
public final class Contact {
public static void form(Request req, Response res) {
ParamsObject data = new ParamsObject().set("page_title","Contact Us");
res.renderView("frontend.contact", data);
}
public static void submit(Request req, Response res) {
ParamsString rules = new ParamsString()
.set("name", "required|min_length[3]|max_length[100]")
.set("email", "required|email")
.set("message", "required|min_length[10]");
if (!req.validatePost(rules)) {
res.redirectBackWithInput("error", "Please fix the highlighted fields"); // terminal
}
// ... send email / persist
res.redirect("contact", "success", "Thanks! We'll get back to you soon."); // terminal
}
}
In your view, render CSRF + flashes + old input (escaped here):
<form method="post" action="{{ url('contact-submit') }}" class="space-y-3">
@csrf
<input name="name" value="{{ old('name') }}" />
<input name="email" value="{{ old('email') }}" />
<textarea name="message">{{ old('message') }}</textarea>
@if (session('error'))
<div class="text-red-600 text-sm">{{ session('error') }}</div>
@endif
@if (session('success'))
<div class="text-green-600 text-sm">{{ session('success') }}</div>
@endif
<button class="px-3 py-2 rounded bg-slate-900 text-white">Send</button>
</form>
File uploads
Read one or many files. Validate type/size, then store or reject.
// Single file
UploadFile avatar = request.getFile("avatar");
if (avatar != null && avatar.isExist()) {
boolean ok = avatar.saveIfContentTypeMatch("image/png"); // enforce MIME
if (!ok) { response.redirectBackWithInput("error", "Only PNG allowed"); } // terminal
}
// Multiple files
if (request.hasFiles()) {
for (UploadFile f : request.getFiles()) {
// e.g., f.saveIfContentTypeMatch("application/pdf");
}
}
Cookies & session
// Read cookies (typed)
String theme = request.getCookie("theme", "light");
boolean logged = request.getCookie("logged_in", false);
int visits = request.getCookie("visits", 0);
// Set cookie (name=value; attributes)
response.setCookie("session_id=" + java.util.UUID.randomUUID() + "; Path=/; HttpOnly; Secure");
// Session (set on login, read later)
response.setSession("user", userJsonString);
String userJson = request.getSession("user");
response.destroySession(); // logout
Rendering views (HTML)
// Normal page
ParamsObject data = new ParamsObject()
.set("page_title", "Home")
.set("intro", "Build fast. Ship faster.");
response.renderView("frontend.home", data); // terminal
// Custom status page (e.g., 404)
response.renderView(404, "Not Found", "error.404",
new ParamsObject().set("page_title","Not Found").set("path", request.getPath())); // terminal
JSON & Datatable responses
// JSON object/array
PJsonObject obj = new PJsonObject().set("ok", true).set("time", System.currentTimeMillis());
PJsonArray arr = new PJsonArray().add(1).add(2).add(3);
response.send(obj); // Content-Type: application/json
response.send(201, "Created", obj); // with status/message
response.send(arr);
// Datatable (server-side response)
Datatable dt = new Datatable("SELECT id, username, email, status FROM users");
dt.edit("status", (col, val, row) -> "1".equals(val) ? "Active" : "Inactive");
dt.addNumbering("no");
response.send(dt); // terminal JSON for DataTables
Bytes & file downloads
// Raw bytes
byte[] csv = "id,name\n1,John".getBytes(java.nio.charset.StandardCharsets.UTF_8);
response.send(csv); // inline (octet-stream)
response.send(csv, "report.csv", "text/csv"); // suggest filename + type
// File handle
File pdf = new File("docs/guide.pdf");
response.send(pdf); // best-effort inline
response.send(pdf, "Guide.pdf"); // suggest filename
response.send(pdf, "Guide.pdf", "application/pdf"); // force type
Empty responses (status only)
response.empty(); // flush with current status & headers
response.empty(204, "No Content"); // useful for OPTIONS, successful PATCH/PUT with no body
Redirects (including back & old input)
// Basic redirect (to a named/url helper inside your app)
response.redirect("dashboard"); // terminal
// With flashes (ParamsString)
ParamsString flashes = new ParamsString().set("success","Saved!");
response.redirect("users", flashes); // terminal
// Single flash key/value overload
response.redirect("login", "error", "Invalid credentials"); // terminal
// Back (uses Referer)
response.redirectBack(); // terminal
response.redirectBack(new ParamsString().set("success","Done"));// terminal
// Back with input (also persists old input)
response.redirectBackWithInput("error","Please fix fields"); // terminal
response.redirectBackWithInput(new ParamsString().set("error","Please fix fields")); // terminal
In templates, access flashes via {{ session('success') }}
/ {{ session('error') }}
and inputs via {{ old('field') }}
.
Common recipes
1) Classic GET form + POST submit (with errors and old input)
r.get("/profile", Profile::form, "auth");
r.post("/profile", Profile::save, "auth");
public final class Profile {
public static void form(Request req, Response res) {
ParamsObject data = new ParamsObject().set("page_title","Edit Profile");
res.renderView("profile.form", data);
}
public static void save(Request req, Response res) {
ParamsString rules = new ParamsString()
.set("name","required|min_length[3]")
.set("email","required|email");
if (!req.validatePost(rules)) {
res.redirectBackWithInput("error","Please fix the fields"); // terminal
}
// ... update DB
res.redirect("profile", "success", "Profile updated"); // terminal
}
}
@extends('layouts.frontend')
@section('body')
<form method="post" action="{{ url('profile') }}" class="space-y-3">
@csrf
<input name="name" value="{{ old('name') }}" />
<input name="email" value="{{ old('email') }}" />
@if (session('error'))
<div class="text-red-600 text-sm">{{ session('error') }}</div>
@endif
@if (session('success'))
<div class="text-green-600 text-sm">{{ session('success') }}</div>
@endif
<button class="px-3 py-2 rounded bg-slate-900 text-white">Save</button>
</form>
@endsection
2) JSON CRUD (show, create, update)
// Show
r.get("/api/users/{id}", UsersApi::show);
public static void show(Request req, Response res) {
int id = req.getParam("id", 0);
// ... fetch from DB
res.send(new PJsonObject().set("id", id).set("name","John Doe"));
}
// Create (201)
r.post("/api/users", UsersApi::create);
public static void create(Request req, Response res) {
PJsonObject payload = req.getJsonObject();
// ... insert & get newId
res.send(201, "Created", new PJsonObject().set("id", 101).set("ok", true));
}
// Update (204)
r.put("/api/users/{id}", UsersApi::update);
public static void update(Request req, Response res) {
int id = req.getParam("id", 0);
PJsonObject payload = req.getJsonObject();
// ... apply update
res.empty(204, "No Content");
}
3) Upload then redirect with message
r.post("/profile/avatar", Avatar::upload, "auth");
public final class Avatar {
public static void upload(Request req, Response res) {
UploadFile f = req.getFile("avatar");
if (f == null || !f.isExist() || !f.saveIfContentTypeMatch("image/png")) {
res.redirectBackWithInput("error","PNG avatar required"); // terminal
}
res.redirect("profile", "success", "Avatar uploaded"); // terminal
}
}
4) CSV export
r.get("/reports/users.csv", Reports::users);
public final class Reports {
public static void users(Request req, Response res) {
String csv = "id,name\n1,John\n2,Jane\n";
res.send(csv.getBytes(java.nio.charset.StandardCharsets.UTF_8),
"users.csv", "text/csv"); // terminal
}
}
Troubleshooting
- Headers missing in response:set thembeforecalling any terminal method; after
send/renderView/redirect/empty
, the response is finalized. - Old input doesn’t show:use
redirectBackWithInput(...)
on failure and in the view use{{ old('field') }}
. - JSON is returned as HTML:set content type in
Core.accept
for/api/*
or callresponse.setContentType("application/json")
beforesend(...)
. - CSRF mismatch on POST:include
@csrf
in the form and ensure you post back to the same origin. - File rejected:confirm MIME checks and server/proxy body-size limits; adjust validation or file handling accordingly.
Router
The router maps HTTP requests to handler methods. In Panaragan, handlers use the functional interface Routers.Boot
with the signature void accept(Request, Response)
, so you can pass method references like Home::index
. Register routes inside Core.boot(Routers r)
.
HTTP verbs & utilities
Supported registration methods (from the source):
r.get(path, handler)
/r.get(path, handler, authority)
r.post(path, handler)
/r.post(path, handler, authority)
r.put(path, handler)
/r.put(path, handler, authority)
r.delete(path, handler)
/r.delete(path, handler, authority)
r.head(path, handler)
/r.head(path, handler, authority)
r.options(path, handler)
/r.options(path, handler, authority)
r.getOrPost(path, handler)
/r.getOrPost(path, handler, authority)
— registers both GET and POST.r.all(path, handler)
/r.all(path, handler, authority)
— registers GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS for the same path.r.path(path, handler)
/r.path(path, handler, authority)
—PANARAGAN PATCH(method namepath
, handles HTTP PATCH).
// Inside Core.boot(Routers r)
r.get("/", Home::index);
r.post("/login", Auth::submit, "guest");
r.put("/profile", Profile::update, "auth");
r.delete("/users/{id}", UsersDelete::remove, "users_delete");
r.path("/profile/avatar", ProfileAvatar::patch, "auth"); // PATCH via r.path(...)
r.head("/files/{id}", Files::headInfo);
r.options("/api", Cors::preflight);
r.getOrPost("/search", Search::index); // register GET and POST
r.all("/webhook", Webhook::receive); // register all verbs for the same handler
Dynamic path parameters
Use {name}
to capture a path segment. Read it in your handler via request.getParam("name")
or a typed overload with default:
// Route
r.get("/users/{id}", UsersShow::show, "users_view");
// Handler
public final class UsersShow {
public static void show(Request req, Response res) {
int id = req.getParam("id", 0); // typed int with default
// ... load user, then render or return JSON
ParamsObject data = new ParamsObject().set("page_title", "User #"+id);
res.renderView("users.show", data); // terminal
}
}
Tip: Request
also provides getQuery(...)
, getPost(...)
, and getPostOrQuery(...)
with typed overloads (String
, int
, boolean
, long
, float
, double
).
Grouping (prefix + authority inheritance)
Use r.group(prefix, group -> { ... })
to apply a common URL prefix. Add an authority string to enforce an authorization requirement for all children (unless a child route overrides its own authority).
// Example: /admin group with "auth" authority
r.group("/admin", "auth", g -> {
g.get("/", Admin::dashboard); // inherits "auth"
g.get("stats", Admin::stats); // inherits "auth"
// Nested group: /admin/users
g.group("users", "users_list", ug -> {
ug.get("/", UsersIndex::index); // requires "users_list"
ug.post("store", UsersAdd::store, "users_add"); // overrides to "users_add"
ug.get("edit/{id}", UsersEdit::form, "users_edit"); // overrides to "users_edit"
ug.delete("{id}", UsersDelete::remove, "users_delete");
});
});
Slashes are normalized for you. Children without an explicit authority inherit from the nearest group.
Special verbs: HEAD & OPTIONS
HEADshould return headers without a body;OPTIONSis commonly used for CORS preflight.
// HEAD: send headers only
r.head("/files/{id}", Files::headInfo);
public final class Files {
public static void headInfo(Request req, Response res) {
String id = req.getParam("id", "0");
// set headers describing the resource
res.addHeader("X-File-ID", id);
res.addHeader("Content-Length", "0");
res.empty(200, "OK"); // terminal, no body
}
}
// OPTIONS: simple CORS preflight on /api
r.options("/api", Cors::preflight);
public final class Cors {
public static void preflight(Request req, Response res) {
res.addHeader("Access-Control-Allow-Origin", "*");
res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS,PATH");
res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.empty(204, "No Content"); // terminal
}
}
PATCH (use r.path(...)
)
Panaragan registers HTTP PATCH via the method named path
.
// PATCH example (profile partial update)
r.path("/profile", Profile::patch, "auth");
public final class Profile {
public static void patch(Request req, Response res) {
// read only provided fields; do partial update
String name = req.getPostOrQuery("name", null);
String about = req.getPostOrQuery("about", null);
// ... apply updates
res.send(new PJsonObject().set("ok", true)); // terminal JSON
}
}
Patterns & best practices
- Prefer static pathswhere possible; they resolve fastest. Use
{id}
segments only where needed. - One responsibility per handler: read inputs, delegate to service, then respond (HTML/JSON/file).
- Use
getOrPost()
for formsyou want to support via both GET (display) and POST (submit). - Use
all()
for webhooksor integrations where providers may vary the verb. - Authority stringsare free-form: decide meaning in
Core.authorize()
(e.g.,"auth"
,"guest"
,"users_list"
).
Mini CRUD example
// boot()
r.group("/posts", "auth", g -> {
g.get("/", PostsIndex::index); // list
g.get("create", PostsCreate::form, "posts_add");
g.post("", PostsCreate::store, "posts_add"); // create
g.get("{id}", PostsShow::show, "posts_view"); // show
g.put("{id}", PostsUpdate::update, "posts_edit"); // full update
g.path("{id}", PostsUpdate::patch, "posts_edit"); // partial update (PATCH)
g.delete("{id}", PostsDelete::remove, "posts_delete");
});
// handler sample: PostsShow
public final class PostsShow {
public static void show(Request req, Response res) {
int id = req.getParam("id", 0);
// Load and render
ParamsObject data = new ParamsObject()
.set("page_title", "Post #"+id)
.set("post_id", id);
res.renderView("posts.show", data); // terminal
}
}
Troubleshooting (Router)
- 404 Not Found:verify the path you registered matches exactly; for dynamic paths, ensure the placeholder names match your
getParam(...)
calls. - Authorize not enforced:set an authority on the route or group, and implement logic in
Core.authorize()
. A terminal response there stops the pipeline (no return needed after it). - Wrong handler called:check for overlapping routes under the same method; prefer specific (static) paths before dynamic ones.
- CORS failing:add an explicit
r.options(...)
route and send the appropriateAccess-Control-*
headers.
Validation
Panaragan ships a request-aware validator. Use request.validatePost(rules[, labels[, messages]])
to validate form input (including multipart uploads). On failure it stores a structured error bag internally and returns false
. Combine it with response.redirectBack(request.getPosts())
to flash field errors and old values back to the form. CSRF is checked first; when the token is invalid, validatePost(...)
returns false
immediately.
API (controller)
boolean ok = request.validatePost(ParamsString rules)
boolean ok = request.validatePost(ParamsString rules, ParamsString labels)
boolean ok = request.validatePost(ParamsString rules, ParamsString labels, ParamsParamsString messages)
- On failure, call
response.redirectBack(request.getPosts())
to flash errors + old values. - In views, use
request.getErrorField(name)
andrequest.getOld(name)
.
Rule syntax
Rules are specified per field as a |
-separated string:
// Example rules map
ParamsString rules = new ParamsString()
.put("email", "required|email|max_length[120]")
.put("password", "required|min_length[8]")
.put("confirm", "required|matches[password]")
.put("age", "numeric|greater_than[17]");
Built-in rules
required
— must be present and non-empty (for multipart, passes if either text value exists or an uploaded file exists for that field).min_length[N]
/max_length[N]
— string length.matches[other_field]
— value must equal another field’s value.numeric
— digits only (\\d+
).alpha
— letters only ([a-zA-Z]+
).alpha_numeric
— letters or digits ([a-zA-Z0-9]+
).email
— basic email pattern.greater_than[N]
/less_than[N]
— numeric comparison (parsed as int).regex[pattern]
— JavaString.matches
pattern. Remember to escape backslashes.
- For
matches[password]
, the referenced field must be included in the same request. - Multipart forms:
required
passes if either the text value is non-emptyorthere is an uploaded file for that field. - When validation fails, Panaragan compiles per-field messages into a string (multiple messages are joined with line breaks) and flashes them to the next view.
1) Minimal example (POST form)
public final class AuthController {
public static void register(Request req, Response res) {
// 1) Define rules
ParamsString rules = new ParamsString()
.put("email", "required|email|max_length[120]")
.put("password", "required|min_length[8]")
.put("confirm", "required|matches[password]");
// 2) Validate (CSRF is checked inside)
if (!req.validatePost(rules)) {
// 3) Flash old input + errors and go back
res.redirectBack(req.getPosts()); // terminal
}
// 4) Use sanitized values (Strings)
String email = req.getPost("email", "");
String pass = req.getPost("password", "");
// ... create user, etc.
res.redirect("login", "success", "Registration successful, please sign in.");
}
}
Form view (show old values & errors)
<!-- escaped example view -->
<form method="post" action="/register" class="space-y-4">
@csrf
<div>
<label class="block mb-1 font-medium">Email</label>
<input name="email" type="email" class="input" value="{{ old('email') }}">
@if(error('email'))<div class="text-red-600 text-sm">{{ error('email') }}</div>@endif
</div>
<div>
<label class="block mb-1 font-medium">Password</label>
<input name="password" type="password" class="input">
@if(error('password'))<div class="text-red-600 text-sm">{{ error('password') }}</div>@endif
</div>
<div>
<label class="block mb-1 font-medium">Confirm Password</label>
<input name="confirm" type="password" class="input">
@if(error('confirm'))<div class="text-red-600 text-sm">{{ error('confirm') }}</div>@endif
</div>
<button class="btn btn-primary">Create account</button>
</form>
2) Labels & custom messages
Supply human-friendly labels and per-rule custom messages per field. Message placeholders: {field}
, {min_length}
, {max_length}
, {greater_than}
, {less_than}
, {matches}
.
// Labels
ParamsString labels = new ParamsString()
.put("email", "Email address")
.put("password", "Password")
.put("confirm", "Password confirmation");
// Messages (field -> (rule -> message))
ParamsParamsString messages = new ParamsParamsString()
.put("email", new ParamsString()
.put("required", "{field} is required")
.put("email", "Please enter a valid {field}")
.put("max_length", "{field} must be under {max_length} characters"))
.put("password", new ParamsString()
.put("required", "{field} is required")
.put("min_length", "{field} must be at least {min_length} characters"))
.put("confirm", new ParamsString()
.put("matches", "{field} must match {matches}"));
if (!req.validatePost(rules, labels, messages)) {
res.redirectBack(req.getPosts()); // terminal
return;
}
3) File uploads (multipart)
For multipart forms, required
on a field passes if the user either typed a non-empty valueoractually selected a file for that field. This is handy for optional text + file inputs. Length, pattern, and matching rules apply to the text value (not the file bytes).
// Controller
public static void saveAvatar(Request req, Response res) {
ParamsString rules = new ParamsString()
.put("avatar", "required"); // require a chosen file
if (!req.validatePost(rules)) {
res.redirectBack(req.getPosts()); // terminal
return;
}
// Access uploaded file
UploadFile file = req.getFile("avatar");
// ... move/store as needed
res.redirect("profile", "success", "Avatar updated.");
}
<!-- View -->
<form method="post" action="/profile/avatar" enctype="multipart/form-data">
<input type="hidden" name="{{ request.getCSRFTokenName() }}"
value="{{ request.getCSRFTokenValue() }}">
<input type="file" name="avatar">
<div class="text-red-600 text-sm">{{ request.getErrorField('avatar') }}</div>
<button class="btn">Save</button>
</form>
4) Advanced: validate outside controllers
For service-level validation (without using request.validatePost(...)
), call the static validator. It returns a ParamsListString
(map: field → list of messages). An empty result means “no errors”.
// Build a data bag (like request posts)
ParamsString inputs = new ParamsString()
.put("email", "bad@")
.put("age", "x");
// Define rules
ParamsString rules = new ParamsString()
.put("email", "required|email")
.put("age", "numeric");
// Optional: labels and messages
ParamsString labels = new ParamsString().put("age", "Age");
ParamsParamsString msgs = new ParamsParamsString()
.put("email", new ParamsString().put("email", "Invalid {field} format"));
// Validate
ParamsListString errs = Validation.input(inputs, rules, labels, msgs);
if (!errs.isEmpty()) {
// handle error bag ...
}
Troubleshooting
- Nothing happens on submit:ensure you include the CSRF hidden input with
request.getCSRFTokenName()
/request.getCSRFTokenValue()
. - Errors not visible:after failure, call
response.redirectBack(request.getPosts())
and in the view print{{ request.getErrorField('field') }}
. - Old values not restored:use
response.redirectBack(request.getPosts())
and in the form setvalue="{{ request.getOld('field') }}"
. regex[...]
not matching:remember Java patterns need escaping (e.g.,\\d+
), and the delimiter is[ ... ]
.
Session
Panaragan provides a cookie-based session with pluggable backends and a simple static API. Use Session.put(request, key, value)
to store values and Session.get(request, key)
to read them. The session id is kept in a cookie (name from .env
) and the store choice + expiry are configured at bootstrap.
Configuration (.env) recap
# Backend & lifetime
session.mode=MEMORY # MEMORY | STORAGE | DATABASE | CUSTOM
session.expired=3600 # seconds (TTL)
# Cookie
session.key=_secure_SESSION_ID # cookie name
session.path=/
session.domain=
session.http_only=true
session.secure=false
session.same_site=Lax # Lax | Strict | None
# Store path/class hint (used by STORAGE / DATABASE / CUSTOM)
session.storage=storage/sessions
- MEMORY: in-process map (lost on restart).
- STORAGE: file-based under
session.storage
(ensure readable/writable). - DATABASE: DB-backed (uses your configured database; see your schema/initializer).
- CUSTOM: provide your own implementation; path/class hint via
session.storage
.
.env
at startup and kept in memory; restart the app to apply changes. Set session.secure=true
and an appropriate SameSite
policy in production.API (controller)
Session.put(Request req, String key, Object value)
Object Session.get(Request req, String key)
Login flow (store user id)
public final class AuthController {
public static void doLogin(Request req, Response res) {
String email = req.getPost("email", "");
String pass = req.getPost("password", "");
// 1) Verify credentials (example)
PJsonObject user = new QueryBuilder()
.select("id","email","password_hash","status")
.from("users")
.where("email = ?", email)
.row();
if (user == null || !Password.check(pass, user.getString("password_hash"))) {
res.redirect("login", "error", "Invalid credentials");
return; // terminal
}
// 2) Put auth keys in session
Session.put(req, "auth_user_id", user.getInt("id"));
Session.put(req, "auth_email", user.getString("email"));
// 3) Proceed
res.redirect("dashboard", "success", "Welcome!");
}
}
Checking auth in a protected action
public final class DashboardController {
public static void index(Request req, Response res) {
Object uid = Session.get(req, "auth_user_id");
if (uid == null) {
res.redirect("login", "error", "Please sign in first");
return; // terminal
}
res.renderView("frontend.dashboard");
}
}
Logout (clear and redirect)
Remove the keys you set for authentication, then redirect. (The exact “clear” strategy depends on your store; the simplest is to overwrite the keys you rely on.)
public final class AuthController {
public static void logout(Request req, Response res) {
// clear what you used to check auth
Session.put(req, "auth_user_id", null);
Session.put(req, "auth_email", null);
res.redirect("login", "success", "Signed out");
}
}
Flash messages (via redirect helpers)
Redirect helpers such as response.redirect(..., "success", "Message")
and response.redirectBack(request.getPosts())
use the session to carry a single-use message (and old form values). They are consumed on the next request automatically.
Per-request defaults in Core.accept(...)
If you need to attach the current user to the request for downstream controllers, read the session inside your Core.accept
and set a payload.
public void accept(final Request req, final Response res) {
Object uid = Session.get(req, "auth_user_id");
if (uid != null) {
// attach a lightweight payload — use what your app needs
req.setPayload(new ParamsObject().set("user_id", uid));
}
}
Store-specific notes
- MEMORY: fastest; data is lost on restart. Good for development or stateless nodes with sticky sessions.
- STORAGE: ensure
session.storage
exists and is writable by the process user. - DATABASE: you’ll need a table compatible with the session format used in your project; expired rows should be cleaned periodically.
- TTL: entries older than
session.expired
seconds are considered invalid by readers; implement a cleanup job if you want to trim persistent stores.
Cleanup job example
// Run daily at midnight to purge DB sessions (if you store them in a table)
Job.everyDay(() -> {
try { Database.delete("sessions", "expired < CURRENT_TIMESTAMP"); }
catch (Exception e) { e.printStackTrace(); }
});
Copy-paste reference
// Write
Session.put(req, "auth_user_id", 123);
// Read
Object uid = Session.get(req, "auth_user_id");
// Clear a value your app relies on
Session.put(req, "auth_user_id", null);
// Guarded route
if (Session.get(req, "auth_user_id") == null) {
res.redirect("login", "error", "Please sign in first");
return; // terminal
}
Template
Panaragan uses a Blade-like HTML templating syntax with layouts and sections. Views live under src/main/resources/views
. Render a view to the HTTP response with Response.renderView(viewName[, data])
, or render into a string (e.g., for email) with Template.render(viewName, data)
.
View location & naming
Dotted names map to subfolders. For example:
src/main/resources/views/
├─ layouts/
│ └─ frontend.html
├─ frontend/
│ ├─ home.html
│ └─ dashboard.html
└─ email/
└─ activation-account.html
"frontend.home"
→ views/frontend/home.html
, "layouts.frontend"
→ views/layouts/frontend.html
, etc.
Layout & sections
Define a base layout with @yield
placeholders. Child views call @extends
and fill @section
blocks. (Escaped below so it won’t render inside these docs.)
Base layout (with Tailwind CDN)
<!-- views/layouts/frontend.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ config('app.name') }} - {{ page_title }}</title>
<meta name="description" content="{{ page_description }}">
<!-- Tailwind CDN to avoid missing local assets -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Optional: highlight.js for code samples in your site -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script>window.addEventListener('DOMContentLoaded', () => hljs.highlightAll());</script>
@yield('style')
</head>
<body class="antialiased text-slate-900 bg-slate-50">
<header class="sticky top-0 z-50 bg-white/70 backdrop-blur-sm border-b border-slate-200">
@yield('menus')
</header>
@yield('body')
<footer class="bg-slate-800 text-slate-300 mt-16">
<div class="max-w-7xl mx-auto px-6 py-8 flex justify-between">
<div class="font-semibold text-white">Panaragan</div>
<div class="text-sm text-slate-400">© <span id="year"></span> Panaragan</div>
</div>
</footer>
@yield('script')
</body>
<script>document.getElementById('year').textContent = new Date().getFullYear();</script>
</html>
Child view
<!-- views/frontend/home.html -->
@extends('layouts.frontend')
@section('menus')
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<a class="flex items-center gap-3" href="/">
<img src="/logo.png" alt="Panaragan" class="h-8 w-8">
<div class="font-semibold">Panaragan</div>
</a>
<nav class="hidden md:flex gap-3 text-sm text-slate-600">
<a href="/docs" class="px-3 py-2 rounded hover:bg-slate-100">Docs</a>
</nav>
</div>
@endsection
@section('body')
<main class="max-w-7xl mx-auto px-6 py-10">
<h1 class="text-2xl font-bold">{{ page_title }}</h1>
<p class="text-slate-600 mt-2">{{ subtitle }}</p>
<!-- Example conditional & loop -->
@if(features != null)
<ul class="list-disc pl-6 mt-4">
@foreach(f in features)
<li>{{ f }}</li>
@endforeach
</ul>
@endif
</main>
@endsection
Rendering in a controller
Pass a ParamsObject
with the variables your view expects.
public final class HomeController {
public static void index(Request req, Response res) {
ParamsObject data = new ParamsObject()
.set("page_title", "Welcome")
.set("page_description", "Panaragan starter")
.set("subtitle", "Build your app to enterprise class")
.set("features", new ListObject().add("Routing").add("Template").add("Database"));
res.renderView("frontend.home", data); // terminal
}
}
Template helpers inside views
A few helpers are available directly in views (as used throughout the sample):
{{ url('path-or-route') }}
— build an absolute URL to a path (e.g.,/css/app.css
).{{ config('app.name') }}
— read a config value.@csrf
— inject a hidden input with the CSRF token (use inside forms).
Forms + CSRF
<form action="/profile/update" method="post" class="space-y-4">
@csrf
<input type="text" name="full_name" class="input" placeholder="Your name">
<button class="btn">Save</button>
</form>
Rendering to a string (emails, etc.)
Use Template.render(view, data)
when you need the HTML string (e.g., email body) instead of sending an HTTP response.
ParamsObject mailData = new ParamsObject()
.set("user_email", "user@example.com")
.set("activation_url", Url.to("activation/ABC123"));
String html = Template.render("email.activation-account", mailData);
// pass to Email.send(...)
Conditionals & loops (in views)
The syntax mirrors the sample project:
<!-- escaped examples -->
@if(user != null)
<p>Hello, {{ user.name }}</p>
@endif
@if(items != null)
<ul>
@foreach(item in items)
<li>{{ item.title }}</li>
@endforeach
</ul>
@endif
SEO/meta example (as used by the sample layout)
<!-- in layouts/frontend.html (escaped) -->
<title>{{ config('app.name') }} - {{ page_title }}</title>
<meta name="description" content="{{ page_description }}">
<meta property="og:title" content="{{ config('app.name') }} - {{ page_title }}">
<meta property="og:description" content="{{ page_description }}">
<link rel="canonical" href="{{ page_url }}">
Troubleshooting
- Blank page:ensure the child view calls
@extends('layouts.frontend')
and defines sections used by the layout (e.g.,menus
,body
). - Undefined variables:pass them via
ParamsObject
when callingrenderView
/Template.render
(e.g.,page_title
,page_description
). - Missing CSS/JS:use the Tailwind CDN snippet above or ensure your asset files exist if you reference
{{ url('css/frontend.css') }}
. - Form submit rejected:include
@csrf
inside your form and POST to the route that validates it.
Copy-paste reference
// Controller → HTTP response
res.renderView("frontend.home", new ParamsObject().set("page_title","Home"));
// Controller → HTML string
String html = Template.render("email.activation-account",
new ParamsObject().set("activation_url", Url.to("activation/TOKEN")));
<!-- Child view (escaped) -->
@extends('layouts.frontend')
@section('body')
<h1>{{ page_title }}</h1>
@endsection
Upload File
Panaragan mem-parsingmultipart/form-datadan menaruh file di Request
. Ambil satu file dengan request.getFile(name)
atau iterasi semua file lewat request.getFiles()
. Gunakan objek UploadFile
untuk menyimpan ke folder upload yang dikonfigurasi oleh app.path_upload
.
Konfigurasi upload di .env
# Lokasi folder upload & URL slug untuk mengaksesnya
app.path_upload=storage/uploads
app.slug_upload=/uploads
# Batas ukuran request (MB)
request.max_size_on_mb=20
- app.path_uploadharus folder yang valid dan writable.
- app.slug_upload(opsional): jika di-set, URL yang diawali slug ini akan dipetakan otomatis ke
app.path_upload
(contoh:/uploads/avatar/john.png
). - request.max_size_on_mb: batas maksimum ukuran request (default 20MB). Jika terlewati, request akan ditolak.
Form HTML
<form method="post" action="/profile/avatar" enctype="multipart/form-data" class="space-y-4">
@csrf
<input type="file" name="avatar" accept="image/png,image/jpeg">
<button class="btn">Upload</button>
</form>
API ringkas
// Ambil satu file
UploadFile file = req.getFile("avatar");
// Cek ada/tidak
boolean exists = (file != null && file.isExist());
// Nama file yang diunggah (original)
String original = file.getUploadFilename();
// Simpan ke folder upload (relatif ke app.path_upload)
boolean ok1 = file.save("avatar/john.png");
// Simpan & cek content-type (single)
boolean ok2 = file.saveIfContentTypeMatch("avatar/john.png", "image/png");
// Simpan & cek content-type (multiple)
boolean ok3 = file.saveIfContentTypeMatch("avatar/john.jpg", new String[]{"image/jpeg","image/jpg"});
// Error terakhir (jika save gagal)
String err = file.getError();
// Iterasi semua file (map: field -> UploadFile)
for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
String field = e.getKey();
UploadFile f = e.getValue();
// ...
}
UploadFile.save(...)
selalu menulis relatif ke app.path_upload
. Method saveIfContentTypeMatch(...)
akan memeriksa MIME type (menggunakan deteksi sederhana) dan menghapus file jika tidak sesuai, lalu mengisi getError()
. Framework juga mencegah path traversal — hanya path di dalam app.path_upload
yang diizinkan.Contoh: upload avatar (validasi + simpan)
public final class ProfileController {
public static void uploadAvatar(Request req, Response res) {
// Validasi sederhana (contoh): pastikan field 'avatar' dikirim
ParamsString rules = new ParamsString().put("avatar", "required");
if (!req.validatePost(rules)) {
res.redirectBack(req.getPosts()); // terminal
return;
}
UploadFile file = req.getFile("avatar");
if (file == null || !file.isExist()) {
res.redirectBack(req.getPosts(), "error", "Please choose a file.");
return; // terminal
}
// Buat nama target (jaga ekstensi dari nama asli bila perlu)
String original = file.getUploadFilename(); // mis. "photo.png"
String target = "avatar/" + req.getUser("id", "user") + "-" + System.currentTimeMillis() +
(original.contains(".") ? original.substring(original.lastIndexOf('.')) : "");
// Batasi hanya PNG/JPEG
boolean ok = file.saveIfContentTypeMatch(target, new String[]{"image/png","image/jpeg"});
if (!ok) {
res.redirectBack(req.getPosts(), "error", file.getError()); // "Content type is not allowed", dll.
return; // terminal
}
// Sukses — tampilkan URL publik jika slug di-setup
// URL: app.slug_upload + target (contoh: /uploads/avatar/123-...png)
String publicUrl = (com.panaragan.Server.UPLOAD_SLUG.isBlank() ? "" : com.panaragan.Server.UPLOAD_SLUG) + target;
res.redirect("profile", "success", "Avatar uploaded: " + publicUrl);
}
}
Contoh: iterasi beberapa file
Jika kamu memiliki beberapa field file (mis. doc1
, doc2
, …), iterasi semua entri dan simpan sesuai kebutuhan.
<form method="post" action="/docs/upload" enctype="multipart/form-data">
@csrf
<input type="file" name="doc1">
<input type="file" name="doc2">
<button class="btn">Upload</button>
</form>
public static void uploadDocs(Request req, Response res) {
java.util.List<String> saved = new java.util.ArrayList<>();
for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
String field = e.getKey(); // "doc1", "doc2", ...
UploadFile f = e.getValue();
if (f != null && f.isExist()) {
String name = f.getUploadFilename();
String target = "docs/" + field + "-" + System.currentTimeMillis() +
(name.contains(".") ? name.substring(name.lastIndexOf('.')) : "");
if (f.save(target)) {
saved.add(target);
}
}
}
if (saved.isEmpty()) {
res.redirectBack(req.getPosts(), "error", "No files uploaded.");
return; // terminal
}
res.redirect("docs", "success", "Uploaded: " + String.join(", ", saved));
}
Mengakses file yang sudah diupload
Jika app.slug_upload
diset (mis. /uploads
), maka /uploads/<path-yang-kamu-simpan>
akan dilayani langsung dari folder app.path_upload
.
app.path_upload = storage/uploads
app.slug_upload = /uploads
// Simpan: file.save("avatar/john.png")
// Akses: GET /uploads/avatar/john.png → storage/uploads/avatar/john.png
Validasi ukuran & tipe
- Ukuran requestdibatasi oleh
request.max_size_on_mb
. Jika terlampaui, request ditolak. - Tipe kontengunakan
saveIfContentTypeMatch(...)
dengan satu atau banyak MIME types.
Troubleshooting
- Gagal simpan:cek
file.getError()
. Pastikanapp.path_upload
valid & writable. - 404 saat akses file:set
app.slug_upload
& pastikan path target saatsave(...)
cocok dengan URL. - “Request too large”:naikkan
request.max_size_on_mb
sesuai kebutuhan. - Jenis file ditolak:cocokkan daftar MIME type pada
saveIfContentTypeMatch
.
Copy-paste reference
// Single file
UploadFile f = req.getFile("avatar");
if (f != null && f.isExist()) {
f.saveIfContentTypeMatch("avatar/u-1.png", "image/png");
}
// Multi-field
for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
UploadFile uf = e.getValue();
if (uf != null && uf.isExist()) { uf.save("misc/" + e.getKey() + ".bin"); }
}
.env
.URL
Panaragan provides simple helpers to generate links consistently for controllers and views. In controllers/services, use Url.to(...)
. In views, use the template helper {{ url('...') }}
. These are used throughout the CMS example (e.g., action links for DataTables, email activation links, asset links).
API
String Url.to(String path)
— build a URL string to a path.{{ url('path') }}
(view helper) — echo a URL inside templates.
- Pass apathlike
"login"
,"users/edit/123"
, or"css/frontend.css"
. - When composing URLs with user data, sanitize/escape in views as needed.
- For uploaded files, combine your saved path with the upload slug (seeUpload File).
Use in controllers/services
// 1) Build an activation link for email
String token = Helper.randomString(64);
String activationUrl = Url.to("activation/" + token);
// 2) Action links for tables / JSON responses
String editUrl = Url.to("users/edit/" + Helper.encryptString(userId));
String deleteUrl = Url.to("users/delete/" + Helper.encryptString(userId));
// 3) Pass URLs to views or JSON
ParamsObject data = new ParamsObject()
.set("activation_url", activationUrl)
.set("profile_url", Url.to("profile"));
res.renderView("frontend.home", data); // terminal
Use in DataTables (server-side)
// Add virtual columns with URLs
dt.add("edit_url", (_, __, row) -> Url.to("users/edit/" + Helper.encryptString(row.get("id"))));
dt.add("delete_url", (_, __, row) -> Url.to("users/delete/" + Helper.encryptString(row.get("id"))));
Use in views (templates)
<!-- escaped examples in a view -->
<a href="{{ url('login') }}" class="btn">Sign in</a>
<link rel="stylesheet" href="{{ url('css/frontend.css') }}">
<script src="{{ url('js/app.js') }}"></script>
<a href="{{ profile_url }}" class="btn-link">My profile</a>
With anchors and basic query strings
Build simple query strings/anchors by concatenation (keep it straightforward and explicit).
// Controller: search URL
String q = "john"; // ensure it's safe for your usage
String listUrl = Url.to("users?search=" + q + "&page=1") + "#result";
res.redirect(listUrl); // terminal
<!-- View: link to a section -->
<a href="{{ url('docs#install') }}">Go to Install</a>
Uploaded file URLs
After saving a file to app.path_upload
(e.g., "avatar/john.png"
), expose it with your upload slug.
// Combine saved path with the configured slug
String savedPath = "avatar/john.png";
String publicUrl = (com.panaragan.Server.UPLOAD_SLUG.isBlank() ? "" : com.panaragan.Server.UPLOAD_SLUG) + savedPath;
// e.g., "/uploads/avatar/john.png"
res.redirect("profile", "success", "Avatar uploaded: " + publicUrl);
<!-- View -->
<img src="{{ public_url }}" alt="Avatar" class="h-12 w-12 rounded-full">
Common patterns
- Email links:create the link in the controller with
Url.to(...)
, inject into the email view, send withEmail.send(...)
. - JSON APIs:include prebuilt URLs (edit/delete/show) so the frontend doesn’t guess paths.
- Assets:use
{{ url('css/...') }}
and{{ url('js/...') }}
in the layout.
Troubleshooting
- Broken links in emails:ensure you build links via
Url.to(...)
in the controller and pass them to the email view. - 404 for uploaded files:verify the prefix matches your configured upload slug and the saved path (seeUpload File).
- Static assets not loading:check your asset files exist under
public/
or use the CDN version where appropriate (e.g., Tailwind CDN).
Copy-paste reference
// Controller
String link = Url.to("users/edit/" + Helper.encryptString(id));
ParamsObject data = new ParamsObject().set("edit_url", link);
res.renderView("frontend.user-detail", data);
<!-- View -->
<a href="{{ edit_url }}" class="btn btn-outline">Edit</a>
Build & Deploy
Prerequisites
- Java Runtime (Temurin recommended).
- A supported database (MySQL, PostgreSQL, SQL Server, Oracle) and the JDBC driver on classpath.
- Reverse proxy (recommended): Nginx/Traefik/Caddy with TLS at the edge.
# .env (examples)
app.env=production
app.port=8080
app.base_url=https://yourdomain.tld
# Database (pick one; keep only what you use)
db.driver=com.mysql.cj.jdbc.Driver
db.url=jdbc:mysql://127.0.0.1:3306/yourdb?useSSL=false&serverTimezone=UTC
db.username=youruser
db.password=yourpass
# PostgreSQL
# db.driver=org.postgresql.Driver
# db.url=jdbc:postgresql://127.0.0.1:5432/yourdb
# SQL Server
# db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
# db.url=jdbc:sqlserver://127.0.0.1:1433;database=yourdb;encrypt=true;trustServerCertificate=true
# Oracle
# db.driver=oracle.jdbc.OracleDriver
# db.url=jdbc:oracle:thin:@//127.0.0.1:1521/ORCLPDB1
Build (Gradle)
# Gradle
./gradlew clean build -x test
# resulting JAR: build/libs/app.jar
java -jar build/libs/app.jar
Build (Maven)
# Maven
mvn -U -B -DskipTests package
# resulting JAR: target/app.jar
java -jar target/app.jar
Dockerfile (distroless, small & secure)
FROM eclipse-temurin:24-jre-jammy as build
WORKDIR /app
# copy your already-built jar here (Gradle or Maven)
COPY build/libs/app.jar /app/app.jar
# or: COPY target/app.jar /app/app.jar
FROM gcr.io/distroless/java24
WORKDIR /app
COPY --from=build /app/app.jar /app/app.jar
# nonroot user is pre-created in distroless
USER nonroot
# distroless provides "java" on PATH; no /bin/sh available
ENTRYPOINT ["java","-jar","/app/app.jar"]
Build & Run Container
# Build
docker build -t yourrepo/panaragan-app:1.0.0 .
# Run (mount .env as env-file)
docker run --rm -p 8080:8080 \
--env-file ./.env \
--name panaragan yourrepo/panaragan-app:1.0.0
Docker Compose (with PostgreSQL)
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: yourdb
POSTGRES_USER: youruser
POSTGRES_PASSWORD: yourpass
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL","pg_isready -U youruser -d yourdb"]
interval: 5s
timeout: 3s
retries: 30
app:
image: yourrepo/panaragan-app:1.0.0
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
env_file: .env
environment:
db.driver: org.postgresql.Driver
db.url: jdbc:postgresql://db:5432/yourdb
db.username: youruser
db.password: yourpass
volumes:
dbdata: {}
Nginx (reverse proxy & TLS)
server {
listen 443 ssl http2;
server_name yourdomain.tld;
ssl_certificate /etc/ssl/your.crt;
ssl_certificate_key /etc/ssl/your.key;
# Hardened defaults
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}
}
server {
listen 80;
server_name yourdomain.tld;
return 308 https://$host$request_uri;
}
Set headers in respond()
Panaragan can emit strict headers directly at the app layer:
// inside your controller or a global Respond helper
response.header("Strict-Transport-Security","max-age=63072000; includeSubDomains; preload");
response.header("X-Content-Type-Options","nosniff");
response.header("X-Frame-Options","DENY");
response.header("Referrer-Policy","strict-origin-when-cross-origin");
// cache control for static assets served by Panaragan
// response.header("Cache-Control","public, max-age=604800, immutable");
Systemd unit (Linux)
# /etc/systemd/system/panaragan.service
[Unit]
Description=Panaragan App
After=network.target
[Service]
User=panaragan
WorkingDirectory=/opt/panaragan
ExecStart=/usr/bin/java -jar /opt/panaragan/app.jar
Restart=always
RestartSec=3
# Let systemd capture stdout/stderr (journald)
StandardOutput=journal
StandardError=journal
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now panaragan
journalctl -u panaragan -f
Windows (NSSM)
# Install service with NSSM
nssm install Panaragan "C:\Program Files\Eclipse Adoptium\jre-24\bin\java.exe" -jar "C:\panaragan\app.jar"
nssm set Panaragan AppDirectory "C:\panaragan"
nssm start Panaragan
Basic health check
Use any public route that returns 200
(e.g., your home page) for probes.
#!/usr/bin/env bash
set -euo pipefail
curl -fsS -m 2 "http://127.0.0.1:8080/" >/dev/null
Container healthcheck
# add to Dockerfile if desired
HEALTHCHECK --interval=15s --timeout=3s --retries=10 \
CMD ["bash","-lc","curl -fsS -m 2 http://127.0.0.1:8080/ >/dev/null"]
Performance & Operations Tips
- JVM:Start with
-Xms
/-Xmx
sized to 60–70% of container/VM RAM. G1GC works well for mixed workloads (-XX:+UseG1GC -XX:MaxGCPauseMillis=100
). For latency-first, testZGC
on JDK if your environment supports it. - CPU pinning:In containers, limit CPUs via
--cpus
and benchmark Panaragan’s NIO selector threads vs core count. - DB connections:Size pool ≈cores × 2as a starting point; measure with your workload. Keep JDBC drivers local in
libs/
if needed. - Static assets:Let Panaragan serve from
resources/public
; enable long-livedCache-Control
headers on hashed assets. - Security:Terminate TLS at the proxy; add strict headers in
respond()
as shown. Validate input at controllers. Return minimal error bodies inproduction
. - Housekeeping:Use your Panaragan
Job
to expire sessions/tokens, purge temp uploads, and warm hot caches on start. - Zero-downtime:RunoldandnewJAR side-by-side on different ports and switch traffic at the proxy (blue/green), or roll containers with healthchecks.
Troubleshooting
- 404 “Template not found”
- Verify
template.path
in.env
points to the base folder that Panaragan loads. - Match the exact view name you render (case-sensitive). Example:
Template.render("backend.users.pdf", data)
requires the corresponding view file packaged in resources. - If it runs in IDE but not from the JAR, ensure the view files are included in the built JAR (under
resources/
).
- Verify
- CSRF mismatch on POST (Blade view)
- Inside every form in a Blade view, include the directive:
@csrf
(do not call Java helpers directly inside a view). - For file uploads, keep
method="post"
and addenctype="multipart/form-data"
alongside@csrf
. - If cookies are used for CSRF/session, ensure the domain/path/secure flags match how you access the app (HTTP vs HTTPS, domain vs subdomain).
- Inside every form in a Blade view, include the directive:
- Session not persisted
- Set an appropriate
session.mode
in.env
(consistent with how Panaragan manages session storage). - Make sure
app.base_url
matches the external host you use; otherwise cookies may not return with requests. - If served behind a reverse proxy, forward the original scheme and host so cookies/redirects are generated correctly.
- Set an appropriate
- DB connection issues
- Confirm the JDBC driver matches your database vendor and is available on classpath.
- Re-check JDBC URL/user/password formats for MySQL/PostgreSQL/SQL Server/Oracle as configured in your
.env
. - If a query fails on parameters, align placeholder counts in
QueryBuilder.where(...)
with the provided values (or use yourParamsObjectvariant you implemented). - If the database went down and came back, the pool will recover only after the DB is reachable again.
- Email not sent
- Verify SMTP host, port, username, password, and TLS setting in your configuration.
- If you maintain multiple SMTP profiles, call the intended one via its index, e.g.
Email.send(1, ...)
.
- Upload blocked or rejected
- Ensure the incoming MIME is in your whitelist for
saveIfContentTypeMatch(...)
. - If you use a reverse proxy, verify its request/body size limit permits your file size.
- Ensure the incoming MIME is in your whitelist for
- Uploaded filename works on disk but 404 in URL (spaces, Unicode)
- When generating links, encode each path segment (so
my picture.jpeg
becomesmy%picture.jpeg
). - When mapping URL → file path, decode after removing your upload slug. Example:
// Map URL path to local file final String raw = request.path(); // e.g. /uploads/users/1757460099365-my%picture.jpeg final String rel = raw.substring(UPLOAD_SLUG.length()); final String filename = java.net.URLDecoder.decode(rel, java.nio.charset.StandardCharsets.UTF_8); // then resolve securely within your uploads root
- Note: query parameter encoding andpath segmentencoding are not identical—encode/decode consistently for the use-case.
- When generating links, encode each path segment (so