Merge pull request #6315 from Budibase/feature/current-user-rest-bindings

REST query bindings for Current User
This commit is contained in:
deanhannigan 2022-07-05 09:22:03 +01:00 committed by GitHub
commit 9b98c617bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 599 additions and 38 deletions

View File

@ -40,5 +40,6 @@
on:change={onChange} on:change={onChange}
on:pick on:pick
on:type on:type
on:blur
/> />
</Field> </Field>

View File

@ -52,7 +52,10 @@
{id} {id}
type="text" type="text"
on:focus={() => (focus = true)} on:focus={() => (focus = true)}
on:blur={() => (focus = false)} on:blur={() => {
focus = false
dispatch("blur")
}}
on:change={onType} on:change={onType}
value={value || ""} value={value || ""}
placeholder={placeholder || ""} placeholder={placeholder || ""}

View File

@ -52,13 +52,6 @@ filterTests(['smoke', 'all'], () => {
// Start create app process. If apps already exist, click second button // Start create app process. If apps already exist, click second button
cy.get(interact.CREATE_APP_BUTTON, { timeout: 1000 }).click({ force: true }) cy.get(interact.CREATE_APP_BUTTON, { timeout: 1000 }).click({ force: true })
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(interact.CREATE_APP_BUTTON).click({ force: true })
}
})
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.get(interact.SPECTRUM_MODAL).within(() => { cy.get(interact.SPECTRUM_MODAL).within(() => {

View File

@ -49,6 +49,95 @@ export const getBindableProperties = (asset, componentId) => {
] ]
} }
/**
* Gets all rest bindable data fields
*/
export const getRestBindings = () => {
const userBindings = getUserBindings()
return [...userBindings, ...getAuthBindings()]
}
/**
* Gets all rest bindable auth fields
*/
export const getAuthBindings = () => {
let bindings = []
const safeUser = makePropSafe("user")
const safeOAuth2 = makePropSafe("oauth2")
const safeAccessToken = makePropSafe("accessToken")
const authBindings = [
{
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`,
key: "accessToken",
},
]
bindings = Object.keys(authBindings).map(key => {
const fieldBinding = authBindings[key]
return {
type: "context",
runtimeBinding: fieldBinding.runtime,
readableBinding: fieldBinding.readable,
fieldSchema: { type: "string", name: fieldBinding.key },
providerId: "user",
}
})
return bindings
}
/**
* Utility - convert a key/value map to an array of custom 'context' bindings
* @param {object} valueMap Key/value pairings
* @param {string} prefix A contextual string prefix/path for a user readable binding
* @return {object[]} An array containing readable/runtime binding objects
*/
export const toBindingsArray = (valueMap, prefix) => {
if (!valueMap) {
return []
}
return Object.keys(valueMap).reduce((acc, binding) => {
if (!binding || !valueMap[binding]) {
return acc
}
acc.push({
type: "context",
runtimeBinding: binding,
readableBinding: `${prefix}.${binding}`,
})
return acc
}, [])
}
/**
* Utility - coverting a map of readable bindings to runtime
*/
export const readableToRuntimeMap = (bindings, ctx) => {
if (!bindings || !ctx) {
return {}
}
return Object.keys(ctx).reduce((acc, key) => {
let parsedQuery = readableToRuntimeBinding(bindings, ctx[key])
acc[key] = parsedQuery
return acc
}, {})
}
/**
* Utility - coverting a map of runtime bindings to readable
*/
export const runtimeToReadableMap = (bindings, ctx) => {
if (!bindings || !ctx) {
return {}
}
return Object.keys(ctx).reduce((acc, key) => {
let parsedQuery = runtimeToReadableBinding(bindings, ctx[key])
acc[key] = parsedQuery
return acc
}, {})
}
/** /**
* Gets the bindable properties exposed by a certain component. * Gets the bindable properties exposed by a certain component.
*/ */
@ -298,7 +387,6 @@ const getUserBindings = () => {
providerId: "user", providerId: "user",
}) })
}) })
return bindings return bindings
} }

View File

@ -10,11 +10,31 @@
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import RestAuthenticationBuilder from "./auth/RestAuthenticationBuilder.svelte" import RestAuthenticationBuilder from "./auth/RestAuthenticationBuilder.svelte"
import ViewDynamicVariables from "./variables/ViewDynamicVariables.svelte" import ViewDynamicVariables from "./variables/ViewDynamicVariables.svelte"
import {
getRestBindings,
readableToRuntimeBinding,
runtimeToReadableMap,
} from "builderStore/dataBinding"
import { cloneDeep } from "lodash/fp"
export let datasource export let datasource
export let queries export let queries
let addHeader let addHeader
let parsedHeaders = runtimeToReadableMap(
getRestBindings(),
cloneDeep(datasource?.config?.defaultHeaders)
)
const onDefaultHeaderUpdate = headers => {
const flatHeaders = cloneDeep(headers).reduce((acc, entry) => {
acc[entry.name] = readableToRuntimeBinding(getRestBindings(), entry.value)
return acc
}, {})
datasource.config.defaultHeaders = flatHeaders
}
</script> </script>
<Divider size="S" /> <Divider size="S" />
@ -30,9 +50,10 @@
</Body> </Body>
<KeyValueBuilder <KeyValueBuilder
bind:this={addHeader} bind:this={addHeader}
bind:object={datasource.config.defaultHeaders} bind:object={parsedHeaders}
on:change on:change={evt => onDefaultHeaderUpdate(evt.detail)}
noAddButton noAddButton
bindings={getRestBindings()}
/> />
<div> <div>
<ActionButton icon="Add" on:click={() => addHeader.addEntry()}> <ActionButton icon="Add" on:click={() => addHeader.addEntry()}>

View File

@ -2,6 +2,8 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { ModalContent, Layout, Select, Body, Input } from "@budibase/bbui" import { ModalContent, Layout, Select, Body, Input } from "@budibase/bbui"
import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes" import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes"
import BindableCombobox from "components/common/bindings/BindableCombobox.svelte"
import { getAuthBindings } from "builderStore/dataBinding"
export let configs export let configs
export let currentConfig export let currentConfig
@ -203,11 +205,23 @@
/> />
{/if} {/if}
{#if form.type === AUTH_TYPES.BEARER} {#if form.type === AUTH_TYPES.BEARER}
<Input <BindableCombobox
label="Token" label="Token"
bind:value={form.bearer.token} value={form.bearer.token}
on:change={onFieldChange} bindings={getAuthBindings()}
on:blur={() => (blurred.bearer.token = true)} on:change={e => {
form.bearer.token = e.detail
console.log(e.detail)
onFieldChange()
}}
on:blur={() => {
blurred.bearer.token = true
onFieldChange()
}}
allowJS={false}
placeholder="Token"
appendBindingsAsOptions={true}
drawerEnabled={false}
error={blurred.bearer.token ? errors.bearer.token : null} error={blurred.bearer.token ? errors.bearer.token : null}
/> />
{/if} {/if}

View File

@ -0,0 +1,68 @@
<script>
import { Combobox } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { createEventDispatcher } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let value = ""
export let bindings = []
export let placeholder
export let label
export let disabled = false
export let options
export let appendBindingsAsOptions = true
export let error
const dispatch = createEventDispatcher()
$: readableValue = runtimeToReadableBinding(bindings, value)
$: isJS = isJSBinding(value)
$: allOptions = buildOptions(options, bindings, appendBindingsAsOptions)
const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) {
value = `{{ ${value} }}`
}
dispatch("change", readableToRuntimeBinding(bindings, value))
}
const buildOptions = (options, bindings, appendBindingsAsOptions) => {
if (!appendBindingsAsOptions) {
return options
}
return []
.concat(options || [])
.concat(bindings?.map(binding => binding.readableBinding) || [])
}
</script>
<div class="control" class:disabled>
<Combobox
{label}
{disabled}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
on:type={e => onChange(e.detail, false)}
on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")}
{placeholder}
options={allOptions}
{error}
/>
</div>
<style>
.control {
flex: 1;
position: relative;
}
.control:not(.disabled) :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style>

View File

@ -18,6 +18,7 @@
export let options export let options
export let allowJS = true export let allowJS = true
export let appendBindingsAsOptions = true export let appendBindingsAsOptions = true
export let error
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
@ -59,8 +60,10 @@
value={isJS ? "(JavaScript function)" : readableValue} value={isJS ? "(JavaScript function)" : readableValue}
on:type={e => onChange(e.detail, false)} on:type={e => onChange(e.detail, false)}
on:pick={e => onChange(e.detail, true)} on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")}
{placeholder} {placeholder}
options={allOptions} options={allOptions}
{error}
/> />
{#if !disabled} {#if !disabled}
<div <div
@ -72,6 +75,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<Drawer bind:this={bindingDrawer} {title}> <Drawer bind:this={bindingDrawer} {title}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.

View File

@ -11,6 +11,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { lowercase } from "helpers" import { lowercase } from "helpers"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
let dispatch = createEventDispatcher() let dispatch = createEventDispatcher()
@ -30,6 +31,7 @@
export let tooltip export let tooltip
export let menuItems export let menuItems
export let showMenu = false export let showMenu = false
export let bindings = []
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
name, name,
@ -108,6 +110,16 @@
/> />
{#if options} {#if options}
<Select bind:value={field.value} on:change={changed} {options} /> <Select bind:value={field.value} on:change={changed} {options} />
{:else if bindings && bindings.length}
<DrawerBindableInput
{bindings}
placeholder="Value"
on:change={e => (field.value = e.detail)}
disabled={readOnly}
value={field.value}
allowJS={false}
fillWidth={true}
/>
{:else} {:else}
<Input <Input
placeholder={valuePlaceholder} placeholder={valuePlaceholder}

View File

@ -57,7 +57,8 @@
placeholder="Default" placeholder="Default"
thin thin
disabled={bindable} disabled={bindable}
bind:value={binding.default} on:change={evt => onBindingChange(binding.name, evt.detail)}
value={runtimeToReadableBinding(bindings, binding.default)}
/> />
{#if bindable} {#if bindable}
<DrawerBindableInput <DrawerBindableInput

View File

@ -12,4 +12,6 @@
} }
</script> </script>
<slot /> {#key $params.selectedDatasource}
<slot />
{/key}

View File

@ -40,13 +40,39 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { RawRestBodyTypes } from "constants/backend" import { RawRestBodyTypes } from "constants/backend"
import {
getRestBindings,
toBindingsArray,
runtimeToReadableBinding,
readableToRuntimeBinding,
runtimeToReadableMap,
readableToRuntimeMap,
} from "builderStore/dataBinding"
let query, datasource let query, datasource
let breakQs = {}, let breakQs = {},
bindings = {} requestBindings = {}
let saveId, url let saveId, url
let response, schema, enabledHeaders let response, schema, enabledHeaders
let authConfigId let authConfigId
let dynamicVariables, addVariableModal, varBinding let dynamicVariables, addVariableModal, varBinding
let restBindings = getRestBindings()
$: staticVariables = datasource?.config?.staticVariables || {}
$: customRequestBindings = toBindingsArray(requestBindings, "Binding")
$: dynamicRequestBindings = toBindingsArray(dynamicVariables, "Dynamic")
$: dataSourceStaticBindings = toBindingsArray(
staticVariables,
"Datasource.Static"
)
$: mergedBindings = [
...restBindings,
...customRequestBindings,
...dynamicRequestBindings,
...dataSourceStaticBindings,
]
$: datasourceType = datasource?.source $: datasourceType = datasource?.source
$: integrationInfo = $integrations[datasourceType] $: integrationInfo = $integrations[datasourceType]
@ -63,8 +89,10 @@
Object.keys(schema || {}).length !== 0 || Object.keys(schema || {}).length !== 0 ||
Object.keys(query?.schema || {}).length !== 0 Object.keys(query?.schema || {}).length !== 0
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
function getSelectedQuery() { function getSelectedQuery() {
return cloneDeep( const cloneQuery = cloneDeep(
$queries.list.find(q => q._id === $queries.selected) || { $queries.list.find(q => q._id === $queries.selected) || {
datasourceId: $params.selectedDatasource, datasourceId: $params.selectedDatasource,
parameters: [], parameters: [],
@ -76,6 +104,7 @@
queryVerb: "read", queryVerb: "read",
} }
) )
return cloneQuery
} }
function checkQueryName(inputUrl = null) { function checkQueryName(inputUrl = null) {
@ -89,7 +118,9 @@
if (!base) { if (!base) {
return base return base
} }
const qs = restUtils.buildQueryString(qsObj) const qs = restUtils.buildQueryString(
runtimeToReadableMap(mergedBindings, qsObj)
)
let newUrl = base let newUrl = base
if (base.includes("?")) { if (base.includes("?")) {
newUrl = base.split("?")[0] newUrl = base.split("?")[0]
@ -98,14 +129,21 @@
} }
function buildQuery() { function buildQuery() {
const newQuery = { ...query } const newQuery = cloneDeep(query)
const queryString = restUtils.buildQueryString(breakQs) const queryString = restUtils.buildQueryString(runtimeUrlQueries)
newQuery.parameters = restUtils.keyValueToQueryParameters(requestBindings)
newQuery.fields.requestBody =
typeof newQuery.fields.requestBody === "object"
? readableToRuntimeMap(mergedBindings, newQuery.fields.requestBody)
: readableToRuntimeBinding(mergedBindings, newQuery.fields.requestBody)
newQuery.fields.path = url.split("?")[0] newQuery.fields.path = url.split("?")[0]
newQuery.fields.queryString = queryString newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders) newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders)
newQuery.schema = restUtils.fieldsToSchema(schema) newQuery.schema = restUtils.fieldsToSchema(schema)
newQuery.parameters = restUtils.keyValueToQueryParameters(bindings)
return newQuery return newQuery
} }
@ -120,6 +158,13 @@
datasource.config.dynamicVariables = rebuildVariables(saveId) datasource.config.dynamicVariables = rebuildVariables(saveId)
datasource = await datasources.save(datasource) datasource = await datasources.save(datasource)
} }
prettifyQueryRequestBody(
query,
requestBindings,
dynamicVariables,
staticVariables,
restBindings
)
} catch (err) { } catch (err) {
notifications.error(`Error saving query`) notifications.error(`Error saving query`)
} }
@ -127,7 +172,7 @@
async function runQuery() { async function runQuery() {
try { try {
response = await queries.preview(buildQuery(query)) response = await queries.preview(buildQuery())
if (response.rows.length === 0) { if (response.rows.length === 0) {
notifications.info("Request did not return any data") notifications.info("Request did not return any data")
} else { } else {
@ -236,6 +281,36 @@
} }
} }
const prettifyQueryRequestBody = (
query,
requestBindings,
dynamicVariables,
staticVariables,
restBindings
) => {
let customRequestBindings = toBindingsArray(requestBindings, "Binding")
let dynamicRequestBindings = toBindingsArray(dynamicVariables, "Dynamic")
let dataSourceStaticBindings = toBindingsArray(
staticVariables,
"Datasource.Static"
)
const prettyBindings = [
...restBindings,
...customRequestBindings,
...dynamicRequestBindings,
...dataSourceStaticBindings,
]
//Parse the body here as now all bindings have been updated.
if (query?.fields?.requestBody) {
query.fields.requestBody =
typeof query.fields.requestBody === "object"
? runtimeToReadableMap(prettyBindings, query.fields.requestBody)
: runtimeToReadableBinding(prettyBindings, query.fields.requestBody)
}
}
onMount(async () => { onMount(async () => {
query = getSelectedQuery() query = getSelectedQuery()
@ -250,6 +325,8 @@
const datasourceUrl = datasource?.config.url const datasourceUrl = datasource?.config.url
const qs = query?.fields.queryString const qs = query?.fields.queryString
breakQs = restUtils.breakQueryString(qs) breakQs = restUtils.breakQueryString(qs)
breakQs = runtimeToReadableMap(mergedBindings, breakQs)
const path = query.fields.path const path = query.fields.path
if ( if (
datasourceUrl && datasourceUrl &&
@ -260,7 +337,7 @@
} }
url = buildUrl(query.fields.path, breakQs) url = buildUrl(query.fields.path, breakQs)
schema = restUtils.schemaToFields(query.schema) schema = restUtils.schemaToFields(query.schema)
bindings = restUtils.queryParametersToKeyValue(query.parameters) requestBindings = restUtils.queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId() authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) { if (!query.fields.disabledHeaders) {
query.fields.disabledHeaders = {} query.fields.disabledHeaders = {}
@ -291,6 +368,14 @@
query.fields.pagination = {} query.fields.pagination = {}
} }
dynamicVariables = getDynamicVariables(datasource, query._id) dynamicVariables = getDynamicVariables(datasource, query._id)
prettifyQueryRequestBody(
query,
requestBindings,
dynamicVariables,
staticVariables,
restBindings
)
}) })
</script> </script>
@ -344,16 +429,26 @@
<Tabs selected="Bindings" quiet noPadding noHorizPadding onTop> <Tabs selected="Bindings" quiet noPadding noHorizPadding onTop>
<Tab title="Bindings"> <Tab title="Bindings">
<KeyValueBuilder <KeyValueBuilder
bind:object={bindings} bind:object={requestBindings}
tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query" tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
name="binding" name="binding"
headings headings
keyPlaceholder="Binding name" keyPlaceholder="Binding name"
valuePlaceholder="Default" valuePlaceholder="Default"
bindings={[
...restBindings,
...dynamicRequestBindings,
...dataSourceStaticBindings,
]}
/> />
</Tab> </Tab>
<Tab title="Params"> <Tab title="Params">
<KeyValueBuilder bind:object={breakQs} name="param" headings /> <KeyValueBuilder
bind:object={breakQs}
name="param"
headings
bindings={mergedBindings}
/>
</Tab> </Tab>
<Tab title="Headers"> <Tab title="Headers">
<KeyValueBuilder <KeyValueBuilder
@ -362,6 +457,7 @@
toggle toggle
name="header" name="header"
headings headings
bindings={mergedBindings}
/> />
</Tab> </Tab>
<Tab title="Body"> <Tab title="Body">

View File

@ -15,6 +15,15 @@ module FetchMock {
}, },
}, },
json: async () => { json: async () => {
//x-www-form-encoded body is a URLSearchParams
//The call to stringify it leaves it blank
if (body?.opts?.body instanceof URLSearchParams) {
const paramArray = Array.from(body.opts.body.entries())
body.opts.body = paramArray.reduce((acc: any, pair: any) => {
acc[pair[0]] = pair[1]
return acc
}, {})
}
return body return body
}, },
} }

View File

@ -129,6 +129,9 @@ export async function preview(ctx: any) {
parameters, parameters,
transformer, transformer,
queryId, queryId,
ctx: {
user: ctx.user,
},
}) })
const { rows, keys, info, extra } = await quotas.addQuery(runFn) const { rows, keys, info, extra } = await quotas.addQuery(runFn)
@ -172,6 +175,9 @@ async function execute(ctx: any, opts = { rowsOnly: false }) {
parameters: enrichedParameters, parameters: enrichedParameters,
transformer: query.transformer, transformer: query.transformer,
queryId: ctx.params.queryId, queryId: ctx.params.queryId,
ctx: {
user: ctx.user,
},
}) })
const { rows, pagination, extra } = await quotas.addQuery(runFn) const { rows, pagination, extra } = await quotas.addQuery(runFn)

View File

@ -346,4 +346,170 @@ describe("/queries", () => {
expect(contents).toBe(null) expect(contents).toBe(null)
}) })
}) })
describe("Current User Request Mapping", () => {
async function previewGet(datasource, fields, params) {
return config.previewQuery(request, config, datasource, fields, params)
}
async function previewPost(datasource, fields, params) {
return config.previewQuery(request, config, datasource, fields, params, "create")
}
it("should parse global and query level header mappings", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource({
defaultHeaders: {
"test": "headerVal",
"emailHdr": "{{[user].[email]}}"
}
})
const res = await previewGet(datasource, {
path: "www.google.com",
queryString: "email={{[user].[email]}}",
headers: {
queryHdr : "{{[user].[firstName]}}",
secondHdr : "1234"
}
})
const parsedRequest = JSON.parse(res.body.extra.raw)
expect(parsedRequest.opts.headers).toEqual({
"test": "headerVal",
"emailHdr": userDetails.email,
"queryHdr": userDetails.firstName,
"secondHdr" : "1234"
})
expect(res.body.rows[0].url).toEqual("http://www.google.com?email=" + userDetails.email)
})
it("should bind the current user to query parameters", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewGet(datasource, {
path: "www.google.com",
queryString: "test={{myEmail}}&testName={{myName}}&testParam={{testParam}}",
}, {
"myEmail" : "{{[user].[email]}}",
"myName" : "{{[user].[firstName]}}",
"testParam" : "1234"
})
expect(res.body.rows[0].url).toEqual("http://www.google.com?test=" + userDetails.email +
"&testName=" + userDetails.firstName + "&testParam=1234")
})
it("should bind the current user the request body - plain text", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewPost(datasource, {
path: "www.google.com",
queryString: "testParam={{testParam}}",
requestBody: "This is plain text and this is my email: {{[user].[email]}}. This is a test param: {{testParam}}",
bodyType: "text"
}, {
"testParam" : "1234"
})
const parsedRequest = JSON.parse(res.body.extra.raw)
expect(parsedRequest.opts.body).toEqual(`This is plain text and this is my email: ${userDetails.email}. This is a test param: 1234`)
expect(res.body.rows[0].url).toEqual("http://www.google.com?testParam=1234")
})
it("should bind the current user the request body - json", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewPost(datasource, {
path: "www.google.com",
queryString: "testParam={{testParam}}",
requestBody: "{\"email\":\"{{[user].[email]}}\",\"queryCode\":{{testParam}},\"userRef\":\"{{userRef}}\"}",
bodyType: "json"
}, {
"testParam" : "1234",
"userRef" : "{{[user].[firstName]}}"
})
const parsedRequest = JSON.parse(res.body.extra.raw)
const test = `{"email":"${userDetails.email}","queryCode":1234,"userRef":"${userDetails.firstName}"}`
expect(parsedRequest.opts.body).toEqual(test)
expect(res.body.rows[0].url).toEqual("http://www.google.com?testParam=1234")
})
it("should bind the current user the request body - xml", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewPost(datasource, {
path: "www.google.com",
queryString: "testParam={{testParam}}",
requestBody: "<note> <email>{{[user].[email]}}</email> <code>{{testParam}}</code> " +
"<ref>{{userId}}</ref> <somestring>testing</somestring> </note>",
bodyType: "xml"
}, {
"testParam" : "1234",
"userId" : "{{[user].[firstName]}}"
})
const parsedRequest = JSON.parse(res.body.extra.raw)
const test = `<note> <email>${userDetails.email}</email> <code>1234</code> <ref>${userDetails.firstName}</ref> <somestring>testing</somestring> </note>`
expect(parsedRequest.opts.body).toEqual(test)
expect(res.body.rows[0].url).toEqual("http://www.google.com?testParam=1234")
})
it("should bind the current user the request body - form-data", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewPost(datasource, {
path: "www.google.com",
queryString: "testParam={{testParam}}",
requestBody: "{\"email\":\"{{[user].[email]}}\",\"queryCode\":{{testParam}},\"userRef\":\"{{userRef}}\"}",
bodyType: "form"
}, {
"testParam" : "1234",
"userRef" : "{{[user].[firstName]}}"
})
const parsedRequest = JSON.parse(res.body.extra.raw)
const emailData = parsedRequest.opts.body._streams[1]
expect(emailData).toEqual(userDetails.email)
const queryCodeData = parsedRequest.opts.body._streams[4]
expect(queryCodeData).toEqual("1234")
const userRef = parsedRequest.opts.body._streams[7]
expect(userRef).toEqual(userDetails.firstName)
expect(res.body.rows[0].url).toEqual("http://www.google.com?testParam=1234")
})
it("should bind the current user the request body - encoded", async () => {
const userDetails = config.getUserDetails()
const datasource = await config.restDatasource()
const res = await previewPost(datasource, {
path: "www.google.com",
queryString: "testParam={{testParam}}",
requestBody: "{\"email\":\"{{[user].[email]}}\",\"queryCode\":{{testParam}},\"userRef\":\"{{userRef}}\"}",
bodyType: "encoded"
}, {
"testParam" : "1234",
"userRef" : "{{[user].[firstName]}}"
})
const parsedRequest = JSON.parse(res.body.extra.raw)
expect(parsedRequest.opts.body.email).toEqual(userDetails.email)
expect(parsedRequest.opts.body.queryCode).toEqual("1234")
expect(parsedRequest.opts.body.userRef).toEqual(userDetails.firstName)
})
});
}) })

View File

@ -9,7 +9,9 @@ export function enrichQueryFields(
parameters = {} parameters = {}
) { ) {
const enrichedQuery: { [key: string]: any } = Array.isArray(fields) ? [] : {} const enrichedQuery: { [key: string]: any } = Array.isArray(fields) ? [] : {}
if (!fields || !parameters) {
return enrichedQuery
}
// enrich the fields with dynamic parameters // enrich the fields with dynamic parameters
for (let key of Object.keys(fields)) { for (let key of Object.keys(fields)) {
if (fields[key] == null) { if (fields[key] == null) {

View File

@ -287,7 +287,7 @@ module RestModule {
input.body = form input.body = form
break break
case BodyTypes.XML: case BodyTypes.XML:
if (object != null) { if (object != null && Object.keys(object).length) {
string = new XmlBuilder().buildObject(object) string = new XmlBuilder().buildObject(object)
} }
input.body = string input.body = string

View File

@ -155,12 +155,27 @@ describe("REST Integration", () => {
expect(output.headers["Content-Type"]).toEqual("application/json") expect(output.headers["Content-Type"]).toEqual("application/json")
}) })
it("should allow XML", () => { it("should allow raw XML", () => {
const output = config.integration.addBody("xml", "<a>1</a><b>2</b>", {})
expect(output.body.includes("<a>1</a>")).toEqual(true)
expect(output.body.includes("<b>2</b>")).toEqual(true)
expect(output.headers["Content-Type"]).toEqual("application/xml")
})
it("should allow a valid js object and parse the contents to xml", () => {
const output = config.integration.addBody("xml", input, {}) const output = config.integration.addBody("xml", input, {})
expect(output.body.includes("<a>1</a>")).toEqual(true) expect(output.body.includes("<a>1</a>")).toEqual(true)
expect(output.body.includes("<b>2</b>")).toEqual(true) expect(output.body.includes("<b>2</b>")).toEqual(true)
expect(output.headers["Content-Type"]).toEqual("application/xml") expect(output.headers["Content-Type"]).toEqual("application/xml")
}) })
it("should allow a valid json string and parse the contents to xml", () => {
const output = config.integration.addBody("xml", JSON.stringify(input), {})
expect(output.body.includes("<a>1</a>")).toEqual(true)
expect(output.body.includes("<b>2</b>")).toEqual(true)
expect(output.headers["Content-Type"]).toEqual("application/xml")
})
}) })
describe("response", () => { describe("response", () => {

View File

@ -93,8 +93,24 @@ describe("migrations", () => {
await clearMigrations() await clearMigrations()
const appId = config.prodAppId const appId = config.prodAppId
const roles = { [appId]: "role_12345" } const roles = { [appId]: "role_12345" }
await config.createUser(undefined, undefined, false, true, roles) // admin only await config.createUser(
await config.createUser(undefined, undefined, false, false, roles) // non admin non builder undefined,
undefined,
undefined,
undefined,
false,
true,
roles
) // admin only
await config.createUser(
undefined,
undefined,
undefined,
undefined,
false,
false,
roles
) // non admin non builder
await config.createTable() await config.createTable()
await config.createRow() await config.createRow()
await config.createRow() await config.createRow()

View File

@ -28,6 +28,8 @@ const { encrypt } = require("@budibase/backend-core/encryption")
const GLOBAL_USER_ID = "us_uuid1" const GLOBAL_USER_ID = "us_uuid1"
const EMAIL = "babs@babs.com" const EMAIL = "babs@babs.com"
const FIRSTNAME = "Barbara"
const LASTNAME = "Barbington"
const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306" const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306"
class TestConfiguration { class TestConfiguration {
@ -59,6 +61,15 @@ class TestConfiguration {
return this.prodAppId return this.prodAppId
} }
getUserDetails() {
return {
globalId: GLOBAL_USER_ID,
email: EMAIL,
firstName: FIRSTNAME,
lastName: LASTNAME,
}
}
async doInContext(appId, task) { async doInContext(appId, task) {
if (!appId) { if (!appId) {
appId = this.appId appId = this.appId
@ -118,6 +129,8 @@ class TestConfiguration {
// USER / AUTH // USER / AUTH
async globalUser({ async globalUser({
id = GLOBAL_USER_ID, id = GLOBAL_USER_ID,
firstName = FIRSTNAME,
lastName = LASTNAME,
builder = true, builder = true,
admin = false, admin = false,
email = EMAIL, email = EMAIL,
@ -135,6 +148,8 @@ class TestConfiguration {
...existing, ...existing,
roles: roles || {}, roles: roles || {},
tenantId: TENANT_ID, tenantId: TENANT_ID,
firstName,
lastName,
} }
await createASession(id, { await createASession(id, {
sessionId: "sessionid", sessionId: "sessionid",
@ -161,6 +176,8 @@ class TestConfiguration {
async createUser( async createUser(
id = null, id = null,
firstName = FIRSTNAME,
lastName = LASTNAME,
email = EMAIL, email = EMAIL,
builder = true, builder = true,
admin = false, admin = false,
@ -169,6 +186,8 @@ class TestConfiguration {
const globalId = !id ? `us_${Math.random()}` : `us_${id}` const globalId = !id ? `us_${Math.random()}` : `us_${id}`
const resp = await this.globalUser({ const resp = await this.globalUser({
id: globalId, id: globalId,
firstName,
lastName,
email, email,
builder, builder,
admin, admin,
@ -520,14 +539,14 @@ class TestConfiguration {
// QUERY // QUERY
async previewQuery(request, config, datasource, fields) { async previewQuery(request, config, datasource, fields, params, verb) {
return request return request
.post(`/api/queries/preview`) .post(`/api/queries/preview`)
.send({ .send({
datasourceId: datasource._id, datasourceId: datasource._id,
parameters: {}, parameters: params || {},
fields, fields,
queryVerb: "read", queryVerb: verb || "read",
name: datasource.name, name: datasource.name,
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())

View File

@ -21,6 +21,8 @@ class QueryRunner {
this.queryId = input.queryId this.queryId = input.queryId
this.noRecursiveQuery = flags.noRecursiveQuery this.noRecursiveQuery = flags.noRecursiveQuery
this.cachedVariables = [] this.cachedVariables = []
// Additional context items for enrichment
this.ctx = input.ctx
// allows the response from a query to be stored throughout this // allows the response from a query to be stored throughout this
// execution so that if it needs to be re-used for another variable // execution so that if it needs to be re-used for another variable
// it can be // it can be
@ -34,16 +36,39 @@ class QueryRunner {
if (!Integration) { if (!Integration) {
throw "Integration type does not exist." throw "Integration type does not exist."
} }
if (datasource.config.authConfigs) {
datasource.config.authConfigs = datasource.config.authConfigs.map(
config => {
return enrichQueryFields(config, this.ctx)
}
)
}
const integration = new Integration(datasource.config) const integration = new Integration(datasource.config)
// pre-query, make sure datasource variables are added to parameters // pre-query, make sure datasource variables are added to parameters
const parameters = await this.addDatasourceVariables() const parameters = await this.addDatasourceVariables()
// Enrich the parameters with the addition context items.
// 'user' is now a reserved variable key in mapping parameters
const enrichedParameters = enrichQueryFields(parameters, this.ctx)
const enrichedContext = { ...enrichedParameters, ...this.ctx }
// Parse global headers
if (datasource.config.defaultHeaders) {
datasource.config.defaultHeaders = enrichQueryFields(
datasource.config.defaultHeaders,
enrichedContext
)
}
let query let query
// handle SQL injections by interpolating the variables // handle SQL injections by interpolating the variables
if (isSQL(datasource)) { if (isSQL(datasource)) {
query = interpolateSQL(fields, parameters, integration) query = interpolateSQL(fields, enrichedParameters, integration)
} else { } else {
query = enrichQueryFields(fields, parameters) query = enrichQueryFields(fields, enrichedContext)
} }
// Add pagination values for REST queries // Add pagination values for REST queries
@ -67,7 +92,7 @@ class QueryRunner {
if (transformer) { if (transformer) {
const runner = new ScriptRunner(transformer, { const runner = new ScriptRunner(transformer, {
data: rows, data: rows,
params: parameters, params: enrichedParameters,
}) })
rows = runner.execute() rows = runner.execute()
} }