(core) Add GET /attachments endpoint for listing attachment metadata

Summary: Combines the code and behaviour of the existing endpoints `GET /records` (for the general shape of the result and the parameters for sort/filter/limit etc) and retrieving a specific attachment with `GET /attachments/:id` for handling fields specific to attachments.

Test Plan: Added a DocApi test. Also updated one test to use the new endpoint instead of raw `GET /tables/_grist_Attachments/records`.

Reviewers: cyprien

Reviewed By: cyprien

Subscribers: cyprien

Differential Revision: https://phab.getgrist.com/D3443
This commit is contained in:
Alex Hall 2022-05-20 13:50:22 +02:00
parent 9bc04a6e66
commit fcbad1c887
2 changed files with 65 additions and 28 deletions

View File

@ -6,6 +6,7 @@ import {isRaisedException} from "app/common/gristTypes";
import {isAffirmative} from "app/common/gutil"; import {isAffirmative} from "app/common/gutil";
import {SortFunc} from 'app/common/SortFunc'; import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {MetaRowRecord} from 'app/common/TableData';
import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager';
import * as Types from "app/plugin/DocApiTypes"; import * as Types from "app/plugin/DocApiTypes";
@ -147,14 +148,14 @@ export class DocWorkerApi {
res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, {parseStrings})); res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, {parseStrings}));
})); }));
async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin) { async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {
const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {}; const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};
// Option to skip waiting for document initialization. // Option to skip waiting for document initialization.
const immediate = isAffirmative(req.query.immediate); const immediate = isAffirmative(req.query.immediate);
if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) { if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) {
throw new ApiError("Invalid query: filter values must be arrays", 400); throw new ApiError("Invalid query: filter values must be arrays", 400);
} }
const tableId = req.params.tableId; const tableId = optTableId || req.params.tableId;
const session = docSessionFromRequest(req); const session = docSessionFromRequest(req);
const tableData = await handleSandboxError(tableId, [], activeDoc.fetchQuery( const tableData = await handleSandboxError(tableId, [], activeDoc.fetchQuery(
session, {tableId, filters}, !immediate)); session, {tableId, filters}, !immediate));
@ -168,23 +169,16 @@ export class DocWorkerApi {
return applyQueryParameters(fromTableDataAction(tableData), params, columns); return applyQueryParameters(fromTableDataAction(tableData), params, columns);
} }
// Get the specified table in column-oriented format async function getTableRecords(
this._app.get('/api/docs/:docId/tables/:tableId/data', canView, activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string
withDoc(async (activeDoc, req, res) => { ): Promise<TableRecordValue[]> {
res.json(await getTableData(activeDoc, req)); const columnData = await getTableData(activeDoc, req, optTableId);
})
);
// Get the specified table in record-oriented format
this._app.get('/api/docs/:docId/tables/:tableId/records', canView,
withDoc(async (activeDoc, req, res) => {
const columnData = await getTableData(activeDoc, req);
const fieldNames = Object.keys(columnData) const fieldNames = Object.keys(columnData)
.filter(k => !( .filter(k => !(
["id", "manualSort"].includes(k) ["id", "manualSort"].includes(k)
|| k.startsWith("gristHelper_") || k.startsWith("gristHelper_")
)); ));
const records = columnData.id.map((id, index) => { return columnData.id.map((id, index) => {
const result: TableRecordValue = {id, fields: {}}; const result: TableRecordValue = {id, fields: {}};
for (const key of fieldNames) { for (const key of fieldNames) {
let value = columnData[key][index]; let value = columnData[key][index];
@ -196,6 +190,19 @@ export class DocWorkerApi {
} }
return result; return result;
}); });
}
// Get the specified table in column-oriented format
this._app.get('/api/docs/:docId/tables/:tableId/data', canView,
withDoc(async (activeDoc, req, res) => {
res.json(await getTableData(activeDoc, req));
})
);
// Get the specified table in record-oriented format
this._app.get('/api/docs/:docId/tables/:tableId/records', canView,
withDoc(async (activeDoc, req, res) => {
const records = await getTableRecords(activeDoc, req);
res.json({records}); res.json({records});
}) })
); );
@ -222,12 +229,28 @@ export class DocWorkerApi {
res.json(await activeDoc.addAttachments(docSessionFromRequest(req), uploadResult.uploadId)); res.json(await activeDoc.addAttachments(docSessionFromRequest(req), uploadResult.uploadId));
})); }));
// Returns the metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table). // Select the fields from an attachment record that we want to return to the user,
// and convert the timeUploaded from a number to an ISO string.
function cleanAttachmentRecord(record: MetaRowRecord<"_grist_Attachments">) {
const {fileName, fileSize, timeUploaded: time} = record;
const timeUploaded = (typeof time === 'number') ? new Date(time).toISOString() : undefined;
return {fileName, fileSize, timeUploaded};
}
// Returns cleaned metadata for all attachments in /records format.
this._app.get('/api/docs/:docId/attachments', canView, withDoc(async (activeDoc, req, res) => {
const rawRecords = await getTableRecords(activeDoc, req, "_grist_Attachments");
const records = rawRecords.map(r => ({
id: r.id,
fields: cleanAttachmentRecord(r.fields as MetaRowRecord<"_grist_Attachments">),
}));
res.json({records});
}));
// Returns cleaned metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table).
this._app.get('/api/docs/:docId/attachments/:attId', canView, withDoc(async (activeDoc, req, res) => { this._app.get('/api/docs/:docId/attachments/:attId', canView, withDoc(async (activeDoc, req, res) => {
const attRecord = activeDoc.getAttachmentMetadata(req.params.attId as string); const attRecord = activeDoc.getAttachmentMetadata(req.params.attId as string);
const {fileName, fileSize, timeUploaded: t} = attRecord; res.json(cleanAttachmentRecord(attRecord));
const timeUploaded = (typeof t === 'number') ? new Date(t).toISOString() : undefined;
res.json({fileName, fileSize, timeUploaded});
})); }));
// Responds with attachment contents, with suitable Content-Type and Content-Disposition. // Responds with attachment contents, with suitable Content-Type and Content-Disposition.

View File

@ -1460,6 +1460,23 @@ function testDocApi() {
assert.deepEqual(resp.data, [3]); assert.deepEqual(resp.data, [3]);
}); });
it("GET /docs/{did}/attachments lists attachment metadata", async function() {
// Test that the usual /records query parameters like sort and filter also work
const url = `${serverUrl}/api/docs/${docIds.TestDoc}/attachments?sort=-fileName&limit=2`;
const resp = await axios.get(url, chimpy);
assert.equal(resp.status, 200);
const {records} = resp.data;
for (const record of records) {
assert.match(record.fields.timeUploaded, /^\d{4}-\d{2}-\d{2}T/);
delete record.fields.timeUploaded;
}
assert.deepEqual(records, [
{id: 2, fields: {fileName: "world.jpg", fileSize: 6}},
{id: 3, fields: {fileName: "hello.png", fileSize: 6}},
]
);
});
it("GET /docs/{did}/attachments/{id} returns attachment metadata", async function() { it("GET /docs/{did}/attachments/{id} returns attachment metadata", async function() {
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/2`, chimpy); const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/2`, chimpy);
assert.equal(resp.status, 200); assert.equal(resp.status, 200);
@ -1700,10 +1717,7 @@ function testDocApi() {
assert.deepEqual(resp.data, [1, 2, 3]); assert.deepEqual(resp.data, [1, 2, 3]);
async function checkAttachmentIds(ids: number[]) { async function checkAttachmentIds(ids: number[]) {
resp = await axios.get( resp = await axios.get(`${docUrl}/attachments`, chimpy);
`${docUrl}/tables/_grist_Attachments/records`,
chimpy,
);
assert.equal(resp.status, 200); assert.equal(resp.status, 200);
assert.deepEqual(resp.data.records.map((r: any) => r.id), ids); assert.deepEqual(resp.data.records.map((r: any) => r.id), ids);
} }