Si has llegado aquí es que pretendes desarrollar un microservicio en Spring Boot utilizando GraphQL. Vamos a repasar las partes claves e ir más allá con las características comunes que probablemente utilizará tu microservicio.

Si no conoces bien la teoría y quieres entender mejor la tecnología que hay por debajo, te recomiendo que le eches un vistazo a este post sobre GraphQL.

¿Por dónde empezamos?

Lo primero que tenemos que hacer es agregar las dependencias que necesitamos para poder trabajar. En este caso, el starter de GraphQL.

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>

Lo siguiente sería añadir nuestra especificación del API en la carpeta /resources/graphql. En mi caso lo he llamado schema.grphqls y podríamos empezar por un ejemplo sencillo.

Estructura del Schema de GraphQL
First Query
@Slf4j
@Controller
public class ProductController {

  @QueryMapping
  public String firstQuery(){
    return "hola";
  }
}

Con estos sencillos pasos ya tendríamos un api muy básico funcionando. Ahora, vamos a ir añadiendo características comunes en los desarrollos.

¿Por dónde seguimos?

El siguiente paso será escribir un api más completo.

Una de las cosas que he echado de menos es tener mayor variedad de tipos (por ejemplo, para fechas con hora) a la hora de definir objetos en mi especificación. Realmente hay pocos tipos oficiales en la documentación.

Comparto en el siguiente enlace una librería muy interesante que he encontrado, que nos permite añadir Scalar comunes a los proyectos y que, a su vez, cumple con las especificaciones ISO correspondientes. A grandes rasgos tendríamos:

Para poder utilizar esta librería, es tan sencillo como añadir la dependencia (también podemos usar gradle) y referenciar en nuestro schema y en la configuración los tipos a usar.

<dependency>
      <groupId>com.graphql-java</groupId>
      <artifactId>graphql-java-extended-scalars</artifactId>
      <version>${graphql-java-extended-scalars.version}</version>
    </dependency>
scalar DateTime
scalar Long

type Query {
 firstQuery : String
 product(id: Long): ProductResponse
 productPaged(pageNumber: Int!, pageSize: Int!): [ProductResponse]
}
type Mutation {
 createProduct(productRequest:ProductRequest): ProductResponse
 updateProduct(productUpdate:ProductUpdateRequest): ProductResponse
 deleteProduct(id: Long): Long
}

También necesitamos ajustar la configuración para añadir estos dos nuevos tipos scalar y ya podríamos usarlos sin problema.

@Configuration
public class GraphQlConfig {
 @Bean
  public RuntimeWiringConfigurer runtimeWiringConfigurer() {
    return wiringBuilder -> wiringBuilder
      .scalar(ExtendedScalars.DateTime).scalar(ExtendedScalars.GraphQLLong);
  }
}

Un entorno gráfico sencillo para probar mi código

Otra funcionalidad que aporta Spring Boot es un entorno gráfico con el que probar nuestro API. Podremos ejecutar nuestras consultas y mutaciones de manera interactiva y ver los resultados.

Para ello, sólo debemos añadir esta propiedad en nuestro archivo properties o yml (no recomendada para entornos productivos, pero sí para local y desarrollo). Por defecto estará en el puerto donde hayamos levantado nuestro microservicio, en este puerto en mi caso.

spring.graphql.graphiql.enabled=true

¿Y qué nos aporta esta interfaz?

Imagen donde se muestra el historial de consultas de ejemplo
Imagen donde se muestra el esquema del Root
Imagen donde se ve el esquema de la pantalla explorer

Api First y que la magia ocurra

Igual que, para endpoint de tipo Rest, tenemos soluciones que nos generan el código de los objetos que intercambiamos, para GraphQL también existe esta posibilidad. El plugin, también compatible con gradle, nos permite generar tanto la parte del servidor o del cliente como los POJO de intercambio, dependiendo del goal que le pongamos.

En el siguiente ejemplo, he generado los POJO (con el goal generatePojo) y he añadido los dos tipos scalar que me he traído de la librería que he comentado anteriormente.

<plugin>
 <groupId>com.graphql-java-generator</groupId>
 <artifactId>graphql-maven-plugin</artifactId>
 <version>${graphql-maven-plugin.version}</version>
 <executions>
   <execution>
     <goals>
       <goal>generatePojo</goal>
     </goals>
   </execution>
 </executions>
 <configuration>
   <schemaFilePattern>graphql/schema.graphqls</schemaFilePattern>
   <scanBasePackages>com.paradigma.poc.graphql.example</scanBasePackages>
   <mode>server</mode>
   <useJakartaEE9>true</useJakartaEE9>
   <packageName>com.paradigma.poc.graphql.example.interfaces.graphql</packageName>

   <customScalars>
     <scalar>
       <graphQLTypeName>DateTime</graphQLTypeName>
       <javaType>java.time.OffsetDateTime</javaType>
       <graphQLScalarTypeStaticField>graphql.scalars.ExtendedScalars.DateTime</graphQLScalarTypeStaticField>
     </scalar>
     <scalar>
       <graphQLTypeName>Long</graphQLTypeName>
       <javaType>java.lang.Long</javaType>
       <graphQLScalarTypeStaticField>graphql.scalars.ExtendedScalars.GraphQLLong</graphQLScalarTypeStaticField>
     </scalar>
   </customScalars>
 </configuration>
</plugin>
Imagen de las fuentes generadas en el ejemplo

Pero, podemos ir más allá y olvidarnos de generar las interfaces de nuestra especificación y la clase de configuración donde indicamos los nuevos tipos de scalar. Esto ocurre si cambiamos el goal a generateServerCode, sólo tendremos que implementar las clases que nos genera y tendremos más trabajo hecho sin esfuerzo.

Imagen donde se muestra el maven plugin del ejemplo

De tal forma que nuestro controlador tiene que implementar los métodos que hemos definido en el contrato y ¡ya estaría hecho!

@Slf4j
@Controller
@RequiredArgsConstructor
public class ProductController
       implements DataFetchersDelegateQuery,
               DataFetchersDelegateProductResponse,
               DataFetchersDelegateMutation {

   private final ProductGetByIdUseCase productGetByIdUseCase;
   private final ProductCreateUseCase productCreateUseCase;
   private final ProductUpdateUseCase productUpdateUseCase;
   private final ProductDeleteUseCase productDeleteUseCase;
   private final ProductGetProductsUseCase productGetProductsUseCase;
   private final ProductDTOMapper mapper;

   @Override
   public ProductResponse createProduct(
           DataFetchingEnvironment dataFetchingEnvironment, ProductRequest productRequest) {
       return mapper.to(productCreateUseCase.createProduct(mapper.from(productRequest)));
   }

Tratamiento de errores

Otro tema bastante importante cuando desarrollamos un API es el tratamiento y manejo de errores. Para el ecosistema spring-boot con GraphQL tenemos que implementar una clase que herede de DataFetcherExceptionResolverAdapter, en la que podemos sobreescribir el método resolveInternal de esta forma:

@Component
public class CustomGraphQLExceptionHandler extends DataFetcherExceptionResolverAdapter {

   @Override
   protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
       if (ex instanceof ProductNotFoundException productNotFoundException) {
           return GraphqlErrorBuilder.newError()
                   .message(productNotFoundException.getDetail())
                   .extensions(createExtensions(ex))
                   .errorType(ErrorType.DataFetchingException)
                   .build();
       } else if (ex instanceof PDException pdException) {
           return GraphqlErrorBuilder.newError()
                   .message(ex.getMessage())
                   .extensions(createExtensions(ex))
                   .errorType(ErrorType.ExecutionAborted)
                   .build();
       } else {
           return GraphqlErrorBuilder.newError()
                   .extensions(createExtensions(ex))
                   .errorType(ErrorType.ExecutionAborted)
                   .build();
       }
   }

   private Map<String, Object> createExtensions(Throwable throwable) {
       Map<String, Object> extensions = new HashMap<>();
       extensions.put("exceptionType", throwable.getClass().getName());
       extensions.put("stackTrace", getStackTraceAsString(throwable));
       return extensions;
   }

   private String getStackTraceAsString(Throwable throwable) {
       StringBuilder result = new StringBuilder();
       for (StackTraceElement element : throwable.getStackTrace()) {
           result.append(element.toString()).append("\n");
       }
       return result.toString();
   }
}

En este ejemplo hemos añadido dos campos más a la respuesta para que dieran más detalle de lo que estaba ocurriendo. Ejemplo de una respuesta de un producto que no se ha encontrado:

Ejemplo de un error para un producto no encontrado

Cabe destacar que debemos devolver una clase GraphQLError que tiene varios atributos, y uno de ellos es el tipo de error, que puede ser:

Seguridad

En cuanto a seguridad, podemos seguir utilizando spring-security como si fuera un recurso REST, por lo que este apartado es similar a las apis tradicionales con las que estamos acostumbrados a trabajar.

@EnableWebSecurity
public class SecurityConfig {

   @Bean
   public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

       httpSecurity
               .csrf(AbstractHttpConfigurer::disable)
               .cors(AbstractHttpConfigurer::disable)
               .authorizeHttpRequests(
                       auth ->
                               auth.requestMatchers(
                                               "/actuator",
                                               "/actuator/**",
                                               "/graphiql/**",
                                               "/graphiql")
                                       .permitAll()
                                       .anyRequest()
                                       .authenticated());

       return httpSecurity.build();
   }
}

Los únicos endpoints que no tienen autenticación son los relativos a GraphiQL y a las sondas de vida de actuator.

Validaciones

Aquí tenemos una pequeña desventaja respecto a las especificaciones de OpenAPI, en el que podemos establecer más reglas a nuestros mensajes de entradas. Lo que sí podemos hacer es declarar los scalar que necesitemos antes de que sean procesados.

Otro camino en el ecosistema Java con Spring sería apoyarnos en el estándar de Java Bean Validation y sus anotaciones.

Pero, como defensores de la perspectiva API First, vamos a intentar especificar todo en el contrato y que se genere todo automáticamente. Por ejemplo, vamos a continuar añadiendo una restricción para un correo electrónico y para un entero entre 0 y 100.

Para el correo electrónico, he utilizado la librería que viene con tipos scalar y he añadido el nuevo tipo al plugin. Primero, lo he añadido a mi contrato y luego he añadido el tipo al plugin:

scalar DateTime
scalar Long
scalar Email
scalar IntRange0To100Scalar

input ProductUpdateRequest{
 id: Long!     #! para que no admita nulos
 name: String! #! para que no admita nulos
 price: IntRange0To100Scalar!
 email: Email
 color: ColorEnum
 expeditionDate: DateTime
 expirationDate: DateTime
 reviews: [ReviewRequest]
}
<scalar>
 <graphQLTypeName>Email</graphQLTypeName>
 <javaType>java.lang.String</javaType>
 <graphQLScalarTypeStaticField>graphql.scalars.ExtendedScalars.newRegexScalar("Email").addPattern(java.util.regex.Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$")).build()</graphQLScalarTypeStaticField>
</scalar>

Para el caso del entero que esté entre 0 y 100, he tenido que hacer una implementación de un tipo y añadirlo al plugin para seguir con la filosofía API First.

Es importante resaltar que hemos tenido que implementar la clase Coercing, que se encarga de convertir el tipo de la entrada en la salida. Tiene varios métodos fundamentales y el de check, que utilizaremos para validar nuestra entrada:

Para más información dejo el enlace a la documentación oficial. En código, sería algo así:

import static graphql.Scalars.GraphQLInt;
import java.util.function.Function;
import graphql.Internal;
import graphql.schema.*;

@Internal
public final class IntRange0To100Scalar implements Coercing<Integer, Integer> {
private IntRange0To100Scalar() {}

public static final GraphQLScalarType INSTANCE =
GraphQLScalarType.newScalar()
.name("IntRange0To100Scalar")
.description("An Int scalar that must be a positive value and max value is 100")
.coercing(
        new Coercing() {
        protected Integer check(
                Integer value,
                Function<String, RuntimeException> exceptionMaker) {
                if (value <= 0 || value > 100) {
                throw exceptionMaker.apply(
                        "The value must be a positive integer and max value"
                                + " is 100");
                }
                return value;
        }
        @Override
        public Integer serialize(Object input)
                throws CoercingSerializeException {
                Integer i = (Integer) GraphQLInt.getCoercing().serialize(input);
                return check(i, CoercingSerializeException::new);
        }
        @Override
        public Integer parseValue(Object input)
                throws CoercingParseValueException {
                Integer i =
                        (Integer) GraphQLInt.getCoercing().parseValue(input);
                return check(i, CoercingParseValueException::new);
        }
        @Override
        public Integer parseLiteral(Object input)
                throws CoercingParseLiteralException {
                Integer i =
                        (Integer) GraphQLInt.getCoercing().parseLiteral(input);
                return check(i, CoercingParseLiteralException::new);
        }
        })
.build();
}

Si lo probamos, vemos que recogemos correctamente la excepción (en este caso, he añadido un número que excede el rango).

Imagen que muestra un error de validación

A modo de conclusión diremos que, aunque requiere un poco más de trabajo, se puede llegar a hacer validaciones avanzadas en nuestras especificaciones aunque, a veces, nos exija desarrollar una implementación. En este punto, la especificación para REST de OpenAPI es más potente.

Testing. ¡No sin mis test!

Como una parte fundamental del desarrollo son los test, no podría finalizar este post sin hablar de cómo hacer nuestros test unitarios sobre las consultas y mutaciones que desarrollemos. Para ello, nos apoyaremos en la librería:

<dependency>
 <groupId>org.springframework.graphql</groupId>
 <artifactId>spring-graphql-test</artifactId>
 <scope>test</scope>
</dependency>

Los test sobre GraphQL son slice test que levantan parcialmente parte del contexto de Spring Boot para realizarlos:

@GraphQlTest(ProductController.class)
public class ProductControllerTest {

   @Autowired private GraphQlTester graphQlTester;

   @Test
   void shouldGetProductById() {

Lo importante aquí es la anotación GraphQlTest y su referencia al controlador que queremos probar y el atributo del tipo GraphQlTester, que nos va a permitir hacer las consultas sobre nuestros recursos.

Con estas simples anotaciones ya podremos hacer los correspondientes test sobre nuestros recursos. Por ejemplo:

@Test
@Sql(value = {"/sql/test_truncate_products.sql", "/sql/test_products.sql"})
void shouldGetProductById() {
   final String document =
           """
             query{
               product(id:1){
                 id,
                 color,
                 email,
                     reviews {
                                 id
                                 comment
                                 evaluation
                               }
               }
             }
               """;
   this.graphQlTester
           .document(document)
           .execute()
           .path("product")
           .hasValue()
           .entity(ProductResponse.class)
           .satisfies(
                   response -> {
                       assertEquals(1, response.getId());
                       assertEquals("ajgarcia@paradigmadigital.com", response.getEmail());
                   });
}

En este test, primero me aseguro que la base de datos está vacía e inserto un registro para poder consultarlo con la anotación @SQL (también podría haber usado mock).

Luego, graphQlTester me exige que le pase el documento (en este caso una consulta, pero podría ser una mutación). En la respuesta recupera $data.PATH, donde path en este ejemplo es product y lo que tengo dentro es un objeto de tipo ProductResponse que puedo mapear con facilidad. Por último, me quedaría validar con aserciones que está haciendo lo que se espera.

Es importante tener en mente que la consulta o mutación que estoy haciendo me devuelve exactamente los campos que le pido, porque un campo que no se ha solicitado no podrá ser validado.

Si quieres descargarte el código y probarlo puedes hacerlo en este enlace.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.