¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
dev
Antonio José García 20/06/2024 Cargando comentarios…
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.
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.
@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.
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);
}
}
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?
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>
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.
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)));
}
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:
Cabe destacar que debemos devolver una clase GraphQLError que tiene varios atributos, y uno de ellos es el tipo de error, que puede ser:
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.
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).
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.
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.
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.
Estamos comprometidos.
Tecnología, personas e impacto positivo.
Cuéntanos qué te parece.