Antes de ver más temas, voy a terminar de implementar el CRUD de libros que empecé en estos últimos capítulos, agregando el resto de operaciones típicas de cualquier CRUD. Un CRUD bien definido típicamente tendrá los siguientes endpoints:
- Listar (GET): traerse de forma masiva una página de resultados, aplicando si procede algún filtro.
- Insertar (POST): dado un cuerpo de petición que se corresponda con lo que queremos insertar, guardarlo en el sistema.
- Recuperar (GET): dado un identificador de un registro, traérselo del sistema y mostrar sus datos.
- Actualizar (PUT o PATCH): dado un identificador de un registro y la información actualizada del mismo como cuerpo de petición, cambiar todos o parte de los datos de la entidad con el identificador por los que se correspondan con los dados en el cuerpo.
- Borrar (DELETE): dado un identificador de un registro, eliminarlo del sistema si existía previamente.
Ya he enseñado varias formas de hacer un listado y una inserción de registros nuevos, pero me voy a centrar en los endpoints que quedan por hacer.
Recuperar un registro por su identificador
Suele ser habitual tener una operación que recupere los datos de un registro dado su identificador. Lo normal es especificar el identificador en la URL, como PathParam, por ejemplo, /books/4156 para traerse los datos del Book cuyo ID en base de datos sea el 4156.
Mediante la operación findById que forma parte de un PanacheRepository o de un PanacheEntity, podemos traernos de base de datos el registro cuyo ID en la tabla se corresponda con el dado como parámetro. Si no existe, este método devuelve null, por lo que lo suyo sería tal vez tratar cualquier posible error.
@GET
@Path("{id}")
public Response retrieveBook(@PathParam("id") Long id) {
Book book = booksRepository.findById(id);
if (book != null) {
return Response.ok(book).build();
}
return Response.status(404).entity("Not found").build();
}
En este caso estoy usando el patrón Repository otra vez, pero si fuese un Active Record, podría recuperar el libro como Book.findById(id). Otra opción sería lanzar una excepción y tratarla mediante un ExceptionMapper, tal como describí en la lección sobre tratamiento de excepciones en Quarkus.
Eliminación de registros
Para eliminar registros, podemos usar el método deleteById() de nuestro PanacheRepository o de nuestra PanacheEntity. Si bien existen más métodos que comienzan por delete, esos suelen servir para borrar todos los registros que cumplan una condición, o simplemente todos los registros (lo que se conoce como un borrado masivo).
deleteById() es una operación que eliminará el registro que tenga el ID proporcionado. Además, devuelve un valor lógico que será verdadero cuando se haya borrado el registro con ese ID. Si el ID proporcionado no se corresponde con ningún registro del sistema, entonces devolverá un valor falso para indicar que no se borró nada porque no hizo falta.
Por supuesto, para hacer esto tendríamos que usar el verbo HTTP DELETE, que en REST va asociado con peticiones HTTP que tengan que ver con el borrado de datos a nivel semántico. Existe una anotación para esto en Jakarta REST, que será la @DELETE. De hecho, para lo que queda, es importante aclarar que también existe una anotación @PUT, una @PATCH y hasta una @OPTIONS.
La operación de borrado podría ser así, por lo tanto:
@DELETE
@Path("{id}")
@Transactional
public Response deleteBook(@PathParam("id") Long id) {
if (booksRepository.deleteById(id)) {
return Response.ok("Objeto borrado correctamente").build();
} else {
return Response.status(400).entity("No hay que borrar nada").build();
}
}
Algunas personas sostienen que como DELETE es un verbo HTTP idempotente, debe ser posible llamarlo múltiples veces con el mismo identificador sin percibir errores. Es decir, DELETE no debería comunicar en la respuesta si se ha borrado de verdad o no, y siempre debería devolver éxito si al menos se intentó. (Debería fallar si, por ejemplo, no puede enviarse el comando DELETE FROM a la base de datos, por ponerlo en contexto). En ese caso, podemos simplificar el método haciendo que no devuelva nada, algo que Quarkus traduce por un HTTP 204, No Content:
@DELETE
@Path("{id}")
@Transactional
public void deleteBook(@PathParam("id") Long id) {
booksRepository.deleteById(id);
}
Actualización de datos
La actualización de datos es el método más largo. Primero hay que traerse el registro que vamos a modificar, y luego aplicarle las modificaciones que están entrando en el cuerpo de la petición. Si estamos recibiendo toda una instancia de Book, por ejemplo, es importante tener en cuenta que el libro que entra es falso y que solo nos importa por su contenido, pero que tenemos que aplicar las modificaciones sobre el objeto de la base de datos y persistir ese.
Cuando llamamos a persist() pasándole una instancia de una entidad que previamente hemos recuperado con un repositorio, entonces estamos intentando persistir una entidad administrada. Esto es porque Hibernate, a la que nos devuelve de base de datos una entidad con los datos de la misma, también le agrega unos metadatos internos especiales que sirven para indicar que ese registro procede del propio ORM. Si llamamos a persist con una entidad administrada, lo tratará como un update, porque se da por hecho que esa entidad ya existía y que por lo tanto no queremos duplicarla. No obstante, el cambio de datos hay que hacerlo a mano y siempre que exista la entidad en primer lugar.
Sobre la distinción entre PUT y PATCH, decir que PUT se suele usar cuando queremos actualizar todo el registro a la vez. PUT en ese sentido es como sustituir todo un recurso por otro. En cambio, PATCH se usa cuando queremos permitir actualizaciones parciales, como cambiar solamente el título de un libro. Sin embargo, pese a que parezcan equivalentes, en realidad la semántica de PATCH es más complicada, sobre todo en aquellos casos en los que es más difícil distinguir entre cuando un campo es nulo porque no queremos actualizar su valor y cuando es nulo porque queremos borrar su valor.
En este caso, lo voy a implementar como un PUT:
@PUT
@Path("{id}")
@Transactional
public Book updateBook(@PathParam("id") Long id, Book bookData) {
Book existingBook = booksRepository.findById(id);
if (existingBook == null) {
throw new NoSuchElementException("Este libro no existe");
}
existingBook.setTitle(bookData.getTitle());
existingBook.setDescription(bookData.getDescription());
existingBook.setPubDate(bookData.getPubDate());
existingBook.setNumPages(bookData.getNumPages());
// El libro que persistimos es el que nos entregó el ORM.
booksRepository.persist(existingBook);
return existingBook;
}
Sí, es necesario abandonar en caso de que la entidad recuperada del sistema sea nula, porque eso significaría que se ha intentado actualizar un registro no encontrado. En cuanto al proceso de actualización, se puede hacer directamente sobre el controlador, o se puede usar un método de un servicio o del modelo para copiar los datos del libro que viene en el payload de la petición al libro que se ha traído de base de datos. Por último, es importante recordar que el libro que hay que persistir es el que ya existía en primer lugar, pero nunca el nuevo (o lo trataría como una inserción de un libro nuevo).
El código se puede ver completo en el siguiente enlace: https://gist.github.com/danirod/f4ed1c8370b7a7524a9315dcb9ac7177