mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Improve API Console and link from Document Settings.
Summary: Changes to building and serving: - Remove unpkg dependencies, add npm module for swagger-ui-dist instead. - Move apiconsole JS logic into core/app/client/apiconsole.ts, and use TypeScript. - Add symlinks to swagger in static/ and core/static/. - Refactor loadScript, and add loadCssFile; use these to load swagger-ui resources. Changes to console itself: - Support docId, workspaceId, orgId URL parameters. When present, the matching value in dropdowns is moved to the front and marked as "(Current)". - Fix the ordering of example values, particularly for workspaces. - Remove unwanted example values. - Hide confusing "Authorize" button. - Hide API keys, and rely consistently on cookies for executing API calls. Integration into Grist: - Added a button to Document Settings, just under document ID in "API". - The button opens a separate page, passing in org, workspace, and doc info for the current doc. Test Plan: Only tested manually, no automated tests yet. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4173
This commit is contained in:
parent
be0b4a1968
commit
11afc08f65
323
app/client/apiconsole.ts
Normal file
323
app/client/apiconsole.ts
Normal file
@ -0,0 +1,323 @@
|
||||
import {loadCssFile, loadScript} from 'app/client/lib/loadScript';
|
||||
import type {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {setUpPage} from 'app/client/ui/setUpPage';
|
||||
import {DocAPIImpl} from 'app/common/UserAPI';
|
||||
import type {RecordWithStringId} from 'app/plugin/DocApiTypes';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import type SwaggerUI from 'swagger-ui';
|
||||
|
||||
/**
|
||||
* This loads the swagger resources as if included as separate <script> and <link> tags in <head>.
|
||||
*
|
||||
* Swagger suggests building via webpack (in which case, it would be included into our JS bundle),
|
||||
* but I couldn't get past webpack errors (also it's unclear if that would be any better).
|
||||
* We load dynamically only to avoid maintaining a separate html file ust for these tags.
|
||||
*/
|
||||
function loadExternal() {
|
||||
return Promise.all([loadScript('swagger-ui-bundle.js'), loadCssFile('swagger-ui.css')]);
|
||||
}
|
||||
|
||||
// Start loading scripts early (before waiting for AppModel to get initialized).
|
||||
const externalScriptsPromise = loadExternal();
|
||||
|
||||
let swaggerUI: SwaggerUI|null = null;
|
||||
|
||||
// Define a few types to allow for type-checking.
|
||||
|
||||
type ParamValue = string|number|null;
|
||||
|
||||
interface Example {
|
||||
value: ParamValue;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface JsonSpec {
|
||||
[propName: string]: any;
|
||||
}
|
||||
interface SpecActions {
|
||||
changeParamByIdentity(...args: unknown[]): unknown;
|
||||
updateJsonSpec(spec: JsonSpec): unknown;
|
||||
}
|
||||
|
||||
|
||||
function applySpecActions(cb: (specActions: SpecActions, jsonSpec: JsonSpec) => void) {
|
||||
// Don't call actions directly within `wrapActions`, react/redux doesn't like it.
|
||||
setTimeout(() => {
|
||||
const system = (swaggerUI as any).getSystem();
|
||||
const jsonSpec = system.getState().getIn(["spec", "json"]);
|
||||
cb(system.specActions, jsonSpec);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function updateSpec(cb: (spec: JsonSpec) => JsonSpec) {
|
||||
applySpecActions((specActions: SpecActions, jsonSpec: JsonSpec) => {
|
||||
// `jsonSpec` is a special immutable object with methods like `getIn/setIn`.
|
||||
// `updateJsonSpec` expects a plain JS object, so we need to convert it.
|
||||
specActions.updateJsonSpec(cb(jsonSpec).toJSON());
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = new URL(location.href).searchParams;
|
||||
|
||||
function setExamples(examplesArr: Example[], paramName: string) {
|
||||
examplesArr.sort((a, b) => String(a.summary || a.value).localeCompare(String(b.summary || b.value)));
|
||||
|
||||
const paramValue = searchParams.get(paramName);
|
||||
if (paramValue) {
|
||||
// If this value appears among examples, move it to the front and label it as "Current".
|
||||
const index = examplesArr.findIndex(v => (String(v.value) == String(paramValue)));
|
||||
if (index >= 0) {
|
||||
const ex = examplesArr.splice(index, 1)[0];
|
||||
ex.summary += " (Current)";
|
||||
examplesArr.unshift(ex);
|
||||
}
|
||||
} else {
|
||||
// When opening an endpoint, parameters with examples are immediately set to the first example.
|
||||
// For documents and tables, this would immediately call our custom code,
|
||||
// fetching lists of tables/columns. This is especially bad for documents,
|
||||
// as the document may have to be loaded from scratch in the doc worker.
|
||||
// So the dropdown has to start with an empty value in those cases.
|
||||
// You'd think this would run into the check for `!value` in `changeParamByIdentity`,
|
||||
// but apparently swagger has its own special handing for empty values before then.
|
||||
examplesArr.unshift({value: "", summary: "Select..."});
|
||||
}
|
||||
|
||||
// Swagger expects `examples` to be an object, not an array.
|
||||
// Prefix keys with something to ensure they aren't viewed as numbers: JS objects will iterate
|
||||
// them in insertion (what we want) order *unless* keys look numeric. SwaggerUI will use the
|
||||
// value from ex.value, so luckily this prefix doesn't actually matter.
|
||||
const examples = Object.fromEntries(examplesArr.map((ex) => ["#" + ex.value, ex]));
|
||||
updateSpec(spec => {
|
||||
return spec.setIn(["components", "parameters", `${paramName}PathParam`, "examples"], examples);
|
||||
});
|
||||
}
|
||||
|
||||
// Set the value of a parameter in all endpoints.
|
||||
function setParamValue(resolvedParam: any, value: ParamValue) {
|
||||
applySpecActions((specActions: SpecActions, spec: JsonSpec) => {
|
||||
// This will be something like:
|
||||
// "https://url-to-grist.yml#/components/parameters/orgIdPathParam"
|
||||
// Note that we're assuming that the endpoint always uses `$ref` to define the parameter,
|
||||
// rather than defining it inline.
|
||||
// https://github.com/gristlabs/grist-help/pull/293 ensures this,
|
||||
// but future changes to the spec must remember to do the same.
|
||||
const ref = resolvedParam.get("$$ref");
|
||||
|
||||
// For every endpoint in the spec...
|
||||
for (const [pathKey, path] of spec.get("paths").entries()) {
|
||||
for (const [method, operation] of path.entries()) {
|
||||
|
||||
const parameters = operation.get("parameters");
|
||||
if (!parameters) { continue; }
|
||||
for (const param of parameters.values()) {
|
||||
// If this is the same parameter...
|
||||
if (ref.endsWith(param.get("$ref"))) {
|
||||
// Set the value. The final `true` is `noWrap` to prevent infinite recursion.
|
||||
specActions.changeParamByIdentity([pathKey, method], resolvedParam, value, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class ExtendedDocAPIImpl extends DocAPIImpl {
|
||||
public listTables(): Promise<{tables: RecordWithStringId[]}> {
|
||||
return this.requestJson(`${this.getBaseUrl()}/tables`);
|
||||
}
|
||||
public listColumns(tableId: string, includeHidden = false): Promise<{columns: RecordWithStringId[]}> {
|
||||
return this.requestJson(`${this.getBaseUrl()}/tables/${tableId}/columns?hidden=${includeHidden ? 1 : 0}`);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapChangeParamByIdentity(appModel: AppModel, system: any, oriAction: any, ...args: any[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [keyPath, param, value, _isXml, noWrap] = args;
|
||||
if (noWrap || !value) {
|
||||
// `noWrap` is our own flag to avoid infinite recursion.
|
||||
// It's set when calling this action inside `setParamValue` below.
|
||||
// `value` is falsy when choosing our default "Select..." option from a dropdown.
|
||||
return oriAction(...args);
|
||||
}
|
||||
|
||||
const paramName = param.get("name");
|
||||
|
||||
// These are the path parameters that we handle specially and provide examples for.
|
||||
// When a value is selected in one endpoint, set the same value in all other endpoints.
|
||||
// This makes a bit more convenient to do multiple different operations on the same object.
|
||||
// But maybe it'll cause confusion/mistakes when operating on different objects?
|
||||
if (["orgId", "workspaceId", "docId", "tableId", "colId"].includes(paramName)) {
|
||||
setParamValue(param, value);
|
||||
}
|
||||
|
||||
// When a docId is selected, fetch the list of that doc's tables and set examples for tableId.
|
||||
// This is a significant convenience, but it causes some UI jankiness.
|
||||
// Updating the spec with these examples takes some CPU and the UI freezes for a moment.
|
||||
// Then things jump around a bit as stuff is re-rendered, although it ends up in the right place
|
||||
// so it shouldn't be too disruptive.
|
||||
// All this happens after a short delay while the tables are being fetched.
|
||||
// It *might* be possible to set these example values more efficiently/lazily but I'm not sure,
|
||||
// and it'll probably significantly more difficult.
|
||||
const baseUrl = appModel.api.getBaseUrl();
|
||||
if (paramName === "docId") {
|
||||
const docAPI = new ExtendedDocAPIImpl(baseUrl, value);
|
||||
docAPI.listTables().then(({tables}: {tables: RecordWithStringId[]}) => {
|
||||
const examples: Example[] = tables.map(table => ({value: table.id, summary: table.id}));
|
||||
setExamples(examples, "tableId");
|
||||
})
|
||||
.catch(reportError);
|
||||
}
|
||||
|
||||
// When a tableId is selected, fetch the list of columns and set examples for colId.
|
||||
// This causes similar UI jankiness as above, but I think less severely since fewer endpoints
|
||||
// have a colId parameter. In fact, there's currently only one: `DELETE /columns`.
|
||||
// We *could* only do this when setting tableId within that endpoint,
|
||||
// but then the dropdown will be missing if you set the tableId elsewhere and then open this endpoint.
|
||||
// Alternatively, `GET /tables` could be modified to return column metadata for each table.
|
||||
if (paramName === "tableId") {
|
||||
// When getting tables after setting docId, `value` is the docId so we have all the info.
|
||||
// Here `value` is the tableId and we need to get the docId separately.
|
||||
const parameters = system.getState().getIn(["spec", "meta", "paths", ...keyPath, "parameters"]);
|
||||
const docId = parameters.find((_value: any, key: any) => key.startsWith("path.docId"))?.get("value");
|
||||
if (docId) {
|
||||
const docAPI = new ExtendedDocAPIImpl(baseUrl, docId);
|
||||
// Second argument of `true` includes hidden columns like gristHelper_Display and manualSort.
|
||||
docAPI.listColumns(value, true)
|
||||
.then(({columns}: {columns: RecordWithStringId[]}) => {
|
||||
const examples = columns.map(col => ({value: col.id, summary: col.fields.label as string}));
|
||||
setExamples(examples, "colId");
|
||||
})
|
||||
.catch(reportError);
|
||||
}
|
||||
}
|
||||
return oriAction(...args);
|
||||
}
|
||||
|
||||
function gristPlugin(appModel: AppModel, system: any) {
|
||||
return {
|
||||
statePlugins: {
|
||||
spec: {
|
||||
wrapActions: {
|
||||
// Customize what happens when a parameter is changed, e.g. selected from a dropdown.
|
||||
changeParamByIdentity: (oriAction: any) => (...args: any[]) =>
|
||||
wrapChangeParamByIdentity(appModel, system, oriAction, ...args),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initialize(appModel: AppModel) {
|
||||
// These are used to set the examples for orgs, workspaces, and docs.
|
||||
const orgsPromise = appModel.api.getOrgs();
|
||||
|
||||
// We make a request for each org - hopefully there aren't too many.
|
||||
// Currently I only see rate limiting in DocApi, which shouldn't be a problem here.
|
||||
// Fortunately we don't need a request for each workspace,
|
||||
// since listing workspaces in an org also lists the docs in each workspace.
|
||||
const workspacesPromise = orgsPromise.then(orgs => Promise.all(orgs.map(org =>
|
||||
appModel.api.getOrgWorkspaces(org.id, false).then(workspaces => ({org, workspaces}))
|
||||
)));
|
||||
|
||||
// To be called after the spec is downloaded and parsed.
|
||||
function onComplete() {
|
||||
// Add an instruction for where to get API key.
|
||||
const description = document.querySelector('.information-container .info');
|
||||
if (description) {
|
||||
const href = urlState().makeUrl({account: 'account'});
|
||||
dom.update(description, dom('div', 'Find or create your API key at ', dom('a', {href}, href), '.'));
|
||||
}
|
||||
|
||||
updateSpec(spec => {
|
||||
// The actual spec sets the server to `https://{subdomain}.getgrist.com/api`,
|
||||
// where {subdomain} is a variable that defaults to `docs`.
|
||||
// We want to use the same server as the page is loaded from.
|
||||
// This simplifies the UI and makes it work e.g. on localhost.
|
||||
spec = spec.set("servers", [{url: window.origin + "/api"}]);
|
||||
|
||||
// Some table-specific parameters have examples with fake data in grist.yml. We don't want
|
||||
// to actually use this for running requests, so clear those out.
|
||||
for (const paramName of [
|
||||
'filterQueryParam', 'sortQueryParam', 'sortHeaderParam',
|
||||
'limitQueryParam', 'limitHeaderParam'
|
||||
]) {
|
||||
spec = spec.removeIn(["components", "parameters", paramName, "example"]);
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
|
||||
// Show that we need a key, but let's not display it. The user may or may not have the API key
|
||||
// set. Actual requests from the console use cookies, so can work anyway. When the key is set,
|
||||
// showing it in cleartext makes it riskier to ask for help with screenshots and the like.
|
||||
// We set a fake key anyway to be clear that it's needed in the curl command.
|
||||
const key = 'XXXXXXXXXXX';
|
||||
swaggerUI!.preauthorizeApiKey('ApiKey', key);
|
||||
|
||||
// Set examples for orgs, workspaces, and docs.
|
||||
orgsPromise.then(orgs => {
|
||||
const examples: Example[] = orgs.map(org => ({
|
||||
value: org.domain,
|
||||
summary: org.name,
|
||||
}));
|
||||
setExamples(examples, "orgId");
|
||||
}).catch(reportError);
|
||||
|
||||
workspacesPromise.then(orgs => {
|
||||
const workSpaceExamples: Example[] = orgs.flatMap(({org, workspaces}) => workspaces.map(ws => ({
|
||||
value: ws.id,
|
||||
summary: `${org.name} » ${ws.name}`
|
||||
})));
|
||||
setExamples(workSpaceExamples, "workspaceId");
|
||||
|
||||
const docExamples = orgs.flatMap(({org, workspaces}) => workspaces.flatMap(ws => ws.docs.map(doc => ({
|
||||
value: doc.id,
|
||||
summary: `${org.name} » ${ws.name} » ${doc.name}`
|
||||
}))));
|
||||
setExamples(docExamples, "docId");
|
||||
}).catch(reportError);
|
||||
}
|
||||
return onComplete;
|
||||
}
|
||||
|
||||
function requestInterceptor(request: SwaggerUI.Request) {
|
||||
delete request.headers.Authorization;
|
||||
return request;
|
||||
}
|
||||
|
||||
setUpPage((appModel) => {
|
||||
// Default Grist page prevents scrolling unnecessarily.
|
||||
document.documentElement.style.overflow = 'initial';
|
||||
|
||||
const rootNode = cssWrapper();
|
||||
const onComplete = initialize(appModel);
|
||||
|
||||
externalScriptsPromise.then(() => {
|
||||
const buildSwaggerUI: typeof SwaggerUI = (window as any).SwaggerUIBundle;
|
||||
swaggerUI = buildSwaggerUI({
|
||||
filter: true,
|
||||
plugins: [gristPlugin.bind(null, appModel)],
|
||||
url: 'https://raw.githubusercontent.com/gristlabs/grist-help/master/api/grist.yml',
|
||||
domNode: rootNode,
|
||||
showMutatedRequest: false,
|
||||
requestInterceptor,
|
||||
onComplete,
|
||||
});
|
||||
})
|
||||
.catch(reportError);
|
||||
|
||||
return rootNode;
|
||||
});
|
||||
|
||||
const cssWrapper = styled('div', `
|
||||
& .scheme-container {
|
||||
display: none;
|
||||
}
|
||||
& .information-container h1 { /* Authorization header, strangely enough */
|
||||
display: none;
|
||||
}
|
||||
& .information-container .info {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`);
|
@ -45,15 +45,8 @@ $(function() {
|
||||
window.exposeModulesForTests = function() {
|
||||
return (import('./exposeModulesForTests' /* webpackChunkName: "modulesForTests" */));
|
||||
};
|
||||
window.exposedModules = {
|
||||
// Several existing tests use window.exposedModules.loadScript has loaded
|
||||
// a file for them. We now load exposedModules asynchronously, so that it
|
||||
// doesn't slow down application startup. To avoid changing tests
|
||||
// unnecessarily, we implement a loadScript wrapper.
|
||||
loadScript(name) {
|
||||
return window.exposeModulesForTests()
|
||||
.then(() => window.exposedModules._loadScript(name));
|
||||
}
|
||||
};
|
||||
|
||||
window.exposedModules = {};
|
||||
// Make it easy for tests to use loadScript() whether or not exposedModules has already loaded.
|
||||
window.loadScript = (name) =>
|
||||
window.exposeModulesForTests().then(() => window.exposedModules.loadScript.loadScript(name));
|
||||
});
|
||||
|
@ -7,6 +7,6 @@ Object.assign(window.exposedModules, {
|
||||
ko: require('knockout'),
|
||||
moment: require('moment-timezone'),
|
||||
Comm: require('app/client/components/Comm'),
|
||||
_loadScript: require('./lib/loadScript'),
|
||||
loadScript: require('./lib/loadScript'),
|
||||
ConnectState: require('./models/ConnectState'),
|
||||
});
|
||||
|
@ -1,19 +0,0 @@
|
||||
const Promise = require('bluebird');
|
||||
const G = require('./browserGlobals').get('document');
|
||||
|
||||
/**
|
||||
* Load dynamically an external JS script from the given URL. Returns a promise that is
|
||||
* resolved when the script is loaded.
|
||||
*/
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = G.document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
script.src = url;
|
||||
G.document.getElementsByTagName("head")[0].appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = loadScript;
|
23
app/client/lib/loadScript.ts
Normal file
23
app/client/lib/loadScript.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
/**
|
||||
* Load dynamically an external JS script from the given URL. Returns a promise that is
|
||||
* resolved when the script is loaded.
|
||||
*/
|
||||
export function loadScript(url: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = dom("script", {type: "text/javascript", src: url, crossorigin: "anonymous"});
|
||||
document.head.appendChild(Object.assign(script, {onload: resolve, onerror: reject}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dynamically an external CSS file from the given URL. Returns a promise that is
|
||||
* resolved when the file is loaded.
|
||||
*/
|
||||
export function loadCssFile(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = dom("link", {rel: "stylesheet", href: url});
|
||||
document.head.appendChild(Object.assign(link, {onload: resolve, onerror: reject}));
|
||||
});
|
||||
}
|
@ -8,6 +8,7 @@ import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
||||
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import type {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {docListHeader} from 'app/client/ui/DocMenuCss';
|
||||
@ -85,6 +86,10 @@ export class DocSettingsPage extends Disposable {
|
||||
await copyToClipboard(docPageModel.currentDocId.get()!);
|
||||
}),
|
||||
)),
|
||||
cssDataRow(primaryButtonLink(t('API Console'), {
|
||||
target: '_blank',
|
||||
href: getApiConsoleLink(docPageModel),
|
||||
})),
|
||||
cssHeader(t('Webhooks'), cssBeta('Beta')),
|
||||
cssDataRow(primaryButtonLink(t('Manage Webhooks'), urlState().setLinkUrl({docPage: 'webhook'}))),
|
||||
);
|
||||
@ -103,6 +108,16 @@ export class DocSettingsPage extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
function getApiConsoleLink(docPageModel: DocPageModel) {
|
||||
const url = new URL(location.href);
|
||||
url.pathname = '/apiconsole';
|
||||
url.searchParams.set('docId', docPageModel.currentDocId.get()!);
|
||||
// Some extra question marks to placate a test fixture at test/fixtures/projects/DocumentSettings.ts
|
||||
url.searchParams.set('workspaceId', String(docPageModel.currentWorkspace?.get()?.id || ''));
|
||||
url.searchParams.set('orgId', String(docPageModel.appModel?.topAppModel.currentSubdomain.get()));
|
||||
return url.href;
|
||||
}
|
||||
|
||||
type LocaleItem = ACSelectItem & {locale?: string};
|
||||
|
||||
function buildLocaleSelect(
|
||||
|
@ -100,17 +100,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
testId('settings'),
|
||||
),
|
||||
cssSpacer(),
|
||||
// TODO make this look nice, then make it visible when the console is ready.
|
||||
// For now let's keep it private, so this shouldn't be uncommented.
|
||||
// cssPageEntry(
|
||||
// cssPageLink(
|
||||
// cssPageIcon('Code'),
|
||||
// cssPageIcon('FieldLink'),
|
||||
// cssLinkText(t("API Console")),
|
||||
// {href: window.origin + '/apiconsole', target: '_blank'}
|
||||
// ),
|
||||
// testId('api'),
|
||||
// ),
|
||||
dom.maybe(docPageModel.currentDoc, (doc) => {
|
||||
const ex = buildExamples().find(e => e.urlId === doc.urlId);
|
||||
if (!ex || !ex.tutorialUrl) { return null; }
|
||||
|
@ -352,7 +352,7 @@ export interface UserAPI {
|
||||
getOrgs(merged?: boolean): Promise<Organization[]>;
|
||||
getWorkspace(workspaceId: number): Promise<Workspace>;
|
||||
getOrg(orgId: number|string): Promise<Organization>;
|
||||
getOrgWorkspaces(orgId: number|string): Promise<Workspace[]>;
|
||||
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
|
||||
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
|
||||
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
|
||||
getDoc(docId: string): Promise<Document>;
|
||||
@ -540,8 +540,8 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
||||
return this.requestJson(`${this._url}/api/orgs/${orgId}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
public async getOrgWorkspaces(orgId: number|string): Promise<Workspace[]> {
|
||||
return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces?includeSupport=1`,
|
||||
public async getOrgWorkspaces(orgId: number|string, includeSupport = true): Promise<Workspace[]> {
|
||||
return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces?includeSupport=${includeSupport ? 1 : 0}`,
|
||||
{ method: 'GET' });
|
||||
}
|
||||
|
||||
@ -907,6 +907,8 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
||||
this._url = `${url}/api/docs/${docId}`;
|
||||
}
|
||||
|
||||
public getBaseUrl(): string { return this._url; }
|
||||
|
||||
public async getRows(tableId: string, options?: GetRowsParams): Promise<TableColValues> {
|
||||
return this._getRecords(tableId, 'data', options);
|
||||
}
|
||||
|
@ -45,7 +45,6 @@ import {
|
||||
getTableId,
|
||||
isSchemaAction,
|
||||
TableDataAction,
|
||||
TableRecordValue,
|
||||
toTableDataAction,
|
||||
UserAction
|
||||
} from 'app/common/DocActions';
|
||||
@ -81,6 +80,7 @@ import {guessColInfo} from 'app/common/ValueGuesser';
|
||||
import {parseUserAction} from 'app/common/ValueParser';
|
||||
import {Document} from 'app/gen-server/entity/Document';
|
||||
import {Share} from 'app/gen-server/entity/Share';
|
||||
import {RecordWithStringId} from 'app/plugin/DocApiTypes';
|
||||
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
|
||||
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
|
||||
import {compileAclFormula} from 'app/server/lib/ACLFormula';
|
||||
@ -1115,7 +1115,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
public async getTableCols(
|
||||
docSession: OptDocSession,
|
||||
tableId: string,
|
||||
includeHidden = false): Promise<TableRecordValue[]> {
|
||||
includeHidden = false): Promise<RecordWithStringId[]> {
|
||||
const metaTables = await this.fetchMetaTables(docSession);
|
||||
const tableRef = tableIdToRef(metaTables, tableId);
|
||||
const [, , colRefs, columnData] = metaTables._grist_Tables_column;
|
||||
@ -1123,7 +1123,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
// colId is pulled out of fields and used as the root id
|
||||
const fieldNames = without(Object.keys(columnData), "colId");
|
||||
|
||||
const columns: TableRecordValue[] = [];
|
||||
const columns: RecordWithStringId[] = [];
|
||||
(columnData.colId as string[]).forEach((id, index) => {
|
||||
const hasNoId = !id;
|
||||
const isHidden = hasNoId || id === "manualSort" || id.startsWith("gristHelper_");
|
||||
@ -1132,7 +1132,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
const column: TableRecordValue = { id, fields: { colRef: colRefs[index] } };
|
||||
const column: RecordWithStringId = { id, fields: { colRef: colRefs[index] } };
|
||||
for (const key of fieldNames) {
|
||||
column.fields[key] = columnData[key][index];
|
||||
}
|
||||
|
@ -458,8 +458,8 @@ export class DocWorkerApi {
|
||||
this._app.get('/api/docs/:docId/tables', canView,
|
||||
withDoc(async (activeDoc, req, res) => {
|
||||
const records = await getTableRecords(activeDoc, req, { optTableId: "_grist_Tables" });
|
||||
const tables = records.map((record) => ({
|
||||
id: record.fields.tableId,
|
||||
const tables: Types.RecordWithStringId[] = records.map((record) => ({
|
||||
id: String(record.fields.tableId),
|
||||
fields: {
|
||||
..._.omit(record.fields, "tableId"),
|
||||
tableRef: record.id,
|
||||
|
@ -198,8 +198,12 @@ export class TableMetadataLoader {
|
||||
for (const tableId of [...newPushes].sort()) {
|
||||
// Put a limit on the number of outstanding pushes permitted.
|
||||
if (this._pushes.size >= this._pushed.size + 3) { break; }
|
||||
this._pushes.set(tableId, this._counted(this.opPush(tableId)));
|
||||
}
|
||||
const promise = this._counted(this.opPush(tableId));
|
||||
this._pushes.set(tableId, promise);
|
||||
// Mark the promise as handled to avoid "unhandledRejection", but without affecting other
|
||||
// code (which will still see `promise`, not the new promise returned by `.catch()`).
|
||||
promise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to keep track of pending promises.
|
||||
|
@ -13,6 +13,7 @@ module.exports = {
|
||||
entry: {
|
||||
main: "app/client/app",
|
||||
errorPages: "app/client/errorMain",
|
||||
apiconsole: "app/client/apiconsole",
|
||||
billing: "app/client/billingMain",
|
||||
// Include client test harness if it is present (it won't be in
|
||||
// docker image).
|
||||
|
@ -78,6 +78,7 @@
|
||||
"@types/selenium-webdriver": "4.1.15",
|
||||
"@types/sinon": "5.0.5",
|
||||
"@types/sqlite3": "3.1.6",
|
||||
"@types/swagger-ui": "3.52.4",
|
||||
"@types/tmp": "0.0.33",
|
||||
"@types/uuid": "3.4.4",
|
||||
"@types/which": "2.0.1",
|
||||
@ -183,6 +184,7 @@
|
||||
"saml2-js": "2.0.5",
|
||||
"short-uuid": "3.1.1",
|
||||
"slugify": "1.6.6",
|
||||
"swagger-ui-dist": "5.11.0",
|
||||
"tmp": "0.0.33",
|
||||
"ts-interface-checker": "1.0.2",
|
||||
"typeorm": "0.3.9",
|
||||
|
@ -1,220 +1,18 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="description" content="SwaggerUI"/>
|
||||
<title>Grist API Console</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.1.0/swagger-ui.css"/>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT META -->
|
||||
<!-- INSERT BASE -->
|
||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
||||
<link rel="stylesheet" href="icons/icons.css">
|
||||
<!-- INSERT LOCALE -->
|
||||
<!-- INSERT CONFIG -->
|
||||
<!-- INSERT CUSTOM -->
|
||||
<title>API Console<!-- INSERT TITLE SUFFIX --></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"/>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.1.0/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script>
|
||||
// Start by initiating various fetches early, we'll use the promise results later.
|
||||
// The API calls are authorized by cookies.
|
||||
// We only fetch the API key to pass to `preauthorizeApiKey` which makes it show
|
||||
// in the example `curl` commands (which isn't unambiguously good, since
|
||||
// it makes screen-sharing more risky).
|
||||
const apiKey = fetch('/api/profile/apiKey').then(r => r.text());
|
||||
|
||||
// These are used to set the examples for orgs, workspaces, and docs.
|
||||
const orgsPromise = fetch('/api/orgs').then(r => r.json());
|
||||
// We make a request for each org - hopefully there aren't too many.
|
||||
// Currently I only see rate limiting in DocApi, which shouldn't be a problem here.
|
||||
// Fortunately we don't need a request for each workspace,
|
||||
// since listing workspaces in an org also lists the docs in each workspace.
|
||||
const workspacesPromise = orgsPromise.then(orgs => Promise.all(orgs.map(org =>
|
||||
fetch(`/api/orgs/${org.id}/workspaces`).then(r => r.json()).then(workspaces => ({org, workspaces}))
|
||||
)));
|
||||
|
||||
function GristPlugin(system) {
|
||||
return {
|
||||
statePlugins: {
|
||||
spec: {
|
||||
wrapActions: {
|
||||
// Customize what happens when a parameter is changed, e.g. selected from a dropdown.
|
||||
changeParamByIdentity: (oriAction) => (...args) => {
|
||||
const [keyPath, param, value, _isXml, noWrap] = args;
|
||||
if (noWrap || !value) {
|
||||
// `noWrap` is our own flag to avoid infinite recursion.
|
||||
// It's set when calling this action inside `setParamValue` below.
|
||||
// `value` is falsy when choosing our default "Select..." option from a dropdown.
|
||||
return oriAction(...args);
|
||||
}
|
||||
|
||||
// These are the path parameters that we handle specially and provide examples for.
|
||||
// When a value is selected in one endpoint, set the same value in all other endpoints.
|
||||
// This makes a bit more convenient to do multiple different operations on the same object.
|
||||
// But maybe it'll cause confusion/mistakes when operating on different objects?
|
||||
if (["orgId", "workspaceId", "docId", "tableId", "colId"].includes(param.get("name"))) {
|
||||
setParamValue(param, value);
|
||||
}
|
||||
|
||||
// When a docId is selected, fetch the list of that doc's tables and set examples for tableId.
|
||||
// This is a significant convenience, but it causes some UI jankiness.
|
||||
// Updating the spec with these examples takes some CPU and the UI freezes for a moment.
|
||||
// Then things jump around a bit as stuff is re-rendered, although it ends up in the right place
|
||||
// so it shouldn't be too disruptive.
|
||||
// All this happens after a short delay while the tables are being fetched.
|
||||
// It *might* be possible to set these example values more efficiently/lazily but I'm not sure,
|
||||
// and it'll probably significantly more difficult.
|
||||
if (param.get("name") === "docId") {
|
||||
fetch(`/api/docs/${value}/tables`).then(r => r.json()).then(({tables}) => {
|
||||
const examples = tables.map(table => ({value: table.id}));
|
||||
setExamples(examples, "tableId", true);
|
||||
});
|
||||
}
|
||||
|
||||
// When a tableId is selected, fetch the list of columns and set examples for colId.
|
||||
// This causes similar UI jankiness as above, but I think less severely since fewer endpoints
|
||||
// have a colId parameter. In fact, there's currently only one: `DELETE /columns`.
|
||||
// We *could* only do this when setting tableId within that endpoint,
|
||||
// but then the dropdown will be missing if you set the tableId elsewhere and then open this endpoint.
|
||||
// Alternatively, `GET /tables` could be modified to return column metadata for each table.
|
||||
if (param.get("name") === "tableId") {
|
||||
// When getting tables after setting docId, `value` is the docId so we have all the info.
|
||||
// Here `value` is the tableId and we need to get the docId separately.
|
||||
const parameters = system.getState().getIn(["spec", "meta", "paths", ...keyPath, "parameters"]);
|
||||
const docId = parameters.find((_value, key) => key.startsWith("path.docId"))?.get("value");
|
||||
if (docId) {
|
||||
// `?hidden=1` includes hidden columns like gristHelper_Display and manualSort.
|
||||
fetch(`/api/docs/${docId}/tables/${value}/columns?hidden=1`).then(r => r.json()).then(({columns}) => {
|
||||
const examples = columns.map(col => ({value: col.id, summary: col.fields.label}));
|
||||
setExamples(examples, "colId");
|
||||
});
|
||||
}
|
||||
}
|
||||
return oriAction(...args);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySpecActions(cb) {
|
||||
// Don't call actions directly within `wrapActions`, react/redux doesn't like it.
|
||||
setTimeout(() => {
|
||||
const system = window.ui.getSystem();
|
||||
const jsonSpec = system.getState().getIn(["spec", "json"]);
|
||||
cb(system.specActions, jsonSpec);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function updateSpec(cb) {
|
||||
applySpecActions((specActions, jsonSpec) => {
|
||||
// `jsonSpec` is a special immutable object with methods like `getIn/setIn`.
|
||||
// `updateJsonSpec` expects a plain JS object, so we need to convert it.
|
||||
specActions.updateJsonSpec(cb(jsonSpec).toJSON());
|
||||
});
|
||||
}
|
||||
|
||||
function setExamples(examplesArr, paramName, startBlank) {
|
||||
if (startBlank) {
|
||||
// When opening an endpoint, parameters with examples are immediately set to the first example.
|
||||
// For documents and tables, this would immediately call our custom code,
|
||||
// fetching lists of tables/columns. This is especially bad for documents,
|
||||
// as the document may have to be loaded from scratch in the doc worker.
|
||||
// So the dropdown has to start with an empty value in those cases.
|
||||
// You'd think this would run into the check for `!value` in `changeParamByIdentity`,
|
||||
// but apparently swagger has its own special handing for empty values before then.
|
||||
//
|
||||
// Somehow, when using this for workspace examples, this blank option becomes the last option in the dropdown.
|
||||
// That looks silly, so `startBlank` is only set for parameters that need it as mentioned above.
|
||||
examplesArr = [
|
||||
{value: "", summary: "Select..."},
|
||||
...examplesArr.sort((a, b) => (a.summary || a.value).localeCompare(b.summary || b.value))
|
||||
];
|
||||
}
|
||||
|
||||
// Swagger expects `examples` to be an object, not an array.
|
||||
const examples = Object.fromEntries(examplesArr.map((ex) => [ex.value, ex]));
|
||||
updateSpec(spec => {
|
||||
return spec.setIn(["components", "parameters", `${paramName}PathParam`, "examples"], examples);
|
||||
});
|
||||
}
|
||||
|
||||
// Set the value of a parameter in all endpoints.
|
||||
function setParamValue(resolvedParam, value) {
|
||||
applySpecActions((specActions, spec) => {
|
||||
// This will be something like:
|
||||
// "https://url-to-grist.yml#/components/parameters/orgIdPathParam"
|
||||
// Note that we're assuming that the endpoint always uses `$ref` to define the parameter,
|
||||
// rather than defining it inline.
|
||||
// https://github.com/gristlabs/grist-help/pull/293 ensures this,
|
||||
// but future changes to the spec must remember to do the same.
|
||||
const ref = resolvedParam.get("$$ref");
|
||||
|
||||
// For every endpoint in the spec...
|
||||
for (const [pathKey, path] of spec.get("paths").entries()) {
|
||||
for (const [method, operation] of path.entries()) {
|
||||
|
||||
const parameters = operation.get("parameters");
|
||||
if (!parameters) continue;
|
||||
for (const param of parameters.values()) {
|
||||
// If this is the same parameter...
|
||||
if (ref.endsWith(param.get("$ref"))) {
|
||||
// Set the value. The final `true` is `noWrap` to prevent infinite recursion.
|
||||
specActions.changeParamByIdentity([pathKey, method], resolvedParam, value, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Called after the spec is downloaded and parsed.
|
||||
function onComplete() {
|
||||
|
||||
// The actual spec sets the server to `https://{subdomain}.getgrist.com/api`,
|
||||
// where {subdomain} is a variable that defaults to `docs`.
|
||||
// We want to use the same server as the page is loaded from.
|
||||
// This simplifies the UI and makes it work e.g. on localhost.
|
||||
updateSpec(spec => spec.set("servers", [{url: window.origin + "/api"}]));
|
||||
|
||||
// See the comment where `apiKey` is defined.
|
||||
apiKey.then(key => window.ui.preauthorizeApiKey('ApiKey', key));
|
||||
|
||||
// Set examples for orgs, workspaces, and docs.
|
||||
orgsPromise.then(orgs => {
|
||||
const examples = orgs.map(org => ({
|
||||
value: org.domain,
|
||||
summary: org.name,
|
||||
}));
|
||||
setExamples(examples, "orgId");
|
||||
});
|
||||
|
||||
workspacesPromise.then(orgs => {
|
||||
const workSpaceExamples = orgs.flatMap(({org, workspaces}) => workspaces.map(ws => ({
|
||||
value: ws.id,
|
||||
summary: `${org.name} » ${ws.name}`
|
||||
})));
|
||||
setExamples(workSpaceExamples, "workspaceId");
|
||||
|
||||
const docExamples = orgs.flatMap(({org, workspaces}) => workspaces.flatMap(ws => ws.docs.map(doc => ({
|
||||
value: doc.id,
|
||||
summary: `${org.name} » ${ws.name} » ${doc.name}`
|
||||
}))));
|
||||
setExamples(docExamples, "docId", true);
|
||||
})
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
plugins: [
|
||||
GristPlugin,
|
||||
],
|
||||
url: 'https://raw.githubusercontent.com/gristlabs/grist-help/master/api/grist.yml',
|
||||
dom_id: '#root',
|
||||
onComplete,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<!-- INSERT WARNING -->
|
||||
<script crossorigin="anonymous" src="apiconsole.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
1
static/swagger-ui-bundle.js
Symbolic link
1
static/swagger-ui-bundle.js
Symbolic link
@ -0,0 +1 @@
|
||||
../node_modules/swagger-ui-dist/swagger-ui-bundle.js
|
1
static/swagger-ui.css
Symbolic link
1
static/swagger-ui.css
Symbolic link
@ -0,0 +1 @@
|
||||
../node_modules/swagger-ui-dist/swagger-ui.css
|
10
yarn.lock
10
yarn.lock
@ -944,6 +944,11 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/swagger-ui@3.52.4":
|
||||
version "3.52.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/swagger-ui/-/swagger-ui-3.52.4.tgz#96c4886e8f86ae392f8d940bf7029cf490a51c72"
|
||||
integrity sha512-7NV7q8BfupqdQxr26OkM0g0YEPB9uXnKGzXadgcearvI9MoCHt3F72lPTX3fZZIlrr21DC0IK26wcDMZ37oFDA==
|
||||
|
||||
"@types/tmp@0.0.33":
|
||||
version "0.0.33"
|
||||
resolved "https://registry.npmjs.org/@types/tmp/-/tmp-0.0.33.tgz"
|
||||
@ -7435,6 +7440,11 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
swagger-ui-dist@5.11.0:
|
||||
version "5.11.0"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.11.0.tgz#9bcfd75278b1fa9c36fe52f206f8fc611470547c"
|
||||
integrity sha512-j0PIATqQSEFGOLmiJOJZj1X1Jt6bFIur3JpY7+ghliUnfZs0fpWDdHEkn9q7QUlBtKbkn6TepvSxTqnE8l3s0A==
|
||||
|
||||
symbol-tree@^3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz"
|
||||
|
Loading…
Reference in New Issue
Block a user