En esta sección, abordaremos aspectos esenciales como la gestión del idioma, la paginación de los datos y la implementación de roles de usuario. Comenzaremos con el manejo del idioma, que es crucial para ofrecer una experiencia de usuario adaptada a las preferencias lingüísticas de cada usuario.
Crearemos una configuración que permita que nuestra aplicación soporte múltiples idiomas de manera dinámica, utilizando la cabecera Accept-Language de las solicitudes HTTP. Esto nos permitirá ofrecer contenido en el idioma que el usuario prefiera, mejorando así la accesibilidad y la usabilidad de nuestra aplicación.
A través de la implementación de interceptores y un gestor de idiomas, garantizaremos que el idioma seleccionado por el usuario se aplique en todas las partes de la aplicación. Esto nos permitirá, por ejemplo, traducir títulos, mensajes de error y otros elementos visuales según el idioma seleccionado, brindando así una experiencia más personalizada y atractiva.
Para almacenar el idioma, usaremos la clase Locale de Java. La clase Locale representa un conjunto de configuraciones específicas de un idioma y una región. Se utiliza para identificar la configuración regional de la aplicación, lo que es crucial para la internacionalización. Un objeto Locale puede contener información sobre el idioma (como “es” para español o “en” para inglés), el país (como “ES” para España o “US” para Estados Unidos) y, en algunos casos, la variante específica de un idioma. Esto permite a la aplicación adaptar el contenido, formato de fechas y números, y otros aspectos a las preferencias del usuario.
Lo primero será crear el package locale en common, donde meteremos todas nuestras clases.
Primero, crearemos la clase LanguageUtils que contendrá dos métodos: setCurrentLocale(), para establecer el Locale proporcionado, y getCurrentLanguage(), que devolverá el idioma correspondiente o el idioma por defecto si no se ha establecido ninguno.
La clase utiliza un objeto ThreadLocal para almacenar el Locale actual, asegurando que cada hilo de ejecución tenga su propio valor.
La clase ThreadLocal en Java proporciona una forma de almacenar datos de manera que cada hilo tenga su propia copia de la información. Esto es útil en aplicaciones multihilo, ya que evita que los hilos compartan el mismo estado y, por lo tanto, minimiza los problemas de concurrencia. Cada vez que un hilo accede a un objeto ThreadLocal, obtiene un valor específico para ese hilo, lo que permite un manejo seguro de datos sin necesidad de sincronización explícita.
public class LanguageUtils { private static final ThreadLocal<Locale> currentLocale = new ThreadLocal<>(); public static void setCurrentLocale(Locale locale) { currentLocale.set(locale); } public static String getCurrentLanguage() { Locale locale = currentLocale.get(); return locale != null ? locale.getLanguage() : Locale.getDefault().getLanguage(); } }
Los interceptores en Java son componentes que permiten interceptar y modificar el comportamiento de las solicitudes y respuestas en una aplicación web. Actúan como filtros que pueden realizar diversas acciones antes y después de que se procese una solicitud, lo que permite implementar funcionalidades como la autenticación, la autorización, el registro y la gestión de idiomas.
En el contexto de Spring, los interceptores son utilizados principalmente para ejecutar lógica de pre-procesamiento y post-procesamiento en las peticiones HTTP. Esto es útil para tareas como validar permisos de acceso, registrar información sobre la solicitud o, en nuestro caso, gestionar el idioma de la aplicación según las preferencias del usuario.
Para adaptar nuestra aplicación a diferentes idiomas, hemos creado un interceptor personalizado que extiende LocaleChangeInterceptor. Este interceptor se encargará de leer la cabecera Accept-Language de las solicitudes entrantes y establecer el Locale actual utilizando la clase LanguageUtils.
public class CustomLocaleChangeInterceptor extends LocaleChangeInterceptor { private final String defaultLanguage; public CustomLocaleChangeInterceptor(String defaultLanguage) { this.defaultLanguage = defaultLanguage; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException { String lang = request.getHeader("Accept-Language"); Locale locale = lang != null ? Locale.forLanguageTag(lang) : Locale.of(defaultLanguage); LanguageUtils.setCurrentLocale(locale); return super.preHandle(request, response, handler); } }
La clase tendrá la propiedad defaultLanguage, la cual inyectaremos en el contructor.
El método preHandle será el encargado de ejecutar las acciones correspondientes cuando se lance el filtro.
La interfaz HttpServletRequest es parte de la API de servlets de Java y representa la solicitud HTTP que un cliente envía a un servidor. Proporciona métodos para acceder a información sobre la solicitud, como los parámetros, encabezados y atributos. En el contexto del interceptor, se utiliza para obtener la cabecera Accept-Language que especifica el idioma preferido por el cliente.
La interfaz HttpServletResponse también forma parte de la API de servlets y representa la respuesta HTTP que un servidor envía de vuelta al cliente. Permite al desarrollador modificar la respuesta, como establecer códigos de estado, agregar encabezados y enviar datos al cliente. En el interceptor, aunque no se modifica directamente, se pasa como argumento para permitir que se interactúe con la respuesta si es necesario.
El parámetro handler es un objeto que representa el controlador que procesará la solicitud. En el contexto de Spring, puede ser un controlador (un método en un controlador anotado con @RequestMapping) u otros componentes que manejan la lógica de la aplicación. Este parámetro se utiliza para determinar qué acción tomar después de que se complete el procesamiento del interceptor.
El método preHandle se ejecuta antes de que la solicitud sea procesada por el controlador. En este método, primero se obtiene el valor de la cabecera Accept-Language de la solicitud. Si se encuentra un valor, se convierte a un objeto Locale utilizando el método forLanguageTag. En caso de que no haya un valor, se utiliza el idioma por defecto.
El método Locale.forLanguageTag(String languageTag) es un método estático de la clase Locale que convierte un identificador de idioma (tag) en un objeto Locale. Este método permite crear fácilmente instancias de Locale utilizando una representación estándar de idioma, como “es-ES” para español de España o “en-US” para inglés de Estados Unidos.
Luego, se establece el Locale actual en el contexto de hilo a través de la clase LanguageUtils, asegurando que el idioma seleccionado se utilice a lo largo de la aplicación.
Finalmente, se llama al método preHandle de la clase padre para continuar con el procesamiento normal de la solicitud. Esto es importante porque la clase padre puede contener lógica que también necesita ejecutarse para garantizar que el interceptor funcione correctamente. Al llamar a este método, se permite que se ejecute la lógica base de la clase padre antes de continuar con el procesamiento de la solicitud.
La clase LocaleConfig es una clase de configuración que se encargará de definir cómo se gestiona el idioma en la aplicación. Esta clase está anotada con @Configuration, lo que indica que contiene definiciones de beans que se utilizarán en el contexto de Spring.
Para definir el idioma por defecto, vamos a utilizar el archivo application-properties, donde crearemos una propiedad con el valor:
app.language.default=es
Spring, nos permite inyectar los valores de las propiedades del fichero en clases que pueda manejar (que sean beans). Al anotar la clase con @Configuration, ésta se convierte en un bean de Spring, con lo que podemos acceder a dichas propiedades.
Para ello, usaremos la anotación de Spring @Value, encerrando entre “${}“ el nombre de la propiedad.
@Configuration public class LocaleConfig implements WebMvcConfigurer { @Value("${app.language.default}") private String defaultLanguage; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new CustomLocaleChangeInterceptor(defaultLanguage)); } }
El método addInterceptors() es una implementación de la interfaz WebMvcConfigurer y se utiliza para registrar interceptores adicionales en el contexto de Spring. En este caso, se añade nuestro interceptor personalizado CustomLocaleChangeInterceptor. Al agregar este interceptor, se asegura que cada vez que se procese una solicitud, se ejecutará la lógica de nuestro interceptor personalizado antes de que llegue a los controladores, permitiendo así que la aplicación maneje correctamente el idioma según las preferencias del usuario.
Ahora, ya podemos saber el idioma del usuario en nuestros mapeadores:
@RequiredArgsConstructor public class CategoryRowMapper implements CustomRowMapper<Category> { @Override public Category mapRow(ResultSet resultSet, int rowNum) throws SQLException { String language = LanguageUtils.getCurrentLanguage(); Category category = new Category(); category.setName(resultSet.getString("categories.name_" + language)); category.setSlug(resultSet.getString("categories.slug")); return category; } }
Si el usuario no proporciona la cabecera Accept-Language o envía un valor como es-*, la respuesta será:
{ "isbn": "9780142424179", "title": "El principito", "price": 15.99, "discount": 10.0, "cover": "http://images.cesguiro.es/books/9780142424179.webp" }
Por otro lado, si el usuario incluye la cabecera Accept-Language con el valor en-GB, la respuesta cambiará a:
{ "isbn": "9780142424179", "title": "The Little Prince", "price": 15.99, "discount": 10.0, "cover": "http://images.cesguiro.es/books/9780142424179.webp" }
La paginación es un aspecto esencial en la gestión de grandes volúmenes de datos, ya que permite dividir los resultados en varias páginas, mejorando la usabilidad y el rendimiento de la aplicación. Para implementar la paginación en nuestra aplicación, crearemos la clase PaginatedResponse en el package webmodel, que se encargará de encapsular la respuesta paginada.
@Data @AllArgsConstructor public class PaginatedResponse<T> { private List<T> data; private int total; private int currentPage; private int pageSize; private String next; private String previous; public PaginatedResponse(List<T> data, int total, int currentPage, int pageSize, String baseUrl) { this.data = data; this.total = total; this.currentPage = currentPage; this.pageSize = pageSize; this.next = createNextLink(baseUrl); this.previous = createPreviousLink(baseUrl); } private String createNextLink(String baseUrl) { if(currentPage * pageSize < total) { return baseUrl + "?page=" + (currentPage + 1) + "&size=" + pageSize; } return null; } private String createPreviousLink(String baseUrl) { if(currentPage > 1) { return baseUrl + "?page=" + (currentPage - 1) + "&size=" + pageSize; } return null; } }
Igual que con el idioma por defecto, para facilitar el proceso crearemos un par de propiedades en application.properties, la url base de nuestra apliación, y el número de recursos por página por defecto:
app.base.url=http://localhost:8080 app.pageSize.default=10
A continuación, inyectaremos esos valores en nuestros controladores y modificaremos el método getAll() en el controlador de libros (BookController) para utilizar la paginación:
@RestController @RequiredArgsConstructor @RequestMapping(BookController.URL) public class BookController { public static final String URL = "/api/books"; @Value("${app.base.url}") private String baseUrl; @Value("${app.pageSize.default}") private String defaultPageSize; private final BookService bookService; @GetMapping public ResponseEntity<PaginatedResponse<BookCollection>> getAll( @RequestParam(defaultValue = "1") int page, @RequestParam(required = false) Integer size) { int pageSize = (size != null) ? size : Integer.parseInt(defaultPageSize); List<BookCollection> books = bookService .getAll(page - 1, pageSize) .stream() .map(BookMapper.INSTANCE::toBookCollection) .toList(); int total = bookService.count(); PaginatedResponse<BookCollection> response = new PaginatedResponse<>(books, total, page, pageSize, baseUrl + URL); return new ResponseEntity<>(response, HttpStatus.OK); }
Para obtener el total de registros, utilizaremos un método en el servicio que contará la cantidad de libros disponibles en la base de datos. Esto nos permitirá proporcionar información sobre cuántos elementos hay en total, lo que es crucial para la paginación. De esta manera, podemos calcular si hay una página siguiente o anterior y gestionar la navegación entre las diferentes páginas de resultados.
La anotación @RequestParam se utiliza para extraer parámetros de consulta de la URL de la solicitud HTTP. En este caso, se utilizará para recibir los valores de page y size desde la petición. Page tendrá un valor predeterminado de 1, por si el usuario no especifica este parámetro. El parámetro size, lo hacemos no obligatorio (con required = false) y miramos si nos lo han pasado. Si no, leemos el valor por defecto.
En la clase BookService, agregaremos dos métodos para manejar la lógica de negocio:
List<Book> getAll(int page, int size); int count();
La implementación de estos métodos será:
@Override public List<Book> getAll(int page, int size) { return bookRepository.getAll(page, size); } @Override public int count() { return bookRepository.count(); }
En la interfaz BookRepository, también definiremos los métodos necesarios para interactuar con la base de datos:
List<Book> getAll(int page, int size); int count();
La implementación en la clase que maneja la persistencia será:
@Override public List<Book> getAll(int page, int size) { String sql = """ SELECT * FROM books LIMIT ? OFFSET ? """; return jdbcTemplate.query(sql, new BookRowMapper(), size, page * size); } @Override public int count() { String sql = """ SELECT COUNT(*) FROM books """; return jdbcTemplate.queryForObject(sql, Integer.class); }
Con esta implementación, la aplicación ahora puede manejar la paginación de los resultados de los libros, lo que proporciona una experiencia de usuario más eficiente.
En nuestra aplicación, es fundamental gestionar de manera efectiva la información presentada a diferentes tipos de usuarios. Mientras que los usuarios estándar necesitan un acceso simplificado y centrado en la experiencia de lectura, los administradores requieren acceso a información más detallada para poder gestionar los libros adecuadamente. Esto genera una problemática: ¿cómo proporcionar la información necesaria sin sobrecargar al usuario?
Por ejemplo, cuando un usuario solicita los detalles de un libro, la respuesta contiene el título en el idioma solicitado:
{ "isbn": "9780142424179", "title": "El principito", "price": 15.99, "discount": 10.0, "cover": "http://images.cesguiro.es/books/9780142424179.webp" ... }
Sin embargo, los administradores necesitan editar libros y, para eso, requieren acceso a campos como title_es y title_en de la bbdd, así como información adicional sobre los autores y las categorías. La solución es separar la lógica del usuario de la del administrador, organizando nuestra aplicación en paquetes específicos. Así, cuando un administrador solicita los detalles de un libro, la respuesta incluye un JSON más completo, como el siguiente:
{ "id": 1, "isbn": "9780142424179", "titleEs": "El principito", "titleEn": "The Little Prince", "synopsisEs": "El principito es una novela corta y la obra más famosa del escritor y ...", "synopsisEn": "The Little Prince is a novella and the most famous work of the French ..." ...
Para manejar la diferenciación entre usuarios y administradores en nuestra aplicación, implementamos una clara separación de componentes por roles. Esta separación se traduce en la creación de paquetes específicos dentro de cada capa de nuestra arquitectura: user y admin. Esta estructura no solo ayuda a mantener el código organizado, sino que también permite escalar y mantener la aplicación de manera más efectiva.
Para implementar la separación de funcionalidades por roles, comenzaremos por renombrar todos nuestros controladores, servicios y repositorios. Por ejemplo, el controlador de libros se convertirá en BookUserController, el servicio en BookUserService y el repositorio en BookRepository. Esta convención de nombres nos ayudará a identificar claramente qué componentes están destinados a la lógica de usuario.
Además, organizaremos todos nuestros componentes relacionados en el paquete user. Esta estructura facilitará la navegación y mantenimiento del código, permitiendo una gestión más eficiente de los roles y sus respectivas funcionalidades dentro de la aplicación.
Lo primero será crear los modelos de dominio de administrador, incluyendo todos los campos con los idiomas:
public class Author { private long id; private String name; private String nationality; private String biographyEs; private String biographyEn; private int birthYear; private int deathYear; }
public class Category { private long id; private String nameEs; private String nameEn; private String slug; }
public class Genre { private long id; private String nameEs; private String nameEn; private String slug; }
public class Publisher { private long id; private String name; private String slug; }
public class Book { private long id; private String isbn; private String titleEs; private String titleEn; private String synopsisEs; private String synopsisEn; private BigDecimal price; private float discount; private String cover; private Publisher publisher; private Category category; private List<Genre> genres; private List<Author> authors; }
En el modelo Book del administrador, añadiremos el siguiente método para gestionar la visualización del título del libro según el idioma elegido por el administrador:
public String getTitle() { String language = LanguageUtils.getCurrentLanguage(); if ("en".equals(language)) { return titleEn; } return titleEs; }
Este método permitirá que, al mostrar los detalles del libro, se devuelva el título en el idioma seleccionado. Así, cuando el administrador consulte el listado de libros, los datos se presentarán en el siguiente formato:
"data": [ { "isbn": "9780142424179", "title": "El principito" }, { "isbn": "9780142410363", "title": "Matilda" }, { "isbn": "9780142418222", "title": "Charlie y la fábrica de chocolate" }, { ...
A continuación, crearemos el modelo BookCollection en la capa de presentación para el administrador. Este modelo representará los datos simplificados que se mostrarán en el listado de libros. La definición del modelo será la siguiente:
public record BookCollection( String isbn, String title ) { }
Este modelo se ubicará en el package admin, permitiendo que se utilice específicamente para las funcionalidades del administrador en la gestión de libros. De esta manera, garantizamos una separación clara de las responsabilidades y facilitamos el acceso a la información necesaria para la administración de la biblioteca.
Para facilitar la gestión de respuestas paginadas y asegurar la reutilización de componentes entre los controladores de usuario y administrador, crearemos un paquete common dentro de la capa de controlador. En este paquete, colocaremos nuestra clase PaginatedResponse, que se encargará de estructurar las respuestas paginadas de manera consistente.
Lo siguiente será crear el mapeador BookMapper. Este mapeador se encargará de convertir los objetos del modelo de dominio Book al modelo de presentación BookCollection.
@Mapper public interface BookMapper { BookMapper INSTANCE = Mappers.getMapper(BookMapper.class); @Mapping(target = "title", source = "book.title") BookCollection toBookCollection(Book book); }
En este mapeador, utilizamos el método getTitle() que hemos definido previamente en la clase Book. Este método se encarga de devolver el título del libro en el idioma correspondiente, según la preferencia del administrador. De esta forma, aseguramos que el modelo de presentación refleje correctamente la información localizada que el administrador necesita para gestionar los libros.
A continuación, procederemos a crear los servicios del administrador. Estos servicios serán prácticamente idénticos a los servicios del usuario, con la diferencia de que llamarán a los repositorios del administrador para obtener y gestionar los datos necesarios. La implementación de estos servicios asegurará que la lógica de negocio para el administrador esté claramente separada de la del usuario:
@Service @RequiredArgsConstructor public class BookAdminServiceImpl implements BookAdminService { private final BookAdminRepository bookAdminRepository; @Override public List<Book> getAll() { return bookAdminRepository.getAll(); } @Override public List<Book> getAll(int page, int size) { return bookAdminRepository.getAll(page, size); } @Override public int count() { return bookAdminRepository.count(); } @Override public Book findByIsbn(String isbn) { return bookAdminRepository.findByIsbn(isbn).orElseThrow(() -> new ResourceNotFoundException("Book isbn " + isbn + " not found")); } }
En la capa de persistencia, también crearemos un paquete common donde ubicaremos nuestro CustomRowMapper, que servirá para reutilizar la lógica de mapeo entre los resultados de las consultas y los modelos de dominio en ambos controladores, de usuario y administrador.
Los mapeadores del administrador contendrán toda la información de la base de datos. Por ejemplo, el mapeador de Book se definirá de la siguiente manera:
@Override public Book mapRow(ResultSet resultSet, int rowNum) throws SQLException { Book book = new Book(); book.setId(resultSet.getLong("id")); book.setIsbn(resultSet.getString("isbn")); book.setTitleEs(resultSet.getString("title_es")); book.setTitleEn(resultSet.getString("title_en")); book.setSynopsisEs(resultSet.getString("synopsis_es")); book.setSynopsisEn(resultSet.getString("synopsis_en")); book.setPrice(new BigDecimal(resultSet.getString("books.price"))); book.setDiscount(resultSet.getFloat("books.discount")); book.setCover(resultSet.getString("books.cover")); if(this.existsColumn(resultSet, "publishers.id")) { book.setPublisher(publisherRowMapper.mapRow(resultSet, rowNum)); } if(this.existsColumn(resultSet, "categories.id")) { book.setCategory(categoryRowMapper.mapRow(resultSet, rowNum)); } return book; } }
Los repositorios del administrador serán muy similares a los del usuario, con la única diferencia de que utilizaremos los mapeadores correspondientes para convertir los ResultSet en modelos de la capa de dominio del administrador.
@Repository @RequiredArgsConstructor public class BookAdminRepositoryImpl implements BookAdminRepository { private final JdbcTemplate jdbcTemplate; private final AuthorAdminRepository authorAdminRepository; private final GenreAdminRepository genreAdminRepository; @Override public List<Book> getAll() { String sql = """ SELECT * FROM books """; return jdbcTemplate.query(sql, new BookRowMapper()); } @Override public List<Book> getAll(int page, int size) { String sql = """ SELECT * FROM books LIMIT ? OFFSET ? """; return jdbcTemplate.query(sql, new BookRowMapper(), size, page * size); } @Override public int count() { String sql = """ SELECT COUNT(*) FROM books """; return jdbcTemplate.queryForObject(sql, Integer.class); } @Override public Optional<Book> findByIsbn(String isbn) { String sql = """ SELECT * FROM books LEFT JOIN categories ON books.category_id = categories.id LEFT JOIN publishers ON books.publisher_id = publishers.id WHERE books.isbn = ? """; try { Book book = jdbcTemplate.queryForObject(sql, new BookRowMapper(), isbn); book.setAuthors(authorAdminRepository.getByIsbnBook(isbn)); book.setGenres(genreAdminRepository.getByIsbnBook(isbn)); return Optional.of(book); } catch (Exception e) { return Optional.empty(); } } }
De esta forma, hemos logrado separar la lógica del administrador de la del usuario. Al implementar paquetes y componentes específicos para cada rol, garantizamos que ambos grupos utilicen casos de uso diferentes con modelos adaptados a sus necesidades. Los administradores tienen acceso a información más detallada y a funcionalidades específicas para la gestión de libros, mientras que los usuarios disfrutan de una experiencia más simplificada y centrada en la lectura. Esta separación no solo mejora la claridad del código, sino que también permite una mejor mantenibilidad y escalabilidad de la aplicación.