UNPKG

@budibase/server

Version:
549 lines (504 loc) • 16.5 kB
import { constants, context, events, utils } from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { utils as JsonUtils, ValidQueryNameRegex } from "@budibase/shared-core" import { findHBSBlocks } from "@budibase/string-templates" import { ContextUser, CreateDatasourceRequest, Datasource, DeleteQueryResponse, ExecuteQueryRequest, ExecuteV1QueryResponse, ExecuteV2QueryResponse, FetchQueriesResponse, FieldType, FindQueryResponse, ImportRestQueryRequest, ImportRestQueryResponse, ImportRestQueryInfoRequest, ImportRestQueryInfoResponse, JsonFieldSubType, PreviewQueryRequest, PreviewQueryResponse, Query, QueryResponse, QuerySchema, SaveQueryRequest, SaveQueryResponse, SessionCookie, SourceName, SSOProviderType, UserCtx, } from "@budibase/types" import { cloneDeep, merge } from "lodash" import { ObjectId } from "mongodb" import { generateQueryID } from "../../../db/utils" import env from "../../../environment" import sdk from "../../../sdk" import { Thread, ThreadType } from "../../../threads" import { QueryEvent, QueryEventParameters } from "../../../threads/definitions" import { invalidateCachedVariable } from "../../../threads/utils" import { save as saveDatasource } from "../datasource" import { RestImporter } from "./import" import fetch from "node-fetch" const Runner = new Thread(ThreadType.QUERY, { timeoutMs: env.QUERY_THREAD_TIMEOUT, }) function sanitiseUserStructure(user: ContextUser) { const copiedUser = cloneDeep(user) delete copiedUser.roles delete copiedUser.account delete copiedUser.license return copiedUser } function validateQueryInputs(parameters: QueryEventParameters) { for (let entry of Object.entries(parameters)) { const [key, value] = entry if (typeof value !== "string") { continue } if (findHBSBlocks(value).length !== 0) { throw new Error( `Parameter '${key}' input contains a handlebars binding - this is not allowed.` ) } } } export async function fetchQueries(ctx: UserCtx<void, FetchQueriesResponse>) { ctx.body = await sdk.queries.fetch() } const resolveImportData = async ( ctx: UserCtx, payload: { data?: string; url?: string } ) => { const data = payload.data?.trim() if (data) { return payload.data as string } const url = payload.url?.trim() if (url) { try { const response = await fetch(url) if (!response.ok) { ctx.throw( response.status, `Failed to fetch import data (status ${response.status})` ) } return await response.text() } catch (error: any) { const message = error?.message || "Unknown error" ctx.throw(502, `Failed to fetch import data - ${message}`) } } ctx.throw(400, "Import data is required") } const _import = async ( ctx: UserCtx<ImportRestQueryRequest, ImportRestQueryResponse> ) => { const body = ctx.request.body const data = await resolveImportData(ctx, body) const importer = new RestImporter(data) await importer.init() const importInfo = await importer.getInfo() let datasourceId if (!body.datasourceId) { const { _id: _discardId, _rev: _discardRev, config: suppliedConfig, ...rest } = body.datasource ? cloneDeep(body.datasource) : ({} as Datasource) const config = suppliedConfig || {} const datasource: Datasource = { ...rest, type: "datasource", source: rest.source || SourceName.REST, name: rest.name || importInfo?.name, config: { ...config, defaultHeaders: config.defaultHeaders ?? {}, rejectUnauthorized: config.rejectUnauthorized ?? true, downloadImages: config.downloadImages ?? true, url: config.url ?? importInfo?.url, }, } importer.prepareDatasourceConfig(datasource) // save the datasource const datasourceCtx: UserCtx<CreateDatasourceRequest> = merge(ctx, { request: { body: { datasource, tablesFilter: [], }, }, }) await saveDatasource(datasourceCtx) datasourceId = datasourceCtx.body.datasource._id } else { // use existing datasource datasourceId = body.datasourceId } let importResult try { importResult = await importer.importQueries( datasourceId, body.selectedEndpointId ) } catch (error: any) { if (body.selectedEndpointId && error?.message) { ctx.throw(400, error.message) } throw error } ctx.body = { ...importResult, datasourceId, } } export { _import as import } export async function importInfo( ctx: UserCtx<ImportRestQueryInfoRequest, ImportRestQueryInfoResponse> ) { const data = await resolveImportData(ctx, ctx.request.body) const importer = new RestImporter(data) await importer.init() const info = await importer.getInfo() const staticVariables = importer.getStaticServerVariables() ctx.body = { name: info.name, url: info.url, docsUrl: info.docsUrl, endpoints: info.endpoints || [], securityHeaders: info.securityHeaders || [], staticVariables, } } export async function save(ctx: UserCtx<SaveQueryRequest, SaveQueryResponse>) { const db = context.getWorkspaceDB() const query: Query = ctx.request.body // Validate query name if (!query?.name.match(ValidQueryNameRegex)) { ctx.throw(400, "Invalid query name") } const datasource = await sdk.datasources.get(query.datasourceId) let eventFn if (!query._id && !query._rev) { query._id = generateQueryID(query.datasourceId) // flag to state whether the default bindings are empty strings (old behaviour) or null query.nullDefaultSupport = true eventFn = () => events.query.created(datasource, query) } else { // check if flag has previously been set, don't let it change // allow it to be explicitly set to false via API incase this is ever needed const existingQuery = await db.get<Query>(query._id) if (existingQuery.nullDefaultSupport && query.nullDefaultSupport == null) { query.nullDefaultSupport = true } eventFn = () => events.query.updated(datasource, query) } const response = await db.put(query) await eventFn() query._rev = response.rev ctx.body = query } export async function find(ctx: UserCtx<void, FindQueryResponse>) { const queryId = ctx.params.queryId ctx.body = await sdk.queries.find(queryId) } //Required to discern between OIDC OAuth config entries function getOAuthConfigCookieId(ctx: UserCtx): string | undefined { if (ctx.user.providerType === SSOProviderType.OIDC) { return utils.getCookie<string>(ctx, constants.Cookie.OIDC_CONFIG) } } function getAuthConfig(ctx: UserCtx) { const authCookie = utils.getCookie<SessionCookie>(ctx, constants.Cookie.Auth) return { configId: getOAuthConfigCookieId(ctx), sessionId: authCookie ? authCookie.sessionId : undefined, } } function enrichParameters( query: Query, requestParameters: QueryEventParameters = {} ): QueryEventParameters { const paramNotSet = (val: unknown) => val === "" || val == undefined // first check parameters are all valid validateQueryInputs(requestParameters) // make sure parameters are fully enriched with defaults for (const parameter of query.parameters) { let value = requestParameters[parameter.name] if (value == null || value === "") { value = parameter.default } if (query.nullDefaultSupport && paramNotSet(value)) { value = null } requestParameters[parameter.name] = value } return requestParameters } export async function preview( ctx: UserCtx<PreviewQueryRequest, PreviewQueryResponse> ) { const { datasource, envVars } = await sdk.datasources.getWithEnvVars( ctx.request.body.datasourceId ) // preview may not have a queryId as it hasn't been saved, but if it does // this stops dynamic variables from calling the same query const queryId = ctx.request.body.queryId // the body contains the makings of a query, which has not been saved yet const query: Query = ctx.request.body // hasn't been saved, new query if (!queryId && !query._id) { query.nullDefaultSupport = true } let existingSchema = query.schema if (queryId && !existingSchema) { try { const db = context.getWorkspaceDB() const existing = (await db.get(queryId)) as Query existingSchema = existing.schema } catch (err: any) { if (err.status !== 404) { ctx.throw(500, "Unable to retrieve existing query") } } } const authConfigCtx = getAuthConfig(ctx) function getFieldMetadata(field: any, key: string): QuerySchema { const makeQuerySchema = ( type: FieldType, name: string, subtype?: string ): QuerySchema => ({ type, name, subtype, }) // Because custom queries have no fixed schema, we dynamically determine the schema, // however types cannot be determined from null. We have no 'unknown' type, so we default to string. let type = typeof field, fieldMetadata = makeQuerySchema(FieldType.STRING, key) if (field != null) switch (type) { case "boolean": fieldMetadata = makeQuerySchema(FieldType.BOOLEAN, key) break case "object": if (field instanceof Date) { fieldMetadata = makeQuerySchema(FieldType.DATETIME, key) } else if (Array.isArray(field)) { if (field.some(item => JsonUtils.hasSchema(item))) { fieldMetadata = makeQuerySchema( FieldType.JSON, key, JsonFieldSubType.ARRAY ) } else { fieldMetadata = makeQuerySchema(FieldType.ARRAY, key) } } else if (field instanceof ObjectId) { fieldMetadata = makeQuerySchema(FieldType.STRING, key) } else { fieldMetadata = makeQuerySchema(FieldType.JSON, key) } break case "number": fieldMetadata = makeQuerySchema(FieldType.NUMBER, key) break } return fieldMetadata } function buildNestedSchema( nestedSchemaFields: { [key: string]: Record<string, string | QuerySchema> }, key: string, fieldArray: any[] ) { let schema: { [key: string]: any } = {} // build the schema by aggregating all row objects in the array for (const item of fieldArray) { if (JsonUtils.hasSchema(item)) { for (const [key, value] of Object.entries(item)) { schema[key] = getFieldMetadata(value, key) } } } nestedSchemaFields[key] = schema } function getSchemaFields( rows: any[], keys: string[] ): { previewSchema: Record<string, string | QuerySchema> nestedSchemaFields: { [key: string]: Record<string, string | QuerySchema> } } { const previewSchema: Record<string, string | QuerySchema> = {} const nestedSchemaFields: { [key: string]: Record<string, string | QuerySchema> } = {} if (rows?.length > 0) { for (let key of new Set(keys)) { const fieldMetadata = getFieldMetadata(rows[0][key], key) previewSchema[key] = fieldMetadata if ( fieldMetadata.type === FieldType.JSON && fieldMetadata.subtype === JsonFieldSubType.ARRAY ) { buildNestedSchema(nestedSchemaFields, key, rows[0][key]) } } } return { previewSchema, nestedSchemaFields } } const inputs: QueryEvent = { appId: ctx.appId, queryVerb: query.queryVerb, fields: query.fields, parameters: enrichParameters(query), transformer: query.transformer, schema: query.schema, nullDefaultSupport: query.nullDefaultSupport, queryId, datasource, // have to pass down to the thread runner - can't put into context now environmentVariables: envVars, ctx: { // sanitise the user object to remove circular references user: sanitiseUserStructure(ctx.user), auth: { ...authConfigCtx }, }, } let queryResponse: QueryResponse try { queryResponse = await Runner.run<QueryResponse>(inputs) } catch (err: any) { ctx.throw(400, err) } const { rows, keys, info, extra } = queryResponse const { previewSchema, nestedSchemaFields } = getSchemaFields(rows, keys) // if existing schema, update to include any previous schema keys if (existingSchema) { for (let key of Object.keys(existingSchema)) { if (!previewSchema[key]) { previewSchema[key] = existingSchema[key] } } } // remove configuration before sending event delete datasource.config await events.query.previewed(datasource, ctx.request.body) ctx.body = { rows, nestedSchemaFields, schema: previewSchema, info, extra, } } async function execute( ctx: UserCtx< ExecuteQueryRequest, ExecuteV2QueryResponse | ExecuteV1QueryResponse >, opts = { rowsOnly: false, isAutomation: false } ) { const db = context.getWorkspaceDB() const query = await db.get<Query>(ctx.params.queryId) const { datasource, envVars } = await sdk.datasources.getWithEnvVars( query.datasourceId ) let authConfigCtx = {} if (!opts.isAutomation) { authConfigCtx = getAuthConfig(ctx) } // call the relevant CRUD method on the integration class try { const inputs: QueryEvent = { appId: ctx.appId, datasource, queryVerb: query.queryVerb, fields: query.fields, pagination: ctx.request.body.pagination, parameters: enrichParameters(query, ctx.request.body.parameters), transformer: query.transformer, queryId: ctx.params.queryId, // have to pass down to the thread runner - can't put into context now environmentVariables: envVars, nullDefaultSupport: query.nullDefaultSupport, ctx: { // sanitise the user object to remove circular references user: sanitiseUserStructure(ctx.user), auth: { ...authConfigCtx }, }, schema: query.schema, } const { rows, pagination, extra, info } = query.queryVerb === "read" || opts.isAutomation ? await Runner.run<QueryResponse>(inputs) : await quotas.addAction(async () => { const response = await Runner.run<QueryResponse>(inputs) events.action.crudExecuted({ type: query.queryVerb }) return response }) // remove the raw from execution incase transformer being used to hide data if (extra?.raw) { delete extra.raw } if (opts && opts.rowsOnly) { ctx.body = rows } else { ctx.body = { data: rows, pagination, ...extra, ...info } } } catch (err: any) { ctx.throw(400, err) } } export async function executeV1( ctx: UserCtx<ExecuteQueryRequest, ExecuteV1QueryResponse> ) { return execute(ctx, { rowsOnly: true, isAutomation: false }) } export async function executeV2( ctx: UserCtx<ExecuteQueryRequest, ExecuteV2QueryResponse> ) { return execute(ctx, { rowsOnly: false, isAutomation: false }) } export async function executeV2AsAutomation( ctx: UserCtx<ExecuteQueryRequest, ExecuteV2QueryResponse> ) { return execute(ctx, { rowsOnly: false, isAutomation: true }) } const removeDynamicVariables = async (queryId: string) => { const db = context.getWorkspaceDB() const query = await db.get<Query>(queryId) const datasource = await sdk.datasources.get(query.datasourceId) const dynamicVariables = datasource.config?.dynamicVariables as any[] if (dynamicVariables) { // delete dynamic variables from the datasource datasource.config!.dynamicVariables = dynamicVariables!.filter( (dv: any) => dv.queryId !== queryId ) await db.put(datasource) // invalidate the deleted variables const variablesToDelete = dynamicVariables!.filter( (dv: any) => dv.queryId === queryId ) await invalidateCachedVariable(variablesToDelete) } } export async function destroy(ctx: UserCtx<void, DeleteQueryResponse>) { const db = context.getWorkspaceDB() const queryId = ctx.params.queryId as string await removeDynamicVariables(queryId) const query = await db.get<Query>(queryId) const datasource = await sdk.datasources.get(query.datasourceId) await db.remove(ctx.params.queryId, ctx.params.revId) ctx.body = { message: `Query deleted.` } await events.query.deleted(datasource, query, context.getWorkspaceId()!) }