budibase/packages/server/src/automations/automationUtils.ts

287 lines
8.5 KiB
TypeScript

import {
decodeJSBinding,
isJSBinding,
encodeJSBinding,
} from "@budibase/string-templates"
import sdk from "../sdk"
import { AutomationAttachment, FieldType, Row } from "@budibase/types"
import { LoopInput, LoopStepType } from "../definitions/automations"
import { objectStore, context } from "@budibase/backend-core"
import * as uuid from "uuid"
import path from "path"
/**
* When values are input to the system generally they will be of type string as this is required for template strings.
* This can generate some odd scenarios as the Schema of the automation requires a number but the builder might supply
* a string with template syntax to get the number from the rest of the context. To support this the server has to
* make sure that the post template statement can be cast into the correct type, this function does this for numbers
* and booleans.
*
* @param inputs An object of inputs, please note this will not recurse down into any objects within, it simply
* cleanses the top level inputs, however it can be used by recursively calling it deeper into the object structures if
* the schema is known.
* @param schema The defined schema of the inputs, in the form of JSON schema. The schema definition of an
* automation is the likely use case of this, however validate.js syntax can be converted closely enough to use this by
* wrapping the schema properties in a top level "properties" object.
* @returns The inputs object which has had all the various types supported by this function converted to their
* primitive types.
*/
export function cleanInputValues(inputs: Record<string, any>, schema?: any) {
if (schema == null) {
return inputs
}
for (let inputKey of Object.keys(inputs)) {
let input = inputs[inputKey]
if (typeof input !== "string") {
continue
}
let propSchema = schema.properties[inputKey]
if (!propSchema) {
continue
}
if (propSchema.type === "boolean") {
let lcInput = input.toLowerCase()
if (lcInput === "true") {
inputs[inputKey] = true
}
if (lcInput === "false") {
inputs[inputKey] = false
}
}
if (propSchema.type === "number") {
let floatInput = parseFloat(input)
if (!isNaN(floatInput)) {
inputs[inputKey] = floatInput
}
}
}
//Check if input field for Update Row should be a relationship and cast to array
for (let key in inputs.row) {
if (
inputs.schema?.[key]?.type === "link" &&
inputs.row[key] &&
typeof inputs.row[key] === "string"
) {
try {
inputs.row[key] = JSON.parse(inputs.row[key])
} catch (e) {
//Link is not an array or object, so continue
}
}
}
return inputs
}
/**
* Given a row input like a save or update row we need to clean the inputs against a schema that is not part of
* the automation but is instead part of the Table/Table. This function will get the table schema and use it to instead
* perform the cleanInputValues function on the input row.
*
* @param tableId The ID of the Table/Table which the schema is to be retrieved for.
* @param row The input row structure which requires clean-up after having been through template statements.
* @returns The cleaned up rows object, will should now have all the required primitive types.
*/
export async function cleanUpRow(tableId: string, row: Row) {
let table = await sdk.tables.getTable(tableId)
return cleanInputValues(row, { properties: table.schema })
}
export function getError(err: any) {
if (err == null) {
return "No error provided."
}
if (
typeof err === "object" &&
(err.toString == null || err.toString() === "[object Object]")
) {
return JSON.stringify(err)
}
return typeof err !== "string" ? err.toString() : err
}
export function guardAttachment(attachmentObject: any) {
if (
attachmentObject &&
(!("url" in attachmentObject) || !("filename" in attachmentObject))
) {
const providedKeys = Object.keys(attachmentObject).join(", ")
throw new Error(
`Attachments must have both "url" and "filename" keys. You have provided: ${providedKeys}`
)
}
}
export async function sendAutomationAttachmentsToStorage(
tableId: string,
row: Row
): Promise<Row> {
const table = await sdk.tables.getTable(tableId)
const attachmentRows: Record<
string,
AutomationAttachment[] | AutomationAttachment
> = {}
for (const [prop, value] of Object.entries(row)) {
const schema = table.schema[prop]
if (
schema?.type === FieldType.ATTACHMENTS ||
schema?.type === FieldType.ATTACHMENT_SINGLE ||
schema?.type === FieldType.SIGNATURE_SINGLE
) {
if (Array.isArray(value)) {
value.forEach(item => guardAttachment(item))
} else {
guardAttachment(value)
}
attachmentRows[prop] = value
}
}
for (const [prop, attachments] of Object.entries(attachmentRows)) {
if (!attachments) {
continue
} else if (Array.isArray(attachments)) {
if (attachments.length) {
row[prop] = await Promise.all(
attachments.map(attachment => generateAttachmentRow(attachment))
)
}
} else if (Object.keys(row[prop]).length > 0) {
row[prop] = await generateAttachmentRow(attachments)
}
}
return row
}
async function generateAttachmentRow(attachment: AutomationAttachment) {
const prodAppId = context.getProdAppId()
async function uploadToS3(
extension: string,
content: objectStore.StreamTypes
) {
const fileName = `${uuid.v4()}${extension}`
const s3Key = `${prodAppId}/attachments/${fileName}`
await objectStore.streamUpload({
bucket: objectStore.ObjectStoreBuckets.APPS,
stream: content,
filename: s3Key,
})
return s3Key
}
async function getSize(s3Key: string) {
return (
await objectStore.getObjectMetadata(
objectStore.ObjectStoreBuckets.APPS,
s3Key
)
).ContentLength
}
try {
const { filename } = attachment
let extension = path.extname(filename)
if (extension.startsWith(".")) {
extension = extension.substring(1, extension.length)
}
const attachmentResult = await objectStore.processAutomationAttachment(
attachment
)
let s3Key = ""
if (
"path" in attachmentResult &&
attachmentResult.path.startsWith(`${prodAppId}/attachments/`)
) {
s3Key = attachmentResult.path
} else {
s3Key = await uploadToS3(extension, attachmentResult.content)
}
const size = await getSize(s3Key)
return {
size,
extension,
name: filename,
key: s3Key,
}
} catch (error) {
console.error("Failed to process attachment:", error)
throw error
}
}
export function substituteLoopStep(hbsString: string, substitute: string) {
let checkForJS = isJSBinding(hbsString)
let substitutedHbsString = ""
let open = checkForJS ? `$("` : "{{"
let closed = checkForJS ? `")` : "}}"
if (checkForJS) {
hbsString = decodeJSBinding(hbsString) as string
}
let pointer = 0,
openPointer = 0,
closedPointer = 0
while (pointer < hbsString?.length) {
openPointer = hbsString.indexOf(open, pointer)
closedPointer = hbsString.indexOf(closed, pointer) + 2
if (openPointer < 0 || closedPointer < 0) {
substitutedHbsString += hbsString.substring(pointer)
break
}
let before = hbsString.substring(pointer, openPointer)
let block = hbsString
.substring(openPointer, closedPointer)
.replace(/loop/, substitute)
substitutedHbsString += before + block
pointer = closedPointer
}
if (checkForJS) {
substitutedHbsString = encodeJSBinding(substitutedHbsString)
}
return substitutedHbsString
}
export function stringSplit(value: string | string[]) {
if (value == null) {
return []
}
if (Array.isArray(value)) {
return value
}
if (typeof value !== "string") {
throw new Error(`Unable to split value of type ${typeof value}: ${value}`)
}
const splitOnNewLine = value.split("\n")
if (splitOnNewLine.length > 1) {
return splitOnNewLine
}
return value.split(",")
}
export function typecastForLooping(input: LoopInput) {
if (!input || !input.binding) {
return null
}
try {
switch (input.option) {
case LoopStepType.ARRAY:
if (typeof input.binding === "string") {
return JSON.parse(input.binding)
}
break
case LoopStepType.STRING:
if (Array.isArray(input.binding)) {
return input.binding.join(",")
}
break
}
} catch (err) {
throw new Error("Unable to cast to correct type")
}
return input.binding
}