Et pourquoi pas GraphQL?
Sommaire
- Qu'est ce que GraphQL ?
- Comment le mettre en place?
- Observations
- Conclusion
GraphQL
Qu'est ce que c'est ?
Définition
GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.
graphql.org
Fonctionnalités
- Récupérer uniquement les informations qui nous intéressent
- Récupérer plusieurs ressources en une seule requête
- Documentation/schéma typé
- Évolutions sans versions
Aperçu
Documentation
-
https://graphql.org/learn/
Bonne documentation sur le langage -
https://graphql.org/code/#java
Attention liens vers librairies bas niveau -
https://www.howtographql.com/graphql-java/0-introduction/
Attention tutos Java vieux / obsolètes
Les bases
Connaîtres les grands principes
Exposition via HTTP
- Pas une obligation mais le plus courant
- Un seul endpoint exposé
(pas une obligation mais c'est le standard) - La requête est au "format GraphQL" mais le retour est en JSON
Type system
- 1 type "Query": Read-only
- Similaire à un "GET" en REST
- 1 type "Mutation": Write/Read
- Similaire à un "POST/PUT/PATCH/DELETE" en REST
- Non-nullability via "!"
-
Arguments possibles dans les types
- Équivalent à une fonction d'objet
-
Variabilisation des inputs
- Équivalent à des paramètres de query SQL
- Types "scalar" préparés
- Int, Float, String, Boolean, ID
- Objets, interfaces, enums, fragments...
Exemple 1
type Character {
name: String!
appearsIn: [Episode!]!
}
enum Episode {
NEWHOPE
EMPIRE
JEDI
}
type Query {
hero(episode: Episode): Character
}
{
hero {
name
appearsIn
}
}
{
"data": {
"hero": {
"name": "R2-D2",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
]
}
}
}
Requête (GraphQL)
Réponse (JSON)
Schéma
Exemple 2
type Review {
stars: Int!
commentary: String
}
enum Episode {
NEWHOPE
EMPIRE
JEDI
}
input ReviewInput {
stars: Int!
commentary: String
}
type Mutation {
createReview(episode: Episode!, review: ReviewInput!): Review
}
mutation ($ep: Episode!, $review: ReviewInput!) {
createReview(episode: $ep, review: $review) {
stars
comment:commentary
}
}
{
"data": {
"createReview": {
"stars": 5,
"comment": "This is a great movie!"
}
}
}
Requête (GraphQL)
Réponse (JSON)
Schéma
Variables (JSON)
{
"ep": "JEDI",
"review": {
"stars": 5,
"commentary": "This is a great movie!"
}
}
Clients
- Postman
- Altair
- GraphiQL
- GraphQL Playground (fusionnera avec GraphiQL 2)
- GraphQL Voyager
Implémentation
Exposer des APIs GraphQL
GraphQL vs REST @ ekino
- Réutiliser la "stack classique ekino"
- JVM (Java/Kotlin)
- Gradle
- Spring Boot
- PostgreSQL
- IntelliJ IDEA
- RestAssured
- JCV
- Migration / Cohabitation des APIs REST déjà en place vers GraphQL
Choix des librairies
- Les plus
- Starters Spring Boot
- Projets actifs
- Développé en Java et Kotlin
- Les moins
- Documentation très légère voir inexistante
- Quelques issues ouvertes
- 2 maintainers officiels
Configuration initiale
- Ajouter la dépendance Gradle
implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:7.0.1")
- Installer le plugin IDEA: JS-GraphQL
- https://plugins.jetbrains.com/plugin/8097-js-graphql
- Validation et auto-completion des schémas / requêtes GraphQL
- Pas obligatoire mais fortement conseillé
Get articles
REST vs GraphQL
[REST] Get articles: doc
paths:
/articles:
get:
tags: [article]
summary: Get all articles
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: Articles list response
content:
application/json:
schema:
$ref: '#/components/schemas/Articles'
components:
schemas:
Articles:
title: List of articles
type: object
properties:
pagination:
$ref: '#/components/schemas/PaginatedResource'
articles:
type: array
items:
$ref: '#/components/schemas/Article'
Article:
title: Article
type: object
properties:
id:
title: ID of the article
type: string
format: uuid
readOnly: true
example: e827659f-4489-4f53-90b3-81cf44b1cf97
headline:
title: Headline of the article
type: string
example: Some awesome article!
content:
title: Content of the article
type: string
example: Some interesting article content
createdDate:
title: Creation date of the article
type: string
format: "date-time"
example: "2020-05-26T15:19:06.732068Z"
lastModifiedDate:
title: Date of the last modification of the article
type: string
format: "date-time"
example: "2020-05-26T15:19:06.732068Z"
author:
$ref: '#/components/schemas/Author'
Author:
title: Author
type: object
properties:
id:
title: ID of the author
type: string
format: uuid
readOnly: true
example: e827659f-4489-4f53-90b3-81cf44b1cf97
name:
title: Name of the author
type: string
example: John Doe
PaginatedResource:
title: Pagination properties
type: object
properties:
offset:
title: Pagination offset
type: integer
example: 20
limit:
title: Pagination limit
type: integer
example: 10
total:
title: Total result count for resource
type: integer
example: 250
parameters:
pathId:
name: id
description: ID of the entity
in: path
required: true
schema:
type: string
format: uuid
example: a07abde3-5ddd-4e7c-887e-e8a69eda4d28
offset:
name: offset
description: Pagination offset
in: query
schema:
type: integer
example: 20
limit:
name: limit
description: Pagination limit
in: query
schema:
type: integer
example: 10
[REST] Get articles: controller
const val ARTICLES_BASE_PATH = "/api/v1/articles"
@RestController
@RequestMapping(ARTICLES_BASE_PATH)
class ArticleApi(
private val articleService: ArticleService
) {
@GetMapping
@ResponseStatus(HttpStatus.OK)
fun getArticles(
@RequestParam(required = false, defaultValue = "0") offset: Long,
@RequestParam(required = false, defaultValue = "20") limit: Int
) =
articleService.getArticles(PaginationData(offset, limit))
.map { it.toDto() }
.toPageDto()
}
[GraphQL] Get articles: schéma
type Article {
id: ID!
"""
Headline of the article
Example: `Some awesome article!`
"""
headline: String!
"""
Content of the article
Example: `Some interesting article content`
"""
content: String!
author: Author!
createdDate: String!
lastModifiedDate: String!
}
type Author {
id: ID!
name: String!
}
type Pagination {
offset: Int!
limit: Int!
total: Int!
}
interface PagedResult {
pagination: Pagination!
}
type PagedArticles implements PagedResult {
pagination: Pagination!
articles: [Article!]!
}
type Query {
findArticles(offset: Int = 0, limit: Int = 20): PagedArticles!
}
@Component
class ArticleQuery(
private val articleService: ArticleService
) : GraphQLQueryResolver {
fun findArticles(offset: Int, limit: Int) = articleService.getArticles(
PaginationData(
offset = offset.toLong(),
limit = limit
)
)
.map { it.toDto() }
.toPageDto()
}
[GraphQL] Get articles: resolver
FieldResolverError: No method found as defined in schema <unknown>:2 with any of the following signatures (with or without one of [interface graphql.schema.DataFetchingEnvironment] as the last argument), in priority order:
GraphQLQueryResolver#findArticles(~offset, ~limit)
GraphQLQueryResolver#getFindArticles(~offset, ~limit)
Résolution / validation au démarrage
GET /api/v1/articles
Get articles: resultats
{
"pagination": {
"offset": 0,
"limit": 20,
"total": 2
},
"articles": [
{
"id": "9a2f1f77-4535-4aae-9c57-1e9b11bbfe63",
"headline": "headline2",
"content": "content2",
"author": {
"id": "d5a9666c-4a63-4aab-a366-0960fbec35aa",
"name": "name2"
},
"created_date": "2020-07-15T12:29:01.066067Z",
"last_modified_date": "2020-07-15T12:29:01.066067Z"
},
{
"id": "3ce1af88-cf58-4504-85f9-7edc7f0a2908",
"headline": "headline1",
"content": "content1",
"author": {
"id": "d5a9666c-4a63-4aab-a366-0960fbec35aa",
"name": "name2"
},
"created_date": "2020-07-15T12:29:00.961553Z",
"last_modified_date": "2020-07-15T12:29:00.961553Z"
}
]
}
# POST /graphql
query {
findArticles {
pagination {
total
}
articles {
id
headline
author {
name
}
lastModifiedDate
}
}
}
{
"data": {
"findArticles": {
"pagination": {
"total": 2
},
"articles": [
{
"id": "9a2f1f77-4535-4aae-9c57-1e9b11bbfe63",
"headline": "headline2",
"author": {
"name": "name2"
},
"lastModifiedDate": "2020-07-15T12:29:01.066067Z"
},
{
"id": "3ce1af88-cf58-4504-85f9-7edc7f0a2908",
"headline": "headline1",
"author": {
"name": "name2"
},
"lastModifiedDate": "2020-07-15T12:29:00.961553Z"
}
]
}
}
}
REST
GraphQL
Create article
REST vs GraphQL
[REST] Create article: doc
paths:
/articles:
post:
tags: [article]
summary: Create an article
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ArticleInput'
responses:
'201':
description: Article creation response
content:
application/json:
schema:
$ref: '#/components/schemas/Article'
components:
schemas:
ArticleInput:
title: Article input
type: object
properties:
headline:
title: Headline of the article
type: string
example: Some awesome article!
content:
title: Content of the article
type: string
example: Some interesting article content
const val ARTICLES_BASE_PATH = "/api/v1/articles"
@RestController
@RequestMapping(ARTICLES_BASE_PATH)
class ArticleApi(
private val articleService: ArticleService
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createArticle(
@RequestBody @Valid input: ArticleInputDto,
@AuthenticationPrincipal user: User
) =
articleService.createArticle(input, user).toDto()
}
[REST] Create article: controller
[GraphQL] Create article: schéma
input ArticleInput {
headline: String!
content: String!
}
extend type Mutation {
createArticle(input: ArticleInput!): Article!
}
@Component
class ArticleMutation(
private val articleService: ArticleService,
private val validatorService: ValidationService
) : GraphQLMutationResolver {
fun createArticle(input: ArticleInputDto): ArticleDto {
validatorService.validate(input)
return articleService.createArticle(input, authenticatedUser()!!).toDto()
}
}
fun authenticatedUser() = SecurityContextHolder
.getContext()
.authentication
?.principal
as? User
[GraphQL] Create article: resolver
/!\ Validation manuelle de l'input à faire
// POST /api/v1/articles
{
"headline": "Some headline...",
"content": "# My awesome Article!!! (WIP)"
}
Create article: resultats
{
"id": "5bd16151-545f-4fea-98fe-af97a0f06926",
"headline": "Some headline...",
"content": "# My awesome Article!!! (WIP)",
"author": {
"id": "690cfd5c-a041-49bf-af5e-df232333cc15",
"name": "name1"
},
"created_date": "2020-07-15T13:22:36.770240Z",
"last_modified_date": "2020-07-15T13:22:36.770240Z"
}
# POST /graphql
mutation {
createArticle(input: {
headline: "Some headline...",
content: "# My awesome Article!!! (WIP)"
}) {
id
}
}
{
"data": {
"createArticle": {
"id": "fa2aef6b-ef97-442f-a5a3-ffab5d533add"
}
}
}
REST
GraphQL
Article comments
GraphQL nested query
Article comments: schéma
type Article {
id: ID!
"""
Headline of the article
Example: `Some awesome article!`
"""
headline: String!
"""
Content of the article
Example: `Some interesting article content`
"""
content: String!
author: Author!
createdDate: String!
lastModifiedDate: String!
findComments(offset: Int = 0, limit: Int = 20): PagedComments!
}
type Comment {
id: String!
content: String!
author: Author!
createdDate: String!
lastModifiedDate: String!
}
type PagedComments implements PagedResult {
pagination: Pagination!
comments: [Comment!]!
}
@Component
class ArticleResolver(
private val commentService: CommentService
) : GraphQLResolver<ArticleDto> {
fun findComments(article: ArticleDto, offset: Int, limit: Int) =
commentService.getArticleComments(
articleId = article.id,
paginationData = PaginationData(
offset = offset.toLong(),
limit = limit
)
)
.map { it.toDto() }
.toPageDto()
}
Article comments: resolver
Article comments: resultats
# POST /graphql
query {
findArticles {
articles {
id
headline
findComments(limit: 2) {
pagination {
total
}
comments {
content
author {
name
}
}
}
lastModifiedDate
}
}
}
{
"data": {
"findArticles": {
"articles": [
{
"id": "5bd16151-545f-4fea-98fe-af97a0f06926",
"headline": "Some headline...",
"findComments": {
"pagination": {
"total": 0
},
"comments": []
},
"lastModifiedDate": "2020-07-15T13:22:36.770240Z"
},
{
"id": "9a2f1f77-4535-4aae-9c57-1e9b11bbfe63",
"headline": "headline2",
"findComments": {
"pagination": {
"total": 3
},
"comments": [
{
"content": "content4",
"author": {
"name": "name2"
}
},
{
"content": "content5",
"author": {
"name": "name2"
}
}
]
},
"lastModifiedDate": "2020-07-15T12:29:01.066067Z"
}
]
}
}
}
GraphQL tests
Comment tester?
Pre-requis
- Test d'intégration via Spring Boot Test
- Client HTTP via RestAssured
- Assertion JSON via {#JCV#}
[REST] Get articles: test
class ArticleApiTest : ComponentBase() {
@Test
fun `get articles`() {
// Given
dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
article()
article()
article()
article()
}
// When / Then
givenUser()
.param("limit", 3)
.get(ARTICLES_BASE_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst("article_list_ok.json"))
}
}
{
"pagination": {
"offset": 0,
"limit": 3,
"total": 4
},
"articles": [
{
"id": "{#uuid#}",
"headline": "headline4",
"content": "content4",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"created_date": "{#date_time_format:iso_instant#}",
"last_modified_date": "{#date_time_format:iso_instant#}"
},
{
"id": "{#uuid#}",
"headline": "headline3",
"content": "content3",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"created_date": "{#date_time_format:iso_instant#}",
"last_modified_date": "{#date_time_format:iso_instant#}"
},
{
"id": "{#uuid#}",
"headline": "headline2",
"content": "content2",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"created_date": "{#date_time_format:iso_instant#}",
"last_modified_date": "{#date_time_format:iso_instant#}"
}
]
}
Requête HTTP GraphQL
query {
findArticles(limit: 3) {
pagination {
offset
limit
total
}
articles {
id
headline
content
author {
id
name
}
createdDate
lastModifiedDate
}
}
}
Requête (GraphQL)
HTTP Body (JSON)
{
"query": "query {\\n findArticles(limit: 3) {\\n pagination {\\n offset\\n limit\\n total\\n }\\n articles {\\n id\\n headline\\n content\\n author {\\n id\\n name\\n }\\n createdDate\\n lastModifiedDate\\n }\\n }\\n}\\n"
}
Utilitaire de tests
- graphql-spring-boot-starter-test
- Simple client HTTP
- Utilisation/flexibilité limitée
- Utilitaire de construction simple de body HTTP
[GraphQL] Get articles: test
class ArticleQueryTest : ComponentBase() {
@Test
fun `should find articles paginated`() {
// Given
dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
article()
article()
article()
article()
}
// When / Then
givenUser()
.contentType(ContentType.JSON)
.body(
graphQLRequestBody(
getResourceContent("findArticles_request.graphql")
)
)
.post(GRAPHQL_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst("findArticles_response.json"))
}
}
{
"data": {
"findArticles": {
"pagination": {
"offset": 0,
"limit": 3,
"total": 4
},
"articles": [
{
"id": "{#uuid#}",
"headline": "headline4",
"content": "content4",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
},
{
"id": "{#uuid#}",
"headline": "headline3",
"content": "content3",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
},
{
"id": "{#uuid#}",
"headline": "headline2",
"content": "content2",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
}
]
}
}
}
query {
findArticles(limit: 3) {
pagination {
offset
limit
total
}
articles {
id
headline
content
author {
id
name
}
createdDate
lastModifiedDate
}
}
}
[REST] Create article: test
class ArticleApiTest : ComponentBase() {
@Test
fun `create article`() {
// Given
val user = dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
}
// When / Then
givenAppUser(user)
.contentType(ContentType.JSON)
.body(
getResourceContent("article_create_input.json")
)
.post(ARTICLES_BASE_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.CREATED.value())
.body(isValidAgainst("article_create_ok.json"))
}
}
{
"id": "{#uuid#}",
"headline": "Some awesome article!",
"content": "Some interesting article content",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"created_date": "{#date_time_format:iso_instant#}",
"last_modified_date": "{#date_time_format:iso_instant#}"
}
{
"headline": "Some awesome article!",
"content": "Some interesting article content"
}
[GraphQL] Create article: test#1
class ArticleMutationTest : ComponentBase() {
@Test
fun `should create article #1`() {
// Given
val user = dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
}
// When / Then
givenAppUser(user)
.body(
graphQLRequestBody(
getResourceContent("createArticle_request.graphql")
)
)
.post(GRAPHQL_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst("createArticle_ok_response.json"))
}
}
{
"data": {
"createArticle": {
"id": "{#uuid#}",
"headline": "Some awesome article!",
"content": "Some interesting article content",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
}
}
}
mutation {
createArticle(input: {
headline: "Some awesome article!"
content: "Some interesting article content"
}) {
id
headline
content
author {
id
name
}
createdDate
lastModifiedDate
}
}
Requête HTTP GraphQL
mutation ($ep: Episode!, $review: ReviewInput!) {
createReview(episode: $ep, review: $review) {
stars
comment:commentary
}
}
Requête (GraphQL)
Variables (JSON)
{
"ep": "JEDI",
"review": {
"stars": 5,
"commentary": "This is a great movie!"
}
}
HTTP Body (JSON)
{
"query": "mutation ($ep: Episode!, $review: ReviewInput!) {\n createReview(episode: $ep, review: $review) {\n stars\n comment:commentary\n }\n}",
"variables": {
"ep": "JEDI",
"review": {
"stars": 5,
"commentary": "This is a great movie!"
}
}
}
[GraphQL] Create article: test#2
class ArticleMutationTest : ComponentBase() {
@Test
fun `should create article`() {
// Given
val user = dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
}
// When / Then
givenAppUser(user)
.body(
graphQLRequestBody(
getResourceContent("createArticle_request_2.graphql"),
"headline" to "Some awesome article!",
"content" to "Some interesting article content"
)
)
.post(GRAPHQL_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst("createArticle_ok_response.json"))
}
}
{
"data": {
"createArticle": {
"id": "{#uuid#}",
"headline": "Some awesome article!",
"content": "Some interesting article content",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
}
}
}
mutation ($headline: String!, $content: String!) {
createArticle(input: {
headline: $headline
content: $content
}) {
id
headline
content
author {
id
name
}
createdDate
lastModifiedDate
}
}
[GraphQL] Create article: test#3
class ArticleMutationTest : ComponentBase() {
@Test
fun `should create article`() {
// Given
val user = dataDsl {
appUser {
email = "user1@yopmail.com"
name = "user1"
role = Role.USER
}
}
// When / Then
givenAppUser(user)
.body(
graphQLRequestBody(
getResourceContent("createArticle_request_3.graphql"),
"input" to ArticleInputDto(
headline = "Some awesome article!",
content = "Some interesting article content"
)
)
)
.post(GRAPHQL_PATH)
.then()
.log().ifValidationFails()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst("createArticle_ok_response.json"))
}
}
{
"data": {
"createArticle": {
"id": "{#uuid#}",
"headline": "Some awesome article!",
"content": "Some interesting article content",
"author": {
"id": "{#uuid#}",
"name": "user1"
},
"createdDate": "{#date_time_format:iso_instant#}",
"lastModifiedDate": "{#date_time_format:iso_instant#}"
}
}
}
mutation ($input: ArticleInput!) {
createArticle(input: $input) {
id
headline
content
author {
id
name
}
createdDate
lastModifiedDate
}
}
Observations
Quelques tests et configurations plus tard...
Désérialisation "camelCase"
- Utilisation de l'ObjectMapper configuré dans Spring par défaut
- Property naming strategy en snake_case ne fait pas bon ménage avec la résolution des resolvers / désérialisation des inputs
- Surchage de la configuration de l'ObjectMapper nécessaire
Configuration ObjectMapper
@Configuration
class GraphQLConfig {
@Bean
fun objectMapperProvider(objectMapper: ObjectMapper): ObjectMapperProvider = ObjectMapperProvider {
configure(objectMapper.copy())
}
@Bean
fun perFieldObjectMapperProvider(objectMapper: ObjectMapper): PerFieldObjectMapperProvider {
return PerFieldObjectMapperProvider {
configure(objectMapper.copy())
}
}
private fun configure(objectMapper: ObjectMapper): ObjectMapper {
return objectMapper
.also {
it.propertyNamingStrategy = PropertyNamingStrategy.LOWER_CAMEL_CASE
}
.findAndRegisterModules()
}
}
graphql.tools.use-default-objectmapper: false
graphql.servlet.use-default-objectmapper: false
Gestion des erreurs
- Système distinct de celui dédié aux controllers REST
- Interprétation des exceptions en "Internal Error" par défaut
-
@ExceptionHandler possible
- Désactivé par défaut
- Pas de contexte de requête
- Contournement possible en ré-implémentant le "DataFetcherExceptionHandler" pour conserver le contexte du graph
Configuration des erreurs
class CustomDataFetcherExceptionHandler: DataFetcherExceptionHandler {
override fun onException(handlerParameters: DataFetcherExceptionHandlerParameters): DataFetcherExceptionHandlerResult {
val exception = handlerParameters.exception
val sourceLocation = handlerParameters.sourceLocation
val path = handlerParameters.path
val exceptionWithEnv = DataFetcherNestedException(exception, handlerParameters.dataFetchingEnvironment)
val error = ExceptionWhileDataFetching(path, exceptionWithEnv, sourceLocation)
return DataFetcherExceptionHandlerResult.newResult().error(error).build()
}
}
@Component
class CustomGraphQLErrorHandler(
private val errorMessageSource: ResourceBundleMessageSource
) {
@ExceptionHandler(DataFetcherNestedException::class)
fun handle(e: DataFetcherNestedException): GraphQLError = handle(e.originalException, e.dataFetchingEnvironment)
@ExceptionHandler(Throwable::class)
fun handle(e: Throwable): GraphQLError = handle(e, null)
fun handle(e: Throwable, env: DataFetchingEnvironment? = null): GraphQLError {
val envLocations = env?.getSourceLocation()?.toMutableList()
return when (e) {
is BusinessRuleException -> object : GraphQLError {
override fun getMessage(): String = e.getMessage(errorMessageSource, env?.resolveLocale() ?: Locale.ENGLISH)
override fun getErrorType(): ErrorClassification = ErrorType.ValidationError
override fun getLocations(): MutableList<SourceLocation>? = envLocations
override fun getExtensions(): MutableMap<String, Any> = mutableMapOf(
"code" to e.getCode()
)
}
is NotFoundException -> object : GraphQLError {
override fun getMessage(): String = e.message ?: "Not found"
override fun getErrorType(): ErrorClassification = ErrorType.DataFetchingException
override fun getLocations(): MutableList<SourceLocation>? = envLocations
override fun getExtensions(): MutableMap<String, Any> = mutableMapOf()
}
else -> object : GraphQLError {
override fun getMessage(): String = e.message ?: "An error occurred (${e.javaClass.canonicalName})"
override fun getErrorType(): ErrorClassification = ErrorType.DataFetchingException
override fun getLocations(): MutableList<SourceLocation>? = envLocations
override fun getExtensions(): MutableMap<String, Any> = mutableMapOf()
}
}
}
private fun DataFetchingEnvironment.resolveLocale() = getContext<DefaultGraphQLServletContext>()
.httpServletRequest
.locale
private fun DataFetchingEnvironment.getSourceLocation() = listOf(mergedField.singleField.sourceLocation)
}
@Configuration
class GraphQLConfig {
@Bean
fun executionStrategy(): ExecutionStrategy = AsyncExecutionStrategy(CustomDataFetcherExceptionHandler())
}
graphql.servlet.exception-handlers-enabled: true
Erreurs avant/après
# @ExceptionHandler enabled
{
"errors": [
{
"message": "User can't be created because there is already a user with this email"
}
]
}
# Custom ExceptionHandler + DataFecthingEnvironment
{
"errors": [
{
"message": "L'utilisateur ne peut être créé car il en existe déjà un avec cet email",
"locations": [
{
"line": 2,
"column": 3
}
],
"extensions": {
"code": "error.user.create.conflict",
"classification": "ValidationError"
}
}
]
}
# Default configuration
{
"errors": [
{
"message": "Internal Server Error(s) while executing query"
}
]
}
Données + erreurs
- Tout retour d'un noeud suite à une exception qui n'est pas nullable, rend le schéma invalide = pas de "data"
- Rendre le retour nullable permet d'obtenir une réponse partielle ainsi que les erreurs
{
"data": {
"findArticles": {
"pagination": {
"limit": 20,
"total": 2
},
"articles": [
{
"id": "2d36162a-b5b9-4fee-bda5-01e9fe0d72e7",
"headline": "headline2",
"lastModifiedDate": "2020-07-16T09:10:54.150765Z"
},
{
"id": "0f512968-43fb-4ea9-a309-f0b7f5af8107",
"headline": "headline1",
"lastModifiedDate": "2020-07-16T09:10:54.054128Z"
}
]
}
},
"errors": [
{
"message": "Accès refusé",
"locations": [
{
"line": 11,
"column": 7
}
],
"extensions": {
"classification": "DataFetchingException"
}
},
{
"message": "Accès refusé",
"locations": [
{
"line": 11,
"column": 7
}
],
"extensions": {
"classification": "DataFetchingException"
}
}
]
}
query {
findArticles {
pagination {
limit
total
}
articles {
id
headline
lastModifiedDate
votes {
count
}
}
}
}
DataFetchingEnvironment
- Contient le contexte d'exécution dans le graph
- Peut être injecté en dernier paramètre de resolver
- Pas toujours complètement renseigné
// Header: "Accept-Language: fr"
// env: DataFetchingEnvironment
/**
* @return the current {@link java.util.Locale} instance used for this request
*/
env.locale // == null
env.getContext<DefaultGraphQLServletContext>()
.httpServletRequest
.locale // == Locale.FRENCH
Ajouter des types "scalar"
- Possibilité de définir des types "scalar" customs
- Déclaration manuelle dans le schéma
- Conversion à implémenter
- Librairie d'extension des types
- https://github.com/graphql-java/graphql-java-extended-scalars
- Intéressant mais incomplet (ex: OffsetDateTime mais pas Instant)
- Délégation de l'implémentation à Jackson possible
Scalar "Void"
"""
A type to use when no result is expected (may result in a null returned value).
"""
scalar Void
@Configuration
class GraphQLScalarConfig {
@Bean
fun voidScalarType(): GraphQLScalarType = GraphQLScalarType
.newScalar()
.name("Void")
.coercing(VoidCoercing())
.build()
}
class VoidCoercing : Coercing<Unit, String?> {
override fun serialize(dataFetcherResult: Any?): String? = null
override fun parseValue(input: Any?) {}
override fun parseLiteral(input: Any?) {}
}
Scalar "UtcDateTime" #1
"""
An RFC-3339 compliant UTC DateTime Scalar.
Example:
`1985-04-12T23:20:50.52Z` represents 20 minutes and 50.52 seconds after the 23rd hour of
April 12th, 1985 in UTC.
"""
scalar UtcDateTime
@Configuration
class GraphQLScalarConfig {
@Bean
fun utcDateTimeScalarType(): GraphQLScalarType = GraphQLScalarType
.newScalar()
.name("UtcDateTime")
.coercing(UtcDateTimeCoercing())
.build()
}
class UtcDateTimeCoercing : Coercing<Instant, String> {
override fun serialize(input: Any?): String {
val instant: Instant = when (input) {
is Instant -> input
is OffsetDateTime -> input.toInstant()
is ZonedDateTime -> input.toInstant()
is String -> parseInstant(input, ::CoercingSerializeException)
else -> throw CoercingSerializeException(
"""Expected something we can convert to 'java.time.Instant' but was '${Kit.typeName(input)}'."""
)
}
try {
return DateTimeFormatter.ISO_INSTANT.format(instant)
} catch (e: DateTimeException) {
throw CoercingSerializeException(
"""Unable to turn TemporalAccessor into Instant because of : '${e.message}'."""
)
}
}
override fun parseValue(input: Any?): Instant = when (input) {
is Instant -> input
is OffsetDateTime -> input.toInstant()
is ZonedDateTime -> input.toInstant()
is String -> parseInstant(input, ::CoercingParseValueException)
else -> throw CoercingParseValueException(
"""Expected a 'String' but was '${Kit.typeName(input)}'."""
)
}
override fun parseLiteral(input: Any?): Instant = (input as StringValue?)
?.let { parseInstant(it.value, ::CoercingParseLiteralException) }
?: throw CoercingParseLiteralException(
"""Expected AST type 'StringValue' but was '${Kit.typeName(input)}'."""
)
private fun parseInstant(input: String, exceptionMaker: (String) -> RuntimeException): Instant = try {
Instant.parse(input)
} catch (e: DateTimeException) {
throw exceptionMaker(
"""Invalid RFC3339 value : '$input'. because of : '${e.message}'"""
)
}
}
Scalar "UtcDateTime" #2
"""
An RFC-3339 compliant UTC DateTime Scalar.
Example:
`1985-04-12T23:20:50.52Z` represents 20 minutes and 50.52 seconds after the 23rd hour of
April 12th, 1985 in UTC.
"""
scalar UtcDateTime
@Configuration
class GraphQLScalarConfig {
@Bean
fun utcDateTimeScalarType(): GraphQLScalarType = GraphQLScalarType
.newScalar(Scalars.GraphQLString)
.name("UtcDateTime")
.build()
}
Directives
- Support des directives standards
- @include
- @skip
- @deprecated
- Directives de validation
- Directives de "connection" (aka "pagination") via Relay
Directives de validation
- Définitions bas niveau, pas de configuration starter
- Déclaration complète manuelle dans le schéma
- Doublon avec la validation serveur
- Système différent de javax.validation
- Validation DTOs côté serveur manuelle
- Incompatibilité avec la version d'Hibernate tirée par SpringBoot (2.3.1)
Directives de "connection"
- Système de pagination par curseur
- Intégré dans le starter
- Pas de configuration de types nécessaire mais schéma incomplet car définitions créées au runtime
- Implémentation Java simpliste
- graphql.relay.SimpleListConnection
- n'est pas compatible avec la notion de "Pageable" SpringData
Connection: exemple
query {
findArticles(first: Int, after: String): ArticleConnection @connection(for: "Article")
}
type ArticleConnection {
edges: [ArticleEdge]
pageInfo: PageInfo
}
type ArticleEdge {
cursor: String
node: Article
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
}
Types générés au runtime:
Sécurité du enpoint /graphql
- Fonctionne comme pour le RestController
- @PreAuthorize
- SecurityContextHolder
- ...
- Filtre authentification "habituel" pour REST inadapté pour l'utilisation anonyme et authentifié sur le même endpoint
Conclusion
Alors GraphQL ou pas?
Les + de GraphQL vs REST
- Schéma standard pour découvrir et documenter plus facilement les services
- Disponible à travers HTTP
- Queries multiples dans une même requête HTTP
- Présentation sous forme de graph / noeuds plutôt que des ressources / sous-ressources
- Le client choisit ce qu'il veut récupérer au besoin
- Des librairies permettent de l'implémenter "facilement" en Java / Kotlin
Les - de GraphQL vs REST
- Les librairies
- Documentation très pauvre sur la configuration et le comportement de certaines librairies
- Pas forcement très mature / complète
- Sécurité plus sensible si le endpoint autorise des accès non authentifiés
- Usage beaucoup moins répandu / demandé par les clients
Retours d'expérience #1
- Ce que j'ai aimé 👍
- Très agréable à utiliser (via les bons outils / clients)
- Gestion plus fine / naturelle des relations entre les entités du modèle
- Pas plus difficile à tester
- Distinction claire entre read ("Query") et write ("Mutation")
- Moins de réflexion sur la structure exposée (pas de status, verbe, chemin...)
- Peut exister en parallèle d'APIs REST déjà en place
- Ce qui m'a freiné 👎
- Beaucoup de recherche/exploration par manque de documentation
- Pas de validation automatique des inputs
Retours d'expérience #2
- Ce que je n'ai pas encore testé / approfondi
- Sécurité mixte entre anonyme et authentifié
- Perfomances graphes volumineux ?
- Comment limiter la profondeur d'une query ?
- Concevoir des DTOs dédiés au schéma GraphQL
- Lazy fetch d'ID uniquement pour alimenter les resolvers si le détail est demandé
- Implémenter un support des connections Relay avec Spring Data
- Tester d'autres librairies/starters
- Tester l'upload de fichiers
- Générer un schéma à partir du code
- Comment monitorer les requêtes ?
- ...
Merci
Et pourquoi pas GraphQL
By Leo Millon
Et pourquoi pas GraphQL
- 132