Guía
Todo lo que hace falta para escribir una aplicación: rutas, parámetros, respuestas, inyección, validación y errores.
Rutas
@Route sobre la clase fija la base; los verbos sobre los métodos añaden el
resto. Una regla, sin excepciones: un valor vacío mapea a la ruta base.
| Anotación | Ruta resultante | Nota |
|---|---|---|
| @Route("/api/tareas") | /api/tareas | Base de la clase |
| @Get | /api/tareas | Valor vacío = la base |
| @Get("/{id}") | /api/tareas/{id} | Variable de ruta |
| @Get("/{id}/notas/{n}") | …/{id}/notas/{n} | Varias variables |
| @Get("/archivos/*") | /api/tareas/archivos/* | Comodín, solo al final |
| @Post @Put @Patch @Delete | — | Mismo criterio |
- Lo literal gana a lo variable.
/tareas/nuevase resuelve antes que/tareas/{id}, sin importar el orden en que se declaren. - Sin
@Route, la base sale del nombre de la clase:ReporteController→/reporte. - Las rutas duplicadas fallan al arrancar, no en la primera petición.
- 405 con
Allow. Si la ruta existe pero el verbo no, la respuesta enumera los verbos válidos. - HEAD se resuelve contra GET automáticamente, sin cuerpo.
Rutas sueltas, sin controlador
.routes(r -> r
.get("/salud", ctx -> "ok")
.get("/version", ctx -> Map.of("version", "0.2.0"))
.post("/eco", ctx -> ctx.bodyText()))
Parámetros
Los argumentos del método se resuelven por anotación o por tipo. La conversión a
int, long, boolean, enumeraciones o fechas es
automática; si el valor no convierte, la respuesta es un 400.
| Anotación | De dónde sale | Ejemplo |
|---|---|---|
| @Path("id") | Variable de la ruta | @Path("id") long id |
| @Query("q") | Cadena de consulta | @Query(value="p", orElse="1") int p |
| @Form("titulo") | Formulario HTML | @Form("titulo") String t |
| @Body | Cuerpo JSON | @Body Tarea tarea |
| @Body @Valid | JSON + validación | responde 422 si falla |
| @Header("X-Traza") | Cabecera | @Header("X-Traza") String t |
| @CookieValue("sesion") | Cookie | @CookieValue("s") String s |
Por tipo, sin anotación
| Tipo | Qué recibe |
|---|---|
| Context | Todo el contexto de la petición |
| Request / Response | Los objetos crudos de lux-http |
| Session | La sesión, creándola si no existe |
| Principal | Quien haya autenticado el Authenticator |
| Part | Un archivo del multipart, por nombre del parámetro |
El contexto, si lo prefieres a mano
ctx.pathVariable("id") ctx.query("q", "por-defecto") ctx.queryAll("tag") ctx.form("titulo") ctx.header("Accept") ctx.cookie("sesion") ctx.body(Tarea.class) ctx.bodyText() ctx.bodyBytes() ctx.part("archivo") ctx.field("nota") ctx.parts() ctx.session() ctx.principal() ctx.authenticated() ctx.attribute("clave", valor) ctx.route()
Respuestas
Lo que devuelve una acción decide el formato, sin anotaciones:
| Devuelves | Sale | Estado |
|---|---|---|
| String | text/plain | 200 |
| null | sin cuerpo | 204 |
| cualquier objeto | application/json | 200 |
| Result | lo que declare | el que fijes |
Result.text("hola") Result.html("<h1>hola</h1>") Result.json(objeto) Result.raw("{\"ya\":\"serializado\"}") Result.view("lista.html", modelo) Result.redirect("/") Result.created(objeto) Result.noContent() Result.status(404, "no existe") // encadenable return Result.json(tarea) .status(201) .header("Location", "/api/tareas/" + tarea.id());
Inyección
Marca un servicio con @Service y se construye la primera vez que alguien lo
pide. Los controladores lo reciben con @Inject, por campo o por constructor.
@Service public class Catalogo { @Inject Repositorio repositorio; // por campo } @Service public class Facturas { private final Catalogo catalogo; @Inject Facturas(Catalogo catalogo) { // por constructor this.catalogo = catalogo; } } // o a mano, útil para sustituir en pruebas Lux.app().service(Reloj.class, () -> Instant.now())
- Los servicios son singleton: una instancia por aplicación.
- Se registran también por sus interfaces, así que puedes pedir el contrato.
- Las dependencias circulares se detectan y el error nombra la cadena completa.
Validación
Las anotaciones van sobre los componentes de un record o los campos de una
clase. @Valid junto a @Body valida antes de entrar al método.
| Anotación | Comprueba | Mensaje por defecto |
|---|---|---|
| @Required | No nulo, no vacío, no en blanco | es obligatorio |
| @Length(min, max) | Longitud del texto | debe tener entre N y M caracteres |
| @Range(min, max) | Rango numérico | debe estar entre N y M |
| Formato de correo | no es un correo válido | |
| @Match("regex") | Expresión regular | no tiene el formato esperado |
| @OneOf({"a","b"}) | Valor de una lista | debe ser uno de: a, b |
| @Satisfies(Regla.class) | Tu propia regla | el de la regla |
public record Tarea( @Id long id, @Required @Length(min = 3, max = 120) String titulo, @OneOf({"baja", "media", "alta"}) String prioridad) {} // una regla propia public class Ruc implements Rule<String> { public boolean test(String v) { return v.length() == 11 && v.startsWith("20"); } public String message() { return "no es un RUC válido"; } }
Cuando algo falla, la respuesta es un 422 con el mapa de campos, sin escribirlo:
{
"error": "Unprocessable Content",
"message": "hay 2 campo(s) inválido(s)",
"path": "/api/tareas",
"fields": {
"titulo": "debe tener entre 3 y 120 caracteres",
"prioridad": "debe ser uno de: baja, media, alta"
}
}
Fuera de una ruta, la misma comprobación a mano:
Validation.check(tarea); // lanza ValidationException (422) Validation.valid(tarea); // true / false Map<String,String> p = Validation.problems(tarea);
Errores
Lanza HttpException con el estado que quieras, o registra un manejador con
@OnError para una excepción de tu dominio.
@Get("/{id}") public Object ver(@Path("id") long id) { Tarea t = tareas.findById(id); if (t == null) throw new HttpException(404, "no existe la tarea " + id); return t; } @OnError(SaldoInsuficiente.class) public Object saldo(SaldoInsuficiente fallo) { return Result.json(Map.of("motivo", fallo.getMessage())).status(422); }
Cualquier excepción que no sea HttpException devuelve un 500 con el
mensaje genérico «error interno». La traza va al ErrorReporter, no a la
respuesta.