Merge pull request #11458 from Budibase/feature/view-api-merge

Merge create, update delete view APIs into row API
This commit is contained in:
Michael Drury 2023-08-10 11:19:39 +01:00 committed by GitHub
commit 6c330c39c2
19 changed files with 165 additions and 426 deletions

View File

@ -78,7 +78,6 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
], ],
}, },
WRITE: { WRITE: {
@ -87,7 +86,6 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.QUERY, PermissionLevel.WRITE),
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
], ],
}, },
@ -98,7 +96,6 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.USER, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
], ],
}, },
@ -109,7 +106,6 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.ADMIN), new Permission(PermissionType.TABLE, PermissionLevel.ADMIN),
new Permission(PermissionType.USER, PermissionLevel.ADMIN), new Permission(PermissionType.USER, PermissionLevel.ADMIN),
new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN),
new Permission(PermissionType.VIEW, PermissionLevel.ADMIN),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
], ],

View File

@ -15,6 +15,7 @@ import {
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "./utils"
export async function handleRequest( export async function handleRequest(
operation: Operation, operation: Operation,
@ -43,7 +44,7 @@ export async function handleRequest(
} }
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const { _id, ...rowData } = ctx.request.body const { _id, ...rowData } = ctx.request.body
const validateResult = await sdk.rows.utils.validate({ const validateResult = await sdk.rows.utils.validate({
@ -70,7 +71,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
const inputs = ctx.request.body const inputs = ctx.request.body
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const validateResult = await sdk.rows.utils.validate({ const validateResult = await sdk.rows.utils.validate({
row: inputs, row: inputs,
tableId, tableId,
@ -98,12 +99,12 @@ export async function save(ctx: UserCtx) {
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx) {
const id = ctx.params.rowId const id = ctx.params.rowId
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
return sdk.rows.external.getRow(tableId, id) return sdk.rows.external.getRow(tableId, id)
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const _id = ctx.request.body._id const _id = ctx.request.body._id
const { row } = (await handleRequest(Operation.DELETE, tableId, { const { row } = (await handleRequest(Operation.DELETE, tableId, {
id: breakRowIdField(_id), id: breakRowIdField(_id),
@ -114,7 +115,7 @@ export async function destroy(ctx: UserCtx) {
export async function bulkDestroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) {
const { rows } = ctx.request.body const { rows } = ctx.request.body
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
let promises: Promise<Row[] | { row: Row; table: Table }>[] = [] let promises: Promise<Row[] | { row: Row; table: Table }>[] = []
for (let row of rows) { for (let row of rows) {
promises.push( promises.push(
@ -130,7 +131,7 @@ export async function bulkDestroy(ctx: UserCtx) {
export async function fetchEnrichedRow(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) {
const id = ctx.params.rowId const id = ctx.params.rowId
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const datasource: Datasource = await sdk.datasources.get(datasourceId!) const datasource: Datasource = await sdk.datasources.get(datasourceId!)
if (!tableName) { if (!tableName) {

View File

@ -13,7 +13,7 @@ import {
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
import * as utils from "./utils" import * as utils from "./utils"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { context, db as dbCore } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { finaliseRow, updateRelatedFormula } from "./staticFormula" import { finaliseRow, updateRelatedFormula } from "./staticFormula"
import { import {
UserCtx, UserCtx,
@ -26,8 +26,8 @@ import {
import sdk from "../../../sdk" import sdk from "../../../sdk"
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const tableId = utils.getTableId(ctx)
const inputs = ctx.request.body const inputs = ctx.request.body
const tableId = inputs.tableId
const isUserTable = tableId === InternalTables.USER_METADATA const isUserTable = tableId === InternalTables.USER_METADATA
let oldRow let oldRow
const dbTable = await sdk.tables.getTable(tableId) const dbTable = await sdk.tables.getTable(tableId)
@ -94,7 +94,8 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
let inputs = ctx.request.body let inputs = ctx.request.body
inputs.tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
inputs.tableId = tableId
if (!inputs._rev && !inputs._id) { if (!inputs._rev && !inputs._id) {
inputs._id = generateRowID(inputs.tableId) inputs._id = generateRowID(inputs.tableId)
@ -132,20 +133,22 @@ export async function save(ctx: UserCtx) {
} }
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx) {
const db = dbCore.getDB(ctx.appId) const tableId = utils.getTableId(ctx),
const table = await sdk.tables.getTable(ctx.params.tableId) rowId = ctx.params.rowId
let row = await utils.findRow(ctx, ctx.params.tableId, ctx.params.rowId) const table = await sdk.tables.getTable(tableId)
let row = await utils.findRow(ctx, tableId, rowId)
row = await outputProcessing(table, row) row = await outputProcessing(table, row)
return row return row
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const tableId = utils.getTableId(ctx)
const { _id } = ctx.request.body const { _id } = ctx.request.body
let row = await db.get<Row>(_id) let row = await db.get<Row>(_id)
let _rev = ctx.request.body._rev || row._rev let _rev = ctx.request.body._rev || row._rev
if (row.tableId !== ctx.params.tableId) { if (row.tableId !== tableId) {
throw "Supplied tableId doesn't match the row's tableId" throw "Supplied tableId doesn't match the row's tableId"
} }
const table = await sdk.tables.getTable(row.tableId) const table = await sdk.tables.getTable(row.tableId)
@ -163,7 +166,7 @@ export async function destroy(ctx: UserCtx) {
await updateRelatedFormula(table, row) await updateRelatedFormula(table, row)
let response let response
if (ctx.params.tableId === InternalTables.USER_METADATA) { if (tableId === InternalTables.USER_METADATA) {
ctx.params = { ctx.params = {
id: _id, id: _id,
} }
@ -176,7 +179,7 @@ export async function destroy(ctx: UserCtx) {
} }
export async function bulkDestroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) {
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
let { rows } = ctx.request.body let { rows } = ctx.request.body
@ -216,7 +219,7 @@ export async function bulkDestroy(ctx: UserCtx) {
export async function fetchEnrichedRow(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const tableId = ctx.params.tableId const tableId = utils.getTableId(ctx)
const rowId = ctx.params.rowId const rowId = ctx.params.rowId
// need table to work out where links go in row // need table to work out where links go in row
let [table, row] = await Promise.all([ let [table, row] = await Promise.all([

View File

@ -45,13 +45,20 @@ export async function findRow(ctx: UserCtx, tableId: string, rowId: string) {
} }
export function getTableId(ctx: Ctx) { export function getTableId(ctx: Ctx) {
if (ctx.request.body && ctx.request.body.tableId) { // top priority, use the URL first
return ctx.request.body.tableId if (ctx.params?.sourceId) {
return ctx.params.sourceId
} }
if (ctx.params && ctx.params.tableId) { // now check for old way of specifying table ID
if (ctx.params?.tableId) {
return ctx.params.tableId return ctx.params.tableId
} }
if (ctx.params && ctx.params.viewName) { // check body for a table ID
if (ctx.request.body?.tableId) {
return ctx.request.body.tableId
}
// now check if a specific view name
if (ctx.params?.viewName) {
return ctx.params.viewName return ctx.params.viewName
} }
} }

View File

@ -4,16 +4,14 @@ import authorized from "../../middleware/authorized"
import { paramResource, paramSubResource } from "../../middleware/resourceId" import { paramResource, paramSubResource } from "../../middleware/resourceId"
import { permissions } from "@budibase/backend-core" import { permissions } from "@budibase/backend-core"
import { internalSearchValidator } from "./utils/validators" import { internalSearchValidator } from "./utils/validators"
import noViewData from "../../middleware/noViewData"
import trimViewRowInfo from "../../middleware/trimViewRowInfo" import trimViewRowInfo from "../../middleware/trimViewRowInfo"
import * as utils from "../../db/utils"
const { PermissionType, PermissionLevel } = permissions const { PermissionType, PermissionLevel } = permissions
const router: Router = new Router() const router: Router = new Router()
router router
/** /**
* @api {get} /api/:tableId/:rowId/enrich Get an enriched row * @api {get} /api/:sourceId/:rowId/enrich Get an enriched row
* @apiName Get an enriched row * @apiName Get an enriched row
* @apiGroup rows * @apiGroup rows
* @apiPermission table read access * @apiPermission table read access
@ -27,13 +25,13 @@ router
* @apiSuccess {object} row The response body will be the enriched row. * @apiSuccess {object} row The response body will be the enriched row.
*/ */
.get( .get(
"/api/:tableId/:rowId/enrich", "/api/:sourceId/:rowId/enrich",
paramSubResource("tableId", "rowId"), paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetchEnrichedRow rowController.fetchEnrichedRow
) )
/** /**
* @api {get} /api/:tableId/rows Get all rows in a table * @api {get} /api/:sourceId/rows Get all rows in a table
* @apiName Get all rows in a table * @apiName Get all rows in a table
* @apiGroup rows * @apiGroup rows
* @apiPermission table read access * @apiPermission table read access
@ -42,37 +40,37 @@ router
* due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then * due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then
* will simply stop. * will simply stop.
* *
* @apiParam {string} tableId The ID of the table to retrieve all rows within. * @apiParam {string} sourceId The ID of the table to retrieve all rows within.
* *
* @apiSuccess {object[]} rows The response body will be an array of all rows found. * @apiSuccess {object[]} rows The response body will be an array of all rows found.
*/ */
.get( .get(
"/api/:tableId/rows", "/api/:sourceId/rows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetch rowController.fetch
) )
/** /**
* @api {get} /api/:tableId/rows/:rowId Retrieve a single row * @api {get} /api/:sourceId/rows/:rowId Retrieve a single row
* @apiName Retrieve a single row * @apiName Retrieve a single row
* @apiGroup rows * @apiGroup rows
* @apiPermission table read access * @apiPermission table read access
* @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve * @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve
* a row by anything other than its _id field, use the search endpoint. * a row by anything other than its _id field, use the search endpoint.
* *
* @apiParam {string} tableId The ID of the table to retrieve a row from. * @apiParam {string} sourceId The ID of the table to retrieve a row from.
* @apiParam {string} rowId The ID of the row to retrieve. * @apiParam {string} rowId The ID of the row to retrieve.
* *
* @apiSuccess {object} body The response body will be the row that was found. * @apiSuccess {object} body The response body will be the row that was found.
*/ */
.get( .get(
"/api/:tableId/rows/:rowId", "/api/:sourceId/rows/:rowId",
paramSubResource("tableId", "rowId"), paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.find rowController.find
) )
/** /**
* @api {post} /api/:tableId/search Search for rows in a table * @api {post} /api/:sourceId/search Search for rows in a table
* @apiName Search for rows in a table * @apiName Search for rows in a table
* @apiGroup rows * @apiGroup rows
* @apiPermission table read access * @apiPermission table read access
@ -80,7 +78,7 @@ router
* and data UI in the builder are built atop this. All filtering, sorting and pagination is * and data UI in the builder are built atop this. All filtering, sorting and pagination is
* handled through this, for internal and external (datasource plus, e.g. SQL) tables. * handled through this, for internal and external (datasource plus, e.g. SQL) tables.
* *
* @apiParam {string} tableId The ID of the table to retrieve rows from. * @apiParam {string} sourceId The ID of the table to retrieve rows from.
* *
* @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true, * @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true,
* defaults to false. * defaults to false.
@ -135,22 +133,22 @@ router
* page. * page.
*/ */
.post( .post(
"/api/:tableId/search", "/api/:sourceId/search",
internalSearchValidator(), internalSearchValidator(),
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search rowController.search
) )
// DEPRECATED - this is an old API, but for backwards compat it needs to be // DEPRECATED - this is an old API, but for backwards compat it needs to be
// supported still // supported still
.post( .post(
"/api/search/:tableId/rows", "/api/search/:sourceId/rows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search rowController.search
) )
/** /**
* @api {post} /api/:tableId/rows Creates a new row * @api {post} /api/:sourceId/rows Creates a new row
* @apiName Creates a new row * @apiName Creates a new row
* @apiGroup rows * @apiGroup rows
* @apiPermission table write access * @apiPermission table write access
@ -159,7 +157,7 @@ router
* links to one. Please note that "_id", "_rev" and "tableId" are fields that are * links to one. Please note that "_id", "_rev" and "tableId" are fields that are
* already used by Budibase tables and cannot be used for columns. * already used by Budibase tables and cannot be used for columns.
* *
* @apiParam {string} tableId The ID of the table to save a row to. * @apiParam {string} sourceId The ID of the table to save a row to.
* *
* @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided. * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
* @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
@ -174,14 +172,14 @@ router
* @apiSuccess {object} body The contents of the row that was saved will be returned as well. * @apiSuccess {object} body The contents of the row that was saved will be returned as well.
*/ */
.post( .post(
"/api/:tableId/rows", "/api/:sourceId/rows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
noViewData, trimViewRowInfo,
rowController.save rowController.save
) )
/** /**
* @api {patch} /api/:tableId/rows Updates a row * @api {patch} /api/:sourceId/rows Updates a row
* @apiName Update a row * @apiName Update a row
* @apiGroup rows * @apiGroup rows
* @apiPermission table write access * @apiPermission table write access
@ -189,14 +187,14 @@ router
* error if an _id isn't provided, it will only function for existing rows. * error if an _id isn't provided, it will only function for existing rows.
*/ */
.patch( .patch(
"/api/:tableId/rows", "/api/:sourceId/rows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
noViewData, trimViewRowInfo,
rowController.patch rowController.patch
) )
/** /**
* @api {post} /api/:tableId/rows/validate Validate inputs for a row * @api {post} /api/:sourceId/rows/validate Validate inputs for a row
* @apiName Validate inputs for a row * @apiName Validate inputs for a row
* @apiGroup rows * @apiGroup rows
* @apiPermission table write access * @apiPermission table write access
@ -204,7 +202,7 @@ router
* given the table schema, this will iterate through all the constraints on the table and * given the table schema, this will iterate through all the constraints on the table and
* check if the request body is valid. * check if the request body is valid.
* *
* @apiParam {string} tableId The ID of the table the row is to be validated for. * @apiParam {string} sourceId The ID of the table the row is to be validated for.
* *
* @apiParam (Body) {any} [any] Any fields provided in the request body will be tested * @apiParam (Body) {any} [any] Any fields provided in the request body will be tested
* against the table schema and constraints. * against the table schema and constraints.
@ -216,20 +214,20 @@ router
* the schema. * the schema.
*/ */
.post( .post(
"/api/:tableId/rows/validate", "/api/:sourceId/rows/validate",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
rowController.validate rowController.validate
) )
/** /**
* @api {delete} /api/:tableId/rows Delete rows * @api {delete} /api/:sourceId/rows Delete rows
* @apiName Delete rows * @apiName Delete rows
* @apiGroup rows * @apiGroup rows
* @apiPermission table write access * @apiPermission table write access
* @apiDescription This endpoint can delete a single row, or delete them in a bulk * @apiDescription This endpoint can delete a single row, or delete them in a bulk
* fashion. * fashion.
* *
* @apiParam {string} tableId The ID of the table the row is to be deleted from. * @apiParam {string} sourceId The ID of the table the row is to be deleted from.
* *
* @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
* key of the request body that are to be deleted. * key of the request body that are to be deleted.
@ -242,117 +240,37 @@ router
* is the deleted row. * is the deleted row.
*/ */
.delete( .delete(
"/api/:tableId/rows", "/api/:sourceId/rows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
trimViewRowInfo,
rowController.destroy rowController.destroy
) )
/** /**
* @api {post} /api/:tableId/rows/exportRows Export Rows * @api {post} /api/:sourceId/rows/exportRows Export Rows
* @apiName Export rows * @apiName Export rows
* @apiGroup rows * @apiGroup rows
* @apiPermission table write access * @apiPermission table write access
* @apiDescription This API can export a number of provided rows * @apiDescription This API can export a number of provided rows
* *
* @apiParam {string} tableId The ID of the table the row is to be deleted from. * @apiParam {string} sourceId The ID of the table the row is to be deleted from.
* *
* @apiParam (Body) {object[]} [rows] The row IDs which are to be exported * @apiParam (Body) {object[]} [rows] The row IDs which are to be exported
* *
* @apiSuccess {object[]|object} * @apiSuccess {object[]|object}
*/ */
.post( .post(
"/api/:tableId/rows/exportRows", "/api/:sourceId/rows/exportRows",
paramResource("tableId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
rowController.exportRows rowController.exportRows
) )
router router.post(
.post(
"/api/v2/views/:viewId/search", "/api/v2/views/:viewId/search",
authorized(PermissionType.VIEW, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.views.searchView rowController.views.searchView
) )
/**
* @api {post} /api/:tableId/rows Creates a new row
* @apiName Creates a new row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This API will create a new row based on the supplied body. If the
* body includes an "_id" field then it will update an existing row if the field
* links to one. Please note that "_id", "_rev" and "tableId" are fields that are
* already used by Budibase tables and cannot be used for columns.
*
* @apiParam {string} tableId The ID of the table to save a row to.
*
* @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
* @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
* must also be provided.
* @apiParam (Body) {string} _viewId The ID of the view should be specified in the row body itself.
* @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself.
* @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches
* a column in the specified table. All other fields will be dropped and not stored.
*
* @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this
* is the rows new ID.
* @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned.
* @apiSuccess {object} body The contents of the row that was saved will be returned as well.
*/
.post(
"/api/v2/views/:viewId/rows",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
trimViewRowInfo,
rowController.save
)
/**
* @api {patch} /api/v2/views/:viewId/rows/:rowId Updates a row
* @apiName Update a row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint is identical to the row creation endpoint but instead it will
* error if an _id isn't provided, it will only function for existing rows.
*/
.patch(
"/api/v2/views/:viewId/rows/:rowId",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
trimViewRowInfo,
rowController.patch
)
/**
* @api {delete} /api/v2/views/:viewId/rows Delete rows for a view
* @apiName Delete rows for a view
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint can delete a single row, or delete them in a bulk
* fashion.
*
* @apiParam {string} tableId The ID of the table the row is to be deleted from.
*
* @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
* key of the request body that are to be deleted.
* @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field.
* @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its
* revision here.
*
* @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array
* of the deleted rows, if deleting a single row then the body will contain a "row" property which
* is the deleted row.
*/
.delete(
"/api/v2/views/:viewId/rows",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
// This is required as the implementation relies on the table id
(ctx, next) => {
ctx.params.tableId = utils.extractViewInfoFromID(
ctx.params.viewId
).tableId
return next()
},
rowController.destroy
)
export default router export default router

View File

@ -16,16 +16,12 @@ import {
FieldType, FieldType,
SortType, SortType,
SortOrder, SortOrder,
DeleteRow,
} from "@budibase/types" } from "@budibase/types"
import { import {
expectAnyInternalColsAttributes, expectAnyInternalColsAttributes,
generator, generator,
structures, structures,
} from "@budibase/backend-core/tests" } from "@budibase/backend-core/tests"
import trimViewRowInfoMiddleware from "../../../middleware/trimViewRowInfo"
import noViewDataMiddleware from "../../../middleware/noViewData"
import router from "../row"
describe("/rows", () => { describe("/rows", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -394,26 +390,6 @@ describe("/rows", () => {
expect(saved.arrayFieldArrayStrKnown).toEqual(["One"]) expect(saved.arrayFieldArrayStrKnown).toEqual(["One"])
expect(saved.optsFieldStrKnown).toEqual("Alpha") expect(saved.optsFieldStrKnown).toEqual("Alpha")
}) })
it("should throw an error when creating a table row with view id data", async () => {
const res = await request
.post(`/api/${row.tableId}/rows`)
.send({ ...row, _viewId: generator.guid() })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual(
"Table row endpoints cannot contain view info"
)
})
it("should setup the noViewData middleware", async () => {
const route = router.stack.find(
r => r.methods.includes("POST") && r.path === "/api/:tableId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(noViewDataMiddleware)
})
}) })
describe("patch", () => { describe("patch", () => {
@ -463,33 +439,6 @@ describe("/rows", () => {
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage) await assertQueryUsage(queryUsage)
}) })
it("should throw an error when creating a table row with view id data", async () => {
const existing = await config.createRow()
const res = await config.api.row.patch(
table._id!,
{
...existing,
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
_viewId: generator.guid(),
},
{ expectStatus: 400 }
)
expect(res.body.message).toEqual(
"Table row endpoints cannot contain view info"
)
})
it("should setup the noViewData middleware", async () => {
const route = router.stack.find(
r => r.methods.includes("PATCH") && r.path === "/api/:tableId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(noViewDataMiddleware)
})
}) })
describe("destroy", () => { describe("destroy", () => {
@ -758,7 +707,7 @@ describe("/rows", () => {
}) })
// the environment needs configured for this // the environment needs configured for this
await setup.switchToSelfHosted(async () => { await setup.switchToSelfHosted(async () => {
context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row]) const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment[0].url).toBe( expect((enriched as Row[])[0].attachment[0].url).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
@ -864,7 +813,7 @@ describe("/rows", () => {
}) })
const data = randomRowData() const data = randomRowData()
const newRow = await config.api.viewV2.row.create(view.id, { const newRow = await config.api.row.save(view.id, {
tableId: config.table!._id, tableId: config.table!._id,
_viewId: view.id, _viewId: view.id,
...data, ...data,
@ -886,16 +835,6 @@ describe("/rows", () => {
expect(row.body.age).toBeUndefined() expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined() expect(row.body.jobTitle).toBeUndefined()
}) })
it("should setup the trimViewRowInfo middleware", async () => {
const route = router.stack.find(
r =>
r.methods.includes("POST") &&
r.path === "/api/v2/views/:viewId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware)
})
}) })
describe("patch", () => { describe("patch", () => {
@ -910,13 +849,13 @@ describe("/rows", () => {
}, },
}) })
const newRow = await config.api.viewV2.row.create(view.id, { const newRow = await config.api.row.save(view.id, {
tableId, tableId,
_viewId: view.id, _viewId: view.id,
...randomRowData(), ...randomRowData(),
}) })
const newData = randomRowData() const newData = randomRowData()
await config.api.viewV2.row.update(view.id, newRow._id!, { await config.api.row.patch(view.id, {
tableId, tableId,
_viewId: view.id, _viewId: view.id,
_id: newRow._id!, _id: newRow._id!,
@ -939,16 +878,6 @@ describe("/rows", () => {
expect(row.body.age).toBeUndefined() expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined() expect(row.body.jobTitle).toBeUndefined()
}) })
it("should setup the trimViewRowInfo middleware", async () => {
const route = router.stack.find(
r =>
r.methods.includes("PATCH") &&
r.path === "/api/v2/views/:viewId/rows/:rowId"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware)
})
}) })
describe("destroy", () => { describe("destroy", () => {
@ -967,10 +896,7 @@ describe("/rows", () => {
const rowUsage = await getRowUsage() const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage() const queryUsage = await getQueryUsage()
const body: DeleteRow = { await config.api.row.delete(view.id, [createdRow])
_id: createdRow._id!,
}
await config.api.viewV2.row.delete(view.id, body)
await assertRowUsage(rowUsage - 1) await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1) await assertQueryUsage(queryUsage + 1)
@ -999,9 +925,7 @@ describe("/rows", () => {
const rowUsage = await getRowUsage() const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage() const queryUsage = await getQueryUsage()
await config.api.viewV2.row.delete(view.id, { await config.api.row.delete(view.id, [rows[0], rows[2]])
rows: [rows[0], rows[2]],
})
await assertRowUsage(rowUsage - 2) await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1) await assertQueryUsage(queryUsage + 1)

View File

@ -34,7 +34,7 @@ router
"/api/views/:viewName", "/api/views/:viewName",
paramResource("viewName"), paramResource("viewName"),
authorized( authorized(
permissions.PermissionType.VIEW, permissions.PermissionType.TABLE,
permissions.PermissionLevel.READ permissions.PermissionLevel.READ
), ),
rowController.fetchView rowController.fetchView

View File

@ -1,5 +1,7 @@
import newid from "./newid" import newid from "./newid"
import { db as dbCore } from "@budibase/backend-core" import { db as dbCore } from "@budibase/backend-core"
import { DocumentType, VirtualDocumentType } from "@budibase/types"
export { DocumentType, VirtualDocumentType } from "@budibase/types"
type Optional = string | null type Optional = string | null
@ -19,7 +21,6 @@ export const BudibaseInternalDB = {
export const SEPARATOR = dbCore.SEPARATOR export const SEPARATOR = dbCore.SEPARATOR
export const StaticDatabases = dbCore.StaticDatabases export const StaticDatabases = dbCore.StaticDatabases
export const DocumentType = dbCore.DocumentType
export const APP_PREFIX = dbCore.APP_PREFIX export const APP_PREFIX = dbCore.APP_PREFIX
export const APP_DEV_PREFIX = dbCore.APP_DEV_PREFIX export const APP_DEV_PREFIX = dbCore.APP_DEV_PREFIX
export const isDevAppID = dbCore.isDevAppID export const isDevAppID = dbCore.isDevAppID
@ -284,10 +285,22 @@ export function getMultiIDParams(ids: string[]) {
* @returns {string} The new view ID which the view doc can be stored under. * @returns {string} The new view ID which the view doc can be stored under.
*/ */
export function generateViewID(tableId: string) { export function generateViewID(tableId: string) {
return `${tableId}${SEPARATOR}${newid()}` return `${
VirtualDocumentType.VIEW
}${SEPARATOR}${tableId}${SEPARATOR}${newid()}`
}
export function isViewID(viewId: string) {
return viewId?.split(SEPARATOR)[0] === VirtualDocumentType.VIEW
} }
export function extractViewInfoFromID(viewId: string) { export function extractViewInfoFromID(viewId: string) {
if (!isViewID(viewId)) {
throw new Error("Unable to extract table ID, is not a view ID")
}
const split = viewId.split(SEPARATOR)
split.shift()
viewId = split.join(SEPARATOR)
const regex = new RegExp(`^(?<tableId>.+)${SEPARATOR}([^${SEPARATOR}]+)$`) const regex = new RegExp(`^(?<tableId>.+)${SEPARATOR}([^${SEPARATOR}]+)$`)
const res = regex.exec(viewId) const res = regex.exec(viewId)
return { return {

View File

@ -1,9 +0,0 @@
import { Ctx, Row } from "@budibase/types"
export default async (ctx: Ctx<Row>, next: any) => {
if (ctx.request.body._viewId) {
return ctx.throw(400, "Table row endpoints cannot contain view info")
}
return next()
}

View File

@ -1,83 +0,0 @@
import { generator } from "@budibase/backend-core/tests"
import { BBRequest, FieldType, Row, Table } from "@budibase/types"
import { Next } from "koa"
import * as utils from "../../db/utils"
import noViewDataMiddleware from "../noViewData"
class TestConfiguration {
next: Next
throw: jest.Mock<(status: number, message: string) => never>
middleware: typeof noViewDataMiddleware
params: Record<string, any>
request?: Pick<BBRequest<Row>, "body">
constructor() {
this.next = jest.fn()
this.throw = jest.fn()
this.params = {}
this.middleware = noViewDataMiddleware
}
executeMiddleware(ctxRequestBody: Row) {
this.request = {
body: ctxRequestBody,
}
return this.middleware(
{
request: this.request as any,
throw: this.throw as any,
params: this.params,
} as any,
this.next
)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("noViewData middleware", () => {
let config: TestConfiguration
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
const getRandomData = () => ({
_id: generator.guid(),
name: generator.name(),
age: generator.age(),
address: generator.address(),
})
it("it should pass without view id data", async () => {
const data = getRandomData()
await config.executeMiddleware({
...data,
})
expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled()
})
it("it should throw an error if _viewid is provided", async () => {
const data = getRandomData()
await config.executeMiddleware({
_viewId: generator.guid(),
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(
400,
"Table row endpoints cannot contain view info"
)
expect(config.next).not.toBeCalled()
})
})

View File

@ -117,7 +117,7 @@ describe("trimViewRowInfo middleware", () => {
}) })
expect(config.request?.body).toEqual(data) expect(config.request?.body).toEqual(data)
expect(config.params.tableId).toEqual(table._id) expect(config.params.sourceId).toEqual(table._id)
expect(config.next).toBeCalledTimes(1) expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled() expect(config.throw).not.toBeCalled()
@ -143,32 +143,9 @@ describe("trimViewRowInfo middleware", () => {
name: data.name, name: data.name,
address: data.address, address: data.address,
}) })
expect(config.params.tableId).toEqual(table._id) expect(config.params.sourceId).toEqual(table._id)
expect(config.next).toBeCalledTimes(1) expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled() expect(config.throw).not.toBeCalled()
}) })
it("it should throw an error if no viewid is provided on the body", async () => {
const data = getRandomData()
await config.executeMiddleware(viewId, {
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(400, "_viewId is required")
expect(config.next).not.toBeCalled()
})
it("it should throw an error if no viewid is provided on the parameters", async () => {
const data = getRandomData()
await config.executeMiddleware(undefined as any, {
_viewId: viewId,
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(400, "viewId path is required")
expect(config.next).not.toBeCalled()
})
}) })

View File

@ -3,26 +3,35 @@ import * as utils from "../db/utils"
import sdk from "../sdk" import sdk from "../sdk"
import { db } from "@budibase/backend-core" import { db } from "@budibase/backend-core"
import { Next } from "koa" import { Next } from "koa"
import { getTableId } from "../api/controllers/row/utils"
export default async (ctx: Ctx<Row>, next: Next) => { export default async (ctx: Ctx<Row>, next: Next) => {
const { body } = ctx.request const { body } = ctx.request
const { _viewId: viewId } = body let { _viewId: viewId } = body
const possibleViewId = getTableId(ctx)
if (utils.isViewID(possibleViewId)) {
viewId = possibleViewId
}
// nothing to do, it is not a view (just a table ID)
if (!viewId) { if (!viewId) {
return ctx.throw(400, "_viewId is required") return next()
} }
if (!ctx.params.viewId) { const { tableId } = utils.extractViewInfoFromID(viewId)
return ctx.throw(400, "viewId path is required")
}
const { tableId } = utils.extractViewInfoFromID(ctx.params.viewId) // don't need to trim delete requests
if (ctx?.method?.toLowerCase() !== "delete") {
const { _viewId, ...trimmedView } = await trimViewFields( const { _viewId, ...trimmedView } = await trimViewFields(
viewId, viewId,
tableId, tableId,
body body
) )
ctx.request.body = trimmedView ctx.request.body = trimmedView
ctx.params.tableId = tableId }
ctx.params.sourceId = tableId
return next() return next()
} }

View File

@ -1,17 +1,14 @@
import { HTTPError, context } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import { FieldSchema, TableSchema, View, ViewV2 } from "@budibase/types" import { FieldSchema, TableSchema, View, ViewV2 } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import merge from "lodash/merge"
export async function get(viewId: string): Promise<ViewV2 | undefined> { export async function get(viewId: string): Promise<ViewV2 | undefined> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
const views = Object.values(table.views!) const views = Object.values(table.views!)
const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined return views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined
return view
} }
export async function create( export async function create(

View File

@ -1,4 +1,4 @@
import { PatchRowRequest } from "@budibase/types" import { PatchRowRequest, SaveRowRequest, Row } from "@budibase/types"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base" import { TestAPI } from "./base"
@ -8,12 +8,12 @@ export class RowAPI extends TestAPI {
} }
get = async ( get = async (
tableId: string, sourceId: string,
rowId: string, rowId: string,
{ expectStatus } = { expectStatus: 200 } { expectStatus } = { expectStatus: 200 }
) => { ) => {
const request = this.request const request = this.request
.get(`/api/${tableId}/rows/${rowId}`) .get(`/api/${sourceId}/rows/${rowId}`)
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect(expectStatus) .expect(expectStatus)
if (expectStatus !== 404) { if (expectStatus !== 404) {
@ -22,16 +22,43 @@ export class RowAPI extends TestAPI {
return request return request
} }
save = async (
sourceId: string,
row: SaveRowRequest,
{ expectStatus } = { expectStatus: 200 }
): Promise<Row> => {
const resp = await this.request
.post(`/api/${sourceId}/rows`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return resp.body as Row
}
patch = async ( patch = async (
tableId: string, sourceId: string,
row: PatchRowRequest, row: PatchRowRequest,
{ expectStatus } = { expectStatus: 200 } { expectStatus } = { expectStatus: 200 }
) => { ) => {
return this.request return this.request
.patch(`/api/${tableId}/rows`) .patch(`/api/${sourceId}/rows`)
.send(row) .send(row)
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(expectStatus) .expect(expectStatus)
} }
delete = async (
sourceId: string,
rows: Row[],
{ expectStatus } = { expectStatus: 200 }
) => {
return this.request
.delete(`/api/${sourceId}/rows`)
.send({ rows })
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
}
} }

View File

@ -1,10 +1,6 @@
import { import {
CreateViewRequest, CreateViewRequest,
UpdateViewRequest, UpdateViewRequest,
DeleteRowRequest,
PatchRowRequest,
PatchRowResponse,
Row,
ViewV2, ViewV2,
SearchViewRowRequest, SearchViewRowRequest,
} from "@budibase/types" } from "@budibase/types"
@ -90,46 +86,4 @@ export class ViewV2API extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(expectStatus) .expect(expectStatus)
} }
row = {
create: async (
viewId: string,
row: Row,
{ expectStatus } = { expectStatus: 200 }
): Promise<Row> => {
const result = await this.request
.post(`/api/v2/views/${viewId}/rows`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return result.body as Row
},
update: async (
viewId: string,
rowId: string,
row: PatchRowRequest,
{ expectStatus } = { expectStatus: 200 }
): Promise<PatchRowResponse> => {
const result = await this.request
.patch(`/api/v2/views/${viewId}/rows/${rowId}`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return result.body as PatchRowResponse
},
delete: async (
viewId: string,
body: DeleteRowRequest,
{ expectStatus } = { expectStatus: 200 }
): Promise<any> => {
const result = await this.request
.delete(`/api/v2/views/${viewId}/rows`)
.send(body)
.set(this.config.defaultHeaders())
.expect(expectStatus)
return result.body
},
}
} }

View File

@ -1,5 +1,5 @@
import { permissions, roles } from "@budibase/backend-core" import { permissions, roles } from "@budibase/backend-core"
import { DocumentType } from "../db/utils" import { DocumentType, VirtualDocumentType } from "../db/utils"
export const CURRENTLY_SUPPORTED_LEVELS: string[] = [ export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
permissions.PermissionLevel.WRITE, permissions.PermissionLevel.WRITE,
@ -11,9 +11,10 @@ export function getPermissionType(resourceId: string) {
const docType = Object.values(DocumentType).filter(docType => const docType = Object.values(DocumentType).filter(docType =>
resourceId.startsWith(docType) resourceId.startsWith(docType)
)[0] )[0]
switch (docType) { switch (docType as DocumentType | VirtualDocumentType) {
case DocumentType.TABLE: case DocumentType.TABLE:
case DocumentType.ROW: case DocumentType.ROW:
case VirtualDocumentType.VIEW:
return permissions.PermissionType.TABLE return permissions.PermissionType.TABLE
case DocumentType.AUTOMATION: case DocumentType.AUTOMATION:
return permissions.PermissionType.AUTOMATION return permissions.PermissionType.AUTOMATION
@ -22,9 +23,6 @@ export function getPermissionType(resourceId: string) {
case DocumentType.QUERY: case DocumentType.QUERY:
case DocumentType.DATASOURCE: case DocumentType.DATASOURCE:
return permissions.PermissionType.QUERY return permissions.PermissionType.QUERY
default:
// views don't have an ID, will end up here
return permissions.PermissionType.VIEW
} }
} }

View File

@ -1,6 +1,8 @@
import { SearchParams } from "../../../sdk" import { SearchParams } from "../../../sdk"
import { Row } from "../../../documents" import { Row } from "../../../documents"
export interface SaveRowRequest extends Row {}
export interface PatchRowRequest extends Row { export interface PatchRowRequest extends Row {
_id: string _id: string
_rev: string _rev: string

View File

@ -39,6 +39,12 @@ export enum DocumentType {
AUDIT_LOG = "al", AUDIT_LOG = "al",
} }
// these documents don't really exist, they are part of other
// documents or enriched into existence as part of get requests
export enum VirtualDocumentType {
VIEW = "view",
}
export interface Document { export interface Document {
_id?: string _id?: string
_rev?: string _rev?: string

View File

@ -14,6 +14,5 @@ export enum PermissionType {
WEBHOOK = "webhook", WEBHOOK = "webhook",
BUILDER = "builder", BUILDER = "builder",
GLOBAL_BUILDER = "globalBuilder", GLOBAL_BUILDER = "globalBuilder",
VIEW = "view",
QUERY = "query", QUERY = "query",
} }