Jx MVC JxMVC

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.

macOS y Linux
curl -fsSL https://raw.githubusercontent.com/Andre031222/jxmvc/main/install.sh | sh -s -- new mi-app
cd mi-app && mvn package cargo:run
Windows · PowerShell
& ([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

Terminal
git clone https://github.com/Andre031222/jxmvc.git
mvn install -f jxmvc/JxMVC.Core/pom.xml
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>
src/main/resources/application.properties
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.

NoteController.java
@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 /.

Parámetros en la firma
@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.

Respuestas
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;
Lectura de la petición
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

Hooks del controlador
@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"));
    }
}
Errores globales
@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.

Consultas
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());
}
Repositorio genérico
@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.

application.properties
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.

Restricciones
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;
}
Validación manual
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 defectoCómo activarlo o ajustarlo
nosniff, Referrer-Policy, X-Frame-Options y CSP mínimajxmvc.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 estrictojxmvc.body.maxBytes; paramRaw para el valor original
Autenticación que falla cerrada en @JxRequireAuthJxSecurity.setProvider(...)
Sin detalles de excepciones en producción; /jx/* ocultosjxmvc.internal.expose
Redirecciones solo al mismo sitiojxmvc.redirect.external
OpcionalCómo activarlo
Límite de peticiones por acción@JxRateLimit(requests = 5, window = 60)
Token de sesión contra CSRFjxmvc.security.csrf = true; @JxCsrfExempt para excluir
IP real detrás de un proxyjxmvc.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.

AppBootstrap.java
@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);
        });
    }
}
Rutas protegidas
@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.

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

Login y callback
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.

JxJson
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

Programación
@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);
Async y reintentos
@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é

Caché declarativa
@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.

ChatEndpoint.java
@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);
    }
}

PDF

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

Servicios
@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

Filtro global
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.

NoteControllerTest.java
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.

application.properties
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.

EndpointContenidoEn producción
/jx/healthEstado del servicioSiempre disponible
/jx/infoVersión, perfil, Java y servidorSolo con jxmvc.internal.expose
/jx/metricsPeticiones, errores y latencia por rutaSolo con jxmvc.internal.expose
/jx/openapiEspecificación OpenAPI 3.0 generadaSolo 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.