Merge pull request #14152 from Budibase/BUDI-8428/row-action-crud

Row action CRUD endpoints
This commit is contained in:
Adria Navarro 2024-07-17 12:46:59 +02:00 committed by GitHub
commit 3411411c3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 645 additions and 4 deletions

View File

@ -4,8 +4,9 @@ import { Ctx } from "@budibase/types"
function validate(
schema: Joi.ObjectSchema | Joi.ArraySchema,
property: string,
opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
opts?: { errorPrefix?: string; allowUnknown?: boolean }
) {
const errorPrefix = opts?.errorPrefix ?? `Invalid ${property}`
// Return a Koa middleware function
return (ctx: Ctx, next: any) => {
if (!schema) {
@ -28,10 +29,12 @@ function validate(
})
}
const { error } = schema.validate(params)
const { error } = schema.validate(params, {
allowUnknown: opts?.allowUnknown,
})
if (error) {
let message = error.message
if (opts.errorPrefix) {
if (errorPrefix) {
message = `Invalid ${property} - ${message}`
}
ctx.throw(400, message)
@ -42,7 +45,7 @@ function validate(
export function body(
schema: Joi.ObjectSchema | Joi.ArraySchema,
opts?: { errorPrefix: string }
opts?: { errorPrefix?: string; allowUnknown?: boolean }
) {
return validate(schema, "body", opts)
}

View File

@ -0,0 +1,80 @@
import {
CreateRowActionRequest,
Ctx,
RowActionResponse,
RowActionsResponse,
UpdateRowActionRequest,
} from "@budibase/types"
import sdk from "../../../sdk"
async function getTable(ctx: Ctx) {
const { tableId } = ctx.params
const table = await sdk.tables.getTable(tableId)
if (!table) {
ctx.throw(404)
}
return table
}
export async function find(ctx: Ctx<void, RowActionsResponse>) {
const table = await getTable(ctx)
if (!(await sdk.rowActions.docExists(table._id!))) {
ctx.body = {
actions: {},
}
return
}
const { actions } = await sdk.rowActions.get(table._id!)
const result: RowActionsResponse = {
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
(acc, [key, action]) => ({
...acc,
[key]: { id: key, tableId: table._id!, ...action },
}),
{}
),
}
ctx.body = result
}
export async function create(
ctx: Ctx<CreateRowActionRequest, RowActionResponse>
) {
const table = await getTable(ctx)
const createdAction = await sdk.rowActions.create(table._id!, {
name: ctx.request.body.name,
})
ctx.body = {
tableId: table._id!,
...createdAction,
}
ctx.status = 201
}
export async function update(
ctx: Ctx<UpdateRowActionRequest, RowActionResponse>
) {
const table = await getTable(ctx)
const { actionId } = ctx.params
const actions = await sdk.rowActions.update(table._id!, actionId, {
name: ctx.request.body.name,
})
ctx.body = {
tableId: table._id!,
...actions,
}
}
export async function remove(ctx: Ctx<void, void>) {
const table = await getTable(ctx)
const { actionId } = ctx.params
await sdk.rowActions.remove(table._id!, actionId)
ctx.status = 204
}

View File

@ -0,0 +1,2 @@
export * from "./crud"
export * from "./run"

View File

@ -0,0 +1,3 @@
export function run() {
throw new Error("Function not implemented.")
}

View File

@ -28,6 +28,7 @@ import opsRoutes from "./ops"
import debugRoutes from "./debug"
import Router from "@koa/router"
import { api as pro } from "@budibase/pro"
import rowActionRoutes from "./rowAction"
export { default as staticRoutes } from "./static"
export { default as publicRoutes } from "./public"
@ -65,6 +66,7 @@ export const mainRoutes: Router[] = [
opsRoutes,
debugRoutes,
environmentVariableRoutes,
rowActionRoutes,
// these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this
tableRoutes,

View File

@ -0,0 +1,53 @@
import Router from "@koa/router"
import * as rowActionController from "../controllers/rowAction"
import { authorizedResource } from "../../middleware/authorized"
import { middleware, permissions } from "@budibase/backend-core"
import Joi from "joi"
const { PermissionLevel, PermissionType } = permissions
export function rowActionValidator() {
return middleware.joiValidator.body(
Joi.object({
name: Joi.string().required(),
}),
{ allowUnknown: true }
)
}
const router: Router = new Router()
// CRUD endpoints
router
.get(
"/api/tables/:tableId/actions",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.find
)
.post(
"/api/tables/:tableId/actions",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionValidator(),
rowActionController.create
)
.put(
"/api/tables/:tableId/actions/:actionId",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionValidator(),
rowActionController.update
)
.delete(
"/api/tables/:tableId/actions/:actionId",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.remove
)
// Other endpoints
.post(
"/api/tables/:tableId/actions/:actionId/run",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.run
)
export default router

View File

@ -0,0 +1,298 @@
import _ from "lodash"
import tk from "timekeeper"
import { CreateRowActionRequest, RowActionResponse } from "@budibase/types"
import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests"
describe("/rowsActions", () => {
const config = setup.getConfig()
let tableId: string
beforeAll(async () => {
tk.freeze(new Date())
await config.init()
})
beforeEach(async () => {
const table = await config.api.table.save(setup.structures.basicTable())
tableId = table._id!
})
afterAll(setup.afterAll)
const createRowAction = config.api.rowAction.save
function createRowActionRequest(): CreateRowActionRequest {
return {
name: generator.string(),
}
}
function createRowActionRequests(count: number): CreateRowActionRequest[] {
return generator
.unique(() => generator.string(), count)
.map(name => ({ name }))
}
function unauthorisedTests() {
it("returns unauthorised (401) for unauthenticated requests", async () => {
await createRowAction(
tableId,
createRowActionRequest(),
{
status: 401,
body: {
message: "Session not authenticated",
},
},
{ publicUser: true }
)
})
it("returns forbidden (403) for non-builder users", async () => {
const user = await config.createUser({
builder: {},
})
await config.withUser(user, async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 403,
})
})
})
it("rejects (404) for a non-existing table", async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 404,
})
})
}
describe("create", () => {
unauthorisedTests()
it("creates new row actions for tables without existing actions", async () => {
const rowAction = createRowActionRequest()
const res = await createRowAction(tableId, rowAction, {
status: 201,
})
expect(res).toEqual({
id: expect.stringMatching(/^row_action_\w+/),
tableId: tableId,
...rowAction,
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
...rowAction,
id: res.id,
tableId: tableId,
},
},
})
})
it("can create multiple row actions for the same table", async () => {
const rowActions = createRowActionRequests(3)
const responses: RowActionResponse[] = []
for (const action of rowActions) {
responses.push(await createRowAction(tableId, action))
}
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[responses[0].id]: { ...rowActions[0], id: responses[0].id, tableId },
[responses[1].id]: { ...rowActions[1], id: responses[1].id, tableId },
[responses[2].id]: { ...rowActions[2], id: responses[2].id, tableId },
},
})
})
it("rejects with bad request when creating with no name", async () => {
const rowAction: CreateRowActionRequest = {
name: "",
}
await createRowAction(tableId, rowAction, {
status: 400,
body: {
message: 'Invalid body - "name" is not allowed to be empty',
},
})
})
it("ignores not valid row action data", async () => {
const rowAction = createRowActionRequest()
const dirtyRowAction = {
...rowAction,
id: generator.guid(),
valueToIgnore: generator.string(),
}
const res = await createRowAction(tableId, dirtyRowAction, {
status: 201,
})
expect(res).toEqual({
id: expect.any(String),
tableId,
...rowAction,
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
id: res.id,
tableId: tableId,
...rowAction,
},
},
})
})
})
describe("find", () => {
unauthorisedTests()
it("returns only the actions for the requested table", async () => {
const rowActions: RowActionResponse[] = []
for (const action of createRowActionRequests(3)) {
rowActions.push(await createRowAction(tableId, action))
}
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {
[rowActions[0].id]: expect.any(Object),
[rowActions[1].id]: expect.any(Object),
[rowActions[2].id]: expect.any(Object),
},
})
})
it("returns empty for tables without row actions", async () => {
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {},
})
})
})
describe("update", () => {
unauthorisedTests()
it("can update existing actions", async () => {
for (const rowAction of createRowActionRequests(3)) {
await createRowAction(tableId, rowAction)
}
const persisted = await config.api.rowAction.find(tableId)
const [actionId, actionData] = _.sample(
Object.entries(persisted.actions)
)!
const updatedName = generator.string()
const res = await config.api.rowAction.update(tableId, actionId, {
...actionData,
name: updatedName,
})
expect(res).toEqual({
id: actionId,
tableId,
name: updatedName,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: expect.objectContaining({
[actionId]: {
...actionData,
name: updatedName,
},
}),
})
)
})
it("throws Bad Request when trying to update by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
tableId,
generator.guid(),
createRowActionRequest(),
{ status: 400 }
)
})
it("throws Bad Request when trying to update by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
otherTable._id!,
action.id,
createRowActionRequest(),
{ status: 400 }
)
})
})
describe("delete", () => {
unauthorisedTests()
it("can delete existing actions", async () => {
const actions: RowActionResponse[] = []
for (const rowAction of createRowActionRequests(3)) {
actions.push(await createRowAction(tableId, rowAction))
}
const actionToDelete = _.sample(actions)!
await config.api.rowAction.delete(tableId, actionToDelete.id, {
status: 204,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: actions
.filter(a => a.id !== actionToDelete.id)
.reduce((acc, c) => ({ ...acc, [c.id]: expect.any(Object) }), {}),
})
)
})
it("throws Bad Request when trying to delete by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(tableId, generator.guid(), {
status: 400,
})
})
it("throws Bad Request when trying to delete by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(otherTable._id!, action.id, {
status: 400,
})
})
})
})

View File

@ -349,3 +349,11 @@ export function isRelationshipColumn(
): column is RelationshipFieldMetadata {
return column.type === FieldType.LINK
}
/**
* Generates a new row actions ID.
* @returns The new row actions ID which the row actions doc can be stored under.
*/
export function generateRowActionsID(tableId: string) {
return `${DocumentType.ROW_ACTIONS}${SEPARATOR}${tableId}`
}

View File

@ -0,0 +1,85 @@
import { context, HTTPError, utils } from "@budibase/backend-core"
import { generateRowActionsID } from "../../db/utils"
import {
SEPARATOR,
TableRowActions,
VirtualDocumentType,
} from "@budibase/types"
export async function create(tableId: string, rowAction: { name: string }) {
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
let doc: TableRowActions
try {
doc = await db.get<TableRowActions>(rowActionsId)
} catch (e: any) {
if (e.status !== 404) {
throw e
}
doc = { _id: rowActionsId, actions: {} }
}
const newId = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}`
doc.actions[newId] = rowAction
await db.put(doc)
return {
id: newId,
...rowAction,
}
}
export async function get(tableId: string) {
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
return await db.get<TableRowActions>(rowActionsId)
}
export async function docExists(tableId: string) {
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
const result = await db.exists(rowActionsId)
return result
}
export async function update(
tableId: string,
rowActionId: string,
rowAction: { name: string }
) {
const actionsDoc = await get(tableId)
if (!actionsDoc.actions[rowActionId]) {
throw new HTTPError(
`Row action '${rowActionId}' not found in '${tableId}'`,
400
)
}
actionsDoc.actions[rowActionId] = rowAction
const db = context.getAppDB()
await db.put(actionsDoc)
return {
id: rowActionId,
...rowAction,
}
}
export async function remove(tableId: string, rowActionId: string) {
const actionsDoc = await get(tableId)
if (!actionsDoc.actions[rowActionId]) {
throw new HTTPError(
`Row action '${rowActionId}' not found in '${tableId}'`,
400
)
}
delete actionsDoc.actions[rowActionId]
const db = context.getAppDB()
await db.put(actionsDoc)
}

View File

@ -10,6 +10,7 @@ import { default as users } from "./users"
import { default as plugins } from "./plugins"
import * as views from "./app/views"
import * as permissions from "./app/permissions"
import * as rowActions from "./app/rowActions"
const sdk = {
backups,
@ -24,6 +25,7 @@ const sdk = {
views,
permissions,
links,
rowActions,
}
// default export for TS

View File

@ -13,6 +13,7 @@ import { UserAPI } from "./user"
import { QueryAPI } from "./query"
import { RoleAPI } from "./role"
import { TemplateAPI } from "./template"
import { RowActionAPI } from "./rowAction"
export default class API {
table: TableAPI
@ -29,6 +30,7 @@ export default class API {
query: QueryAPI
roles: RoleAPI
templates: TemplateAPI
rowAction: RowActionAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@ -45,5 +47,6 @@ export default class API {
this.query = new QueryAPI(config)
this.roles = new RoleAPI(config)
this.templates = new TemplateAPI(config)
this.rowAction = new RowActionAPI(config)
}
}

View File

@ -0,0 +1,73 @@
import {
CreateRowActionRequest,
RowActionResponse,
RowActionsResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class RowActionAPI extends TestAPI {
save = async (
tableId: string,
rowAction: CreateRowActionRequest,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._post<RowActionResponse>(
`/api/tables/${tableId}/actions`,
{
body: rowAction,
expectations: {
...expectations,
status: expectations?.status || 201,
},
...config,
}
)
}
find = async (
tableId: string,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._get<RowActionsResponse>(
`/api/tables/${tableId}/actions`,
{
expectations,
...config,
}
)
}
update = async (
tableId: string,
rowActionId: string,
rowAction: CreateRowActionRequest,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._put<RowActionResponse>(
`/api/tables/${tableId}/actions/${rowActionId}`,
{
body: rowAction,
expectations,
...config,
}
)
}
delete = async (
tableId: string,
rowActionId: string,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._delete<RowActionResponse>(
`/api/tables/${tableId}/actions/${rowActionId}`,
{
expectations,
...config,
}
)
}
}

View File

@ -7,3 +7,4 @@ export * from "./table"
export * from "./permission"
export * from "./attachment"
export * from "./user"
export * from "./rowAction"

View File

@ -0,0 +1,14 @@
interface RowActionData {
name: string
}
export interface CreateRowActionRequest extends RowActionData {}
export interface UpdateRowActionRequest extends RowActionData {}
export interface RowActionResponse extends RowActionData {
id: string
tableId: string
}
export interface RowActionsResponse {
actions: Record<string, RowActionResponse>
}

View File

@ -16,3 +16,4 @@ export * from "./links"
export * from "./component"
export * from "./sqlite"
export * from "./snippet"
export * from "./rowAction"

View File

@ -0,0 +1,11 @@
import { Document } from "../document"
export interface TableRowActions extends Document {
_id: string
actions: Record<
string,
{
name: string
}
>
}

View File

@ -39,6 +39,7 @@ export enum DocumentType {
AUDIT_LOG = "al",
APP_MIGRATION_METADATA = "_design/migrations",
SCIM_LOG = "scimlog",
ROW_ACTIONS = "ra",
}
// these are the core documents that make up the data, design
@ -68,6 +69,7 @@ export enum InternalTable {
// documents or enriched into existence as part of get requests
export enum VirtualDocumentType {
VIEW = "view",
ROW_ACTION = "row_action",
}
export interface Document {