mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	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
		
			
				
	
	
		
			221 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			221 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
<!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>
 |