(core) Relocate export urls to /download/

Summary:
Moves CSV and XLSX export urls under /download/, and
removes the document title query parameter which is now
retrieved from the backend.

Test Plan: No new tests. Existing tests that verify endpoints still function.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3010
This commit is contained in:
George Gevoian 2021-09-01 14:07:53 -07:00
parent cecebded1f
commit 0717ee627e
9 changed files with 153 additions and 101 deletions

View File

@ -46,7 +46,7 @@ import {isSchemaAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {HashLink, IDocPage} from 'app/common/gristUrls'; import {HashLink, IDocPage} from 'app/common/gristUrls';
import {RecalcWhen} from 'app/common/gristTypes'; import {RecalcWhen} from 'app/common/gristTypes';
import {encodeQueryParams, undef, waitObs} from 'app/common/gutil'; import {undef, waitObs} from 'app/common/gutil';
import {LocalPlugin} from "app/common/plugin"; import {LocalPlugin} from "app/common/plugin";
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData'; import {TableData} from 'app/common/TableData';
@ -625,29 +625,20 @@ export class GristDoc extends DisposableWithEvents {
); );
} }
public getXlsxLink() {
const baseUrl = this.docPageModel.appModel.api.getDocAPI(this.docId()).getGenerateXlsxUrl();
const params = {
title: this.docPageModel.currentDocTitle.get(),
};
return baseUrl + '?' + encodeQueryParams(params);
}
public getCsvLink() { public getCsvLink() {
const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({ const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({
colRef : field.colRef.peek(), colRef : field.colRef.peek(),
filter : field.activeFilter.peek() filter : field.activeFilter.peek()
})); }));
const baseUrl = this.docPageModel.appModel.api.getDocAPI(this.docId()).getGenerateCsvUrl();
const params = { const params = {
title: this.docPageModel.currentDocTitle.get(),
viewSection: this.viewModel.activeSectionId(), viewSection: this.viewModel.activeSectionId(),
tableId: this.viewModel.activeSection().table().tableId(), tableId: this.viewModel.activeSection().table().tableId(),
activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()), activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()),
filters : JSON.stringify(filters), filters : JSON.stringify(filters),
}; };
return baseUrl + '?' + encodeQueryParams(params);
return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadCsvUrl(params);
} }
public hasGranularAccessRules(): boolean { public hasGranularAccessRules(): boolean {

View File

@ -223,8 +223,10 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
), ),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
menuIcon('Download'), 'Export CSV', testId('tb-share-option')), menuIcon('Download'), 'Export CSV', testId('tb-share-option')),
menuItemLink({ href: gristDoc.getXlsxLink(), target: '_blank', download: ''}, menuItemLink({
menuIcon('Download'), 'Export XLSX', testId('tb-share-option')), href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
target: '_blank', download: ''
}, menuIcon('Download'), 'Export XLSX', testId('tb-share-option')),
menuItem(() => sendToDrive(doc, pageModel), menuItem(() => sendToDrive(doc, pageModel),
menuIcon('Download'), 'Send to Google Drive', testId('tb-share-option')), menuIcon('Download'), 'Send to Google Drive', testId('tb-share-option')),
]; ];

View File

@ -11,6 +11,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {addCurrentOrgToPath} from 'app/common/urlUtils';
import {encodeQueryParams} from 'app/common/gutil';
export {FullUser} from 'app/common/LoginSessionAPI'; export {FullUser} from 'app/common/LoginSessionAPI';
@ -320,6 +321,16 @@ export interface UserAPI {
forRemoved(): UserAPI; // Get a version of the API that works on removed resources. forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
} }
/**
* Parameters for the download CSV endpoint (/download/csv).
*/
export interface DownloadCsvParams {
tableId: string;
viewSection?: number;
activeSortSpec?: string;
filters?: string;
}
/** /**
* Collect endpoints related to the content of a single document that we've been thinking * Collect endpoints related to the content of a single document that we've been thinking
* of as the (restful) "Doc API". A few endpoints that could be here are not, for historical * of as the (restful) "Doc API". A few endpoints that could be here are not, for historical
@ -347,8 +358,8 @@ export interface DocAPI {
// is HEAD, the result will contain a copy of any rows added or updated. // is HEAD, the result will contain a copy of any rows added or updated.
compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>; compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>;
getDownloadUrl(template?: boolean): string; getDownloadUrl(template?: boolean): string;
getGenerateXlsxUrl(): string; getDownloadXlsxUrl(): string;
getGenerateCsvUrl(): string; getDownloadCsvUrl(params: DownloadCsvParams): string;
/** /**
* Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first * Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first
* acquire "code" via Google Auth Endpoint (see ShareMenu.ts for an example). * acquire "code" via Google Auth Endpoint (see ShareMenu.ts for an example).
@ -792,12 +803,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this._url + `/download?template=${Number(template)}`; return this._url + `/download?template=${Number(template)}`;
} }
public getGenerateXlsxUrl() { public getDownloadXlsxUrl() {
return this._url + '/gen-xlsx'; return this._url + '/download/xlsx';
} }
public getGenerateCsvUrl() { public getDownloadCsvUrl(params: DownloadCsvParams) {
return this._url + '/gen-csv'; // We spread `params` to work around TypeScript being overly cautious.
return this._url + '/download/csv?' + encodeQueryParams({...params});
} }
public async sendToDrive(code: string, title: string): Promise<{url: string}> { public async sendToDrive(code: string, title: string): Promise<{url: string}> {

View File

@ -42,8 +42,6 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/remove', withDoc); app.use('/api/docs/:docId/remove', withDoc);
app.delete('/api/docs/:docId', withDoc); app.delete('/api/docs/:docId', withDoc);
app.use('/api/docs/:docId/download', withDoc); app.use('/api/docs/:docId/download', withDoc);
app.use('/api/docs/:docId/gen-csv', withDoc);
app.use('/api/docs/:docId/gen-xlsx', withDoc);
app.use('/api/docs/:docId/send-to-drive', withDoc); app.use('/api/docs/:docId/send-to-drive', withDoc);
app.use('/api/docs/:docId/fork', withDoc); app.use('/api/docs/:docId/fork', withDoc);
app.use('/api/docs/:docId/create-fork', withDoc); app.use('/api/docs/:docId/create-fork', withDoc);

View File

@ -33,7 +33,9 @@ import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth";
import * as _ from "lodash"; import * as _ from "lodash";
import {isRaisedException} from "app/common/gristTypes"; import {isRaisedException} from "app/common/gristTypes";
import {localeFromRequest} from "app/server/lib/ServerLocale"; import {localeFromRequest} from "app/server/lib/ServerLocale";
import { generateCSV, generateXLSX } from "app/server/serverMethods"; import { downloadCSV, DownloadCSVOptions } from "app/server/lib/ExportCSV";
import { downloadXLSX, DownloadXLSXOptions } from "app/server/lib/ExportXLSX";
import { parseExportParameters } from "app/server/lib/Export";
// Cap on the number of requests that can be outstanding on a single document via the // 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 // rest doc api. When this limit is exceeded, incoming requests receive an immediate
@ -566,9 +568,31 @@ export class DocWorkerApi {
res.json(result); res.json(result);
})); }));
this._app.get('/api/docs/:docId/gen-csv', canView, withDoc(generateCSV)); this._app.get('/api/docs/:docId/download/csv', canView, withDoc(async (activeDoc, req, res) => {
// Query DB for doc metadata to get the doc title.
const {name: docTitle} =
await this._dbManager.getDoc({userId: getUserId(req), org: req.org, urlId: getDocId(req)});
this._app.get('/api/docs/:docId/gen-xlsx', canView, withDoc(generateXLSX)); const params = parseExportParameters(req);
const filename = docTitle + (params.tableId === docTitle ? '' : '-' + params.tableId);
const options: DownloadCSVOptions = {
...params,
filename,
};
await downloadCSV(activeDoc, req, res, options);
}));
this._app.get('/api/docs/:docId/download/xlsx', canView, withDoc(async (activeDoc, req, res) => {
// Query DB for doc metadata to get the doc title (to use as the filename).
const {name: filename} =
await this._dbManager.getDoc({userId: getUserId(req), org: req.org, urlId: getDocId(req)});
const options: DownloadXLSXOptions = {filename};
await downloadXLSX(activeDoc, req, res, options);
}));
this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive)); this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive));

View File

@ -11,7 +11,7 @@ import {DocumentSettings} from 'app/common/DocumentSettings';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithLogin} from 'app/server/lib/Authorizer';
import {docSessionFromRequest} from 'app/server/lib/DocSession'; import {docSessionFromRequest} from 'app/server/lib/DocSession';
import { integerParam, optJsonParam, stringParam} from 'app/server/lib/requestUtils'; import {optIntegerParam, optJsonParam, stringParam} from 'app/server/lib/requestUtils';
import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters'; import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters';
import * as express from 'express'; import * as express from 'express';
import * as _ from 'underscore'; import * as _ from 'underscore';
@ -72,7 +72,7 @@ export interface ExportData {
*/ */
export interface ExportParameters { export interface ExportParameters {
tableId: string; tableId: string;
viewSectionId: number; viewSectionId: number | undefined;
sortOrder: number[]; sortOrder: number[];
filters: Filter[]; filters: Filter[];
} }
@ -82,7 +82,7 @@ export interface ExportParameters {
*/ */
export function parseExportParameters(req: express.Request): ExportParameters { export function parseExportParameters(req: express.Request): ExportParameters {
const tableId = stringParam(req.query.tableId); const tableId = stringParam(req.query.tableId);
const viewSectionId = integerParam(req.query.viewSection); const viewSectionId = optIntegerParam(req.query.viewSection);
const sortOrder = optJsonParam(req.query.activeSortSpec, []) as number[]; const sortOrder = optJsonParam(req.query.activeSortSpec, []) as number[];
const filters: Filter[] = optJsonParam(req.query.filters, []); const filters: Filter[] = optJsonParam(req.query.filters, []);
@ -94,20 +94,6 @@ export function parseExportParameters(req: express.Request): ExportParameters {
}; };
} }
/**
* Calculates the file name (without an extension) for exported table.
* @param activeDoc ActiveDoc
* @param req Request (with export params)
*/
export function parseExportFileName(activeDoc: ActiveDoc, req: express.Request) {
const title = req.query.title;
const tableId = req.query.tableId;
const docName = title || activeDoc.docName;
const name = docName +
(tableId === docName ? '' : '-' + tableId);
return name;
}
// Makes assertion that value does exists or throws an error // Makes assertion that value does exists or throws an error
function safe<T>(value: T, msg: string) { function safe<T>(value: T, msg: string) {
if (!value) { throw new Error(msg); } if (!value) { throw new Error(msg); }

View File

@ -1,16 +1,54 @@
import {createFormatter} from 'app/common/ValueFormatter'; import {createFormatter} from 'app/common/ValueFormatter';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {ExportData, exportSection, Filter} from 'app/server/lib/Export'; import {ExportData, exportSection, exportTable, Filter} from 'app/server/lib/Export';
import * as bluebird from 'bluebird'; import * as bluebird from 'bluebird';
import * as csv from 'csv'; import * as csv from 'csv';
import * as express from 'express'; import * as express from 'express';
import * as log from 'app/server/lib/log';
import * as contentDisposition from 'content-disposition';
export interface DownloadCSVOptions {
filename: string;
tableId: string;
viewSectionId: number | undefined;
filters: Filter[];
sortOrder: number[];
}
// promisify csv // promisify csv
bluebird.promisifyAll(csv); bluebird.promisifyAll(csv);
/** /**
* Returns a csv stream that can be transformed or parsed. See https://github.com/wdavidw/node-csv * Converts `activeDoc` to a CSV and sends the converted data through `res`.
* for API details. */
export async function downloadCSV(activeDoc: ActiveDoc, req: express.Request,
res: express.Response, options: DownloadCSVOptions) {
log.info('Generating .csv file...');
const {filename, tableId, viewSectionId, filters, sortOrder} = options;
try {
const data = viewSectionId ?
await makeCSVFromViewSection(activeDoc, viewSectionId, sortOrder, filters, req) :
await makeCSVFromTable(activeDoc, tableId, req);
res.set('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', contentDisposition(filename + '.csv'));
res.send(data);
} catch (err) {
log.error("Exporting to CSV has failed. Request url: %s", req.url, err);
const errHtml =
`<!doctype html>
<html>
<body>There was an unexpected error while generating a csv file.</body>
</html>
`;
res.status(400).send(errHtml);
}
}
/**
* Returns a csv stream of a view section that can be transformed or parsed.
*
* See https://github.com/wdavidw/node-csv for API details.
* *
* @param {Object} activeDoc - the activeDoc that the table being converted belongs to. * @param {Object} activeDoc - the activeDoc that the table being converted belongs to.
* @param {Integer} viewSectionId - id of the viewsection to export. * @param {Integer} viewSectionId - id of the viewsection to export.
@ -18,7 +56,7 @@ bluebird.promisifyAll(csv);
* @param {Filter[]} filters (optional) - filters defined from ui. * @param {Filter[]} filters (optional) - filters defined from ui.
* @return {Promise<string>} Promise for the resulting CSV. * @return {Promise<string>} Promise for the resulting CSV.
*/ */
export async function makeCSV( export async function makeCSVFromViewSection(
activeDoc: ActiveDoc, activeDoc: ActiveDoc,
viewSectionId: number, viewSectionId: number,
sortOrder: number[], sortOrder: number[],
@ -30,6 +68,31 @@ export async function makeCSV(
return file; return file;
} }
/**
* Returns a csv stream of a table that can be transformed or parsed.
*
* @param {Object} activeDoc - the activeDoc that the table being converted belongs to.
* @param {Integer} tableId - id of the table to export.
* @return {Promise<string>} Promise for the resulting CSV.
*/
export async function makeCSVFromTable(
activeDoc: ActiveDoc,
tableId: string,
req: express.Request) {
if (!activeDoc.docData) {
throw new Error('No docData in active document');
}
// Look up the table to make a CSV from.
const tables = activeDoc.docData.getTable('_grist_Tables')!;
const tableRef = tables.findRow('tableId', tableId);
const data = await exportTable(activeDoc, tableRef, req);
const file = convertToCsv(data);
return file;
}
function convertToCsv({ function convertToCsv({
rowIds, rowIds,
access, access,

View File

@ -3,6 +3,37 @@ import {createExcelFormatter} from 'app/server/lib/ExcelFormatter';
import {ExportData, exportDoc} from 'app/server/lib/Export'; import {ExportData, exportDoc} from 'app/server/lib/Export';
import {Alignment, Border, Fill, Workbook} from 'exceljs'; import {Alignment, Border, Fill, Workbook} from 'exceljs';
import * as express from 'express'; import * as express from 'express';
import * as log from 'app/server/lib/log';
import * as contentDisposition from 'content-disposition';
export interface DownloadXLSXOptions {
filename: string;
}
/**
* Converts `activeDoc` to CSV and sends the converted data through `res`.
*/
export async function downloadXLSX(activeDoc: ActiveDoc, req: express.Request,
res: express.Response, {filename}: DownloadXLSXOptions) {
log.debug(`Generating .xlsx file`);
try {
const data = await makeXLSX(activeDoc, req);
res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', contentDisposition(filename + '.xlsx'));
res.send(data);
log.debug('XLSX file generated');
} catch (err) {
log.error("Exporting to XLSX has failed. Request url: %s", req.url, err);
// send a generic information to client
const errHtml =
`<!doctype html>
<html>
<body>There was an unexpected error while generating a xlsx file.</body>
</html>
`;
res.status(400).send(errHtml);
}
}
/** /**
* Creates excel document with all tables from an active Grist document. * Creates excel document with all tables from an active Grist document.

View File

@ -1,55 +0,0 @@
import {parseExportFileName, parseExportParameters} from 'app/server/lib/Export';
import {makeCSV} from 'app/server/lib/ExportCSV';
import {makeXLSX} from 'app/server/lib/ExportXLSX';
import * as log from 'app/server/lib/log';
import * as contentDisposition from 'content-disposition';
import * as express from 'express';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
export async function generateCSV(activeDoc: ActiveDoc, req: express.Request, res: express.Response) {
log.info('Generating .csv file...');
const {
viewSectionId,
filters,
sortOrder
} = parseExportParameters(req);
// Generate a decent name for the exported file.
const name = parseExportFileName(activeDoc, req);
try {
const data = await makeCSV(activeDoc, viewSectionId, sortOrder, filters, req);
res.set('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', contentDisposition(name + '.csv'));
res.send(data);
} catch (err) {
log.error("Exporting to CSV has failed. Request url: %s", req.url, err);
const errHtml =
`<!doctype html>
<html>
<body>There was an unexpected error while generating a csv file.</body>
</html>
`;
res.status(400).send(errHtml);
}
}
export async function generateXLSX(activeDoc: ActiveDoc, req: express.Request, res: express.Response) {
log.debug(`Generating .xlsx file`);
try {
const data = await makeXLSX(activeDoc, req);
res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', contentDisposition((req.query.title || activeDoc.docName) + '.xlsx'));
res.send(data);
log.debug('XLSX file generated');
} catch (err) {
log.error("Exporting to XLSX has failed. Request url: %s", req.url, err);
// send a generic information to client
const errHtml =
`<!doctype html>
<html>
<body>There was an unexpected error while generating a xlsx file.</body>
</html>
`;
res.status(400).send(errHtml);
}
}