08 - Capa presentación
La capa de presentación en una arquitectura por capas se encarga de gestionar la interacción del usuario con la aplicación. Su principal función es recibir las solicitudes del usuario (normalmente a través de controladores REST o MVC en el caso de aplicaciones web), procesarlas y devolver una respuesta adecuada. Aquí se implementan los controladores que definen los endpoints de la API y, opcionalmente, la lógica de validación básica de las solicitudes.
Esta capa debe ser lo más independiente posible de la lógica de negocio y la persistencia. Se comunica con la capa de dominio o de aplicación para ejecutar los casos de uso requeridos, devolviendo al usuario las respuestas en el formato adecuado (como JSON o HTML). También puede encargarse del manejo de excepciones y la validación básica de los datos de entrada.
endpoints
En el desarrollo de APIs, es una buena práctica organizar y estructurar los endpoints para que sean intuitivos y fáciles de mantener. Un cambio común es añadir un prefijo como /api a las rutas, lo que ayuda a distinguir los recursos de la API del resto de las rutas que puede tener la aplicación. En nuestro caso, pasaremos de localhost:8080/books a localhost:8080/api/books, lo que mejora la claridad al indicar que estas rutas pertenecen a la API de la aplicación. Este enfoque es útil para proyectos que combinan API y otros tipos de recursos, como páginas web o servicios estáticos, ya que permite tener una estructura más limpia y comprensible.
Además, este tipo de organización facilita el mantenimiento de la aplicación a medida que crece. Los equipos de desarrollo pueden añadir fácilmente nuevas funcionalidades sin que se mezcle la lógica de negocio con otros aspectos de la aplicación.
@RestController
@RequestMapping(BookController.URL)
@RequiredArgsConstructor
public class BookController {
public static final String URL = "/api/books";
Modelos de la capa de presentación
En la capa de presentación, a veces es conveniente utilizar modelos diferentes a los de la capa de dominio para adaptarse a las necesidades de la interfaz y la experiencia del usuario. Esto permite reducir la cantidad de información enviada en ciertas situaciones y evitar exponer detalles innecesarios.
Por ejemplo, para el método findAllBooks() del controlador, que devuelve un listado de libros, podríamos devolver sólo algunos atributos, como isbn, titleEs, titleEn, basePrice, discountPercentage, price y cover. Aquí podríamos crear un modelo de presentación simplificado que contenga solo estos campos.
Por otro lado, para el detalle de un libro (getBookByIsbn()), podemos usar un modelo completo que incluya toda la información relevante que contenga todos los atributos del libro, incluyendo sinopsis, editoriales y autores. Esto asegura que el modelo utilizado en la respuesta del controlador esté optimizado y ajustado al contexto de cada operación, manteniendo la separación de responsabilidades entre la capa de presentación y la capa de dominio.
Para el resto de modelos sería lo mismo. Por ejemplo, en el detalle de un libro podemos devolver sólo el nombre y slug de los autores, mientras que en el detalle de un autor devolveríamos todos los datos del mismo.
Vamos a crear los modelos para ver diferentes opciones. La idea es que la respuesta del listado de libros sea:
{
"data": [
{
"isbn": "9780142424179",
"titleEs": "El principito",
"titleEn": "The Little Prince",
"basePrice": 15.99,
"discountPercentage": 10.00,
"price": 14.39,
"cover": "http://images.cesguiro.es/books/9780142424179.webp"
},
{
"isbn": "9780142410363",
"titleEs": "Matilda",
"titleEn": "Matilda",
"basePrice": 14.99,
"discountPercentage": 5.00,
"price": 14.24,
"cover": "http://images.cesguiro.es/books/9780142410363.webp"
},
{
"isbn": "9780142418222",
"titleEs": "Charlie y la fábrica de chocolate",
"titleEn": "Charlie and the Chocolate Factory",
"basePrice": 13.99,
"discountPercentage": 15.00,
"price": 11.89,
"cover": "http://images.cesguiro.es/books/9780142418222.jpeg"
},
...
],
"pageNumber": 1,
"pageSize": 10,
"totalElements": 24,
"totalPages": 3
}
Por otra parte, el detalle de un libro contendrá la siguiente información:
{
"isbn": "9780060557912",
"titleEs": "Buenos presagios",
"titleEn": "Good Omens",
"synopsisEs": "Buenos presagios cuenta la historia de un ángel y un demonio que unen fuerzas para evitar el apocalipsis. Ambos han vivido en la Tierra durante siglos y se han encariñado con la humanidad, por lo que harán todo lo posible para detener el fin del mundo.",
"synopsisEn": "Good Omens tells the story of an angel and a demon who team up to prevent the apocalypse. Both have lived on Earth for centuries and have grown fond of humanity, so they will do everything possible to stop the end of the world.",
"basePrice": 16.99,
"discountPercentage": 0.00,
"price": 16.99,
"cover": "http://images.cesguiro.es/books/9780060557912.jpg",
"publicationDate": "1990-05-01",
"publisher": {
"name": "Alfaguara",
"slug": "alfaguara"
},
"authors": [
{
"name": "Terry Pratchett",
"slug": "terry-pratchett"
},
{
"name": "Neil Gaiman",
"slug": "neil-gaiman"
}
]
}
En nuestro caso, dividiremos los modelos en dos tipos: Summary y Detail, cada uno destinado a un propósito específico. Los modelos Summary se utilizarán para representar el resumen de los modelos, mientras que los modelos Detail ofrecerán información detallada sobre un recurso individual.
Para implementar esta división de manera eficiente, podemos aprovechar el tipo de datos Record introducido en Java 16. Los records nos permiten crear clases inmutables de manera concisa, ideales para representar estos modelos sin la sobrecarga de escribir código repetitivo como getters, setters o constructores.
Los modelos los crearemos en el package /controller/webModel/response para las respuestas y /controller/webModel/request para los datos de entrada (para insertar libros, actualizar autores…).
De esta forma, nuestros modelos de respuesta quedarían:
public record AuthorDetailResponse(
String name,
String nationality,
String biographyEs,
String biographyEn,
Integer birthYear,
Integer deathYear,
String slug
) {
}
public record AuthorSummaryResponse(
String name,
String slug
) {
}
public record BookDetailResponse(
String isbn,
String titleEs,
String titleEn,
String synopsisEs,
String synopsisEn,
BigDecimal basePrice,
BigDecimal discountPercentage,
BigDecimal price,
String cover,
LocalDate publicationDate,
PublisherSummaryResponse publisher,
List<AuthorSummaryResponse> authors
)
{}
public record BookSummaryResponse(
String isbn,
String titleEs,
String titleEn,
BigDecimal basePrice,
BigDecimal discountPercentage,
BigDecimal price,
String cover
) {
}
public record PublisherDetailResponse(
String name,
String slug
) {
}
public record PublisherSummaryResponse(
String name,
String slug
) {
}
Respuesta
Para utilizar los nuevos modelos en las respuestas del controlador, debemos realizar cambios en los métodos que manejan las solicitudes. Por ejemplo, cambiaremos el tipo de retorno del método findAllBooks() para que devuelva Page<BookSummaryResponse> y el método getBookByIsbn() para que devuelva un objeto BookDetailResponse:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public Page<BookSummaryResponse> findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<BookDto> bookDtoPage = bookService.getAll(page, size);
List<BookSummaryResponse> bookSummaries = bookDtoPage.data().stream()
.map(BookMapper::fromBookDtoToBookSummaryResponse)
.toList();
return new Page<>(
bookSummaries,
bookDtoPage.pageNumber(),
bookDtoPage.pageSize(),
bookDtoPage.totalElements(),
bookDtoPage.totalPages()
);
}
@GetMapping("/{isbn}")
public BookDetailResponse getBookByIsbn(@PathVariable String isbn) {
return BookMapper.fromBookDtoToBookDetailResponse(bookService.getByIsbn(isbn));
}
ResponseEntity
Para mejorar el manejo de las respuestas en el controlador, vamos a cambiar el tipo de respuesta a ResponseEntity. La clase ResponseEntity es una parte fundamental de Spring que permite representar una respuesta HTTP, incluyendo el cuerpo, los encabezados y el estado de la respuesta. Esto proporciona un mayor control sobre cómo se envían las respuestas al cliente:
@GetMapping
public ResponseEntity<Page<BookSummaryResponse>> findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<BookDto> bookDtoPage = bookService.getAll(page, size);
List<BookSummaryResponse> bookSummaries = bookDtoPage.data().stream()
.map(BookMapper::fromBookDtoToBookSummaryResponse)
.toList();
Page<BookSummaryResponse> bookSummaryPage = new Page<>(
bookSummaries,
bookDtoPage.pageNumber(),
bookDtoPage.pageSize(),
bookDtoPage.totalElements(),
bookDtoPage.totalPages()
);
return new ResponseEntity<>(bookSummaryPage, HttpStatus.OK);
}
@GetMapping("/{isbn}")
public ResponseEntity<BookDetailResponse> getBookByIsbn(@PathVariable String isbn) {
BookDetailResponse bookDetailResponse = BookMapper.fromBookDtoToBookDetailResponse(bookService.getByIsbn(isbn));
return new ResponseEntity<>(bookDetailResponse, HttpStatus.OK);
}
Usar ResponseEntity en el controlador ofrece varias ventajas que mejoran la interacción con el cliente de la API:
- Control del estado de la respuesta: Permite establecer el código de estado HTTP que se desea enviar al cliente, lo que es útil para indicar el resultado de la operación (por ejemplo, HttpStatus.OK, HttpStatus.NOT_FOUND, etc.).
- Personalización de encabezados: Puedes agregar encabezados personalizados a la respuesta, lo que puede ser útil para informar sobre el tipo de contenido, la caché, etc.
- Flexibilidad en el cuerpo de la respuesta: Permite enviar cualquier tipo de objeto como cuerpo de la respuesta, manteniendo la capacidad de enviar datos complejos sin perder la estructura de la respuesta HTTP.
Tratamiento de excepciones
Podríamos tratar las excepciones en los controladores utilizando bloques try…catch, pero esto podría resultar en un código repetitivo y difícil de mantener, especialmente en aplicaciones grandes. Afortunadamente, Spring nos ofrece una forma de centralizar el tratamiento de excepciones a través de las anotaciones @ControllerAdvice y @RestControllerAdvice.
El uso de @ControllerAdvice permite definir un único lugar para manejar todas las excepciones que pueden ocurrir en los controladores de la aplicación. Esto no solo simplifica la gestión de errores al evitar la duplicación de lógica en cada controlador, sino que también asegura que todas las respuestas de error tengan una estructura y formato consistentes, facilitando así a los consumidores de la API la comprensión y el manejo de los errores.
Sin embargo, es importante distinguir entre ambos tipos de controladores:
- @ControllerAdvice está pensado para aplicaciones MVC tradicionales que devuelven vistas (Thymeleaf, JSP…). Cuando maneja una excepción, el valor devuelto se interpreta como una vista, a menos que se anote el método con @ResponseBody.
- @RestControllerAdvice es una especialización de @ControllerAdvice que incluye automáticamente @ResponseBody. Esto significa que todas las respuestas generadas se serializan directamente a JSON (o al formato configurado), igual que ocurre con los controladores anotados con @RestController.
Por ello, en APIs REST es recomendable utilizar @RestControllerAdvice, ya que asegura que todas las respuestas de error se devuelvan correctamente serializadas sin necesidad de añadir @ResponseBody en cada método.
Además, @ControllerAdvice / @RestControllerAdvice permiten manejar diferentes tipos de excepciones de manera específica, devolviendo respuestas personalizadas según la situación, con códigos HTTP adecuados y mensajes descriptivos. Así se logra un código más limpio y mantenible, separando la lógica de manejo de errores de la lógica de negocio.
Para manejar las excepciones en la aplicación de manera efectiva, crearemos dos clases en el paquete exception: ErrorMessage y ApiExceptionHandler.
La clase ErrorMessage contiene dos propiedades: error y message. Estas propiedades se utilizan para enviar información relevante sobre el error que ocurrió.
public class ErrorMessage {
private final String error;
private final String message;
public ErrorMessage(Exception exception) {
this.error = exception.getClass().getSimpleName();
this.message = exception.getMessage();
}
public String getError() {
return error;
}
public String getMessage() {
return message;
}
}
La clase ApiExceptionHandler utiliza la anotación @RestControllerAdvice para manejar excepciones globalmente. Aquí, se maneja la excepción ResourceNotFoundException devolviendo un ErrorMessage con un código de estado 404 (NO FOUND).
Para capturar el resto de las excepciones de manera más segura y evitar exponer detalles sensibles sobre el error interno, podemos crear un método en la clase ApiExceptionHandler para que devuelva un mensaje genérico como “Internal error”. Esto garantiza que cualquier excepción no manejada específicamente también se trate de manera adecuada, proporcionando una respuesta coherente al cliente.
@RestControllerAdvice
public class ApiExceptionHandler {
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler({
ResourceNotFoundException.class
})
@ResponseBody
public ErrorMessage notFoundRequest(ResourceNotFoundException exception) {
return new ErrorMessage(exception);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
@ResponseBody
public ErrorMessage handleGeneralException(Exception exception) {
return new ErrorMessage(exception);
}
}
Inserciones, actualizaciones y borrados
Para las operaciones de creación y actualización de libros, necesitaremos modelos de entrada (request) distintos, ya que la información requerida no siempre es la misma:
- BookInsertRequest: Se utiliza al crear un nuevo libro.
- BookUpdateRequest: Se utiliza al actualizar un libro existente e incluye el identificador id.
public record BookInsertRequest(
String isbn,
String titleEs,
String titleEn,
String synopsisEs,
String synopsisEn,
String cover,
@JsonFormat(pattern = "dd-MM-yyyy")
LocalDate publicationDate,
BigDecimal basePrice,
BigDecimal discountPercentage,
Long publisherId,
Long[] authorIds
) {
}
public record BookUpdateRequest(
Long id,
String isbn,
String titleEs,
String titleEn,
String synopsisEs,
String synopsisEn,
String cover,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy")
LocalDate publicationDate,
BigDecimal basePrice,
BigDecimal discountPercentage,
Long publisherId,
Long[] authorIds
) {
}
Anotaciones JSON y formato fechas
La anotación @JsonFormat de Jackson permite definir el formato de serialización y deserialización de los campos, en este caso LocalDate.
Por defecto, Jackson 2.X no sabe cómo convertir automáticamente LocalDate a un string en el formato deseado, y puede generar errores de parseo.
Para que Spring con Jackson 2.X maneje correctamente LocalDate y otras clases de la API de tiempo (java.time.*), es necesario registrar el módulo jackson-datatype-jsr310.
En el pom.xml, añadimos:
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${com.fasterxml.jackson.version}</version>
</dependency>
Esto permite que Jackson pueda serializar y deserializar correctamente LocalDate, LocalDateTime y otras clases de la API de tiempo de Java 8+.