Spring Data REST - Google Cloud Storage - Google Datastore


Ú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 - 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
@Data
public 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étodo GET apis/users; el resto de los métodos los hemos sobrescrito utilizando un controlador UserController 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)  
@EnableMethodSecurity
public 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;
    }

Las authorities 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 la authority, si se usa hasRole la authority 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.


@ControllerAdvice
public 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.


@ControllerAdvice
public 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: java17
automatic_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
@Component
public 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
@EnableCaching
public 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
@AutoConfigureMockMvc
class 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

Si te ha gustado y quieres compartirme


Volver a los siguientes apartados


Etiquetas del artículo

Comentarios