Merge branch 'master' into execute-script-v2
This commit is contained in:
commit
6f9e625b5e
|
@ -1,18 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import { FieldType } from "@budibase/types"
|
||||
import { FieldType, InternalTable } from "@budibase/types"
|
||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||
import { getContext } from "svelte"
|
||||
import Field from "./Field.svelte"
|
||||
import type {
|
||||
SearchFilter,
|
||||
RelationshipFieldMetadata,
|
||||
Table,
|
||||
Row,
|
||||
} from "@budibase/types"
|
||||
|
||||
const { API } = getContext("sdk")
|
||||
|
||||
export let field: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let placeholder: any = undefined
|
||||
|
@ -20,10 +17,10 @@
|
|||
export let readonly: boolean = false
|
||||
export let validation: any
|
||||
export let autocomplete: boolean = true
|
||||
export let defaultValue: string | undefined = undefined
|
||||
export let defaultValue: string | string[] | undefined = undefined
|
||||
export let onChange: any
|
||||
export let filter: SearchFilter[]
|
||||
export let datasourceType: "table" | "user" | "groupUser" = "table"
|
||||
export let datasourceType: "table" | "user" = "table"
|
||||
export let primaryDisplay: string | undefined = undefined
|
||||
export let span: number | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
|
@ -32,191 +29,305 @@
|
|||
| FieldType.BB_REFERENCE
|
||||
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
||||
|
||||
type RelationshipValue = { _id: string; [key: string]: any }
|
||||
type OptionObj = Record<string, RelationshipValue>
|
||||
type OptionsObjType = Record<string, OptionObj>
|
||||
type BasicRelatedRow = { _id: string; primaryDisplay: string }
|
||||
type OptionsMap = Record<string, BasicRelatedRow>
|
||||
|
||||
const { API } = getContext("sdk")
|
||||
|
||||
// Field state
|
||||
let fieldState: any
|
||||
let fieldApi: any
|
||||
let fieldSchema: RelationshipFieldMetadata | undefined
|
||||
let tableDefinition: Table | null | undefined
|
||||
let searchTerm: any
|
||||
let open: boolean
|
||||
let selectedValue: string[] | string
|
||||
|
||||
// need a cast version of this for reactivity, components below aren't typed
|
||||
$: castSelectedValue = selectedValue as any
|
||||
// Local UI state
|
||||
let searchTerm: any
|
||||
let open: boolean = false
|
||||
|
||||
// Options state
|
||||
let options: BasicRelatedRow[] = []
|
||||
let optionsMap: OptionsMap = {}
|
||||
let loadingMissingOptions: boolean = false
|
||||
|
||||
// Determine if we can select multiple rows or not
|
||||
$: multiselect =
|
||||
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
||||
fieldSchema?.relationshipType !== "one-to-many"
|
||||
$: linkedTableId = fieldSchema?.tableId!
|
||||
$: fetch = fetchData({
|
||||
|
||||
// Get the proper string representation of the value
|
||||
$: realValue = fieldState?.value
|
||||
$: selectedValue = parseSelectedValue(realValue, multiselect)
|
||||
$: selectedIDs = getSelectedIDs(selectedValue)
|
||||
|
||||
// If writable, we use a fetch to load options
|
||||
$: linkedTableId = fieldSchema?.tableId
|
||||
$: writable = !disabled && !readonly
|
||||
$: fetch = createFetch(writable, datasourceType, filter, linkedTableId)
|
||||
|
||||
// Attempt to determine the primary display field to use
|
||||
$: tableDefinition = $fetch?.definition
|
||||
$: primaryDisplayField = primaryDisplay || tableDefinition?.primaryDisplay
|
||||
|
||||
// Build our options map
|
||||
$: rows = $fetch?.rows || []
|
||||
$: processOptions(realValue, rows, primaryDisplayField)
|
||||
|
||||
// If we ever have a value selected for which we don't have an option, we must
|
||||
// fetch those rows to ensure we can render them as options
|
||||
$: missingIDs = selectedIDs.filter(id => !optionsMap[id])
|
||||
$: loadMissingOptions(missingIDs, linkedTableId, primaryDisplayField)
|
||||
|
||||
// Convert our options map into an array for display
|
||||
$: updateOptions(optionsMap)
|
||||
$: !open && sortOptions()
|
||||
|
||||
// Search for new options when search term changes
|
||||
$: debouncedSearchOptions(searchTerm || "", primaryDisplayField)
|
||||
|
||||
// Ensure backwards compatibility
|
||||
$: enrichedDefaultValue = enrichDefaultValue(defaultValue)
|
||||
|
||||
// We need to cast value to pass it down, as those components aren't typed
|
||||
$: emptyValue = multiselect ? [] : null
|
||||
$: displayValue = missingIDs.length ? emptyValue : (selectedValue as any)
|
||||
|
||||
// Ensures that we flatten any objects so that only the IDs of the selected
|
||||
// rows are passed down. Not sure how this can be an object to begin with?
|
||||
const parseSelectedValue = (
|
||||
value: any,
|
||||
multiselect: boolean
|
||||
): undefined | string | string[] => {
|
||||
return multiselect ? flatten(value) : flatten(value)[0]
|
||||
}
|
||||
|
||||
// Where applicable, creates the fetch instance to load row options
|
||||
const createFetch = (
|
||||
writable: boolean,
|
||||
dsType: typeof datasourceType,
|
||||
filter: SearchFilter[],
|
||||
linkedTableId?: string
|
||||
) => {
|
||||
if (!linkedTableId) {
|
||||
return undefined
|
||||
}
|
||||
const datasource =
|
||||
datasourceType === "table"
|
||||
? {
|
||||
type: datasourceType,
|
||||
tableId: fieldSchema?.tableId!,
|
||||
}
|
||||
: {
|
||||
type: datasourceType,
|
||||
tableId: InternalTable.USER_METADATA,
|
||||
}
|
||||
return fetchData({
|
||||
API,
|
||||
datasource: {
|
||||
// typing here doesn't seem correct - we have the correct datasourceType options
|
||||
// but when we configure the fetchData, it seems to think only "table" is valid
|
||||
type: datasourceType as any,
|
||||
tableId: linkedTableId,
|
||||
},
|
||||
datasource,
|
||||
options: {
|
||||
filter,
|
||||
limit: 100,
|
||||
limit: writable ? 100 : 1,
|
||||
},
|
||||
})
|
||||
|
||||
$: tableDefinition = $fetch.definition
|
||||
$: selectedValue = multiselect
|
||||
? flatten(fieldState?.value) ?? []
|
||||
: flatten(fieldState?.value)?.[0]
|
||||
$: component = multiselect ? CoreMultiselect : CoreSelect
|
||||
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
|
||||
|
||||
let optionsObj: OptionsObjType = {}
|
||||
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
|
||||
|
||||
$: {
|
||||
if (primaryDisplay && fieldState && !optionsObj) {
|
||||
// Persist the initial values as options, allowing them to be present in the dropdown,
|
||||
// even if they are not in the inital fetch results
|
||||
let valueAsSafeArray = fieldState.value || []
|
||||
if (!Array.isArray(valueAsSafeArray)) {
|
||||
valueAsSafeArray = [fieldState.value]
|
||||
}
|
||||
optionsObj = valueAsSafeArray.reduce(
|
||||
(
|
||||
accumulator: OptionObj,
|
||||
value: { _id: string; primaryDisplay: any }
|
||||
|
||||
// Small helper to represent the selected value as an array
|
||||
const getSelectedIDs = (
|
||||
selectedValue: undefined | string | string[]
|
||||
): string[] => {
|
||||
if (!selectedValue) {
|
||||
return []
|
||||
}
|
||||
return Array.isArray(selectedValue) ? selectedValue : [selectedValue]
|
||||
}
|
||||
|
||||
// Builds a map of all available options, in a consistent structure
|
||||
const processOptions = (
|
||||
realValue: any | any[],
|
||||
rows: Row[],
|
||||
primaryDisplay?: string
|
||||
) => {
|
||||
// fieldState has to be an array of strings to be valid for an update
|
||||
// therefore we cannot guarantee value will be an object
|
||||
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
|
||||
if (!value._id) {
|
||||
return accumulator
|
||||
// First ensure that all options included in the value are present as valid
|
||||
// options. These can be basic related row shapes which already include
|
||||
// a value for primary display
|
||||
if (realValue) {
|
||||
const valueArray = Array.isArray(realValue) ? realValue : [realValue]
|
||||
for (let val of valueArray) {
|
||||
const option = parseOption(val, primaryDisplay)
|
||||
if (option) {
|
||||
optionsMap[option._id] = option
|
||||
}
|
||||
accumulator[value._id] = {
|
||||
_id: value._id,
|
||||
[primaryDisplay]: value.primaryDisplay,
|
||||
}
|
||||
return accumulator
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
|
||||
const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
|
||||
const result = (fetchResults || [])?.reduce((accumulator, row) => {
|
||||
if (!accumulator[row._id!]) {
|
||||
accumulator[row._id!] = row
|
||||
}
|
||||
return accumulator
|
||||
}, optionsObj || {})
|
||||
|
||||
return Object.values(result)
|
||||
}
|
||||
$: {
|
||||
// We don't want to reorder while the dropdown is open, to avoid UX jumps
|
||||
if (!open && primaryDisplay) {
|
||||
enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
|
||||
const selectedValues = flatten(fieldState?.value) || []
|
||||
|
||||
const aIsSelected = selectedValues.find(
|
||||
(v: RelationshipValue) => v === a._id
|
||||
)
|
||||
const bIsSelected = selectedValues.find(
|
||||
(v: RelationshipValue) => v === b._id
|
||||
)
|
||||
if (aIsSelected && !bIsSelected) {
|
||||
return -1
|
||||
} else if (!aIsSelected && bIsSelected) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
|
||||
})
|
||||
// Process all rows loaded from our fetch
|
||||
for (let row of rows) {
|
||||
const option = parseOption(row, primaryDisplay)
|
||||
if (option) {
|
||||
optionsMap[option._id] = option
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (filter || defaultValue) {
|
||||
forceFetchRows()
|
||||
// Reassign to trigger reactivity
|
||||
optionsMap = optionsMap
|
||||
}
|
||||
}
|
||||
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||
|
||||
const forceFetchRows = async () => {
|
||||
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
|
||||
optionsObj = {}
|
||||
fieldApi?.setValue([])
|
||||
selectedValue = []
|
||||
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||
// Parses a row-like structure into a properly shaped option
|
||||
const parseOption = (
|
||||
option: any | BasicRelatedRow | Row,
|
||||
primaryDisplay?: string
|
||||
): BasicRelatedRow | null => {
|
||||
if (!option || typeof option !== "object" || !option?._id) {
|
||||
return null
|
||||
}
|
||||
async function fetchRows(
|
||||
searchTerm: any,
|
||||
primaryDisplay: string,
|
||||
defaultVal: string | string[]
|
||||
// If this is a basic related row shape (_id and PD only) then just use
|
||||
// that
|
||||
if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
|
||||
return {
|
||||
_id: option._id,
|
||||
primaryDisplay: ensureString(option.primaryDisplay),
|
||||
}
|
||||
}
|
||||
// Otherwise use the primary display field specified
|
||||
if (primaryDisplay) {
|
||||
return {
|
||||
_id: option._id,
|
||||
primaryDisplay: ensureString(
|
||||
option[primaryDisplay as keyof typeof option]
|
||||
),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
_id: option._id,
|
||||
primaryDisplay: option._id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loads any rows which are selected and aren't present in the currently
|
||||
// available option set. This is typically only IDs specified as default
|
||||
// values.
|
||||
const loadMissingOptions = async (
|
||||
missingIDs: string[],
|
||||
linkedTableId?: string,
|
||||
primaryDisplay?: string
|
||||
) => {
|
||||
if (
|
||||
loadingMissingOptions ||
|
||||
!missingIDs.length ||
|
||||
!linkedTableId ||
|
||||
!primaryDisplay
|
||||
) {
|
||||
const allRowsFetched =
|
||||
$fetch.loaded &&
|
||||
!Object.keys($fetch.query?.string || {}).length &&
|
||||
!$fetch.hasNextPage
|
||||
// Don't request until we have the primary display or default value has been fetched
|
||||
if (allRowsFetched || !primaryDisplay) {
|
||||
return
|
||||
}
|
||||
// must be an array
|
||||
const defaultValArray: string[] = !defaultVal
|
||||
? []
|
||||
: !Array.isArray(defaultVal)
|
||||
? defaultVal.split(",")
|
||||
: defaultVal
|
||||
loadingMissingOptions = true
|
||||
try {
|
||||
const res = await API.searchTable(linkedTableId, {
|
||||
query: {
|
||||
oneOf: {
|
||||
_id: missingIDs,
|
||||
},
|
||||
},
|
||||
})
|
||||
for (let row of res.rows) {
|
||||
const option = parseOption(row, primaryDisplay)
|
||||
if (option) {
|
||||
optionsMap[option._id] = option
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
defaultVal &&
|
||||
optionsObj &&
|
||||
defaultValArray.some(val => !optionsObj[val])
|
||||
) {
|
||||
await fetch.update({
|
||||
query: { oneOf: { _id: defaultValArray } },
|
||||
// Reassign to trigger reactivity
|
||||
optionsMap = optionsMap
|
||||
updateOptions(optionsMap)
|
||||
} catch (error) {
|
||||
console.error("Error loading missing row IDs", error)
|
||||
} finally {
|
||||
// Ensure we have some sort of option for all IDs
|
||||
for (let id of missingIDs) {
|
||||
if (!optionsMap[id]) {
|
||||
optionsMap[id] = {
|
||||
_id: id,
|
||||
primaryDisplay: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
loadingMissingOptions = false
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the options list to reflect the currently available options
|
||||
const updateOptions = (optionsMap: OptionsMap) => {
|
||||
let newOptions = Object.values(optionsMap)
|
||||
|
||||
// Only override options if the quantity of options changes
|
||||
if (newOptions.length !== options.length) {
|
||||
options = newOptions
|
||||
sortOptions()
|
||||
}
|
||||
}
|
||||
|
||||
// Sorts the options list by selected state, then by primary display
|
||||
const sortOptions = () => {
|
||||
// Create a quick lookup map so we can test whether options are selected
|
||||
const selectedMap: Record<string, boolean> = selectedIDs.reduce(
|
||||
(map, id) => ({ ...map, [id]: true }),
|
||||
{}
|
||||
)
|
||||
options.sort((a, b) => {
|
||||
const aSelected = !!selectedMap[a._id]
|
||||
const bSelected = !!selectedMap[b._id]
|
||||
if (aSelected === bSelected) {
|
||||
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
|
||||
} else {
|
||||
return aSelected ? -1 : 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
(Array.isArray(selectedValue) &&
|
||||
selectedValue.some(val => !optionsObj[val])) ||
|
||||
(selectedValue && !optionsObj[selectedValue as string])
|
||||
) {
|
||||
await fetch.update({
|
||||
query: {
|
||||
oneOf: {
|
||||
_id: Array.isArray(selectedValue) ? selectedValue : [selectedValue],
|
||||
},
|
||||
},
|
||||
})
|
||||
// Util to ensure a value is stringified
|
||||
const ensureString = (val: any): string => {
|
||||
return typeof val === "string" ? val : JSON.stringify(val)
|
||||
}
|
||||
|
||||
// We previously included logic to manually process default value, which
|
||||
// should not be done as it is handled by the core form logic.
|
||||
// This logic included handling a comma separated list of IDs, so for
|
||||
// backwards compatibility we must now unfortunately continue to handle that
|
||||
// at this level.
|
||||
const enrichDefaultValue = (val: any) => {
|
||||
if (!val || typeof val !== "string") {
|
||||
return val
|
||||
}
|
||||
return val.includes(",") ? val.split(",") : val
|
||||
}
|
||||
|
||||
// Searches for new options matching the given term
|
||||
async function searchOptions(searchTerm: string, primaryDisplay?: string) {
|
||||
if (!primaryDisplay) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we match all filters, rather than any
|
||||
let newFilter: any = filter
|
||||
if (searchTerm) {
|
||||
// @ts-expect-error this doesn't fit types, but don't want to change it yet
|
||||
const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr")
|
||||
await fetch.update({
|
||||
filter: [
|
||||
...baseFilter,
|
||||
{
|
||||
newFilter = (newFilter || []).filter(x => x.operator !== "allOr")
|
||||
newFilter.push({
|
||||
// Use a big numeric prefix to avoid clashing with an existing filter
|
||||
field: `999:${primaryDisplay}`,
|
||||
operator: "string",
|
||||
value: searchTerm,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
await fetch?.update({
|
||||
filter: newFilter,
|
||||
})
|
||||
}
|
||||
const debouncedSearchOptions = Utils.debounce(searchOptions, 250)
|
||||
|
||||
const flatten = (values: any | any[]) => {
|
||||
// Flattens an array of row-like objects into a simple array of row IDs
|
||||
const flatten = (values: any | any[]): string[] => {
|
||||
if (!values) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
values = [values]
|
||||
}
|
||||
|
@ -226,16 +337,11 @@
|
|||
return values
|
||||
}
|
||||
|
||||
const getDisplayName = (row: Row) => {
|
||||
return row?.[primaryDisplay!] || "-"
|
||||
}
|
||||
|
||||
const handleChange = (e: any) => {
|
||||
let value = e.detail
|
||||
if (!multiselect) {
|
||||
value = value == null ? [] : [value]
|
||||
}
|
||||
|
||||
if (
|
||||
type === FieldType.BB_REFERENCE_SINGLE &&
|
||||
value &&
|
||||
|
@ -243,7 +349,6 @@
|
|||
) {
|
||||
value = value[0] || null
|
||||
}
|
||||
|
||||
const changed = fieldApi.setValue(value)
|
||||
if (onChange && changed) {
|
||||
onChange({
|
||||
|
@ -251,12 +356,6 @@
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!$fetch.loading) {
|
||||
fetch.nextPage()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field
|
||||
|
@ -265,31 +364,31 @@
|
|||
{disabled}
|
||||
{readonly}
|
||||
{validation}
|
||||
{defaultValue}
|
||||
{type}
|
||||
{span}
|
||||
{helpText}
|
||||
defaultValue={enrichedDefaultValue}
|
||||
bind:fieldState
|
||||
bind:fieldApi
|
||||
bind:fieldSchema
|
||||
>
|
||||
{#if fieldState}
|
||||
<svelte:component
|
||||
this={component}
|
||||
options={enrichedOptions}
|
||||
{autocomplete}
|
||||
value={castSelectedValue}
|
||||
on:change={handleChange}
|
||||
on:loadMore={loadMore}
|
||||
id={fieldState.fieldId}
|
||||
disabled={fieldState.disabled}
|
||||
readonly={fieldState.readonly}
|
||||
getOptionLabel={getDisplayName}
|
||||
this={multiselect ? CoreMultiselect : CoreSelect}
|
||||
value={displayValue}
|
||||
id={fieldState?.fieldId}
|
||||
disabled={fieldState?.disabled}
|
||||
readonly={fieldState?.readonly}
|
||||
loading={!!$fetch?.loading}
|
||||
getOptionLabel={option => option.primaryDisplay}
|
||||
getOptionValue={option => option._id}
|
||||
{options}
|
||||
{placeholder}
|
||||
{autocomplete}
|
||||
bind:searchTerm
|
||||
loading={$fetch.loading}
|
||||
bind:open
|
||||
on:change={handleChange}
|
||||
on:loadMore={() => fetch?.nextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</Field>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { GroupUserDatasource, InternalTable } from "@budibase/types"
|
|||
|
||||
interface GroupUserQuery {
|
||||
groupId: string
|
||||
emailSearch: string
|
||||
emailSearch?: string
|
||||
}
|
||||
|
||||
interface GroupUserDefinition {
|
||||
|
|
|
@ -9,8 +9,8 @@ import {
|
|||
} from "@budibase/types"
|
||||
|
||||
interface UserFetchQuery {
|
||||
appId: string
|
||||
paginated: boolean
|
||||
appId?: string
|
||||
paginated?: boolean
|
||||
}
|
||||
|
||||
interface UserDefinition {
|
||||
|
|
|
@ -156,6 +156,7 @@
|
|||
"@types/pouchdb": "6.4.2",
|
||||
"@types/server-destroy": "1.0.1",
|
||||
"@types/supertest": "2.0.14",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/tar": "6.1.5",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/uuid": "8.3.4",
|
||||
|
|
|
@ -4,11 +4,11 @@ import { examples, schemas } from "./resources"
|
|||
import * as parameters from "./parameters"
|
||||
import * as security from "./security"
|
||||
|
||||
const swaggerJsdoc = require("swagger-jsdoc")
|
||||
import swaggerJsdoc from "swagger-jsdoc"
|
||||
|
||||
const VARIABLES = {}
|
||||
|
||||
const options = {
|
||||
const opts: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
|
@ -58,7 +58,6 @@ const options = {
|
|||
}
|
||||
|
||||
function writeFile(output: any, filename: string) {
|
||||
try {
|
||||
const path = join(__dirname, filename)
|
||||
let spec = output
|
||||
if (filename.endsWith("json")) {
|
||||
|
@ -71,17 +70,15 @@ function writeFile(output: any, filename: string) {
|
|||
writeFileSync(path, spec)
|
||||
console.log(`Wrote spec to ${path}`)
|
||||
return path
|
||||
} catch (err) {
|
||||
console.error("Error writing spec file", err)
|
||||
}
|
||||
|
||||
export function spec() {
|
||||
return swaggerJsdoc({ ...opts, format: ".json" })
|
||||
}
|
||||
|
||||
export function run() {
|
||||
const outputJSON = swaggerJsdoc(options)
|
||||
options.format = ".yaml"
|
||||
const outputYAML = swaggerJsdoc(options)
|
||||
writeFile(outputJSON, "openapi.json")
|
||||
return writeFile(outputYAML, "openapi.yaml")
|
||||
writeFile(swaggerJsdoc({ ...opts, format: ".json" }), "openapi.json")
|
||||
return writeFile(swaggerJsdoc({ ...opts, format: ".yaml" }), "openapi.yaml")
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { object } from "./utils"
|
||||
import Resource from "./utils/Resource"
|
||||
|
||||
const errorSchema = object({
|
||||
status: {
|
||||
type: "number",
|
||||
description: "The HTTP status code of the error.",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "A descriptive message about the error.",
|
||||
},
|
||||
})
|
||||
|
||||
export default new Resource()
|
||||
.setExamples({
|
||||
error: {},
|
||||
})
|
||||
.setSchemas({
|
||||
error: errorSchema,
|
||||
})
|
|
@ -48,7 +48,7 @@ function getUser(ctx: UserCtx, userId?: string) {
|
|||
if (userId) {
|
||||
ctx.params = { userId }
|
||||
} else if (!ctx.params?.userId) {
|
||||
throw "No user ID provided for getting"
|
||||
throw new Error("No user ID provided for getting")
|
||||
}
|
||||
return readGlobalUser(ctx)
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { paramResource, paramSubResource } from "../../../middleware/resourceId"
|
|||
import { PermissionLevel, PermissionType } from "@budibase/types"
|
||||
import { CtxFn } from "./utils/Endpoint"
|
||||
import mapperMiddleware from "./middleware/mapper"
|
||||
import testErrorHandling from "./middleware/testErrorHandling"
|
||||
import env from "../../../environment"
|
||||
import { middleware, redis } from "@budibase/backend-core"
|
||||
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
|
||||
|
@ -144,6 +145,10 @@ function applyRoutes(
|
|||
// add the output mapper middleware
|
||||
addMiddleware(endpoints.read, mapperMiddleware, { output: true })
|
||||
addMiddleware(endpoints.write, mapperMiddleware, { output: true })
|
||||
if (env.isTest()) {
|
||||
addMiddleware(endpoints.read, testErrorHandling())
|
||||
addMiddleware(endpoints.write, testErrorHandling())
|
||||
}
|
||||
addToRouter(endpoints.read)
|
||||
addToRouter(endpoints.write)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { Ctx } from "@budibase/types"
|
||||
import environment from "../../../../environment"
|
||||
|
||||
export default () => {
|
||||
if (!environment.isTest()) {
|
||||
throw new Error("This middleware is only for testing")
|
||||
}
|
||||
|
||||
return async (ctx: Ctx, next: any) => {
|
||||
try {
|
||||
await next()
|
||||
} catch (err: any) {
|
||||
if (!ctx.headers["x-budibase-include-stacktrace"]) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const status = err.status || err.statusCode || 500
|
||||
|
||||
let error = err
|
||||
while (error.cause) {
|
||||
error = error.cause
|
||||
}
|
||||
|
||||
ctx.status = status
|
||||
ctx.body = { status, message: error.message, stack: error.stack }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,11 +2,14 @@ import jestOpenAPI from "jest-openapi"
|
|||
import { run as generateSchema } from "../../../../../specs/generate"
|
||||
import * as setup from "../../tests/utilities"
|
||||
import { generateMakeRequest } from "./utils"
|
||||
import { Table, App, Row, User } from "@budibase/types"
|
||||
import { Table, App, Row } from "@budibase/types"
|
||||
import nock from "nock"
|
||||
import environment from "../../../../environment"
|
||||
|
||||
const yamlPath = generateSchema()
|
||||
jestOpenAPI(yamlPath!)
|
||||
|
||||
describe("compare", () => {
|
||||
let config = setup.getConfig()
|
||||
let apiKey: string, table: Table, app: App, makeRequest: any
|
||||
|
||||
|
@ -19,6 +22,10 @@ beforeAll(async () => {
|
|||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
describe("check the applications endpoints", () => {
|
||||
it("should allow retrieving applications through search", async () => {
|
||||
const res = await makeRequest("post", "/applications/search")
|
||||
|
@ -58,6 +65,10 @@ describe("check the applications endpoints", () => {
|
|||
})
|
||||
|
||||
it("should allow deleting an application", async () => {
|
||||
nock(environment.WORKER_URL!)
|
||||
.delete(`/api/global/roles/${config.getProdAppId()}`)
|
||||
.reply(200, {})
|
||||
|
||||
const res = await makeRequest(
|
||||
"delete",
|
||||
`/applications/${config.getAppId()}`
|
||||
|
@ -109,9 +120,13 @@ describe("check the rows endpoints", () => {
|
|||
let row: Row
|
||||
it("should allow retrieving rows through search", async () => {
|
||||
table = await config.upsertTable()
|
||||
const res = await makeRequest("post", `/tables/${table._id}/rows/search`, {
|
||||
const res = await makeRequest(
|
||||
"post",
|
||||
`/tables/${table._id}/rows/search`,
|
||||
{
|
||||
query: {},
|
||||
})
|
||||
}
|
||||
)
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
|
@ -135,7 +150,10 @@ describe("check the rows endpoints", () => {
|
|||
})
|
||||
|
||||
it("should allow retrieving a row", async () => {
|
||||
const res = await makeRequest("get", `/tables/${table._id}/rows/${row._id}`)
|
||||
const res = await makeRequest(
|
||||
"get",
|
||||
`/tables/${table._id}/rows/${row._id}`
|
||||
)
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
|
@ -148,38 +166,10 @@ describe("check the rows endpoints", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("check the users endpoints", () => {
|
||||
let user: User
|
||||
it("should allow retrieving users through search", async () => {
|
||||
user = await config.createUser()
|
||||
const res = await makeRequest("post", "/users/search")
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
it("should allow creating a user", async () => {
|
||||
const res = await makeRequest("post", "/users")
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
it("should allow updating a user", async () => {
|
||||
const res = await makeRequest("put", `/users/${user._id}`)
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
it("should allow retrieving a user", async () => {
|
||||
const res = await makeRequest("get", `/users/${user._id}`)
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
|
||||
it("should allow deleting a user", async () => {
|
||||
const res = await makeRequest("delete", `/users/${user._id}`)
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
})
|
||||
|
||||
describe("check the queries endpoints", () => {
|
||||
it("should allow retrieving queries through search", async () => {
|
||||
const res = await makeRequest("post", "/queries/search")
|
||||
expect(res).toSatisfyApiSpec()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,132 +1,143 @@
|
|||
import * as setup from "../../tests/utilities"
|
||||
import { generateMakeRequest, MakeRequestResponse } from "./utils"
|
||||
import { User } from "@budibase/types"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||
import nock from "nock"
|
||||
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||
import { mockWorkerUserAPI } from "./utils"
|
||||
|
||||
import * as workerRequests from "../../../../utilities/workerRequests"
|
||||
|
||||
const mockedWorkerReq = jest.mocked(workerRequests)
|
||||
|
||||
let config = setup.getConfig()
|
||||
let apiKey: string, globalUser: User, makeRequest: MakeRequestResponse
|
||||
describe("public users API", () => {
|
||||
const config = new TestConfiguration()
|
||||
let globalUser: User
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
globalUser = await config.globalUser()
|
||||
apiKey = await config.generateApiKey(globalUser._id)
|
||||
makeRequest = generateMakeRequest(apiKey)
|
||||
mockedWorkerReq.readGlobalUser.mockImplementation(() =>
|
||||
Promise.resolve(globalUser)
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
function base() {
|
||||
return {
|
||||
tenantId: config.getTenantId(),
|
||||
firstName: "Test",
|
||||
lastName: "Test",
|
||||
}
|
||||
}
|
||||
beforeEach(async () => {
|
||||
globalUser = await config.globalUser()
|
||||
|
||||
function updateMock() {
|
||||
mockedWorkerReq.readGlobalUser.mockImplementation(ctx => ctx.request.body)
|
||||
}
|
||||
|
||||
describe("check user endpoints", () => {
|
||||
it("should not allow a user to update their own roles", async () => {
|
||||
const res = await makeRequest("put", `/users/${globalUser._id}`, {
|
||||
...globalUser,
|
||||
roles: {
|
||||
app_1: "ADMIN",
|
||||
},
|
||||
})
|
||||
expect(
|
||||
mockedWorkerReq.saveGlobalUser.mock.lastCall?.[0].body.data.roles["app_1"]
|
||||
).toBeUndefined()
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.roles["app_1"]).toBeUndefined()
|
||||
nock.cleanAll()
|
||||
mockWorkerUserAPI(globalUser)
|
||||
})
|
||||
|
||||
it("should not allow a user to delete themselves", async () => {
|
||||
const res = await makeRequest("delete", `/users/${globalUser._id}`)
|
||||
expect(res.status).toBe(405)
|
||||
expect(mockedWorkerReq.deleteGlobalUser.mock.lastCall).toBeUndefined()
|
||||
describe("read", () => {
|
||||
it("should allow a user to read themselves", async () => {
|
||||
const user = await config.api.user.find(globalUser._id!)
|
||||
expect(user._id).toBe(globalUser._id)
|
||||
})
|
||||
|
||||
it("should allow a user to read another user", async () => {
|
||||
const otherUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: {},
|
||||
})
|
||||
const user = await config.withUser(globalUser, () =>
|
||||
config.api.public.user.find(otherUser._id!)
|
||||
)
|
||||
expect(user._id).toBe(otherUser._id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("no user role update in free", () => {
|
||||
beforeAll(() => {
|
||||
updateMock()
|
||||
describe("create", () => {
|
||||
it("can successfully create a new user", async () => {
|
||||
const email = generator.email({ domain: "example.com" })
|
||||
const newUser = await config.api.public.user.create({
|
||||
email,
|
||||
roles: {},
|
||||
})
|
||||
expect(newUser.email).toBe(email)
|
||||
expect(newUser._id).toBeDefined()
|
||||
})
|
||||
|
||||
describe("role creation on free tier", () => {
|
||||
it("should not allow 'roles' to be updated", async () => {
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: { app_a: "BASIC" },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.roles["app_a"]).toBeUndefined()
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(newUser.roles["app_a"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not allow 'admin' to be updated", async () => {
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: {},
|
||||
admin: { global: true },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.admin).toBeUndefined()
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(newUser.admin).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not allow 'builder' to be updated", async () => {
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: {},
|
||||
builder: { global: true },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.builder).toBeUndefined()
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(newUser.builder).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("no user role update in business", () => {
|
||||
describe("role creation on business tier", () => {
|
||||
beforeAll(() => {
|
||||
updateMock()
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
})
|
||||
|
||||
it("should allow 'roles' to be updated", async () => {
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: { app_a: "BASIC" },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.roles["app_a"]).toBe("BASIC")
|
||||
expect(res.body.message).toBeUndefined()
|
||||
expect(newUser.roles["app_a"]).toBe("BASIC")
|
||||
})
|
||||
|
||||
it("should allow 'admin' to be updated", async () => {
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: {},
|
||||
admin: { global: true },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.admin.global).toBe(true)
|
||||
expect(res.body.message).toBeUndefined()
|
||||
expect(newUser.admin?.global).toBe(true)
|
||||
})
|
||||
|
||||
it("should allow 'builder' to be updated", async () => {
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
const newUser = await config.api.public.user.create({
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
roles: {},
|
||||
builder: { global: true },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.builder.global).toBe(true)
|
||||
expect(res.body.message).toBeUndefined()
|
||||
expect(newUser.builder?.global).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
it("can update a user", async () => {
|
||||
const updatedUser = await config.api.public.user.update({
|
||||
...globalUser,
|
||||
email: `updated-${globalUser.email}`,
|
||||
})
|
||||
expect(updatedUser.email).toBe(`updated-${globalUser.email}`)
|
||||
})
|
||||
|
||||
it("should not allow a user to update their own roles", async () => {
|
||||
await config.withUser(globalUser, () =>
|
||||
config.api.public.user.update({
|
||||
...globalUser,
|
||||
roles: { app_1: "ADMIN" },
|
||||
})
|
||||
)
|
||||
const updatedUser = await config.api.user.find(globalUser._id!)
|
||||
expect(updatedUser.roles?.app_1).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
it("should not allow a user to delete themselves", async () => {
|
||||
await config.withUser(globalUser, () =>
|
||||
config.api.public.user.destroy(globalUser._id!, { status: 405 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import * as setup from "../../tests/utilities"
|
||||
import { checkSlashesInUrl } from "../../../../utilities"
|
||||
import supertest from "supertest"
|
||||
import { User } from "@budibase/types"
|
||||
import environment from "../../../../environment"
|
||||
import nock from "nock"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
||||
export type HttpMethod = "post" | "get" | "put" | "delete" | "patch"
|
||||
|
||||
|
@ -91,3 +95,43 @@ export function generateMakeRequestWithFormData(
|
|||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export function mockWorkerUserAPI(...seedUsers: User[]) {
|
||||
const users: Record<string, User> = {
|
||||
...seedUsers.reduce((acc, user) => {
|
||||
acc[user._id!] = user
|
||||
return acc
|
||||
}, {} as Record<string, User>),
|
||||
}
|
||||
|
||||
nock(environment.WORKER_URL!)
|
||||
.get(new RegExp(`/api/global/users/.*`))
|
||||
.reply(200, (uri, body) => {
|
||||
const id = uri.split("/").pop()
|
||||
return users[id!]
|
||||
})
|
||||
.persist()
|
||||
|
||||
nock(environment.WORKER_URL!)
|
||||
.post(`/api/global/users`)
|
||||
.reply(200, (uri, body) => {
|
||||
const newUser = body as User
|
||||
if (!newUser._id) {
|
||||
newUser._id = `us_${generator.guid()}`
|
||||
}
|
||||
users[newUser._id!] = newUser
|
||||
return newUser
|
||||
})
|
||||
.persist()
|
||||
|
||||
nock(environment.WORKER_URL!)
|
||||
.put(new RegExp(`/api/global/users/.*`))
|
||||
.reply(200, (uri, body) => {
|
||||
const id = uri.split("/").pop()!
|
||||
const updatedUser = body as User
|
||||
const existingUser = users[id] || {}
|
||||
users[id] = { ...existingUser, ...updatedUser }
|
||||
return users[id]
|
||||
})
|
||||
.persist()
|
||||
}
|
||||
|
|
|
@ -3,44 +3,6 @@ import supertest from "supertest"
|
|||
|
||||
export * as structures from "../../../../tests/utilities/structures"
|
||||
|
||||
function user() {
|
||||
return {
|
||||
_id: "user",
|
||||
_rev: "rev",
|
||||
createdAt: Date.now(),
|
||||
email: "test@example.com",
|
||||
roles: {},
|
||||
tenantId: "default",
|
||||
status: "active",
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("../../../../utilities/workerRequests", () => ({
|
||||
getGlobalUsers: jest.fn(() => {
|
||||
return {
|
||||
_id: "us_uuid1",
|
||||
}
|
||||
}),
|
||||
getGlobalSelf: jest.fn(() => {
|
||||
return {
|
||||
_id: "us_uuid1",
|
||||
}
|
||||
}),
|
||||
allGlobalUsers: jest.fn(() => {
|
||||
return [user()]
|
||||
}),
|
||||
readGlobalUser: jest.fn(() => {
|
||||
return user()
|
||||
}),
|
||||
saveGlobalUser: jest.fn(() => {
|
||||
return { _id: "user", _rev: "rev" }
|
||||
}),
|
||||
deleteGlobalUser: jest.fn(() => {
|
||||
return { message: "deleted user" }
|
||||
}),
|
||||
removeAppFromUserRoles: jest.fn(),
|
||||
}))
|
||||
|
||||
export function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@ import {
|
|||
View,
|
||||
Webhook,
|
||||
WithRequired,
|
||||
DevInfo,
|
||||
} from "@budibase/types"
|
||||
|
||||
import API from "./api"
|
||||
|
@ -249,7 +250,7 @@ export default class TestConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
async withUser(user: User, f: () => Promise<void>) {
|
||||
async withUser<T>(user: User, f: () => Promise<T>): Promise<T> {
|
||||
const oldUser = this.user
|
||||
this.user = user
|
||||
try {
|
||||
|
@ -470,7 +471,10 @@ export default class TestConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
defaultHeaders(extras = {}, prodApp = false) {
|
||||
defaultHeaders(
|
||||
extras: Record<string, string | string[]> = {},
|
||||
prodApp = false
|
||||
) {
|
||||
const tenantId = this.getTenantId()
|
||||
const user = this.getUser()
|
||||
const authObj: AuthToken = {
|
||||
|
@ -499,10 +503,13 @@ export default class TestConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
publicHeaders({ prodApp = true } = {}) {
|
||||
publicHeaders({
|
||||
prodApp = true,
|
||||
extras = {},
|
||||
}: { prodApp?: boolean; extras?: Record<string, string | string[]> } = {}) {
|
||||
const appId = prodApp ? this.prodAppId : this.appId
|
||||
|
||||
const headers: any = {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Cookie: "",
|
||||
}
|
||||
|
@ -515,6 +522,7 @@ export default class TestConfiguration {
|
|||
return {
|
||||
...headers,
|
||||
...this.temporaryHeaders,
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -578,17 +586,17 @@ export default class TestConfiguration {
|
|||
}
|
||||
const db = tenancy.getTenantDB(this.getTenantId())
|
||||
const id = dbCore.generateDevInfoID(userId)
|
||||
let devInfo: any
|
||||
try {
|
||||
devInfo = await db.get(id)
|
||||
} catch (err) {
|
||||
devInfo = { _id: id, userId }
|
||||
const devInfo = await db.tryGet<DevInfo>(id)
|
||||
if (devInfo && devInfo.apiKey) {
|
||||
return devInfo.apiKey
|
||||
}
|
||||
devInfo.apiKey = encryption.encrypt(
|
||||
|
||||
const apiKey = encryption.encrypt(
|
||||
`${this.getTenantId()}${dbCore.SEPARATOR}${newid()}`
|
||||
)
|
||||
await db.put(devInfo)
|
||||
return devInfo.apiKey
|
||||
const newDevInfo: DevInfo = { _id: id, userId, apiKey }
|
||||
await db.put(newDevInfo)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// APP
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import jestOpenAPI from "jest-openapi"
|
||||
import { spec } from "../../../../specs/generate"
|
||||
import TestConfiguration from "../TestConfiguration"
|
||||
import request, { SuperTest, Test, Response } from "supertest"
|
||||
import { ReadStream } from "fs"
|
||||
import { getServer } from "../../../app"
|
||||
|
||||
jestOpenAPI(spec() as any)
|
||||
|
||||
type Headers = Record<string, string | string[] | undefined>
|
||||
type Method = "get" | "post" | "put" | "patch" | "delete"
|
||||
|
||||
|
@ -46,6 +50,7 @@ export interface RequestOpts {
|
|||
export abstract class TestAPI {
|
||||
config: TestConfiguration
|
||||
request: SuperTest<Test>
|
||||
prefix = ""
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.config = config
|
||||
|
@ -53,26 +58,26 @@ export abstract class TestAPI {
|
|||
}
|
||||
|
||||
protected _get = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||
return await this._request<T>("get", url, opts)
|
||||
return await this._request<T>("get", `${this.prefix}${url}`, opts)
|
||||
}
|
||||
|
||||
protected _post = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||
return await this._request<T>("post", url, opts)
|
||||
return await this._request<T>("post", `${this.prefix}${url}`, opts)
|
||||
}
|
||||
|
||||
protected _put = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||
return await this._request<T>("put", url, opts)
|
||||
return await this._request<T>("put", `${this.prefix}${url}`, opts)
|
||||
}
|
||||
|
||||
protected _patch = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||
return await this._request<T>("patch", url, opts)
|
||||
return await this._request<T>("patch", `${this.prefix}${url}`, opts)
|
||||
}
|
||||
|
||||
protected _delete = async <T>(
|
||||
url: string,
|
||||
opts?: RequestOpts
|
||||
): Promise<T> => {
|
||||
return await this._request<T>("delete", url, opts)
|
||||
return await this._request<T>("delete", `${this.prefix}${url}`, opts)
|
||||
}
|
||||
|
||||
protected _requestRaw = async (
|
||||
|
@ -88,7 +93,6 @@ export abstract class TestAPI {
|
|||
fields = {},
|
||||
files = {},
|
||||
expectations,
|
||||
publicUser = false,
|
||||
} = opts || {}
|
||||
const { status = 200 } = expectations || {}
|
||||
const expectHeaders = expectations?.headers || {}
|
||||
|
@ -97,7 +101,7 @@ export abstract class TestAPI {
|
|||
expectHeaders["Content-Type"] = /^application\/json/
|
||||
}
|
||||
|
||||
let queryParams = []
|
||||
let queryParams: string[] = []
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value) {
|
||||
queryParams.push(`${key}=${value}`)
|
||||
|
@ -107,18 +111,10 @@ export abstract class TestAPI {
|
|||
url += `?${queryParams.join("&")}`
|
||||
}
|
||||
|
||||
const headersFn = publicUser
|
||||
? (_extras = {}) =>
|
||||
this.config.publicHeaders.bind(this.config)({
|
||||
prodApp: opts?.useProdApp,
|
||||
})
|
||||
: (extras = {}) =>
|
||||
this.config.defaultHeaders.bind(this.config)(extras, opts?.useProdApp)
|
||||
|
||||
const app = getServer()
|
||||
let req = request(app)[method](url)
|
||||
req = req.set(
|
||||
headersFn({
|
||||
await this.getHeaders(opts, {
|
||||
"x-budibase-include-stacktrace": "true",
|
||||
})
|
||||
)
|
||||
|
@ -167,10 +163,18 @@ export abstract class TestAPI {
|
|||
}
|
||||
}
|
||||
|
||||
protected _checkResponse = (
|
||||
response: Response,
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
protected async getHeaders(
|
||||
opts?: RequestOpts,
|
||||
extras?: Record<string, string | string[]>
|
||||
): Promise<Record<string, string | string[]>> {
|
||||
if (opts?.publicUser) {
|
||||
return this.config.publicHeaders({ prodApp: opts?.useProdApp, extras })
|
||||
} else {
|
||||
return this.config.defaultHeaders(extras, opts?.useProdApp)
|
||||
}
|
||||
}
|
||||
|
||||
protected _checkResponse(response: Response, expectations?: Expectations) {
|
||||
const { status = 200 } = expectations || {}
|
||||
|
||||
if (response.status !== status) {
|
||||
|
@ -236,3 +240,34 @@ export abstract class TestAPI {
|
|||
).body
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class PublicAPI extends TestAPI {
|
||||
prefix = "/api/public/v1"
|
||||
|
||||
protected async getHeaders(
|
||||
opts?: RequestOpts,
|
||||
extras?: Record<string, string | string[]>
|
||||
): Promise<Record<string, string | string[]>> {
|
||||
const apiKey = await this.config.generateApiKey()
|
||||
|
||||
const headers: Record<string, string | string[]> = {
|
||||
Accept: "application/json",
|
||||
Host: this.config.tenantHost(),
|
||||
"x-budibase-api-key": apiKey,
|
||||
"x-budibase-app-id": this.config.getAppId(),
|
||||
...extras,
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
protected _checkResponse(response: Response, expectations?: Expectations) {
|
||||
const checked = super._checkResponse(response, expectations)
|
||||
if (checked.status >= 200 && checked.status < 300) {
|
||||
// We don't seem to have documented our errors yet, so for the time being
|
||||
// we'll only do the schema check for successful responses.
|
||||
expect(checked).toSatisfyApiSpec()
|
||||
}
|
||||
return checked
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import { RowActionAPI } from "./rowAction"
|
|||
import { AutomationAPI } from "./automation"
|
||||
import { PluginAPI } from "./plugin"
|
||||
import { WebhookAPI } from "./webhook"
|
||||
import { UserPublicAPI } from "./public/user"
|
||||
|
||||
export default class API {
|
||||
application: ApplicationAPI
|
||||
|
@ -38,6 +39,10 @@ export default class API {
|
|||
viewV2: ViewV2API
|
||||
webhook: WebhookAPI
|
||||
|
||||
public: {
|
||||
user: UserPublicAPI
|
||||
}
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.application = new ApplicationAPI(config)
|
||||
this.attachment = new AttachmentAPI(config)
|
||||
|
@ -57,5 +62,8 @@ export default class API {
|
|||
this.user = new UserAPI(config)
|
||||
this.viewV2 = new ViewV2API(config)
|
||||
this.webhook = new WebhookAPI(config)
|
||||
this.public = {
|
||||
user: new UserPublicAPI(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { UnsavedUser, User } from "@budibase/types"
|
||||
import { Expectations, PublicAPI } from "../base"
|
||||
|
||||
export class UserPublicAPI extends PublicAPI {
|
||||
find = async (id: string, expectations?: Expectations): Promise<User> => {
|
||||
const response = await this._get<{ data: User }>(`/users/${id}`, {
|
||||
expectations,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
update = async (user: User, expectations?: Expectations): Promise<User> => {
|
||||
const response = await this._put<{ data: User }>(`/users/${user._id}`, {
|
||||
body: user,
|
||||
expectations,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
destroy = async (id: string, expectations?: Expectations): Promise<void> => {
|
||||
return await this._delete(`/users/${id}`, { expectations })
|
||||
}
|
||||
|
||||
create = async (
|
||||
user: UnsavedUser,
|
||||
expectations?: Expectations
|
||||
): Promise<User> => {
|
||||
const response = await this._post<{ data: User }>("/users", {
|
||||
body: user,
|
||||
expectations,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
11
yarn.lock
11
yarn.lock
|
@ -2796,9 +2796,9 @@
|
|||
through2 "^2.0.0"
|
||||
|
||||
"@budibase/pro@npm:@budibase/pro@latest":
|
||||
version "3.4.20"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.20.tgz#0d855d6ed8fe92fd178c74a8963d879cc124b034"
|
||||
integrity sha512-hUteGvhMOKjBo0fluxcqNs7d4x8OU5W8Oqqrm7eIS9Ohe7ala2iWNCcrj+x+S9CavIm6s7JZZnAewa2Maiz2zQ==
|
||||
version "3.4.22"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.22.tgz#943f23cb7056041bc1f433ee60b3d093145e7a4a"
|
||||
integrity sha512-Du3iZsmRLopfoi2SvxQyY1P2Su3Nw0WbITOrKmZFsVLjZ9MzzTZs0Ph/SJHzrfJpM7rn9+8788BLSf3Z3l9KcQ==
|
||||
dependencies:
|
||||
"@anthropic-ai/sdk" "^0.27.3"
|
||||
"@budibase/backend-core" "*"
|
||||
|
@ -7142,6 +7142,11 @@
|
|||
dependencies:
|
||||
"@types/superagent" "*"
|
||||
|
||||
"@types/swagger-jsdoc@^6.0.4":
|
||||
version "6.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz#bb4f60f3a5f103818e022f2e29ff8935113fb83d"
|
||||
integrity sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==
|
||||
|
||||
"@types/tar-fs@2.0.1":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.1.tgz#6391dcad1b03dea2d79fac07371585ab54472bb1"
|
||||
|
|
Loading…
Reference in New Issue