Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

[SIP-4] add lerna monorepo and@superset-ui/core package with SupersetClient #1

Merged
merged 11 commits into from
Sep 8, 2018

Conversation

williaster
Copy link
Contributor

This PR

  1. sets up the superset-ui monorepo (i.e., a repository of multiple packages) with lerna and
  2. adds the first package @superset-core, which implements SupersetClient as described in SIP-4. SupersetClient is pulled from step 2 this PR, which was abandoned for smaller steps, but supports all the client-side async functionality encompassed by the current Superset app.

Below are some more details about the monorepo + @superset-ui/core package, see the README (link soon) for API/usage details for the SupersetClient.

Monorepo overview

lerna is used to manage versions and dependencies between
packages in this monorepo.

superset-ui/
  lerna.json
  package.json
  ...
  packages/
    package1/
      package.json
      ...
      src/
      test/
      ...
      lib/
      esm/
      ...
    ...

To develop:

  1. clone this repo
  2. install the root npm modules including lerna and yarn
  3. have lerna install package dependencies and manage the symlinking between packages for you
git clone ...superset-ui && cd superset-ui
npm install
lerna bootstrap

After this you can run scripts across the entire monorep (e.g., yarn run test runs tests across all repos) or within a given package. The latter means that each package defines its own build config, linting, and testing. I initially set it up to have shared things like jest, prettier, and linting but I think this will be more painful down the line. I think we can achieve consistency with yeoman package generators which @kristw and I will do in a future PR.

@superset-ui/core

After this PR is merged, we can publish the first version of this package.

@data-ui/build-config is used to manage all babel, jest testing, eslint, prettier. This type of "shareable" build config will save a lot of copy/paste as the # of packages grows.

Testing

We should have good coverage for client-side code from the start 🔥 In a future PR we'll set up code coverage but here are the stats for what I wrote 🚀

=== Coverage summary ===                                                                                                                
Statements   : 98.82% ( 84/85 )
Branches     : 90% ( 81/90 )
Functions    : 100% ( 34/34 )
Lines        : 98.78% ( 81/82 )
=====================

@kristw @conglei @mistercrunch @graceguo-supercat @john-bodley @hughhhh @betodealmeida @michellethomas

.gitignore Outdated
@@ -0,0 +1,30 @@
# Logs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I would just enumurate these in a single alphabetical list as it’s easier to process and maintain.

@@ -0,0 +1,9 @@
💔 Breaking Changes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be added to the .github folder?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe! I'll give it a shot

timeout: this.timeout,
url: this.getUrl({ endpoint: 'superset/csrf_token/', host: this.host }),
})
.then(response => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps use .then(onFulfilled, onRejected)
instead of .then(onFulfilled).catch(onRejected)

.catch(onRejected) is equivalent to.then(undefined, onRejected)

The current catch clause will handle Promise.reject thrown from the then clause as well, which has the same result in this case, but is redundant work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, I think the .catch is a little more readable but don't have a super strong opinion.

}

getCSRFToken() {
this.requestingCsrf = true;
Copy link
Contributor

@kristw kristw Sep 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having boolean flags this.requestingCsrf, we can store the promise returned from callApi() in this.requestingCsrf. The initial value of this.requestingCsrf can be Promise.reject('not initialized').

This may remove the need for this.waitForCSRF and polling/setTimeout mechanism as this.ensureAuth can return this.requestingCsrf; directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a great idea, I'll try to move toward that 👍

return Promise.race([
new Promise((resolve, reject) => {
if (typeof timeout === 'number') {
timeoutId = setTimeout(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps create another function called createTimer.

function createTimer(timeout) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(...), timeout);
  });
}

To avoid worrying about clearing timeout or passing around timeoutId, can break the callApi function into two parts

function callApiWithoutParsing() {
  // the first half of original callApi()
  // handle params and return fetch();
}

function parseResponse(apiPromise) {
  return apiPromise.then( .... ).then( .... ); // do the parsing
}

function callApi() {
  return parseResponse(callApiWithoutParsing());
}

If timeout is not a number, there is no need to race.

function callApiWithTimeout() {
  const apiPromise = callApiWithoutParsing(...);
  // This will race until results come back from server
  // excluding parsing time
  const racedPromise = (typeof timeout === 'number') 
    ? Promise.race([createTimer(timeout), apiPromise])
    : apiPromise;
  return parseResponse(racedPromise);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, my only concern here is what happens to the timeouts without clearing them (when say a dashboard makes 15 requests for chart data). I couldn't find much info online about this, assume they're garbage collected.

});
}

return Promise.resolve(typeof text === 'undefined' ? { json, response } : { response, text });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this resolve is extraneous, will remove. the rejection above is useful to forward the error.

@@ -0,0 +1,497 @@
/* eslint promise/no-callback-in-promise: 'off' */
import sinon from 'sinon';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just learned more about jest spys, should be able to move this

SupersetClient.get({}).then(...).catch(...);

if (IWantToCancelForSomeReason) {
signal.abort(); // Promise is rejected, request `catch` is invoked
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it cancel every outgoing request? Is there a way to cancel just one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops I forgot an important part of this usage (I'll update), you pass the signal to the request, so it's per-request aborting.

see here for an example in superset.

const apiPromise = callApi(rest);

const racedPromise =
typeof timeout === 'number'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&& timeout > 0

return typeof text === 'undefined' ? { json, response } : { response, text };
}),
// forward the error
error => Promise.reject(error),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is probably not necessary as error propagate through already

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linting has complained is some places that we need a catch for every then, but it doesn't here 🤷‍♀️


const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
const mockErrorPayload = { status: 500, statusText: 'Internal errorz!' };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errorz. typo?

@@ -0,0 +1,156 @@
/* eslint promise/no-callback-in-promise: 'off' */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome for having tests for all of these.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

learned a lot about jest!

}

get({ host, url, endpoint, mode, credentials, headers, body, timeout, signal }) {
return this.ensureAuth().then(() =>
Copy link
Contributor

@kristw kristw Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about attaching an abort function to the returned promise from get and post?

const promise = this.ensureAuth().then(...);
promise.abort = signal && () => signal.abort();
return promise;

So can use like this.

const myPromise = SupersetClient.get(...);
if(wannaCancel && myPromise.abort) {
  myPromise.abort();
}

The concern is the function abort() will only be available to the original Promise returned from get/post and not on the chained then(), which may be not obvious to developer.

Another thing is instead of creating a signal from outside and passing it in as an argument, can we construct the signal in get/post or callApi so all calls get abort functionality by default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, that's an interesting thought, the abort controller api is a little verbose. I do think it would be non-ideal to have the abort just attached to the initial promise.

maybe we can see how it goes with signal and we could develop a nicer / more complex api around aborting in the future? there were only a couple of places in Superset that use this currently.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in practice you either have to pass the abort controller around, or the promise, and so I'm not sure there's much advantage of one over the other except that the former is full-fledged API spec not just our library API.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm. We can try that later. I think this first version looks good to me.

Copy link
Contributor

@kristw kristw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall lgtm. Left one question for the getCSRFToken() but not blocking.

}

get({ host, url, endpoint, mode, credentials, headers, body, timeout, signal }) {
return this.ensureAuth().then(() =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm. We can try that later. I think this first version looks good to me.

import rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import throwIfCalled from '../utils/throwIfCalled';

describe('rejectAfterTimeout', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit
Add () in describe('rejectAfterTimeout()') so we know it is a function when reading test result

import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';

describe('parseResponse', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit
Add () in describe('parseResponse()') so we know it is a function when reading test result

import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';

describe('callApiAndParseWithTimeout', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit
Add () in describe('callApiAndParseWithTimeout()') so we know it is a function when reading test result

import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';

describe('callApi', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit
Add () in describe('callApi()') so we know it is a function when reading test result

return Promise.reject({ error: 'Failed to fetch CSRF token' });
}

return response;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this return response or this.csrfToken?
Nobody is really using its result at this point, but seems like this.csrfToken will be more expected?

If somebody called this multiple times, it will send new request every time. Is that expected behavior? or if a promise already exists, then return this.csrfPromise so it will send request only once.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants