diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index 2b2589406a..e47dc0bb58 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -201,25 +201,24 @@ spec: image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }} imagePullPolicy: Always + {{- if .Values.services.apps.startupProbe }} + {{- with .Values.services.apps.startupProbe }} + startupProbe: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.apps.livenessProbe }} + {{- with .Values.services.apps.livenessProbe }} livenessProbe: - httpGet: - path: /health - port: {{ .Values.services.apps.port }} - initialDelaySeconds: 10 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 3 + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.apps.readinessProbe }} + {{- with .Values.services.apps.readinessProbe }} readinessProbe: - httpGet: - path: /health - port: {{ .Values.services.apps.port }} - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 3 - + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} name: bbapps ports: - containerPort: {{ .Values.services.apps.port }} diff --git a/charts/budibase/templates/proxy-service-deployment.yaml b/charts/budibase/templates/proxy-service-deployment.yaml index c087627100..53bba6232d 100644 --- a/charts/budibase/templates/proxy-service-deployment.yaml +++ b/charts/budibase/templates/proxy-service-deployment.yaml @@ -40,24 +40,24 @@ spec: - image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }} imagePullPolicy: Always name: proxy-service + {{- if .Values.services.proxy.startupProbe }} + {{- with .Values.services.proxy.startupProbe }} + startupProbe: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.proxy.livenessProbe }} + {{- with .Values.services.proxy.livenessProbe }} livenessProbe: - httpGet: - path: /health - port: {{ .Values.services.proxy.port }} - initialDelaySeconds: 0 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 2 - timeoutSeconds: 3 + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.proxy.readinessProbe }} + {{- with .Values.services.proxy.readinessProbe }} readinessProbe: - httpGet: - path: /health - port: {{ .Values.services.proxy.port }} - initialDelaySeconds: 0 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 2 - timeoutSeconds: 3 + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} ports: - containerPort: {{ .Values.services.proxy.port }} env: diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index 5fed80b355..124c667807 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -190,24 +190,24 @@ spec: {{ end }} image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }} imagePullPolicy: Always + {{- if .Values.services.worker.startupProbe }} + {{- with .Values.services.worker.startupProbe }} + startupProbe: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.worker.livenessProbe }} + {{- with .Values.services.worker.livenessProbe }} livenessProbe: - httpGet: - path: /health - port: {{ .Values.services.worker.port }} - initialDelaySeconds: 10 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 3 + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- if .Values.services.worker.readinessProbe }} + {{- with .Values.services.worker.readinessProbe }} readinessProbe: - httpGet: - path: /health - port: {{ .Values.services.worker.port }} - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 3 + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} name: bbworker ports: - containerPort: {{ .Values.services.worker.port }} diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 74e4c52654..12e21a8e9c 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -119,15 +119,37 @@ services: port: 10000 replicaCount: 1 upstreams: - apps: 'http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}' - worker: 'http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}' - minio: 'http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}' - couchdb: 'http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}' + apps: "http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}" + worker: "http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}" + minio: "http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}" + couchdb: "http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}" resources: {} -# annotations: -# co.elastic.logs/module: nginx -# co.elastic.logs/fileset.stdout: access -# co.elastic.logs/fileset.stderr: error + startupProbe: + httpGet: + path: /health + port: 10000 + scheme: HTTP + failureThreshold: 30 + periodSeconds: 3 + readinessProbe: + httpGet: + path: /health + port: 10000 + scheme: HTTP + enabled: true + periodSeconds: 3 + failureThreshold: 1 + livenessProbe: + httpGet: + path: /health + port: 10000 + scheme: HTTP + failureThreshold: 3 + periodSeconds: 5 + # annotations: + # co.elastic.logs/module: nginx + # co.elastic.logs/fileset.stdout: access + # co.elastic.logs/fileset.stderr: error apps: port: 4002 @@ -135,23 +157,67 @@ services: logLevel: info httpLogging: 1 resources: {} -# nodeDebug: "" # set the value of NODE_DEBUG -# annotations: -# co.elastic.logs/multiline.type: pattern -# co.elastic.logs/multiline.pattern: '^[[:space:]]' -# co.elastic.logs/multiline.negate: false -# co.elastic.logs/multiline.match: after + startupProbe: + httpGet: + path: /health + port: 4002 + scheme: HTTP + failureThreshold: 30 + periodSeconds: 3 + readinessProbe: + httpGet: + path: /health + port: 4002 + scheme: HTTP + enabled: true + periodSeconds: 3 + failureThreshold: 1 + livenessProbe: + httpGet: + path: /health + port: 4002 + scheme: HTTP + failureThreshold: 3 + periodSeconds: 5 + # nodeDebug: "" # set the value of NODE_DEBUG + # annotations: + # co.elastic.logs/multiline.type: pattern + # co.elastic.logs/multiline.pattern: '^[[:space:]]' + # co.elastic.logs/multiline.negate: false + # co.elastic.logs/multiline.match: after worker: port: 4003 replicaCount: 1 logLevel: info httpLogging: 1 resources: {} -# annotations: -# co.elastic.logs/multiline.type: pattern -# co.elastic.logs/multiline.pattern: '^[[:space:]]' -# co.elastic.logs/multiline.negate: false -# co.elastic.logs/multiline.match: after + startupProbe: + httpGet: + path: /health + port: 4003 + scheme: HTTP + failureThreshold: 30 + periodSeconds: 3 + readinessProbe: + httpGet: + path: /health + port: 4003 + scheme: HTTP + enabled: true + periodSeconds: 3 + failureThreshold: 1 + livenessProbe: + httpGet: + path: /health + port: 4003 + scheme: HTTP + failureThreshold: 3 + periodSeconds: 5 + # annotations: + # co.elastic.logs/multiline.type: pattern + # co.elastic.logs/multiline.pattern: '^[[:space:]]' + # co.elastic.logs/multiline.negate: false + # co.elastic.logs/multiline.match: after couchdb: enabled: true diff --git a/lerna.json b/lerna.json index f41e29c815..2295b6a975 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.8.29-alpha.6", + "version": "2.8.29-alpha.8", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 4cbf17d844..7f6f494621 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -11,10 +11,6 @@ import { Row, PatchRowRequest, PatchRowResponse, - SearchResponse, - SortOrder, - SortType, - ViewV2, } from "@budibase/types" import * as utils from "./utils" import { gridSocket } from "../../../websockets" @@ -23,6 +19,7 @@ import { fixRow } from "../public/rows" import sdk from "../../../sdk" import * as exporters from "../view/exporters" import { apiFileReturn } from "../../../utilities/fileSystem" +export * as views from "./views" function pickApi(tableId: any) { if (isExternalTable(tableId)) { @@ -37,6 +34,7 @@ export async function patch( const appId = ctx.appId const tableId = utils.getTableId(ctx) const body = ctx.request.body + // if it doesn't have an _id then its save if (body && !body._id) { return save(ctx) @@ -62,13 +60,14 @@ export async function patch( } } -export const save = async (ctx: any) => { +export const save = async (ctx: UserCtx) => { const appId = ctx.appId const tableId = utils.getTableId(ctx) const body = ctx.request.body + // if it has an ID already then its a patch if (body && body._id) { - return patch(ctx) + return patch(ctx as UserCtx) } const { row, table, squashed } = await quotas.addRow(() => quotas.addQuery(() => pickApi(tableId).save(ctx), { @@ -147,7 +146,7 @@ async function deleteRows(ctx: UserCtx) { const rowDeletes: Row[] = await processDeleteRowsRequest(ctx) deleteRequest.rows = rowDeletes - let { rows } = await quotas.addQuery( + const { rows } = await quotas.addQuery( () => pickApi(tableId).bulkDestroy(ctx), { datasourceId: tableId, @@ -167,13 +166,13 @@ async function deleteRow(ctx: UserCtx) { const appId = ctx.appId const tableId = utils.getTableId(ctx) - let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), { + const resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), { datasourceId: tableId, }) await quotas.removeRow() ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row) - gridSocket?.emitRowDeletion(ctx, resp.row._id) + gridSocket?.emitRowDeletion(ctx, resp.row._id!) return resp } @@ -212,83 +211,6 @@ export async function search(ctx: any) { }) } -function getSortOptions( - ctx: Ctx, - view: ViewV2 -): - | { - sort: string - sortOrder?: SortOrder - sortType?: SortType - } - | undefined { - const { sort_column, sort_order, sort_type } = ctx.query - if (Array.isArray(sort_column)) { - ctx.throw(400, "sort_column cannot be an array") - } - if (Array.isArray(sort_order)) { - ctx.throw(400, "sort_order cannot be an array") - } - if (Array.isArray(sort_type)) { - ctx.throw(400, "sort_type cannot be an array") - } - - if (sort_column) { - return { - sort: sort_column, - sortOrder: sort_order as SortOrder, - sortType: sort_type as SortType, - } - } - if (view.sort) { - return { - sort: view.sort.field, - sortOrder: view.sort.order, - sortType: view.sort.type, - } - } - - return -} - -export async function searchView(ctx: Ctx) { - const { viewId } = ctx.params - - const view = await sdk.views.get(viewId) - if (!view) { - ctx.throw(404, `View ${viewId} not found`) - } - - if (view.version !== 2) { - ctx.throw(400, `This method only supports viewsV2`) - } - - const table = await sdk.tables.getTable(view?.tableId) - - const viewFields = - (view.columns && - Object.entries(view.columns).length && - Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) || - undefined - - ctx.status = 200 - const result = await quotas.addQuery( - () => - sdk.rows.search({ - tableId: view.tableId, - query: view.query || {}, - fields: viewFields, - ...getSortOptions(ctx, view), - }), - { - datasourceId: view.tableId, - } - ) - - result.rows.forEach(r => (r._viewId = view.id)) - ctx.body = result -} - export async function validate(ctx: Ctx) { const tableId = utils.getTableId(ctx) // external tables are hard to validate currently diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index 1153461b89..2ff1df0933 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -93,7 +93,6 @@ export async function patch(ctx: UserCtx) { } export async function save(ctx: UserCtx) { - const db = context.getAppDB() let inputs = ctx.request.body inputs.tableId = ctx.params.tableId @@ -177,7 +176,6 @@ export async function destroy(ctx: UserCtx) { } export async function bulkDestroy(ctx: UserCtx) { - const db = context.getAppDB() const tableId = ctx.params.tableId const table = await sdk.tables.getTable(tableId) let { rows } = ctx.request.body @@ -206,6 +204,7 @@ export async function bulkDestroy(ctx: UserCtx) { }) ) } else { + const db = context.getAppDB() await db.bulkDocs(processedRows.map(row => ({ ...row, _deleted: true }))) } // remove any attachments that were on the rows from object storage diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts new file mode 100644 index 0000000000..c38b7fe56e --- /dev/null +++ b/packages/server/src/api/controllers/row/views.ts @@ -0,0 +1,86 @@ +import { quotas } from "@budibase/pro" +import { + UserCtx, + SearchResponse, + SortOrder, + SortType, + ViewV2, +} from "@budibase/types" +import sdk from "../../../sdk" + +export async function searchView(ctx: UserCtx) { + const { viewId } = ctx.params + + const view = await sdk.views.get(viewId) + if (!view) { + ctx.throw(404, `View ${viewId} not found`) + } + + if (view.version !== 2) { + ctx.throw(400, `This method only supports viewsV2`) + } + + const table = await sdk.tables.getTable(view?.tableId) + + const viewFields = + (view.columns && + Object.entries(view.columns).length && + Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) || + undefined + + ctx.status = 200 + const result = await quotas.addQuery( + () => + sdk.rows.search({ + tableId: view.tableId, + query: view.query || {}, + fields: viewFields, + ...getSortOptions(ctx, view), + }), + { + datasourceId: view.tableId, + } + ) + + result.rows.forEach(r => (r._viewId = view.id)) + ctx.body = result +} + +function getSortOptions( + ctx: UserCtx, + view: ViewV2 +): + | { + sort: string + sortOrder?: SortOrder + sortType?: SortType + } + | undefined { + const { sort_column, sort_order, sort_type } = ctx.query + if (Array.isArray(sort_column)) { + ctx.throw(400, "sort_column cannot be an array") + } + if (Array.isArray(sort_order)) { + ctx.throw(400, "sort_order cannot be an array") + } + if (Array.isArray(sort_type)) { + ctx.throw(400, "sort_type cannot be an array") + } + + if (sort_column) { + return { + sort: sort_column, + sortOrder: sort_order as SortOrder, + sortType: sort_type as SortType, + } + } + if (view.sort) { + return { + sort: view.sort.field, + sortOrder: view.sort.order, + sortType: view.sort.type, + } + } + + return +} diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index 5fdc02b7a7..ac0cd2b4a4 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -4,6 +4,9 @@ import authorized from "../../middleware/authorized" import { paramResource, paramSubResource } from "../../middleware/resourceId" import { permissions } from "@budibase/backend-core" import { internalSearchValidator } from "./utils/validators" +import noViewData from "../../middleware/noViewData" +import trimViewRowInfo from "../../middleware/trimViewRowInfo" +import * as utils from "../../db/utils" const { PermissionType, PermissionLevel } = permissions const router: Router = new Router() @@ -146,11 +149,6 @@ router authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) - .get( - "/api/v2/views/:viewId/search", - authorized(PermissionType.VIEW, PermissionLevel.READ), - rowController.searchView - ) /** * @api {post} /api/:tableId/rows Creates a new row * @apiName Creates a new row @@ -179,6 +177,7 @@ router "/api/:tableId/rows", paramResource("tableId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), + noViewData, rowController.save ) /** @@ -193,6 +192,7 @@ router "/api/:tableId/rows", paramResource("tableId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), + noViewData, rowController.patch ) /** @@ -268,4 +268,91 @@ router rowController.exportRows ) +router + .get( + "/api/v2/views/:viewId/search", + authorized(PermissionType.VIEW, PermissionLevel.READ), + 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 diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index baec8ddb85..479f27d679 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -16,13 +16,16 @@ import { FieldType, SortType, SortOrder, - PatchRowRequest, + DeleteRow, } from "@budibase/types" import { expectAnyInternalColsAttributes, generator, structures, } from "@budibase/backend-core/tests" +import trimViewRowInfoMiddleware from "../../../middleware/trimViewRowInfo" +import noViewDataMiddleware from "../../../middleware/noViewData" +import router from "../row" describe("/rows", () => { let request = setup.getRequest() @@ -391,6 +394,26 @@ describe("/rows", () => { expect(saved.arrayFieldArrayStrKnown).toEqual(["One"]) 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", () => { @@ -440,6 +463,33 @@ describe("/rows", () => { await assertRowUsage(rowUsage) 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", () => { @@ -1008,4 +1058,208 @@ describe("/rows", () => { expect(response.body.rows).toHaveLength(0) }) }) + + describe("view 2.0", () => { + function userTable(): Table { + return { + name: "user", + type: "user", + schema: { + name: { + type: FieldType.STRING, + name: "name", + }, + surname: { + type: FieldType.STRING, + name: "name", + }, + age: { + type: FieldType.NUMBER, + name: "age", + }, + address: { + type: FieldType.STRING, + name: "address", + }, + jobTitle: { + type: FieldType.STRING, + name: "jobTitle", + }, + }, + } + } + + const randomRowData = () => ({ + name: generator.first(), + surname: generator.last(), + age: generator.age(), + address: generator.address(), + jobTitle: generator.word(), + }) + + describe("create", () => { + it("should persist a new row with only the provided view fields", async () => { + const table = await config.createTable(userTable()) + const view = await config.api.viewV2.create({ + tableId: table._id!, + columns: { + name: { visible: true }, + surname: { visible: true }, + address: { visible: true }, + }, + }) + + const data = randomRowData() + const newRow = await config.api.viewV2.row.create(view.id, { + tableId: config.table!._id, + _viewId: view.id, + ...data, + }) + + const row = await config.api.row.get(table._id!, newRow._id!) + expect(row.body).toEqual({ + name: data.name, + surname: data.surname, + address: data.address, + tableId: config.table!._id, + type: "row", + _id: expect.any(String), + _rev: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + expect(row.body._viewId).toBeUndefined() + expect(row.body.age).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", () => { + it("should update only the view fields for a row", async () => { + const table = await config.createTable(userTable()) + const tableId = table._id! + const view = await config.api.viewV2.create({ + tableId, + columns: { + name: { visible: true }, + address: { visible: true }, + }, + }) + + const newRow = await config.api.viewV2.row.create(view.id, { + tableId, + _viewId: view.id, + ...randomRowData(), + }) + const newData = randomRowData() + await config.api.viewV2.row.update(view.id, newRow._id!, { + tableId, + _viewId: view.id, + _id: newRow._id!, + _rev: newRow._rev!, + ...newData, + }) + + const row = await config.api.row.get(tableId, newRow._id!) + expect(row.body).toEqual({ + ...newRow, + type: "row", + name: newData.name, + address: newData.address, + _id: expect.any(String), + _rev: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + expect(row.body._viewId).toBeUndefined() + expect(row.body.age).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", () => { + it("should be able to delete a row", async () => { + const table = await config.createTable(userTable()) + const tableId = table._id! + const view = await config.api.viewV2.create({ + tableId, + columns: { + name: { visible: true }, + address: { visible: true }, + }, + }) + + const createdRow = await config.createRow() + const rowUsage = await getRowUsage() + const queryUsage = await getQueryUsage() + + const body: DeleteRow = { + _id: createdRow._id!, + } + await config.api.viewV2.row.delete(view.id, body) + + await assertRowUsage(rowUsage - 1) + await assertQueryUsage(queryUsage + 1) + + await config.api.row.get(tableId, createdRow._id!, { + expectStatus: 404, + }) + }) + + it("should be able to delete multiple rows", async () => { + const table = await config.createTable(userTable()) + const tableId = table._id! + const view = await config.api.viewV2.create({ + tableId, + columns: { + name: { visible: true }, + address: { visible: true }, + }, + }) + + const rows = [ + await config.createRow(), + await config.createRow(), + await config.createRow(), + ] + const rowUsage = await getRowUsage() + const queryUsage = await getQueryUsage() + + await config.api.viewV2.row.delete(view.id, { + rows: [rows[0], rows[2]], + }) + + await assertRowUsage(rowUsage - 2) + await assertQueryUsage(queryUsage + 1) + + await config.api.row.get(tableId, rows[0]._id!, { + expectStatus: 404, + }) + await config.api.row.get(tableId, rows[2]._id!, { + expectStatus: 404, + }) + await config.api.row.get(tableId, rows[1]._id!, { expectStatus: 200 }) + }) + }) + }) }) diff --git a/packages/server/src/middleware/noViewData.ts b/packages/server/src/middleware/noViewData.ts new file mode 100644 index 0000000000..809424b6bf --- /dev/null +++ b/packages/server/src/middleware/noViewData.ts @@ -0,0 +1,9 @@ +import { Ctx, Row } from "@budibase/types" + +export default async (ctx: Ctx, next: any) => { + if (ctx.request.body._viewId) { + return ctx.throw(400, "Table row endpoints cannot contain view info") + } + + return next() +} diff --git a/packages/server/src/middleware/tests/noViewData.spec.ts b/packages/server/src/middleware/tests/noViewData.spec.ts new file mode 100644 index 0000000000..54b0ca8ff8 --- /dev/null +++ b/packages/server/src/middleware/tests/noViewData.spec.ts @@ -0,0 +1,83 @@ +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 + request?: Pick, "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() + }) +}) diff --git a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts new file mode 100644 index 0000000000..89b831b647 --- /dev/null +++ b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts @@ -0,0 +1,174 @@ +import { generator } from "@budibase/backend-core/tests" +import { BBRequest, FieldType, Row, Table } from "@budibase/types" +import * as utils from "../../db/utils" +import trimViewRowInfoMiddleware from "../trimViewRowInfo" + +jest.mock("../../sdk", () => ({ + views: { + ...jest.requireActual("../../sdk/app/views"), + get: jest.fn(), + }, + tables: { + getTable: jest.fn(), + }, +})) + +import sdk from "../../sdk" +import { Next } from "koa" + +const mockGetView = sdk.views.get as jest.MockedFunction +const mockGetTable = sdk.tables.getTable as jest.MockedFunction< + typeof sdk.tables.getTable +> + +class TestConfiguration { + next: Next + throw: jest.Mock<(status: number, message: string) => never> + middleware: typeof trimViewRowInfoMiddleware + params: Record + request?: Pick, "body"> + + constructor() { + this.next = jest.fn() + this.throw = jest.fn() + this.params = {} + + this.middleware = trimViewRowInfoMiddleware + } + + executeMiddleware(viewId: string, ctxRequestBody: Row) { + this.request = { + body: ctxRequestBody, + } + this.params.viewId = viewId + return this.middleware( + { + request: this.request as any, + next: this.next, + throw: this.throw as any, + params: this.params, + } as any, + this.next + ) + } + + afterEach() { + jest.clearAllMocks() + } +} + +describe("trimViewRowInfo middleware", () => { + let config: TestConfiguration + + beforeEach(() => { + config = new TestConfiguration() + }) + + afterEach(() => { + config.afterEach() + }) + + const table: Table = { + _id: utils.generateTableID(), + name: generator.word(), + type: "table", + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + age: { + name: "age", + type: FieldType.NUMBER, + }, + address: { + name: "address", + type: FieldType.STRING, + }, + }, + } + let viewId: string = undefined! + + beforeEach(() => { + jest.resetAllMocks() + mockGetTable.mockResolvedValue(table) + viewId = utils.generateViewID(table._id!) + }) + + const getRandomData = () => ({ + _id: generator.guid(), + name: generator.name(), + age: generator.age(), + address: generator.address(), + }) + + it("when no columns are defined, same data is returned", async () => { + mockGetView.mockResolvedValue({ + version: 2, + id: viewId, + name: generator.guid(), + tableId: table._id!, + }) + + const data = getRandomData() + await config.executeMiddleware(viewId, { + _viewId: viewId, + ...data, + }) + + expect(config.request?.body).toEqual(data) + expect(config.params.tableId).toEqual(table._id) + + expect(config.next).toBeCalledTimes(1) + expect(config.throw).not.toBeCalled() + }) + + it("when columns are defined, trimmed data is returned", async () => { + mockGetView.mockResolvedValue({ + version: 2, + id: viewId, + name: generator.guid(), + tableId: table._id!, + columns: { name: { visible: true }, address: { visible: true } }, + }) + + const data = getRandomData() + await config.executeMiddleware(viewId, { + _viewId: viewId, + ...data, + }) + + expect(config.request?.body).toEqual({ + _id: data._id, + name: data.name, + address: data.address, + }) + expect(config.params.tableId).toEqual(table._id) + + expect(config.next).toBeCalledTimes(1) + 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() + }) +}) diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts new file mode 100644 index 0000000000..2e3ded27f5 --- /dev/null +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -0,0 +1,52 @@ +import { Ctx, Row } from "@budibase/types" +import * as utils from "../db/utils" +import sdk from "../sdk" +import { db } from "@budibase/backend-core" +import { Next } from "koa" + +export default async (ctx: Ctx, next: Next) => { + const { body } = ctx.request + const { _viewId: viewId } = body + if (!viewId) { + return ctx.throw(400, "_viewId is required") + } + + if (!ctx.params.viewId) { + return ctx.throw(400, "viewId path is required") + } + + const { tableId } = utils.extractViewInfoFromID(ctx.params.viewId) + const { _viewId, ...trimmedView } = await trimViewFields( + viewId, + tableId, + body + ) + ctx.request.body = trimmedView + ctx.params.tableId = tableId + + return next() +} + +export async function trimViewFields( + viewId: string, + tableId: string, + data: T +): Promise { + const view = await sdk.views.get(viewId) + if (!view?.columns || !Object.keys(view.columns).length) { + return data + } + + const table = await sdk.tables.getTable(tableId) + const { schema } = sdk.views.enrichSchema(view!, table.schema) + const result: Record = {} + for (const key of [ + ...Object.keys(schema), + ...db.CONSTANT_EXTERNAL_ROW_COLS, + ...db.CONSTANT_INTERNAL_ROW_COLS, + ]) { + result[key] = data[key] !== null ? data[key] : undefined + } + + return result as T +} diff --git a/packages/server/src/tests/utilities/api/row.ts b/packages/server/src/tests/utilities/api/row.ts index 9c7e33278d..c7c72368f5 100644 --- a/packages/server/src/tests/utilities/api/row.ts +++ b/packages/server/src/tests/utilities/api/row.ts @@ -7,6 +7,21 @@ export class RowAPI extends TestAPI { super(config) } + get = async ( + tableId: string, + rowId: string, + { expectStatus } = { expectStatus: 200 } + ) => { + const request = this.request + .get(`/api/${tableId}/rows/${rowId}`) + .set(this.config.defaultHeaders()) + .expect(expectStatus) + if (expectStatus !== 404) { + request.expect("Content-Type", /json/) + } + return request + } + patch = async ( tableId: string, row: PatchRowRequest, diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts index b404904a28..5ad2b2d3d7 100644 --- a/packages/server/src/tests/utilities/api/viewV2.ts +++ b/packages/server/src/tests/utilities/api/viewV2.ts @@ -3,6 +3,10 @@ import { SortOrder, SortType, UpdateViewRequest, + DeleteRowRequest, + PatchRowRequest, + PatchRowResponse, + Row, ViewV2, } from "@budibase/types" import TestConfiguration from "../TestConfiguration" @@ -106,4 +110,46 @@ export class ViewV2API extends TestAPI { .expect("Content-Type", /json/) .expect(expectStatus) } + + row = { + create: async ( + viewId: string, + row: Row, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + 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 => { + 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 => { + const result = await this.request + .delete(`/api/v2/views/${viewId}/rows`) + .send(body) + .set(this.config.defaultHeaders()) + .expect(expectStatus) + return result.body + }, + } }