Communiquer avec l'API Google Sheet en Java

Comme tous les services Google, GSheet est accessible via API. Et comme toute API, google proposer un sdk de helper pour vous faciliter la vie.

Cependant, cette évidence amène un ensemble de soucis auxquels mon équipe et moi on a dû faire face récemment, alors je vous propose ce petit "truc et astuce" pour vous aider à lire/écrire des Google Spreadsheets avec java.

Ma principale motivation à en faire un billet est que des dizaines de tuto existent sur internet pour faire ça. Ils ont tous néanmoins deux faiblesses majeures :

  • ils reposent sur l’idée que vous allez pouvoir demander à vos utilisateurs de valider l’accès avec leur navigateur
  • ils utilisent les API de 2015, qui sont toutes dépréciées

Du coup, je me dis qu’un petit billet en Français avec un code à jour, ça peut être utile.

OAuth -tu là?

L’ensemble des applications Google reposent sur OAuth. L’identification se fait via leur IAM interne qui est branché à votre compte google perso ou d’entreprise.

Comme le permet OAuth, plusieurs flux d’authentification existent, ceux qui vont nous intéresser aujourd’hui sont ceux que google appellent le flux "InstalledApp" et le flux "Service User" qui est une implémentation de "Implicit Grant".

Ce billet se concentre sur ces deux méthodes car elles seront les plus utilisées en Java.

InstalledApp Flow, la base pour votre application

Se préparer

L’installedApp Flow est le mode d’utilisation à mettre en place si vous avez un client lourd ou une application mobile. La raison à cela est que l’application va devoir demander à ce que vous ouvriez le navigateur pour sélectionner votre utilisateur.

Avant de coder, nous allons donc aller dans l’interface d’admin du google workspace. Une fois cela fait, il faudra absolument créer une application.

C’est bien une application qu’il faudra créer, en précisent si c’est une app web ou pas, mais surtout pas une simple clef d’API. Ces dernières ne pourront qu’accéder en lecture aux feuilles publiques, jamais aux feuilles privées.

Une fois votre credentials OAuth créé, vous allez devoir télécharger le json qui lui est associé. Le mettre dans les ressources de votre projet java.

Coder

Maintenant vous pouvez créer votre projet java.

Si vous utilisez maven, ajoutez ces dépendances à votre pom :

        <dependency>
            <groupId>com.google.api-client</groupId>
            <artifactId>google-api-client</artifactId>
            <version>1.35.2</version>
        </dependency>
        <dependency>
            <groupId>com.google.oauth-client</groupId>
            <artifactId>google-oauth-client-jetty</artifactId>
            <version>1.34.1</version>
        </dependency>
        <dependency>
            <groupId>com.google.apis</groupId>
            <artifactId>google-api-services-sheets</artifactId>
            <version>v4-rev612-1.25.0</version>
        </dependency>
attention il existe des verisons plus récentes de google-api-client, mais elles ne sont pas compatibles avec la version la plus récente de api-services-sheet… Merci google

A partir de là, vous allez pouvoir mettre en place le code fourni par tous les tutos du monde. Je vous propose le code pour la lecture :


public class Main {
// adaptez le nom en fonction de celui de votre fichier
private static final String CREDENTIALS_FILE_PATH = "client_secrets.json"; 
private static HttpRequestInitializer getCredentials(final NetHttpTransport HTTP_TRANSPORT, String path)
            throws IOException {

        // Load client secrets.
        ExternalPreferences preferences = mapper.loadPreferences();
        try (InputStream in = Main.class.getResourceAsStream(CREDENTIALS_FILE_PATH)) {
            // Build flow and trigger user authorization request.
        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
                        .setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH)))
                        .setAccessType("offline").build();
        LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(-1).build();
        //Mettre le port a -1 permet d'utiliser n'importe quel port de libre.
        // Ceci dit c'est assez peu conseillé : dans votre configuration vous avez entré un port
        // pour votre callback url et google s'attend à contacter ce port
        // notons que LocalServerReceiver va vraiment écouter un port, ce qui implique que si vous ne 
        // mettez pas -1 il soit libre.
        return new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
        }
    }

    public static void readSheet(String spreadsheetId, String path) {
        final NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
        // Le string dans Application Name permettra d'afficher le nom de votre application dans le popup
        // de login de google
        Sheets service = new Sheets.Builder(httpTransport, GsonFactory.getDefaultInstance(), getCredentials(httpTransport, path))
                .setApplicationName("An arbitrary string").build();
        // On récupère toute la spreadsheet (y compris les autres feuilles
        Sheets.Spreadsheets.Get request = service.spreadsheets().get(spreadsheetId);
        // Important, sinon 0 données ne sera récupérées
        request.setIncludeGridData(true);
        // Récupération de la spreadsheet
        Spreadsheet spreadsheet = request.execute();
        
        /**
         * Une feuille est composé d'une liste de GridData, ces GridData contiennent toutes les informations nécéssaires
         * : styles, largeur, background couleur etc. A partir des GridData, on peut récupérer des RowsData.
         */
        List<Sheet> sheets = spreadsheet.getSheets();
        for (Sheet sheet : sheets) {
            System.out.println("Titre: " + sheet.getProperties().getTitle());
            List<GridData> datas = sheet.getData();
            // bien que ça soit une liste, je n'ai toujours eu qu'une seule griddata par sheet
            // peut être mon échantillon de test n'était-il pas assez large
            for (GridData data : datas) {
                List<RowData> rowdata = data.getRowData();
                // me demandez pas pourquoi, il m'est arrivé d'avoir un null, sans savoir d'où ça venait
                if (rowdata != null) {
                    for (RowData row : rowdata) {
                        for (CellData cell: row.getValues()) {
                            //accéder ici aux valeurs
                        }
                    }
                }
            }
        }
}
Plus d’info sur la doc https://developers.google.com/sheets/api/guides/values#java_1

Quelques remarques

Déjà les API sont complètes, et parfois redondées. On peut avoir les valeurs à partir des sheets comme je l’ai fait ou à partir d’autres endpoints.

Ensuite, côté fonctionnement, si vous mettez un identifiant de feuille correct, vous allez être invité à vous connecter à votre compte google puis le système va la lire.

Pour détecter que vous vous êtes connecté, le système a créé un listener qui va écouter un port de retour sur lequel votre navigateur va envoyer une requête en résultat de votre authentification.

Et là, c’est le drame :

  • le listener a du mal à s’arrêter même quand on fait "stop", ce qui force à avoir le port en écoute tout le temps.
  • cela signifie que votre application doit être installée sur la même machine que votre browser
  • vous ne pouvez pas faire de tâche en fond avec ce système.

C’est pour ça qu’on va devoir passer à la suite : les comptes de service.

Compte de service et GSheet

Le but de mon équipe est de pouvoir consommer des GSheet en backend, on ne peut donc pas demander à un utilisateur d’aller sur son navigateur dès qu’on a besoin d’accéder à une sheet.

L’astuce consiste à utiliser les comptes de service et c’est là que les tutos sur internet sont à la ramasse.

Il vous faudra d’abord créer le compte de service et le lier aux credentials OAuth précedemment créés.

Une fois cela fait, un nouveau fichier json devra être téléchargé. Si vous êtes curieux vous verrez que la structure est un peu différente :

  • tous les champs sont à la racine
  • il y a des clefs privée : il faut donc absolument sécuriser ce fichier

Vous pouvez retirer le fichier précédemment ajouté à vos ressources, ça ne sert plus à rien.

Par contre, pour commencer à tester, il va falloir setter la variable d’environnement GOOGLE_APPLICATION_CREDENTIALS pour qu’elle pointe vers notre nouveau fichier json.

Le code précédent va simplement être changé au niveau de la fonction credentials, mais avant ça il faut… ajouter deux dépendances (ça serait pas drôle sinon) :

        <dependency>
            <groupId>com.google.auth</groupId>
            <artifactId>google-auth-library-credentials</artifactId>
            <version>1.11.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.auth</groupId>
            <artifactId>google-auth-library-oauth2-http</artifactId>
            <version>1.11.0</version>
        </dependency>
Oui, pour ne pas avoir les API dépréciées, il faut deux dépendances

On va pouvoir changer notre méthode :

    ServiceAccountCredentials credentials = (ServiceAccountCredentials) GoogleCredentials.getApplicationDefault();
    credentials.createScoped(SCOPES);
    return new HttpCredentialsAdapter(credentials);

Attention wrapper dans HttpCredentialsAdapter est obligatoire pour rester compatible avec le reste de l’API.

Et voilà.

ça marche pas

Ah mais oui, j’ai oublié un détail, maintenant il va falloir que votre utilisateur de service ait accès aux documents. Pour cela deux possibilités :

  • lui donner un à un les accès aux documents et dossiers via le partage. Son nom est trouvable dans le fichier json, dans l’attribut client_email;
  • faire un domain-level delegation dans votre console d’administration.

Maintenant ça marche.

Je suis vraiment obligé d’utiliser la variable d’environnement

Oui et non (tu aimes?). Globalement une variable d’environnement aide à la dockerisation car du coup c’est facilement configurable.

Notre équipe a choisi de stocker les credentials dans un vault pour lequel nous avons une API qui retourne un InputStream. Au lieu de faire getApplicationDefault il suffit alors de faire fromStream et le tour est joué.


10 commentaires

J’ai fait tous ce que vous avez décrit mais j’ai l’erreur :

Could not find matching constructor for: com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlowBuilder(com.google.api.client.http.javanet.NetHttpTransport,com.google.api.client.json.gson.GsonFactory,java.lang.String,java.util.CollectionsBuilder(com.google.api.client.http.javanet.NetHttpTransport, com.google.api.client.json.gson.GsonFactory, java.lang.String, java.util.CollectionsSingletonList)

Sur la ligne :

GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(HTTP_TRANSPORT, JSON_FACTORY, CREDENTIALS_FILE_PATH, SCOPES).setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH))).setAccessType("offline").build();

Pourriez-vous m’apporter votre éclairage svp ?

Dans un autre test case, j’ai :

java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;

pour la ligne : ValueRange response = service.spreadsheets().values().get(spreadsheetId, range).execute();

service correspond à : Sheets service = new Sheets.Builder(HTTP_TRANSPORT, JSON_FACTORY,getCredentials(HTTP_TRANSPORT)).setApplicationName(APPLICATION_NAME).build();

Je bug depuis 2 semaines c à n’y rien comprendre et je ne trouve pas ou poster ce msg chez google, arg!!

Merci pour votre retour

BOnjour:

java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;

Les bibliothèques utilisées sont-elles exactement les mêmes (même groupId, même artifactId et surtout même version?) et surtout toutes les libs ont-elles été importées?

C’est d’ailleurs la même question pour la toute première erreur. Surtout, il faut s’assurer de n’avoir que ces versions dans le classpath.

J’ai 40 librairies et elles sont toutes à jour :

image.png
image.png
image.png
image.png

J’ai suivi scrupuleusement un article qui date d’il y’a 3 ans mais je n’ai aucune idée de quel librairie il faut downgrader (car il est sûre que la version de votre code marche avec des librairies antérieurs aux dernières versions, c’est sur !)

Je n’utilise pas d’outil de gestion de dépendances quel que Maven ou Gradle car Katalon est au stade du POC avec Gradle.

Pourriez-vous me dire quel librairies utilisez vous svp ?

Cela me permettra de supprimer celle qui n’ont rien à faire. Merci

C’est complqué car vous utilisez un ensemble de libs qui sont totalement différentes de celles que je mets dans ce billet. Certaines sont plus récentes, d’autres plus anciennes.

Voici, pour information, la résolution des dépendances que donne maven chez moi :

Arbre des dépendances maven
Arbre des dépendances maven

J’ai trouvé ceux qu’il faut que je télécharge : Manquantes : image.png

Pourrais-tu me dire comment se fait-il que tu as ces libs là?? Quel tutos as-tu suivi ?

A re-uploader: google-auth-library-credentials-1.11.0.jar google-auth-library-oauth2-http-1.11.0.jar google-http-client-1.42.0.jar google-http-client-apache-v2–1.42.0.jar google-http-client-gson-1.42.0.jar httpcore-4.4.15.jar

Je vais supprimer tous les autres jar et je verrai…

Merci pour ton retour

Le premier c’est pas nécessaire, c’est le nom de notre projet à nous :p

open-census je sais pas si c’est nécessire.

Pour les libs, c’est mon collègue qui a trouvé la bonne association par essai/échec car les tutos sur internet étaient pêtés. C’est d’ailleurs une des raisons qui m’ont poussé à faire ce billet.

Donc j’ai enlever tous les libs et est laissé que les 15 lib que tu m’a donné. tjrs la même erreur :

java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;


janv. 04, 2023 2:33:20 PM com.google.api.client.util.store.FileDataStoreFactory setPermissionsToOwnerOnly
AVERTISSEMENT: unable to change permissions for everybody: C:\Users\soukna\nefertiti_auto\tokens
janv. 04, 2023 2:33:20 PM com.google.api.client.util.store.FileDataStoreFactory setPermissionsToOwnerOnly
AVERTISSEMENT: unable to change permissions for owner: C:\Users\soukna\nefertiti_auto\tokens
2023-01-04 14:33:21.697 ERROR k.k.c.m.CustomKeywordDelegatingMetaClass - ❌ java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;
2023-01-04 14:33:21.707 ERROR c.k.katalon.core.main.TestCaseExecutor   - ❌ Test Cases/Draft/LireGoogleSheet FAILED.
Reason:
org.codehaus.groovy.runtime.InvokerInvocationException: java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;
	at technicalFunctions.ReadSpreadsheet.invokeMethod(ReadSpreadsheet.groovy)
	at com.kms.katalon.core.main.CustomKeywordDelegatingMetaClass.invokeStaticMethod(CustomKeywordDelegatingMetaClass.java:55)
	at LireGoogleSheet.run(LireGoogleSheet:26)
	at com.kms.katalon.core.main.ScriptEngine.run(ScriptEngine.java:194)
	at com.kms.katalon.core.main.ScriptEngine.runScriptAsRawText(ScriptEngine.java:119)
	at com.kms.katalon.core.main.TestCaseExecutor.runScript(TestCaseExecutor.java:448)
	at com.kms.katalon.core.main.TestCaseExecutor.doExecute(TestCaseExecutor.java:439)
	at com.kms.katalon.core.main.TestCaseExecutor.processExecutionPhase(TestCaseExecutor.java:418)
	at com.kms.katalon.core.main.TestCaseExecutor.accessMainPhase(TestCaseExecutor.java:410)
	at com.kms.katalon.core.main.TestCaseExecutor.execute(TestCaseExecutor.java:285)
	at com.kms.katalon.core.main.TestCaseMain.runTestCase(TestCaseMain.java:142)
	at com.kms.katalon.core.main.TestCaseMain.runTestCase(TestCaseMain.java:133)
	at com.kms.katalon.core.main.TestCaseMain$runTestCase$0.call(Unknown Source)
	at TempTestCase1672839187342.run(TempTestCase1672839187342.groovy:25)
Caused by: java.lang.NoSuchMethodError: com.google.api.client.http.HttpRequest.setResponseReturnRawInputStream(Z)Lcom/google/api/client/http/HttpRequest;
	at com.google.api.client.googleapis.services.AbstractGoogleClientRequest.buildHttpRequest(AbstractGoogleClientRequest.java:429)
	at com.google.api.client.googleapis.services.AbstractGoogleClientRequest.executeUnparsed(AbstractGoogleClientRequest.java:525)
	at com.google.api.client.googleapis.services.AbstractGoogleClientRequest.executeUnparsed(AbstractGoogleClientRequest.java:466)
	at com.google.api.client.googleapis.services.AbstractGoogleClientRequest.execute(AbstractGoogleClientRequest.java:576)
	at com.google.api.client.googleapis.services.AbstractGoogleClientRequest$execute.call(Unknown Source)
	at technicalFunctions.ReadSpreadsheet.getSpreadSheetRecords(ReadSpreadsheet.groovy:120)
	... 14 more

Sur la ligne :

ValueRange response = service.spreadsheets().values().get(spreadsheetId, range).execute();

Je ne sais vraiment pas ce qui ne va pas.

De rien, merci beaucoup d’avoir posté la réponse à ton soucis pour que d’autres puissent en profiter! Ne connaissant pas katalon et ayant bien plus l’habitude d’utiliser des systèmes de build où j’obtiens facilement les arbres de dépendances, c’était un peu difficile pour moi d’investiguer comme ça. Mais tant mieux si tu as trouvé le bon jar.

Bonjour, il y a une erreur dans la fonction qui génère les credentials du compte de service.

credentials.createScope(SCOPES)

Cette fonction n’a pas d’effet de bord, par conséquent, son utilisation ne sert à rien sans réaffecter la variable :

credentials = (ServiceAccountCredentials) credentials.createScoped(scopes);

Cela peut amener à des problèmes de permissions, des erreurs 401 et d’autres choses dans le même style.

Cependant, merci pour le tutoriel, il était très utile !

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte