mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Adding schema validation for records endpoint
Summary: Adding validation for api /records endpoint, that checks if the json payload is valid. Modifying POST /records endpoint to allow creating blank or partial records. Test Plan: Updated tests Reviewers: alexmojaki Reviewed By: alexmojaki Differential Revision: https://phab.getgrist.com/D3061
This commit is contained in:
parent
276adc5f51
commit
3e661db38c
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
|
34
app/plugin/GristData-ti.ts
Normal file
34
app/plugin/GristData-ti.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
export const GristObjCode = t.enumtype({
|
||||
"List": "L",
|
||||
"Dict": "O",
|
||||
"DateTime": "D",
|
||||
"Date": "d",
|
||||
"Skip": "S",
|
||||
"Censored": "C",
|
||||
"Reference": "R",
|
||||
"ReferenceList": "r",
|
||||
"Exception": "E",
|
||||
"Pending": "P",
|
||||
"Unmarshallable": "U",
|
||||
"Versions": "V",
|
||||
});
|
||||
|
||||
export const CellValue = t.union("number", "string", "boolean", "null", t.tuple("GristObjCode", t.rest(t.array("unknown"))));
|
||||
|
||||
export const RowRecord = t.iface([], {
|
||||
"id": "number",
|
||||
[t.indexKey]: "CellValue",
|
||||
});
|
||||
|
||||
const exportedTypeSuite: t.ITypeSuite = {
|
||||
GristObjCode,
|
||||
CellValue,
|
||||
RowRecord,
|
||||
};
|
||||
export default exportedTypeSuite;
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
|
@ -2,12 +2,13 @@ import { createEmptyActionSummary } from "app/common/ActionSummary";
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { BrowserSettings } from "app/common/BrowserSettings";
|
||||
import {
|
||||
CellValue, fromTableDataAction, TableColValues, TableRecordValue,
|
||||
BulkColValues, CellValue, fromTableDataAction, TableColValues, TableRecordValue,
|
||||
} from 'app/common/DocActions';
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import { arrayRepeat, isAffirmative } from "app/common/gutil";
|
||||
import { SortFunc } from 'app/common/SortFunc';
|
||||
import { DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
|
||||
import GristDataTI from 'app/plugin/GristData-ti';
|
||||
import { HomeDBManager, makeDocAuthResult } from 'app/gen-server/lib/HomeDBManager';
|
||||
import { concatenateSummaries, summarizeAction } from "app/server/lib/ActionSummary";
|
||||
import { ActiveDoc, tableIdToRef } from "app/server/lib/ActiveDoc";
|
||||
@ -34,12 +35,16 @@ import { SandboxError } from "app/server/lib/sandboxUtil";
|
||||
import {localeFromRequest} from "app/server/lib/ServerLocale";
|
||||
import {allowedEventTypes, isUrlAllowed, WebhookAction, WebHookSecret} from "app/server/lib/Triggers";
|
||||
import { handleOptionalUpload, handleUpload } from "app/server/lib/uploads";
|
||||
import DocApiTypesTI from "app/server/lib/DocApiTypes-ti";
|
||||
import * as Types from "app/server/lib/DocApiTypes";
|
||||
import * as contentDisposition from 'content-disposition';
|
||||
import { Application, NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import * as _ from "lodash";
|
||||
import fetch from 'node-fetch';
|
||||
import * as path from 'path';
|
||||
import * as uuidv4 from "uuid/v4";
|
||||
import * as t from "ts-interface-checker";
|
||||
import { Checker } from "ts-interface-checker";
|
||||
|
||||
// Cap on the number of requests that can be outstanding on a single document via the
|
||||
// rest doc api. When this limit is exceeded, incoming requests receive an immediate
|
||||
@ -48,6 +53,29 @@ const MAX_PARALLEL_REQUESTS_PER_DOC = 10;
|
||||
|
||||
type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>;
|
||||
|
||||
// Schema validators for api endpoints that creates or updates records.
|
||||
const {RecordsPatch, RecordsPost} = t.createCheckers(DocApiTypesTI, GristDataTI);
|
||||
RecordsPatch.setReportedPath("body");
|
||||
RecordsPost.setReportedPath("body");
|
||||
|
||||
/**
|
||||
* Middleware for validating request's body with a Checker instance.
|
||||
*/
|
||||
function validate(checker: Checker): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
checker.check(req.body);
|
||||
} catch(err) {
|
||||
res.status(400).json({
|
||||
error : "Invalid payload",
|
||||
details: String(err)
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to track the number of requests outstanding on each document, and to
|
||||
* throw an exception when the maximum number of requests are already outstanding.
|
||||
@ -209,15 +237,17 @@ export class DocWorkerApi {
|
||||
.send(fileData);
|
||||
}));
|
||||
|
||||
/**
|
||||
* Adds records to a table. If columnValues is an empty object (or not provided) it will create empty records.
|
||||
* @param columnValues Optional values for fields (can be an empty object to add empty records)
|
||||
* @param count Number of records to add
|
||||
*/
|
||||
async function addRecords(
|
||||
req: RequestWithLogin, activeDoc: ActiveDoc, columnValues: {[colId: string]: CellValue[]}
|
||||
req: RequestWithLogin, activeDoc: ActiveDoc, count: number, columnValues: BulkColValues
|
||||
): Promise<number[]> {
|
||||
const tableId = req.params.tableId;
|
||||
const colNames = Object.keys(columnValues);
|
||||
// user actions expect [null, ...] as row ids, first let's figure the number of items to add by
|
||||
// looking at the length of a column
|
||||
const count = columnValues[colNames[0]].length;
|
||||
// then, let's create [null, ...]
|
||||
// user actions expect [null, ...] as row ids
|
||||
const rowIds = arrayRepeat(count, null);
|
||||
const sandboxRes = await handleSandboxError(tableId, colNames, activeDoc.applyUserActions(
|
||||
docSessionFromRequest(req),
|
||||
@ -225,24 +255,44 @@ export class DocWorkerApi {
|
||||
return sandboxRes.retValues[0];
|
||||
}
|
||||
|
||||
function recordFieldsToColValues(fields: {[colId: string]: CellValue}[]): {[colId: string]: CellValue[]} {
|
||||
return _.mapValues(fields[0], (_value, key) => _.map(fields, key));
|
||||
function areSameFields(records: Array<Types.Record | Types.NewRecord>) {
|
||||
const recordsFields = records.map(r => new Set(Object.keys(r.fields || {})));
|
||||
const firstFields = recordsFields[0];
|
||||
const allSame = recordsFields.every(s => _.isEqual(firstFields, s));
|
||||
return allSame;
|
||||
}
|
||||
|
||||
function convertToBulkColValues(records: Array<Types.Record | Types.NewRecord>): BulkColValues {
|
||||
// User might want to create empty records, without providing a field name, for example for requests:
|
||||
// { records: [{}] }; { records: [{fields:{}}] }
|
||||
// Retrieve all field names from fields property.
|
||||
const fieldNames = new Set<string>(_.flatMap(records, r => Object.keys(r.fields ?? {})));
|
||||
const result: BulkColValues = {};
|
||||
for (const fieldName of fieldNames) {
|
||||
result[fieldName] = records.map(record => record.fields?.[fieldName] || null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Adds records given in a column oriented format,
|
||||
// returns an array of row IDs
|
||||
this._app.post('/api/docs/:docId/tables/:tableId/data', canEdit,
|
||||
withDoc(async (activeDoc, req, res) => {
|
||||
const ids = await addRecords(req, activeDoc, req.body);
|
||||
const colValues = req.body as BulkColValues;
|
||||
const count = colValues[Object.keys(colValues)[0]].length;
|
||||
const ids = await addRecords(req, activeDoc, count, colValues);
|
||||
res.json(ids);
|
||||
})
|
||||
);
|
||||
|
||||
// Adds records given in a record oriented format,
|
||||
// returns in the same format as GET /records but without the fields object for now
|
||||
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit,
|
||||
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit, validate(RecordsPost),
|
||||
withDoc(async (activeDoc, req, res) => {
|
||||
const ids = await addRecords(req, activeDoc, recordFieldsToColValues(_.map(req.body.records, 'fields')));
|
||||
const body = req.body as Types.RecordsPost;
|
||||
const postRecords = convertToBulkColValues(body.records);
|
||||
// postRecords can be an empty object, in that case we will create empty records.
|
||||
const ids = await addRecords(req, activeDoc, body.records.length, postRecords);
|
||||
const records = ids.map(id => ({id}));
|
||||
res.json({records});
|
||||
})
|
||||
@ -333,11 +383,18 @@ export class DocWorkerApi {
|
||||
);
|
||||
|
||||
// Update records given in records format
|
||||
this._app.patch('/api/docs/:docId/tables/:tableId/records', canEdit,
|
||||
this._app.patch('/api/docs/:docId/tables/:tableId/records', canEdit, validate(RecordsPatch),
|
||||
withDoc(async (activeDoc, req, res) => {
|
||||
const records = req.body.records;
|
||||
const rowIds = _.map(records, 'id');
|
||||
const columnValues = recordFieldsToColValues(_.map(records, 'fields'));
|
||||
const body = req.body as Types.RecordsPatch;
|
||||
const rowIds = _.map(body.records, r => r.id);
|
||||
if (!areSameFields(body.records)) {
|
||||
throw new ApiError("PATCH requires all records to have same fields", 400);
|
||||
}
|
||||
const columnValues = convertToBulkColValues(body.records);
|
||||
if (!rowIds.length || !columnValues) {
|
||||
// For patch method, we require at least one valid record.
|
||||
throw new ApiError("PATCH requires a valid record object", 400);
|
||||
}
|
||||
await updateRecords(req, activeDoc, columnValues, rowIds);
|
||||
res.json(null);
|
||||
})
|
||||
@ -479,7 +536,7 @@ export class DocWorkerApi {
|
||||
}
|
||||
if (req.body.select === 'unlisted') {
|
||||
// Remove any snapshots not listed in inventory. Ideally, there should be no
|
||||
// snapshots, and this undocument feature is just for fixing up problems.
|
||||
// snapshots, and this undocumented feature is just for fixing up problems.
|
||||
const full = (await activeDoc.getSnapshots(true)).snapshots.map(s => s.snapshotId);
|
||||
const listed = new Set((await activeDoc.getSnapshots()).snapshots.map(s => s.snapshotId));
|
||||
const unlisted = full.filter(snapshotId => !listed.has(snapshotId));
|
||||
@ -525,7 +582,7 @@ export class DocWorkerApi {
|
||||
const activeDoc = await this._getActiveDoc(req);
|
||||
await activeDoc.flushDoc();
|
||||
// flushDoc terminates once there's no pending operation on the document.
|
||||
// There could still be async operations in progess. We mute their effect,
|
||||
// There could still be async operations in progress. We mute their effect,
|
||||
// as if they never happened.
|
||||
activeDoc.docClients.interruptAllClients();
|
||||
activeDoc.setMuted();
|
||||
|
34
app/server/lib/DocApiTypes-ti.ts
Normal file
34
app/server/lib/DocApiTypes-ti.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
export const NewRecord = t.iface([], {
|
||||
"fields": t.opt(t.iface([], {
|
||||
[t.indexKey]: "CellValue",
|
||||
})),
|
||||
});
|
||||
|
||||
export const Record = t.iface([], {
|
||||
"id": "number",
|
||||
"fields": t.iface([], {
|
||||
[t.indexKey]: "CellValue",
|
||||
}),
|
||||
});
|
||||
|
||||
export const RecordsPatch = t.iface([], {
|
||||
"records": t.tuple("Record", t.rest(t.array("Record"))),
|
||||
});
|
||||
|
||||
export const RecordsPost = t.iface([], {
|
||||
"records": t.tuple("NewRecord", t.rest(t.array("NewRecord"))),
|
||||
});
|
||||
|
||||
const exportedTypeSuite: t.ITypeSuite = {
|
||||
NewRecord,
|
||||
Record,
|
||||
RecordsPatch,
|
||||
RecordsPost,
|
||||
};
|
||||
export default exportedTypeSuite;
|
30
app/server/lib/DocApiTypes.ts
Normal file
30
app/server/lib/DocApiTypes.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { CellValue } from "app/plugin/GristData";
|
||||
|
||||
/**
|
||||
* JSON schema for api /record endpoint. Used in POST method for adding new records.
|
||||
*/
|
||||
export interface NewRecord {
|
||||
fields?: { [coldId: string]: CellValue }; // fields is optional, user can create blank records
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for api /record endpoint. Used in PATCH method for updating existing records.
|
||||
*/
|
||||
export interface Record {
|
||||
id: number;
|
||||
fields: { [coldId: string]: CellValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for the body of api /record PATCH endpoint
|
||||
*/
|
||||
export interface RecordsPatch {
|
||||
records: [Record, ...Record[]]; // at least one record is required
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for the body of api /record POST endpoint
|
||||
*/
|
||||
export interface RecordsPost {
|
||||
records: [NewRecord, ...NewRecord[]]; // at least one record is required
|
||||
}
|
@ -95,7 +95,7 @@
|
||||
"express": "4.16.4",
|
||||
"file-type": "14.1.4",
|
||||
"fs-extra": "7.0.0",
|
||||
"grain-rpc": "0.1.6",
|
||||
"grain-rpc": "0.1.7",
|
||||
"grainjs": "1.0.1",
|
||||
"highlight.js": "9.13.1",
|
||||
"i18n-iso-countries": "6.1.0",
|
||||
@ -121,7 +121,7 @@
|
||||
"saml2-js": "2.0.3",
|
||||
"short-uuid": "3.1.1",
|
||||
"tmp": "0.0.33",
|
||||
"ts-interface-checker": "0.1.6",
|
||||
"ts-interface-checker": "1.0.2",
|
||||
"typeorm": "0.2.18",
|
||||
"uuid": "3.3.2",
|
||||
"winston": "2.4.5",
|
||||
@ -129,6 +129,6 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"jquery": "2.2.1",
|
||||
"ts-interface-checker": "0.1.6"
|
||||
"ts-interface-checker": "1.0.2"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user