mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) API console
Summary: Adds a custom interactive Swagger API console at `/apiconsole`. For now, this isn't visibly linked anywhere. Test Plan: Manual, this is still an experimental and private feature. The idea is to merge this soon so that we have a chance to try it out in production. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4151
This commit is contained in:
parent
a5544b9b01
commit
a2bd753649
@ -100,6 +100,17 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
|||||||
testId('settings'),
|
testId('settings'),
|
||||||
),
|
),
|
||||||
cssSpacer(),
|
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) => {
|
dom.maybe(docPageModel.currentDoc, (doc) => {
|
||||||
const ex = buildExamples().find(e => e.urlId === doc.urlId);
|
const ex = buildExamples().find(e => e.urlId === doc.urlId);
|
||||||
if (!ex || !ex.tutorialUrl) { return null; }
|
if (!ex || !ex.tutorialUrl) { return null; }
|
||||||
|
@ -44,6 +44,9 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) =>
|
app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) =>
|
||||||
sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'})));
|
sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'})));
|
||||||
|
|
||||||
|
app.get('/apiconsole', expressWrap(async (req, res) =>
|
||||||
|
sendAppPage(req, res, {path: 'apiconsole.html', status: 200, config: {}})));
|
||||||
|
|
||||||
app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => {
|
app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => {
|
||||||
if (!useWorkerPool()) {
|
if (!useWorkerPool()) {
|
||||||
// Let the client know there is not a separate pool of workers,
|
// Let the client know there is not a separate pool of workers,
|
||||||
|
220
static/apiconsole.html
Normal file
220
static/apiconsole.html
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<!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>
|
||||||
|
</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>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user