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

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

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")

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
  • 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

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