budibase/packages/server/src/integrations/googlesheets.ts

436 lines
11 KiB
TypeScript
Raw Normal View History

2021-11-25 18:12:12 +01:00
import {
DatasourceFieldType,
DatasourcePlus,
2021-11-25 18:12:12 +01:00
Integration,
PaginationJson,
QueryJson,
QueryType,
SearchFilters,
SortJson,
Table,
TableSchema,
} from "@budibase/types"
2022-01-06 09:08:54 +01:00
import { OAuth2Client } from "google-auth-library"
import { buildExternalTableId } from "./utils"
2022-01-15 19:28:04 +01:00
import { DataSourceOperation, FieldTypes } from "../constants"
import { GoogleSpreadsheet } from "google-spreadsheet"
2023-02-27 17:25:26 +01:00
import fetch from "node-fetch"
import { configs, HTTPError } from "@budibase/backend-core"
import { dataFilters } from "@budibase/shared-core"
interface GoogleSheetsConfig {
spreadsheetId: string
auth: OAuthClientConfig
}
2022-01-06 09:08:54 +01:00
interface OAuthClientConfig {
appId: string
accessToken: string
refreshToken: string
}
2021-11-25 18:12:12 +01:00
interface AuthTokenRequest {
client_id: string
client_secret: string
refresh_token: string
}
interface AuthTokenResponse {
access_token: string
}
const SCHEMA: Integration = {
plus: true,
auth: {
type: "google",
},
relationships: false,
docs: "https://developers.google.com/sheets/api/quickstart/nodejs",
description:
"Create and collaborate on online spreadsheets in real-time and from any device. ",
friendlyName: "Google Sheets",
type: "Spreadsheet",
datasource: {
spreadsheetId: {
display: "Google Sheet URL",
type: DatasourceFieldType.STRING,
required: true,
2022-01-06 09:08:54 +01:00
},
},
query: {
create: {
type: QueryType.FIELDS,
fields: {
sheet: {
type: DatasourceFieldType.STRING,
required: true,
},
row: {
type: QueryType.JSON,
required: true,
},
2021-11-25 18:12:12 +01:00
},
},
read: {
type: QueryType.FIELDS,
fields: {
sheet: {
type: DatasourceFieldType.STRING,
required: true,
2021-11-25 18:12:12 +01:00
},
},
},
update: {
type: QueryType.FIELDS,
fields: {
sheet: {
type: DatasourceFieldType.STRING,
required: true,
2021-11-25 18:12:12 +01:00
},
rowIndex: {
type: DatasourceFieldType.STRING,
required: true,
},
row: {
type: QueryType.JSON,
required: true,
2021-11-25 18:12:12 +01:00
},
},
},
delete: {
type: QueryType.FIELDS,
fields: {
sheet: {
type: DatasourceFieldType.STRING,
required: true,
},
rowIndex: {
type: DatasourceFieldType.NUMBER,
required: true,
2021-11-25 18:12:12 +01:00
},
},
},
},
}
class GoogleSheetsIntegration implements DatasourcePlus {
private readonly config: GoogleSheetsConfig
2023-02-27 17:25:26 +01:00
private client: GoogleSpreadsheet
public tables: Record<string, Table> = {}
public schemaErrors: Record<string, string> = {}
constructor(config: GoogleSheetsConfig) {
this.config = config
const spreadsheetId = this.cleanSpreadsheetUrl(this.config.spreadsheetId)
this.client = new GoogleSpreadsheet(spreadsheetId)
2021-11-25 18:12:12 +01:00
}
getBindingIdentifier() {
return ""
}
2021-11-25 18:12:12 +01:00
getStringConcat(parts: string[]) {
return ""
}
2022-01-15 19:28:04 +01:00
/**
* Pull the spreadsheet ID out from a valid google sheets URL
* @param spreadsheetId - the URL or standard spreadsheetId of the google sheet
* @returns spreadsheet Id of the google sheet
*/
cleanSpreadsheetUrl(spreadsheetId: string) {
if (!spreadsheetId) {
throw new Error(
"You must set a spreadsheet ID in your configuration to fetch tables."
)
}
const parts = spreadsheetId.split("/")
return parts.length > 5 ? parts[5] : spreadsheetId
}
async fetchAccessToken(
payload: AuthTokenRequest
): Promise<AuthTokenResponse> {
const response = await fetch("https://www.googleapis.com/oauth2/v4/token", {
method: "POST",
body: JSON.stringify({
...payload,
grant_type: "refresh_token",
}),
headers: {
"Content-Type": "application/json",
},
})
const json = await response.json()
2021-11-25 18:12:12 +01:00
if (response.status !== 200) {
throw new Error(
`Error authenticating with google sheets. ${json.error_description}`
)
}
return json
}
async connect() {
try {
// Initialise oAuth client
let googleConfig = await configs.getGoogleDatasourceConfig()
if (!googleConfig) {
throw new HTTPError("Google config not found", 400)
}
const oauthClient = new OAuth2Client({
clientId: googleConfig.clientID,
clientSecret: googleConfig.clientSecret,
})
const tokenResponse = await this.fetchAccessToken({
client_id: googleConfig.clientID,
client_secret: googleConfig.clientSecret,
refresh_token: this.config.auth.refreshToken,
})
oauthClient.setCredentials({
refresh_token: this.config.auth.refreshToken,
access_token: tokenResponse.access_token,
})
this.client.useOAuth2Client(oauthClient)
await this.client.loadInfo()
} catch (err) {
console.error("Error connecting to google sheets", err)
throw err
}
}
async buildSchema(datasourceId: string) {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheets = this.client.sheetsByIndex
const tables: Record<string, Table> = {}
for (let sheet of sheets) {
// must fetch rows to determine schema
await sheet.getRows()
// build schema
const schema: TableSchema = {}
// build schema from headers
for (let header of sheet.headerValues) {
schema[header] = {
name: header,
type: FieldTypes.STRING,
}
}
// create tables
tables[sheet.title] = {
_id: buildExternalTableId(datasourceId, sheet.title),
name: sheet.title,
primary: ["rowNumber"],
schema,
2021-11-25 18:12:12 +01:00
}
}
this.tables = tables
}
async query(json: QueryJson) {
const sheet = json.endpoint.entityId
const handlers = {
[DataSourceOperation.CREATE]: () =>
this.create({ sheet, row: json.body }),
[DataSourceOperation.READ]: () => this.read({ ...json, sheet }),
[DataSourceOperation.UPDATE]: () =>
this.update({
// exclude the header row and zero index
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2,
sheet,
row: json.body,
}),
[DataSourceOperation.DELETE]: () =>
this.delete({
// exclude the header row and zero index
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2,
sheet,
}),
[DataSourceOperation.CREATE_TABLE]: () =>
this.createTable(json?.table?.name),
[DataSourceOperation.UPDATE_TABLE]: () => this.updateTable(json.table),
[DataSourceOperation.DELETE_TABLE]: () =>
this.deleteTable(json?.table?.name),
}
2022-11-26 16:10:41 +01:00
// @ts-ignore
const internalQueryMethod = handlers[json.endpoint.operation]
2022-01-18 17:15:29 +01:00
return await internalQueryMethod()
}
2022-01-18 17:15:29 +01:00
buildRowObject(headers: string[], values: string[], rowNumber: number) {
const rowObject: { rowNumber: number; [key: string]: any } = { rowNumber }
for (let i = 0; i < headers.length; i++) {
rowObject._id = rowNumber
rowObject[headers[i]] = values[i]
}
return rowObject
}
async createTable(name?: string) {
try {
await this.connect()
fixes for google sheets, admin checklist, and deleting an app from API (#8846) * fixes for google sheets, admin checklist, and deleting an app from API * code review * splitting unpublish endpoint, moving deploy endpoint to applications controller. Still to do public API work and move deployment controller into application controller * updating REST method for unpublish in API test * unpublish and publish endpoint on public API, delete endpoint unpublishes and deletes app * removing skip_setup from prodAppDb call * removing commented code * unit tests and open API spec updates * unpublish, publish unit tests - delete still in progress * remove line updating app name in API test * unit tests * v2.1.46 * Update pro version to 2.1.46 * v2.2.0 * Update pro version to 2.2.0 * Fix for budibase plugin skeleton, which utilises the old import style. * Fix side nav styles * v2.2.1 * Update pro version to 2.2.1 * using dist folder to allow importing constants for openAPI specs * v2.2.2 * Update pro version to 2.2.2 * Fix for user enrichment call (updating to @budibase/nano fork) (#9038) * Fix for #9029 - this should fix the issue users have been experiencing with user enrichment calls in apps, essentially it utilises a fork of the nano library we use to interact with CouchDB, which has been updated to use a POST request rather than a GET request as it supports a larger set of data being sent as query parameters. * Incrementing Nano version to attempt to fix yarn registry issues. * v2.2.3 * Update pro version to 2.2.3 * Fix SQL table `_id` filtering (#9030) * Re-add support for filtering on _id using external SQL tables and fix filter key prefixes not working with _id field * Remove like operator from internal tables and only allow basic operators on SQL table _id column * Update data section filtering to respect new rules * Update automation section filtering to respect new rules * Update dynamic filter component to respect new rules * v2.2.4 * Update pro version to 2.2.4 * lock changes (#9047) * v2.2.5 * Update pro version to 2.2.5 * Make looping arrow point in right direction (#9053) * v2.2.6 * Update pro version to 2.2.6 * Types/attaching license to account (#9065) * adding license type to account * removing planDuration * v2.2.7 * Update pro version to 2.2.7 * Environment variable type coercion fix (#9074) * Environment variable type coercion fix * Update .gitignore * v2.2.8 * Update pro version to 2.2.8 * tests passing * all tests passing, updates to public API response * update unpublish call to return 204, openAPI spec and unit * fixing API tests Co-authored-by: Budibase Release Bot <> Co-authored-by: mike12345567 <me@michaeldrury.co.uk> Co-authored-by: Andrew Kingston <andrew@kingston.dev> Co-authored-by: melohagan <101575380+melohagan@users.noreply.github.com> Co-authored-by: Rory Powell <rory.codes@gmail.com>
2022-12-19 14:18:00 +01:00
return await this.client.addSheet({ title: name, headerValues: ["test"] })
} catch (err) {
console.error("Error creating new table in google sheets", err)
throw err
2021-11-25 18:12:12 +01:00
}
}
2021-11-25 18:12:12 +01:00
async updateTable(table?: any) {
try {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheet = this.client.sheetsByTitle[table.name]
await sheet.loadHeaderRow()
2022-01-18 17:15:29 +01:00
if (table._rename) {
const headers = []
for (let header of sheet.headerValues) {
if (header === table._rename.old) {
headers.push(table._rename.updated)
} else {
headers.push(header)
2022-01-18 17:15:29 +01:00
}
}
await sheet.setHeaderRow(headers)
} else {
2023-02-27 13:33:19 +01:00
const updatedHeaderValues = [...sheet.headerValues]
const newField = Object.keys(table.schema).find(
key => !sheet.headerValues.includes(key)
)
2023-02-27 13:33:19 +01:00
if (newField) {
updatedHeaderValues.push(newField)
}
await sheet.setHeaderRow(updatedHeaderValues)
2022-01-18 17:15:29 +01:00
}
} catch (err) {
console.error("Error updating table in google sheets", err)
throw err
2022-01-18 17:15:29 +01:00
}
}
2022-01-18 17:15:29 +01:00
async deleteTable(sheet: any) {
try {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheetToDelete = this.client.sheetsByTitle[sheet]
return await sheetToDelete.delete()
} catch (err) {
console.error("Error deleting table in google sheets", err)
throw err
2021-11-25 18:12:12 +01:00
}
}
2021-11-25 18:12:12 +01:00
async create(query: { sheet: string; row: any }) {
try {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheet = this.client.sheetsByTitle[query.sheet]
const rowToInsert =
typeof query.row === "string" ? JSON.parse(query.row) : query.row
const row = await sheet.addRow(rowToInsert)
return [
this.buildRowObject(sheet.headerValues, row._rawData, row._rowNumber),
]
} catch (err) {
console.error("Error writing to google sheets", err)
throw err
2021-11-25 18:12:12 +01:00
}
}
2021-11-25 18:12:12 +01:00
async read(query: {
sheet: string
filters?: SearchFilters
sort?: SortJson
paginate?: PaginationJson
}) {
try {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
const filtered = dataFilters.runLuceneQuery(rows, query.filters)
const headerValues = sheet.headerValues
let response = []
for (let row of filtered) {
response.push(
this.buildRowObject(headerValues, row._rawData, row._rowNumber)
)
2021-11-25 18:12:12 +01:00
}
if (query.sort) {
if (Object.keys(query.sort).length !== 1) {
console.warn("Googlesheets does not support multiple sorting", {
sortInfo: query.sort,
})
}
const [sortField, sortInfo] = Object.entries(query.sort)[0]
response = dataFilters.luceneSort(
response,
sortField,
sortInfo.direction,
sortInfo.type
)
}
return response
} catch (err) {
console.error("Error reading from google sheets", err)
throw err
2021-11-25 18:12:12 +01:00
}
}
2021-11-25 18:12:12 +01:00
async update(query: { sheet: string; rowIndex: number; row: any }) {
try {
2021-11-25 18:12:12 +01:00
await this.connect()
2023-02-27 17:25:26 +01:00
const sheet = this.client.sheetsByTitle[query.sheet]
2021-11-25 18:12:12 +01:00
const rows = await sheet.getRows()
const row = rows[query.rowIndex]
if (row) {
const updateValues =
typeof query.row === "string" ? JSON.parse(query.row) : query.row
for (let key in updateValues) {
row[key] = updateValues[key]
}
await row.save()
return [
this.buildRowObject(sheet.headerValues, row._rawData, row._rowNumber),
]
2021-11-25 18:12:12 +01:00
} else {
throw new Error("Row does not exist.")
}
} catch (err) {
console.error("Error reading from google sheets", err)
throw err
2021-11-25 18:12:12 +01:00
}
}
async delete(query: { sheet: string; rowIndex: number }) {
await this.connect()
2023-02-27 17:25:26 +01:00
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
const row = rows[query.rowIndex]
if (row) {
await row.delete()
return [{ deleted: query.rowIndex }]
} else {
throw new Error("Row does not exist.")
}
2021-11-25 18:12:12 +01:00
}
}
export default {
schema: SCHEMA,
integration: GoogleSheetsIntegration,
}