Última revisión en enero del 2024.
A un usuario previamente autenticado usando el servidor de autorización Google Identity, le permitiremos darse de alta en nuestra aplicación entrando la información pertinente que incluye una imagen. Parte de la información del usuario será accesible públicamente.
La aplicación está desarrollada en Java utilizando Spring Boot con Jetty como servidor. Utilizamos Spring Data REST para crear la API, Google Cloud Storage para almacenar las imágenes y Google Datastore para guardar la información de los usuarios. Además, documentamos nuestra API con springdoc-openapi y utilizamos Swagger UI como interfaz para interactuar con la API. También hablamos de cómo hemos uniformado el reporte de errores de la API siguiendo el estándar RFC 7807. Y también proporcionamos detalles sobre cómo hemos implementado el sistema de caché para actuar en el servidor y en el navegador.
La fat JAR ejecutable generada por Spring Boot, junto con todos los archivos estáticos, se despliega a través de la plataforma como servicio de Google App Engine.
Podéis encontrar el código completo de este artículo en GitHub.
El proyecto
Pongamos que tenemos una aplicación en la que permitimos generar contenido a cualquier usuario que tenga una cuenta de Google, y que este contenido va firmado por el usuario, que actúa como editor y autor. En este artículo, se detalla cómo crear una API que permita a nuestros usuarios gestionar la información de firma asociada a sus publicaciones, y cómo esta misma API facilita a nuestra aplicación el consumo de dicha información.
Debajo, presentamos un ejemplo de la firma del usuario: una caja con un name
, una description
y una picture
, que acompaña a todo el contenido generado por este usuario. El lector, al hacer clic en la caja de firma, es redirigido a la web
especificada por el usuario que publica el contenido.
Tecnologías usadas
- Spring
- Spring Boot 3.2.1
- Spring Data REST
- Spring Security
- springdoc-openapi
- Swagger UI
- Google Sign In
- Google App Engine
- Google Datastore
- Google Cloud Storage
- Eclipse Jetty
- Java - JVM
- Maven
- Vanilla JS
Temas tratados
- Spring Data REST
- Swagger UI
- Seguridad OAuth2: autenticación y autorización
- Reporte de errores y excepciones
- Caching
- Testing
Spring Data REST - The API First
La entidad User
La entidad User
contiene toda la información necesaria de cada usuario para crear su caja de firma. Esta información se guarda en la base de datos Google Datastore con el nombre users
, y cada instancia se identifica con un @id generado a partir del correo electrónico del usuario.
Además, hemos incluido información auxiliar como @CreatedDate
, @LastModifiedDate
, @LastModifiedBy
, que forman parte de la auditoría de la base de datos que se activa con (@EnableDatastoreAuditing
), y @Version
, al que se le da diferentes funcionalidades en Spring Data pero que en nuestro caso por el momento no usamos para nada.
@Entity(name = "users")@AllArgsConstructor@NoArgsConstructor@Datapublic class User{ @Id private String id;
@Email(message = "Email should be valid.") @NotBlank(message = "Email must be defined.") private String email;
@Size(min = 5, max = 25, message = "Publisher name must be between 5 and 25 characters.") @NotBlank(message = "Publisher name must be defined.") private String name;
@Size(min = 5, max = 25, message = "Publisher description must be between 5 and 25 characters.") @NotBlank(message = "Publisher description must be defined.") private String description;
@NotBlank(message = "Publisher picture must be defined.") @URLValidator(message = "Publisher picture must be a valid URL.") private String picture; @NotBlank(message = "Publisher web must be defined.") @URLValidator(message = "Publisher web must be a valid URL.") private String web;
@CreatedDate private Instant createdDate;
@LastModifiedDate private Instant lastModifiedDate; @LastModifiedBy private String lastModifiedBy;
@Version private int version;
La proyección pública de la entidad
Una proyección es una simplificación de la información contenida en una entidad de la base de datos. Utilizamos estas proyecciones para generar listas de usuarios que luego son empleadas en la creación de las firmas de sus publicaciones en las páginas web de la aplicación.
@Projection(name = "userProjection", types = { User.class })public interface UserProjection{ String getId(); String getName(); String getDescription(); String getPicture(); String getWeb();}
El repositorio
La interfaz para el repositorio del recurso users
extiende DatastoreRepository
, la interfaz propia de Google Cloud Datastore para Spring Data. A su vez, hereda de diversas interfaces, incluyendo PagingAndSortingRepository
y CrudRepository
.
En esta interfaz, se define el tipo de entidad (User
) que se almacena en la base de datos, la proyección obtenida al realizar una solicitud GET
apis/users
(UserProjection.class
), y se especifica que el identificador (id) es de tipo String.
También podemos ver que el método findAll
, que es la base del GET apis/users
, va acompañado de anotaciones de caché que más adelante detallaremos cómo funcionan.
@Tag(name="User Info in the Google Datastore")@RepositoryRestResource( excerptProjection = UserProjection.class, collectionResourceDescription = @Description("Public Info of users of Consolidando"))public interface UserRepository extends DatastoreRepository<User, String>{
@Operation( summary = "I do not know how to set up Swagger annotations for this method!!!" ) @CacheControl(maxAge = 30, timeUnit = TimeUnit.DAYS) @Cacheable(CACHE_USER_PUBLIC_INFO) @Override public Page<User> findAll(Pageable pageable);
}
La mágia de Spring Data REST
Spring Data REST es capaz de establecer, de manera automática, la conexión con Google Cloud Datastore y generar toda la REST API a partir de la interfaz del repositorio, utilizando la anotación @RepositoryRestResource
.
La adopción de Spring Data REST impone ciertas restricciones a nuestra API, ya que salir de ellas implica un esfuerzo mayor en comparación con las ventajas que ofrece.
Desarrollar con Spring Data requiere muy poco código, pero es mucho menos eficiente que el uso directo de Hibernate o JPA.
El identificador de la entidad - id
El identificador de la entidad en la base de datos debe ser inmutable; en este caso, utilizamos el email codificado en base64, una versión segura para URL y nombres de archivos.
El recurso users en plural
Dado que el recurso representa la información asociada a la firma de una colección de usuarios, utilizaremos users
en plural para referirnos a él.
POST, PUT y PATCH
Optamos por utilizar el verbo PUT en lugar de POST. PUT nos permite crear o modificar un usuario específico mediante un identificador {id} particular, en contraste con POST, que crearía instancias con un identificador {id} subordinado al recurso users
.
POST | /users | no idempotente |
---|---|---|
PUT | /users/{id} | idempotente |
No obstante, emplearemos el método POST para obtener el identificador {id} asociado a un correo electrónico y para enviar una imagen junto con la información del usuario al servidor. Estas funcionalidades se implementan como extensiones a la API generada por Spring Data REST, en el controlador UserController
.
A nivel de Spring Data REST que genera automáticamente el API desactivamos el PATCH para items y el POST para colecciones de items.
En realidad, de la API generada por Spring Data REST, solo hemos conservado el métodoGET apis/users
; el resto de los métodos los hemos sobrescrito utilizando un controladorUserController
con la anotación@BasePathAwareController
.
HATEOAS
Spring Data REST nos proporciona un enfoque hypermedia-driven REST que podemos utilizar para la paginación de la información de los usuarios.
A continuación, se muestra el resultado del GET apis/users
generado por Spring Data REST y obtenido a través de Swagger UI. Se destaca que la proyección de la información del único usuario que había en la base de datos se encuentra dentro de la sección llamada _embedded
, permitiendo diferenciarla de otros campos presentes en la respuesta, como las secciones de hateoas _links
y la de paginación page
.
{
"_embedded": {
"users": [
{
"name": "Test Name",
"id": "dGVzdEB0ZXN0LmNvbQ==",
"description": "Test Description",
"picture": "https://storage.googleapis.com/download/storage/v1/b/emp-2022.appspot.com/o/dGVzdEB0ZXN0LmNvbQ==%2Ftemporary?generation=1703848206390214&alt=media",
"web": "https://test.test.com/",
"_links": {
"self": {
"href": "http://localhost:8080/apis/users/dGVzdEB0ZXN0LmNvbQ=="
},
"user": {
"href": "http://localhost:8080/apis/users/dGVzdEB0ZXN0LmNvbQ=={?projection}",
"templated": true
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/apis/users?page=0&size=20"
},
"profile": {
"href": "http://localhost:8080/apis/profile/users"
}
},
"page": {
"size": 20,
"totalElements": 1,
"totalPages": 1,
"number": 0
}
}
Swagger UI - La interfaz de usuario
La interfaz de usuario es básicamente la Swagger UI que nos permite visualizar e interactuar con la API, incluso en el caso que este securizada.
En los siguientes apartados se explica las diferentes partes del API del recurso users
apoyándonos en Swagger UI.
Get User ID
El id
asociado al email del usuario se puede obtener con un GET o un POST.
User Info
No he encontrado como incluir las anotaciones de Swagger para el método GET apis/users
. Se trata del único método público sin restricciones de seguridad, por eso no lleva el candado del resto de métodos en la imagen posterior. Al tratarse de un método público devuelve una proyección de la información de los usuarios, solo se devuelve la información pública. El resto de la información se tiene que acceder haciendo un GET apis/users/{id}
con un token de autenticación del usuario o de un administrador, donde id es el identificador asociado al usuario.
User Picture
La imagen del usuario se preenvía al servidor con un POST multipart, guardándose como imagen temporal en Google Cloud Storage. Esta imagen se hace permanente cuando se hace un PUT con toda información del usuario.
Ejemplo de las anotaciones de Swagger
@Tag, @Operation, @ApiResponses, @PostMapping y @Parameter
son anotaciones de Swagger la cuales no se están usando en toda su extensión para el método POST apis/users/{id}/picture
.
@Tag(name = "User Picture in the Google Cloud Storage") @Operation( summary = "Saves a temporary picture that is used for creating or updating the User with a PUT {id}.", security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponses( { @ApiResponse(responseCode = "200", description = "Successful operation"), @ApiResponse(responseCode = "500", description = "Google Cloud Storage Problem") }) @PostMapping( path = "users/{id}/picture", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) @PreAuthorize("@securityService.isAuthorized(#id)") public ResponseEntity<?> saveTemporaryFileInGoogleCloudStorage( @Parameter(description = "Id of the user") @PathVariable String id, @Parameter(description = "Temporaty picture of the user") @RequestPart("file-data") MultipartFile file) throws IOException { String fileName; String mediaLink;
fileName = getTemporatyPictureName(id);
try (InputStream is = file.getInputStream()) { mediaLink = storageService.save(fileName, is); }
return (ResponseEntity.ok().body(UserMediaLink.of(mediaLink))); }
Las anotaciones de Swagger no afectan a la funcionalidad de la API sólo a la documentación de springdoc-openapi y a la aplicación Swagger UI.
El candado - @SecurityRequirement
@Operation incuye @SecurityRequirement que define las restricciones de seguridad que en nuestro caso tiene el nombre de "Bearer Authentication".
En la @Configuration de OpenAPI definimos @SecurityScheme con nombre "Bearer Authentication" que nos permite a Swagger UI interactuar con la parte securizada de nuestra API si le facilitamos un token de identificación válido.
@Configuration@SecurityScheme( name = "Bearer Authentication", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer", description = "This operation must be authenticated with a USER or ADMINISTRATOR Google ID token" )public class OpenApiConfig{
@Bean public OpenAPI userDatabaseOpenAPI() { return new OpenAPI() .info(new Info().title("Consolidando Users REST API") .description("This API allows users to register on the Consolidando website.") .license(new License() .name("Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License") .url("https://creativecommons.org/licenses/by-nc-nd/4.0/")) .contact(new Contact() .name("Consolidando") .url("https://diy.elmolidelanoguera.com/")) .version("1.0")); }}
Interaccionando con el recurso users
Si abrimos el método GET apis/users/id
en Swagger UI tenemos que pulsar el botón "Try it out" y entonces nos aparece un botón "Execute" que debemos pulsar una vez hemos introducido los parámetros necesarios.
Debajo tenemos la respuesta de error visualizada por Swagger UI porque la solicitud no tenía el token de identificación.
Al hacer clic en el candado, podemos introducir el ID token que obtenemos al identificarnos con Google Sign In. ¿Dónde podemos conseguir este ID token? Este ID token se imprime en la página principal de la aplicación una vez que hemos entrado nuestras credenciales en Google Sign In y aceptado usar la Aplicación Consolidando. Luego, podemos copiar y pegar este token en Swagger UI para utilizar los métodos seguros mientras el token sea válido.
Ahora, al realizar la misma solicitud, se devuelve el ID del email contenido en un claim del token de identificación. Por lo tanto, este GET solo es válido para usuarios; los administradores deben utilizar el método POST apis/users/id
para obtener el id
de un usuario específico.
Seguridad OAuth2: Autenticación y Autorización
Autenticación
Implementamos un filtro de seguridad que hace que cualquier solicitud a un usuario específico apis/users/{id}/*
tenga que estar autenticada con un token de identificación. Podemos encontrar los detalles en el artículo anterior "Spring OAuth 2.0 Resource Server - Google Sign In"
@Configuration@EnableWebSecurity(debug = false) @EnableMethodSecuritypublic class SecurityConfig{
...
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http ... .authorizeHttpRequests((request) -> request .requestMatchers("apis/users").permitAll() .requestMatchers("apis/users/**").authenticated() .anyRequest().permitAll() ) ... return http.build(); }
Autorización
Concedemos autorización de acceso según el email de uno de los claims del token de identificación, generando un GrantedAuthority
para el usuario autentificado Principal
con roles de USER o ADMIN según una lista de emails de administradores que en este caso son una variable de entorno del servidor. O sea, cualquier usuario autenticado con una cuenta Google que no es administrador tiene un acceso USER y puede manipular exclusivamente su información.
@Bean public JwtAuthenticationConverter customJwtAuthenticationConverter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { Collection<GrantedAuthority> authorities = new ArrayList<>(); String email = source.getClaimAsString("email"); String[] adminEmails = adminEmailsList.split(","); if (Arrays.asList(adminEmails).contains(email)) { authorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + ADMIN_ROLE)); } else { authorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + USER_ROLE)); } return authorities; });
return converter; }
Lasauthorities
pueden tener diferentes formas: roles, permisions, scopes. En el caso de los roles en Spring se les asigna el prefijo "ROLE_". En Spring si se usa el método hasAuthority se tiene que poner el prefijo "ROLE_" en laauthority
, si se usa hasRole laauthority
se tiene que poner sin prefijo.
La autorización de los usuarios la llevamos a cabo a nivel de métodos del API, habilitándola mediante la anotación @EnableMethodSecurity
y aplicando un filtro de preautorización con @PreAuthorize
. Este filtro verifica si el usuario autenticado corresponde al usuario con el {id} del path de acceso o si se trata de un administrador con permisos para manipular la información de cualquier usuario.
A continuación, se presenta un ejemplo con el método DELETE users {id}
, el cual utiliza la anotación del filtro de preautorización: @securityService.isAuthorized(#id)
.
@Operation( summary = "Deletes User Info from the Google Cloud Datastore and its picture from the Google Cloud Storage.", security = @SecurityRequirement(name = "Bearer Authentication") ) @CacheEvict(value = CachingConfig.CACHE_USER_PUBLIC_INFO, allEntries = true) @DeleteMapping("users/{id}") @PreAuthorize("@securityService.isAuthorized(#id)") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUser(@PathVariable String id) { userRepository.deleteById(id); storageService.delete(getPictureName(id)); }
Excepciones y Errores
Implementamos un @ControllerAdvice
con el objetivo de homogeneizar la forma en que manejamos y retornamos los errores, concentrando el manejo de excepciones de nuestra aplicación en un solo punto.
Los errores son devueltos en el cuerpo de la respuesta en formato JSON, siguiendo la especificación RFC 7807 para "Problem Details" en APIs HTTP.
@Valid - Errores de validación de la información entrada por el usuario
Nuestro @ControllerAdvice
extiende ResponseEntityExceptionHandler
y sobrecarga funciones como handleMethodArgumentNotValid
para proporcionar información más precisa sobre la validación a nivel de servidor especificada con anotaciones en la entidad User
.
@ControllerAdvicepublic class UserControllerAdvice extends ResponseEntityExceptionHandler{
...
@Override protected ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problemDetail = ex.getBody(); var errors = new HashMap<String, String>(); ex.getBindingResult().getAllErrors().forEach(error -> { String fieldName = ((FieldError) error).getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); }); problemDetail.setProperty("Validation Errors:", errors); return super.handleMethodArgumentNotValid(ex, headers, status, request); }}
En el caso de hacer un PUT apis/users/{id}
con Swagger UI sin entrar ninguna información del usuario nos devuelve el siguiente error de validación que sigue el formato de ProblemDetail
con propiedades específicas para la validación realizada en el servidor.
@ExceptionHandler
También incorporamos todos los @ExceptionHandler
específicos del proyecto, como por ejemplo las excepciones ocurridas en Google Cloud Storage.
@ControllerAdvicepublic class UserControllerAdvice extends ResponseEntityExceptionHandler{
...
@ExceptionHandler( { StorageReadingException.class, StorageWritingException.class, StorageRenamingException.class }) public ResponseEntity<ProblemDetail> handleStorageException(RuntimeException ex) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); return (ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail)); } ...}
Excepciones de Seguridad
Hemos derivado también a nuestro @ControllerAdvice
las excepciones de seguridad AuthenticationException
y AccessDeniedException
, a las cuales se les ha añadido el ProblemDetail
del estándar RFC 7807 en el cuerpo de la respuesta, manteniendo el http header WWW-Authenticate
del estándar RFC 6750.
Spring Oauth2 Resource Server por defecto sigue RFC 6750 - WWW-Authenticate Response Header Field para reportar las excepciones.
Debajo tenemos la respuesta a una solicitud GET
a apis/users/id
sin un token de autenticación, la cual devuelve un estado 401 y "WWW-Authenticate: Bearer" siguiendo la sección 3 de la RFC 6750.
Nosotros hemos incluido además el RFC 7807 - Problem Details for HTTP APIs, al igual que hace Spring Web.
Caching
Static files
Los archivos estáticos se sirven directamente usando Google App Engine y se especifica en su fichero de configuración app.yaml
los controladores donde se define el http header Cache-Control
según la extensión del archivo.
runtime: java17automatic_scaling: max_idle_instances: 1 max_concurrent_requests: 10 max_instances: 2
handlers:- url: (/.*\.(gif|png|jpg|svg|ico|css|js|html)) static_files: __static__\0 upload: __NOT_USED__ require_matching_file: true login: optional secure: optional expiration: 7d- url: / static_files: __static__/index.html upload: __NOT_USED__ require_matching_file: true login: optional secure: optional expiration: 7d
GET apis/users
La lista de información pública de los usuarios, obtenida en páginas de Google Datastore, se almacena en caché tanto en el servidor (@Cacheable
) como en el navegador (@CacheControl
). Esto implica que la aplicación del navegador debe realizar una solicitud versionada cuando le falte información de algún usuario, con el fin de obtener una lista actualizada de la base de datos y evitar los distintos caches que puedan guardar la información desactualizada.
@Tag(name="User Info in the Google Datastore")@RepositoryRestResource( excerptProjection = UserProjection.class, collectionResourceDescription = @Description("Public Info of users of Consolidando"))public interface UserRepository extends DatastoreRepository<User, String>{
@Operation( summary = "I do not know how to set up Swagger annotations for this method!!!" ) @CacheControl(maxAge = 30, timeUnit = TimeUnit.DAYS) @Cacheable(CACHE_USER_PUBLIC_INFO) @Override public Page<User> findAll(Pageable pageable);
}
La anotación @Cacheable(CACHE_USER_PUBLIC_INFO)
que acompaña al método findAll
, usado para implementar la respuesta a la solicitud GET apis/users
, guarda su resultado para las siguientes peticiones. Este caché es descartado por la anotación @CacheEvict(value = CachingConfig.CACHE_USER_PUBLIC_INFO, allEntries = true)
, la cual acompaña a DELETE apis/users/{id}
y PUT apis/users/{id}
, ya que estas operaciones modifican la lista de información de los usuarios.
En la caché del servidor, se almacenan las páginas de información de usuarios utilizando Page<User>
, que es el tipo que devuelve el método findAll
del repositorio. Estas páginas en caché son diferentes de las que recibe el cliente, que son proyecciones públicas de la información de los usuarios, además de una montón de enlaces HATEOAS y detalles de paginación.
En la caché se almacena la respuesta del repositorio, no la respuesta de la API.
Las imágenes que devuelve GET apis/users
Para las imágenes de los usuarios guardadas en Google Cloud Storage, aplicamos un Cache-Control
de un mes, que se guarda como metadata de la imagen. Además, el enlace multimedia que devuelve Google Cloud Storage incluye el parámetro de query generation=numero_entero
, en el cual el numero_entero cambia cada vez que el objeto recibe una actualización, incluso si el nombre permanece sin cambios.
Claves Públicas de Google
Además, almacenamos en la caché del servidor las claves públicas que se utilizan para verificar la firma de los tokens.
@Bean JwtDecoder jwtDecoder(CacheManager cacheManager) { return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) .cache(cacheManager.getCache(CACHE_GOOGLE_PUBLIC_KEYS)) .build(); }
Swagger UI
To be done
@CacheControl - Caché en el navegador
A continuación, te presentamos la implementación de la anotación @CacheControl
, que agrega el encabezado Cache-Control
a la respuesta HTTP del servidor. Este encabezado es utilizado por el navegador para determinar cómo gestionar la caché del archivo. En nuestro caso, lo aplicamos a la solicitud GET apis/users
.
@Aspect@Componentpublic class CacheControlAspect{ @Before("@annotation(cacheControl)") public void configureCacheControl(CacheControl cacheControl) { long maxAge = TimeUnit.SECONDS.convert(cacheControl.maxAge(), cacheControl.timeUnit()); HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); if (response != null) { response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=" + maxAge); } }}
@Cacheable - Cache en el backend
A continuación, presentamos la activación de @Cacheable
, que se utiliza para almacenar en caché las respuestas del repositorio utilizadas para generar la respuesta a GET apis/users
(CACHE_USER_PUBLIC_INFO) y las claves públicas de Google (CACHE_GOOGLE_PUBLIC_KEYS). Ambos caches son del tipo ConcurrentMapCache
, que utiliza ConcurrentHashMap
como implementación por defecto.
@Configuration@EnableCachingpublic class CachingConfig{ public final static String CACHE_USER_PUBLIC_INFO = "userPublicInfo"; public final static String CACHE_GOOGLE_PUBLIC_KEYS = "googlePublicKeys"; @Bean public CacheManager cacheManager() { return new ConcurrentMapCacheManager(); }}
Spring Cloud GCP
Spring Cloud GCP es un proyecto independiente de Spring, pero es muy activo y sigue los constantes releases de Spring.
No entro en los detalles de Google Cloud Platform ya que el artículo es sobre Spring.
Credenciales Google Cloud en Spring
Tanto la identificación del proyecto como la localización de las credenciales en Google Cloud Platform pueden setearse en el fichero de configuración de Spring Boot application.yml
. Si no se encuentran en este fichero, Spring Cloud GCP los buscará en sitios comunes como variables de entorno o ficheros de configuración de aplicaciones en Google Cloud.
Testing
Las pruebas automáticas son fundamentales para asegurar la calidad del software. Aunque el código cuenta con varios tests de integración, nos centraremos en un par de comentarios sobre cómo probamos la caché del repositorio.
En el primer fragmento de código, en el métodos setup
, se está configurando un JwtRequestPostProcessor
utilizando SecurityMockMvcRequestPostProcessors
para simular un token JWT
durante las pruebas de integración. Al token se le asigna un claim
TEST_EMAIL
y un rol USER
, lo cual nos permite realizar pruebas de integración en el recurso users
.
@SpringBootTest@AutoConfigureMockMvcclass DataRestApplicationTest{
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri;
String TEST_EMAIL = "test@test.com";
@Autowired private MockMvc mvc;
@Autowired private ObjectMapper objectMapper;
@Autowired CacheManager cacheManager;
@Autowired UserRepository userRepository;
@Autowired private JwtDecoder jwtDecoder;
private JwtRequestPostProcessor jwt;
@BeforeEach public void setup() { jwt = SecurityMockMvcRequestPostProcessors.jwt() .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) .jwt((t) -> t.claim("email", TEST_EMAIL)); }
En la siguiente rutina se verifica que, después de realizar una solicitud GET apis/users
, la caché ha almacenado la información de la primera página de usuarios y que es idéntica a la que devolvería si accediéramos directamente al repositorio.
/** * Checks if the first page is correctly cached after making a GET request * to retrieve the users' collection. * * @throws Exception if an error occurs during the test. */ private void checkIfFirstPageIsCorrectlyCachedAfterGetCollection() throws Exception { // Step 1: Retrieve Users Collection and cache them performGetRequest("/apis/users");
// Step 2: Gets the first page from the repository PageRequest pageRequest = PageRequest.of(0, 20); Page<User> pageUsers = userRepository.findAll(pageRequest);
// Step 3: Look for the previous object in the cache Cache cache = cacheManager.getCache(CachingConfig.CACHE_USER_PUBLIC_INFO); ValueWrapper valueWrapper = cache.get(pageRequest); Page<User> pageUsersCache = (Page<User>) valueWrapper.get();
// Step 4: Check that the cache is consistent assertEquals(pageUsers, pageUsersCache, "The first page should be correctly cached."); }
Ahora verificamos que después de realizar un DELETE de un usuario, la caché ha eliminado toda la información de todos los usuarios.
/** * Checks that the cache has been flushed after deleting a user. * * @param pathId the path of the user to be deleted. * @throws Exception if an error occurs during the test. */ private void checkThatCacheHasBeenFlushedAfterUserDeletion(String pathId) throws Exception { // Step 1: Delete user => Cache is flushed performDeleteRequest(pathId);
// Step 2: Check that the cache has been flushed Cache cache = cacheManager.getCache(CachingConfig.CACHE_USER_PUBLIC_INFO); ValueWrapper valueWrapper = cache.get(PageRequest.of(0, 20)); assertNull("The cache should be empty after user deletion.", valueWrapper); }
Para finalizar
Este artículo fue escrito y codificado entre turrones y cava durante las Navidades del 2023; y además no está respaldado por ningún proyecto real, por lo que podría contener errores.
Nos queda pendiente implementar la paginación de la información de usuarios, utilizando los HATEOAS devueltos por la solicitud GET apis/users
. Abordaremos este tema en algún próximo artículo, donde desarrollaremos una aplicación cliente con React o Angular.
En el artículo anterior "Spring OAuth 2.0 Resource Server - Google Sign In", podéis encontrar la explicación de muchos elementos estructurales que también han sido utilizados para esta aplicación.
El Código
Puedes encontrar el código de este post en el repositorio de GitHub: Consolidando - Spring Boot.
Más información
- Spring Data REST
- SpringDoc-OpenAPI
- Swagger UI
- RFC 7807 - Problem Details for HTTP APIS
- RFC 6750 - The WWW-Authenticate Response Header Field
- Spring Cloud GCP
- Google Identity
- Spring - OAuth 2.0 Resource Server
- Spring - OAuth 2.0 Resource Server - Google Sign In
Si te ha gustado y quieres compartirme
Volver a los siguientes apartados
- Descripción de proyecto
- Spring Data REST
- Swagger UI
- Seguridad OAuth2: autenticación y autorización
- Reporte de errores y excepciones
- Caching
- Testing
Comentarios
Publicar un comentario