Documentación
JxMVC 3.5.1
Jakarta EE 10 · Java 17+ · sin dependencias de terceros. Cada ejemplo usa la API pública del core.
Instalación
Necesitas Java 17 o superior y Maven 3.8 o superior. El instalador descarga la última versión, instala jxmvc-core en tu repositorio Maven local y crea un proyecto listo para ejecutar.
curl -fsSL https://raw.githubusercontent.com/Andre031222/jxmvc/main/install.sh | sh -s -- new mi-app cd mi-app && mvn package cargo:run
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Andre031222/jxmvc/main/install.ps1))) new mi-app cd mi-app mvn package cargo:run
cargo:run descarga Tomcat 10.1 y despliega la aplicación. Para un Tomcat propio, copia el WAR de target/ a su carpeta webapps.
Desde el código fuente
git clone https://github.com/Andre031222/jxmvc.git mvn install -f jxmvc/JxMVC.Core/pom.xml
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-api</artifactId>
<version>10.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jxmvc</groupId>
<artifactId>jxmvc-core</artifactId>
<version>3.5.1</version>
</dependency>jxmvc.controllers.package = com.miapp.controllers jxmvc.services.package = com.miapp.services jxmvc.profiles.active = dev
Routing
Un controlador extiende JxController. Las rutas se resuelven por anotación, por plantilla con variables o por convención /controlador/accion/argumentos, en ese orden. Si la ruta existe pero el método no, la respuesta es 405; HEAD se atiende como GET.
@JxControllerMapping("notes")
public class NoteController extends JxController {
@JxGetMapping("/{id}")
public ActionResult show() {
long id = model.pathVarLong("id");
try (JxDB db = db()) {
return json(db.queryRow("SELECT * FROM note WHERE id = ?", id));
}
}
@JxPostMapping("/")
public ActionResult create(@JxValid @JxBody Note note) {
long id = new NoteRepository().save(note);
return redirect("/notes/" + id);
}
public ActionResult list() {
int page = model.paramInt("page", 1);
return text("page " + page);
}
}list() no tiene anotación y responde en /notes/list por convención. Los argumentos posicionales de esa ruta se leen con model.arg(0). También existen @JxPutMapping, @JxDeleteMapping, @JxPatchMapping y @JxAnyMapping, y @JxControllerMain marca el controlador de la raíz /.
@JxGetMapping("/{id}")
public ActionResult get(@JxPathVar long id, @JxParam("fields") String fields) {
return json(repo.findById(id));
}Controladores
Dentro de una acción, model es la petición y view es la respuesta.
return view("notes/list");
return text("pong");
return json(objeto);
return redirect("/notes");
return view("notes/created").status(201);
view.raw(bytes, "application/pdf", "reporte.pdf");
return null;String q = model.param("q");
String raw = model.paramRaw("q");
int page = model.paramInt("page", 1);
String id = model.pathVar("id");
String first = model.arg(0);
String auth = model.header("Authorization");
String body = model.body();
model.setVar("title", "Notas");
sessionSet("user", user);
Object user = sessionGet("user");model.param y model.arg devuelven el valor sanitizado; paramRaw y argRaw, el valor original.
Hooks y manejo de errores
@JxControllerMapping("admin")
public class AdminController extends JxController {
@JxBeforeAction(only = {"create", "update"})
public void requireAdmin() {
if (!"ADMIN".equals(sessionGet("role"))) throw JxException.forbidden("Solo administradores");
}
@JxModelAttr
public void common() {
model.setVar("user", sessionGet("user"));
}
}@JxControllerAdvice
public class GlobalAdvice {
@JxExceptionHandler(JxException.class)
public ActionResult onError(JxException ex) {
return ActionResult.json("{\"error\":\"" + ex.getMessage() + "\"}").status(ex.getStatus());
}
}Base de datos
JxDB ejecuta SQL con parámetros y devuelve filas que se leen por columna, sin reflexión. Se obtiene con db() desde un controlador o un modelo y se cierra con try.
try (JxDB db = db()) {
DBRowSet rows = db.query("SELECT id, title FROM note WHERE owner = ?", owner);
DBRow one = db.queryRow("SELECT * FROM note WHERE id = ?", id);
long newId = db.insert("note", DBRow.of("title", "Hola"));
int changed = db.exec("UPDATE note SET title = ? WHERE id = ?", "Nuevo", id);
String title = one.GetString("title");
model.setVar("notes", rows.asList());
}@JxTable("note")
public class Note {
@JxId public Long id;
@JxRequired @JxMaxLength(200) public String title;
}
public class NoteRepository extends JxRepository<Note, Long> {
public NoteRepository() { super("note", Note.class); }
}
NoteRepository repo = new NoteRepository();
Note n = repo.findById(1L);
List<Note> all = repo.findAll();
long id = repo.save(note);
repo.deleteById(1L);@JxSoftDelete convierte el borrado en lógico y @JxTransactional ejecuta una acción dentro de una transacción.
jxmvc.db.url = jdbc:postgresql://localhost:5432/mi_db jxmvc.db.user = postgres jxmvc.db.pass = secret jxmvc.pool.enabled = true jxmvc.pool.size = 10 jxmvc.pool.timeout = 5
Solo necesitas añadir el driver JDBC de tu base de datos; el pool es parte del core.
Validación
Las restricciones se declaran en los campos. Con @JxValid en el parámetro, una entrada inválida responde 400 antes de ejecutar la acción.
public class Registro {
@JxRequired @JxMinLength(3) @JxMaxLength(50) public String nombre;
@JxRequired @JxEmail public String email;
@JxPattern("[0-9]{8}") public String dni;
@JxRange(min = 18, max = 120) public int edad;
@JxFuture public LocalDate reserva;
}JxValidation.validate(registro); List<String> errores = JxValidation.check(registro); Map<String, String> porCampo = JxValidation.checkMap(registro);
Disponibles: @JxRequired, @JxNotNull, @JxNotEmpty, @JxMinLength, @JxMaxLength, @JxLength, @JxEmail, @JxPhone, @JxUrl, @JxPattern, @JxSafe, @JxDigits, @JxIn, @JxMin, @JxMax, @JxRange, @JxPositive, @JxFuture, @JxPast y @JxCheck para validadores propios que implementan JxValidation.JxConstraint.
Seguridad por defecto
Las protecciones correctas para cualquier aplicación vienen activas y se aplican en el pipeline antes del controlador. Las que dependen del despliegue son opcionales y se activan con una anotación o una propiedad.
| Activo por defecto | Cómo activarlo o ajustarlo |
|---|---|
nosniff, Referrer-Policy, X-Frame-Options y CSP mínima | jxmvc.security.csp, jxmvc.security.frame-options, jxmvc.security.hsts |
Rechazo de peticiones con Origin de otro host | @JxCors(origins = ...) por controlador o acción |
| Parámetros sanitizados, cuerpo máximo de 10 MB, JSON estricto | jxmvc.body.maxBytes; paramRaw para el valor original |
Autenticación que falla cerrada en @JxRequireAuth | JxSecurity.setProvider(...) |
Sin detalles de excepciones en producción; /jx/* ocultos | jxmvc.internal.expose |
| Redirecciones solo al mismo sitio | jxmvc.redirect.external |
| Opcional | Cómo activarlo |
|---|---|
| Límite de peticiones por acción | @JxRateLimit(requests = 5, window = 60) |
| Token de sesión contra CSRF | jxmvc.security.csrf = true; @JxCsrfExempt para excluir |
| IP real detrás de un proxy | jxmvc.trustProxy = true, solo con un proxy de confianza |
El límite de peticiones no viene activo porque detrás de un proxy no declarado todos los usuarios comparten una IP y un límite por defecto los bloquearía juntos.
Autenticación
Registra un proveedor al arrancar. Si no hay proveedor, las rutas protegidas se deniegan.
@WebListener
public class AppBootstrap implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
JxSecurity.setProvider((request, roles) -> {
HttpSession s = request.getSession(false);
AppUser user = s == null ? null : (AppUser) s.getAttribute("user");
if (user == null) return false;
return roles.length == 0 || Arrays.stream(roles).anyMatch(user::hasRole);
});
}
}@JxRequireAuth
@JxGetMapping("perfil")
public ActionResult perfil() { return view("auth/perfil"); }
@JxRequireRole({"ADMIN"})
@JxGetMapping("panel")
public ActionResult panel() { return view("admin/panel"); }Contraseñas
JxPasswords usa PBKDF2-SHA256 con sal aleatoria y verifica en tiempo constante.
String stored = JxPasswords.hash(password); boolean ok = JxPasswords.verify(input, stored); if (ok && JxPasswords.needsRehash(stored)) stored = JxPasswords.hash(input);
OpenID Connect con Google
La redirección al proveedor se emite con la cabecera Location, porque redirect() solo acepta destinos del mismo sitio. Flujo de código con PKCE y verificación RS256 del token de identidad, solo con el JDK. Las credenciales van en variables de entorno: JXMVC_OAUTH_GOOGLE_CLIENT_ID, JXMVC_OAUTH_GOOGLE_CLIENT_SECRET y JXMVC_OAUTH_GOOGLE_REDIRECT_URI.
JxOAuth.Flow flow = JxOAuth.google().start();
sessionSet("oauth.state", flow.state());
sessionSet("oauth.verifier", flow.codeVerifier());
view.status(302);
view.header("Location", flow.url());
return text("");
if (!model.param("state").equals(sessionGet("oauth.state"))) throw JxException.forbidden("state inválido");
JxOAuth.User u = JxOAuth.google().login(model.param("code"), (String) sessionGet("oauth.verifier"));
sessionSet("user", u);JSON
JxJson serializa objetos, colecciones, mapas, DBRow y fechas de java.time en formato ISO. El parser sigue RFC 8259 de forma estricta: un documento truncado o mal formado responde 400 y la profundidad máxima es 64.
String out = JxJson.toJson(pedido);
Pedido p = JxJson.fromJson(body, Pedido.class);
@JxPostMapping("pedidos")
public ActionResult crear(@JxValid @JxBody Pedido pedido) {
return json(service.guardar(pedido));
}Tareas, async y eventos
@JxService
public class Tareas {
@JxScheduled(cron = "0 3 * * *")
public void backup() { }
@JxScheduled(fixedRate = 30_000)
public void refrescar() { }
@JxScheduled(fixedDelay = 60_000, initialDelay = 5_000)
public void purgar() { }
}
JxScheduler.scheduleCron(() -> backup(), "0 2 * * *");
JxScheduler.runOnce(() -> notificar(), 5_000);@JxAsync
@JxRetry(attempts = 3, backoff = 500)
@JxPostMapping("exportar")
public ActionResult exportar() {
service.generarYEnviar();
return null;
}@JxAsync responde 202 y ejecuta la acción en el pool definido por jxmvc.async.threads; en Java 21 o superior usa hilos virtuales. @JxEventListener suscribe un método al bus de eventos síncrono JxEventBus.
Caché
@JxGetMapping("catalogo")
@JxCacheable(value = "productos", ttl = 300)
public ActionResult catalogo() {
try (JxDB db = db()) { return json(db.query("SELECT * FROM producto")); }
}
@JxPostMapping("catalogo")
@JxCacheEvict(value = "productos")
public ActionResult guardar() { return ok(); }
String data = JxCache.get("api").computeIfAbsent("clave", 120, () -> consultarApi());Las entradas expiran por tiempo y la región tiene un máximo configurable con jxmvc.cache.maxEntries.
WebSocket
Una clase con @JxWsEndpoint se registra sola al arrancar. Las salas permiten difundir mensajes a un grupo de sesiones.
@JxWsEndpoint("/ws/chat/{sala}")
public class ChatEndpoint extends JxWebSocket {
@Override
protected void onOpen(Session session, String... vars) {
String sala = vars.length > 0 ? vars[0] : "general";
joinRoom(sala, session);
broadcast(sala, session.getId() + " conectado");
}
@Override
protected void onMessage(Session session, String message) {
broadcast((String) session.getUserProperties().get("sala"), message);
}
@Override
protected void onClose(Session session) {
leaveRoom((String) session.getUserProperties().get("sala"), session);
}
}JxPdf lee el texto y los metadatos de un PDF, incluidos los cifrados con RC4 o AES, detecta las páginas escaneadas sin texto y genera documentos de texto simples. No hace OCR.
JxPdf.Doc doc = JxPdf.read(Path.of("contrato.pdf"));
int pages = doc.pageCount();
String text = doc.text();
String first = doc.page(0);
boolean scan = doc.isScanned(2);
String author = doc.info().get("Author");
JxPdf.Doc secret = JxPdf.read(bytes, "clave");
byte[] pdf = JxPdf.create("Reporte", "Contenido del reporte");
view.raw(pdf, "application/pdf", "reporte.pdf");
return null;Servicios e inyección
@JxService
public class EmailService {
public void enviar(String to, String body) { }
}
@JxControllerMapping("usuarios")
public class UsuarioController extends JxController {
@JxInject
private EmailService email;
}
EmailService svc = JxServiceRegistry.get(EmailService.class);Las clases con @JxService del paquete jxmvc.services.package se registran al arrancar como instancias únicas.
Filtros
JxFilters.add(new JxFilter() {
@Override
public boolean before(JxFilterContext ctx) {
return !ctx.path().startsWith("/privado") || ctx.request().getSession(false) != null;
}
@Override
public void after(JxFilterContext ctx) {
ctx.response().header("X-App", "mi-app");
}
});Si before devuelve false, la cadena se corta y el controlador no se ejecuta.
Pruebas
JxTest invoca acciones de un controlador con una petición simulada, sin contenedor ni librerías de pruebas.
JxTest.Response r = JxTest.invoke(NoteController.class)
.method("GET")
.pathVar("id", "1")
.run("show");
if (!r.isOk() || !r.bodyContains("title")) throw new AssertionError(r);
JxTest.mockService(EmailService.class, new FakeEmailService());Configuración
Todo vive en src/main/resources/application.properties. Los secretos deben ir en variables de entorno.
jxmvc.controllers.package = com.miapp.controllers jxmvc.services.package = com.miapp.services jxmvc.profiles.active = prod jxmvc.log.level = INFO jxmvc.db.url = jdbc:postgresql://localhost:5432/mi_db jxmvc.db.user = app jxmvc.db.pass = secret jxmvc.pool.enabled = true jxmvc.pool.size = 10 jxmvc.async.threads = 8 jxmvc.gzip.enabled = true jxmvc.body.maxBytes = 10485760 jxmvc.cache.maxEntries = 10000 jxmvc.security.frame-options = SAMEORIGIN jxmvc.security.hsts = true jxmvc.security.csp = default-src 'self' jxmvc.security.csrf = false jxmvc.trustProxy = false jxmvc.redirect.external = false jxmvc.internal.expose = false
Producción y endpoints
Con jxmvc.profiles.active = prod las respuestas de error no incluyen detalles de excepciones y los endpoints internos de diagnóstico quedan ocultos.
| Endpoint | Contenido | En producción |
|---|---|---|
/jx/health | Estado del servicio | Siempre disponible |
/jx/info | Versión, perfil, Java y servidor | Solo con jxmvc.internal.expose |
/jx/metrics | Peticiones, errores y latencia por ruta | Solo con jxmvc.internal.expose |
/jx/openapi | Especificación OpenAPI 3.0 generada | Solo con jxmvc.internal.expose |
Detrás de un proxy inverso con HTTPS, activa jxmvc.security.hsts y, si el proxy es de confianza, jxmvc.trustProxy para obtener la IP real del cliente.