Парсим данные из Google Sheets с помощью Kotlin и Retrofit в Android

Зависимости

Для работы понадобится Retrofit и Gson

implementation ("com.squareup.retrofit2:retrofit:2.11.0")
implementation ("com.squareup.retrofit2:converter-gson:2.11.0")

Формат зависимостей — старый, поскольку он подходит как для проектов с version catalog, так и без него.

Получение данных с Google Sheets

Retrofit позволяет получить данные с Google Sheets (далее GS) довольно просто, а главное — без всяких API-ключей, по ссылке: для этого нужно только открыть доступ к таблице и получить на неё ссылку.

Для начала нам понадобится сервис, реализующий запросы к GS. Запрос имеет следующую форму: https://docs.google.com/spreadsheets/d//<какой-то запрос>. Одним из вариантов получения данных — JSON, и запрос на его получение выглядит так: «gviz/tq? tqx=out: json».

Сервис с одним запросом на получение Json’а выглядит следующим образом:

interface GSheetsService {
  
  @GET("gviz/tq?tqx=out:json")    
  suspend fun getSheetData(): ResponseBody

}

Где ResponseBody — класс для хранения всех данных с запроса, но нам от него понадобится лишь метод string (), который позволит получить строковое представление данных

Далее идет реализация этого интерфейса с помощью Retrofit:

val gSService = Retrofit.Builder()    
  .baseUrl(
    "https://docs.google.com/spreadsheets/d//"
  ).build().create(GSheetsService::class.java)

P.S. Я не использую здесь convert factory, об этом будет позже при рассмотрении структуры тела ответа.

Непосредственно, данные

В качестве тестовых данных я возьму табличку, и помещу туда следующие данные:

67e5b6bc6271ef4156d91835562135e9.png

С помощью gSService, который мы создали выше, считаем данные с этой таблички:

val data: String = gSService.getSheetData().string()

Данные:

/O_o/ 
google.visualization.Query.setResponse(
{
    "version": "0.6",
    "reqId": "0",
    "status": "ok",
    "sig": "123456789",
    "table": {
        "cols": [
            {
                "id": "A",
                "label": "",
                "type": "string"
            },
            {
                "id": "B",
                "label": "",
                "type": "string"
            },
            {
                "id": "C",
                "label": "",
                "type": "string"
            },
            {
                "id": "D",
                "label": "",
                "type": "string"
            }
        ],
        "rows": [
            {
                "c": [
                    {
                        "v": "text1"
                    },
                    {
                        "v": "1"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text2"
                    },
                    {
                        "v": "2"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text3"
                    },
                    {
                        "v": "3"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text4"
                    },
                    {
                        "v": "4"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text5"
                    },
                    {
                        "v": "5"
                    },
                    null,
                    {
                        "v": null
           ж         }
                ]
            }
        ],
        "parsedNumHeaders": 0
    }
});

Первые 2 строчки ответа не позволяют использовать converter factory, поскольку сразу получаем ошибку: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient (true) to accept malformed JSON at line 1 column 2 path

Эта ошибка указывает на неправильную структуру JSON-файла или же лишние пробелы. В нашем случае это как раз тот самый эмодзи, за которые следует еще и google.visualization.Query.setResponse (, только после которого следует нужный нам JSON-файл. Значит, ничего не остается, как парсить вручную)

Парсим JSON: создаем класс для самостоятельной конвертации с помощью GSON

Так как наш запрос состоит из <смайлик> + google.visualization.Query.setResponse (+ <нужные нам данные> +);, то сразу уберем этот мусор:

fun String.dataToJsonString(): String = 
  substringAfter("Query.setResponse(").let {
    it.substring(0, it.lastIndex - 1)
}

Итак, наш JSON теперь выглядит следующим образом:

{
    "version": "0.6",
    "reqId": "0",
    "status": "ok",
    "sig": "123456789",
    "table": {
        "cols": [
            {
                "id": "A",
                "label": "",
                "type": "string"
            },
            {
                "id": "B",
                "label": "",
                "type": "string"
            },
            {
                "id": "C",
                "label": "",
                "type": "string"
            },
            {
                "id": "D",
                "label": "",
                "type": "string"
            }
        ],
        "rows": [
            {
                "c": [
                    {
                        "v": "text1"
                    },
                    {
                        "v": "1"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text2"
                    },
                    {
                        "v": "2"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text3"
                    },
                    {
                        "v": "3"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text4"
                    },
                    {
                        "v": "4"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            },
            {
                "c": [
                    {
                        "v": "text5"
                    },
                    {
                        "v": "5"
                    },
                    null,
                    {
                        "v": null
                    }
                ]
            }
        ],
        "parsedNumHeaders": 0
    }
}

Первые 4 поля определить несложно, остальные будем определять постепенно, все глубже и глубже:
Немного справки: аннотация SerializedName позволяет указать ключ поля, данные из которого должны быть записаны в эту переменную в случае, когда есть несколько переменных одного типа.

    @SerializedName("version") val version: String,
    @SerializedName("reqId") val reqId: String,
    @SerializedName("status") val status: String,
    @SerializedName("sig") val sig: String,

    @SerializedName("table") val table: Table

Я отделил пятое, о нём как раз сейчас и пойдет речь.
Внутри тэга table находится еще 3 тэга: cols, rows, parsedNumHeaders

ddc16feb4318fc9a9446f4287c2f821a.png

Так и запишем:

data class Table(
    @SerializedName("cols") val cols: List,
    @SerializedName("rows") val rows: List,
    @SerializedName("parsedNumHeaders") val parsedNumHeaders: Int
)

И далее аналогичным образом разберемся с внутренностями каждого из полей с составным типом:
Внутри тэга cols находятся трехстрочные объекты:

Так и запишем:

data class Column(
    @SerializedName("id") val id: String,
    @SerializedName("label") val label: String,
    @SerializedName("type") val type: String
)

Что касается тэга rows, то там все немного сложнее:

7b4fcc03e192460a7d72beb12a931d38.png

Внутри данного тэга лежит список объектов с картинки выше, которые внутри себя содержат еще список из объектов. Т.к. это список строк, то несложно догадаться, что список строк содержит в себе в качестве объекта саму строку, а строка содержит в себе список объектов — клеточек с данными. Класс строки будет выглядеть так:

data class Row(
    @SerializedName("c") val cells: List
) 

Тип внутри списка — nullable, поскольку даже в нашем случае появились значения null. Ну и класс клетки содержит в себе лишь значение в этой клетке:

data class Cell(
    @SerializedName("v") val value: String
)

Теперь, когда выходных классы данных определены, дело остается только за конвертацией:

fun String.getResponseFromJson(): ResponseWrapper =
    GsonBuilder().setLenient().create()
  .fromJson(this, ResponseWrapper::class.java)

На этом моменте мы получаем тот самый класс, который написали выше на основе JSON‑файла.

Скорее всего, самая полезная часть этого класса — данные в тэге rows, потому что это как раз те самые клеточки, получение данных из которых — тема данной статьи.

Заключение

В этой статье был рассмотрен процесс преобразования данных Google Sheets из JSON формата в классы Kotlin.

Более того, в статье фигурировала suspend функция — «Best wishes, coroutines» — с расчётом на то, что читатель имеет минимальные представления о том, как работать с этими функциями.

No errors, no warnings, gentlemen and ladies!

© Habrahabr.ru