diff --git a/packages/backend-core/src/middleware/joi-validator.ts b/packages/backend-core/src/middleware/joi-validator.ts index 5047cdbbc1..a85c0e7108 100644 --- a/packages/backend-core/src/middleware/joi-validator.ts +++ b/packages/backend-core/src/middleware/joi-validator.ts @@ -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) } diff --git a/packages/server/src/api/controllers/rowAction/crud.ts b/packages/server/src/api/controllers/rowAction/crud.ts new file mode 100644 index 0000000000..640bc35378 --- /dev/null +++ b/packages/server/src/api/controllers/rowAction/crud.ts @@ -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) { + 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>( + (acc, [key, action]) => ({ + ...acc, + [key]: { id: key, tableId: table._id!, ...action }, + }), + {} + ), + } + ctx.body = result +} + +export async function create( + ctx: Ctx +) { + 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 +) { + 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) { + const table = await getTable(ctx) + const { actionId } = ctx.params + + await sdk.rowActions.remove(table._id!, actionId) + ctx.status = 204 +} diff --git a/packages/server/src/api/controllers/rowAction/index.ts b/packages/server/src/api/controllers/rowAction/index.ts new file mode 100644 index 0000000000..bf65119f1a --- /dev/null +++ b/packages/server/src/api/controllers/rowAction/index.ts @@ -0,0 +1,2 @@ +export * from "./crud" +export * from "./run" diff --git a/packages/server/src/api/controllers/rowAction/run.ts b/packages/server/src/api/controllers/rowAction/run.ts new file mode 100644 index 0000000000..06c4b36f86 --- /dev/null +++ b/packages/server/src/api/controllers/rowAction/run.ts @@ -0,0 +1,3 @@ +export function run() { + throw new Error("Function not implemented.") +} diff --git a/packages/server/src/api/routes/index.ts b/packages/server/src/api/routes/index.ts index 5a42c258cf..2079eb01fd 100644 --- a/packages/server/src/api/routes/index.ts +++ b/packages/server/src/api/routes/index.ts @@ -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, diff --git a/packages/server/src/api/routes/rowAction.ts b/packages/server/src/api/routes/rowAction.ts new file mode 100644 index 0000000000..f4f20822d1 --- /dev/null +++ b/packages/server/src/api/routes/rowAction.ts @@ -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 diff --git a/packages/server/src/api/routes/tests/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts new file mode 100644 index 0000000000..08a57eccb8 --- /dev/null +++ b/packages/server/src/api/routes/tests/rowAction.spec.ts @@ -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, + }) + }) + }) +}) diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index dfad00535e..e3fe945863 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -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}` +} diff --git a/packages/server/src/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts new file mode 100644 index 0000000000..4e96339b06 --- /dev/null +++ b/packages/server/src/sdk/app/rowActions.ts @@ -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(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(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) +} diff --git a/packages/server/src/sdk/index.ts b/packages/server/src/sdk/index.ts index c3057e3d4f..a871546b60 100644 --- a/packages/server/src/sdk/index.ts +++ b/packages/server/src/sdk/index.ts @@ -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 diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index 554fa36588..a19b68a872 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -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) } } diff --git a/packages/server/src/tests/utilities/api/rowAction.ts b/packages/server/src/tests/utilities/api/rowAction.ts new file mode 100644 index 0000000000..80535e5853 --- /dev/null +++ b/packages/server/src/tests/utilities/api/rowAction.ts @@ -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( + `/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( + `/api/tables/${tableId}/actions`, + { + expectations, + ...config, + } + ) + } + + update = async ( + tableId: string, + rowActionId: string, + rowAction: CreateRowActionRequest, + expectations?: Expectations, + config?: { publicUser?: boolean } + ) => { + return await this._put( + `/api/tables/${tableId}/actions/${rowActionId}`, + { + body: rowAction, + expectations, + ...config, + } + ) + } + + delete = async ( + tableId: string, + rowActionId: string, + expectations?: Expectations, + config?: { publicUser?: boolean } + ) => { + return await this._delete( + `/api/tables/${tableId}/actions/${rowActionId}`, + { + expectations, + ...config, + } + ) + } +} diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts index cb1cea2b08..55e1428fb9 100644 --- a/packages/types/src/api/web/app/index.ts +++ b/packages/types/src/api/web/app/index.ts @@ -7,3 +7,4 @@ export * from "./table" export * from "./permission" export * from "./attachment" export * from "./user" +export * from "./rowAction" diff --git a/packages/types/src/api/web/app/rowAction.ts b/packages/types/src/api/web/app/rowAction.ts new file mode 100644 index 0000000000..ba95ba6b95 --- /dev/null +++ b/packages/types/src/api/web/app/rowAction.ts @@ -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 +} diff --git a/packages/types/src/documents/app/index.ts b/packages/types/src/documents/app/index.ts index 3809fba6e5..2b13676ba1 100644 --- a/packages/types/src/documents/app/index.ts +++ b/packages/types/src/documents/app/index.ts @@ -16,3 +16,4 @@ export * from "./links" export * from "./component" export * from "./sqlite" export * from "./snippet" +export * from "./rowAction" diff --git a/packages/types/src/documents/app/rowAction.ts b/packages/types/src/documents/app/rowAction.ts new file mode 100644 index 0000000000..ea55d5dcd2 --- /dev/null +++ b/packages/types/src/documents/app/rowAction.ts @@ -0,0 +1,11 @@ +import { Document } from "../document" + +export interface TableRowActions extends Document { + _id: string + actions: Record< + string, + { + name: string + } + > +} diff --git a/packages/types/src/documents/document.ts b/packages/types/src/documents/document.ts index 0de4337f4b..f5facfae9d 100644 --- a/packages/types/src/documents/document.ts +++ b/packages/types/src/documents/document.ts @@ -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 {