Merge pull request #1 from apache-superset/chris--core-package

[SIP-4] add lerna monorepo and`@superset-ui/core` package with `SupersetClient`
This commit is contained in:
Chris Williams 2018-09-07 18:36:47 -07:00 committed by Yongjie Zhao
parent 01bcbefac6
commit c4b946f965
22 changed files with 1390 additions and 0 deletions

View File

@ -0,0 +1,9 @@
💔 Breaking Changes
🏆 Enhancements
📜 Documentation
🐛 Bug Fix
🏠 Internal

View File

@ -0,0 +1,21 @@
.babelrc
.eslintcache
.eslintignore
.eslintrc.js
.idea
.npm
.prettierignore
.yarnclean
*.log
*.map
*.min.js
build/
coverage/
esm/
jest.config.js
lib/
logs/
node_modules/
prettier.config.js

View File

@ -0,0 +1,61 @@
# `@superset-ui`
Collection of packages that power the Apache Superset UI, and can be used to craft custom data
applications that leverage a Superset backend :chart_with_upwards_trend:
## Packages
[@superset-ui/core](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-core)
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)
#### Coming :soon:
- Data providers
- Embeddable charts
- Chart collections
- Demo storybook package
### Development
[lerna](https://github.com/lerna/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/
...
...
```
For easiest development
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
```sh
git clone ...superset-ui && cd superset-ui
npm install
lerna bootstrap
```
### Builds, linting, and testing
Each package defines its own build config, linting, and testing. You can have lerna run commands
across all packages using the syntax `lerna exec test` from the root `@superset/monorepo` root
directory.
### License
Apache-2.0

View File

@ -0,0 +1,5 @@
{
"lerna": "3.2.1",
"packages": ["packages/*"],
"version": "0.0.0"
}

View File

@ -0,0 +1,39 @@
{
"name": "@superset-ui/monorepo",
"version": "0.0.0",
"description": "Superset UI",
"private": true,
"scripts": {
"build": "lerna run build",
"jest": "lerna run test",
"lint": "lerna run lint",
"prerelease": "yarn run build",
"prepare-release": "git checkout master && git pull --rebase origin master && yarn run test",
"release": "yarn run prepare-release && lerna publish && lerna run gh-pages",
"test": "lerna bootstrap && yarn run lint && yarn run jest"
},
"repository": "https://github.com/apache-superset/superset-ui.git",
"keywords": [
"apache",
"superset",
"data",
"analytics",
"analysis",
"visualization",
"react",
"d3",
"data-ui",
"vx"
],
"license": "Apache-2.0",
"devDependencies": {
"lerna": "^3.2.1",
"yarn": "^1.9.4"
},
"engines": {
"node": ">=8.10.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,90 @@
## `@superset-ui/core`
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)
Core modules for Superset:
- `SupersetClient` requests and authentication
- (future) `i18n` locales and translation
### SupersetClient
The `SupersetClient` handles all client-side requests to the Superset backend. It can be configured
for use within the Superset application, or used to issue `CORS` requests in other applications. At
a high-level it supports:
- `CSRF` token authentication
- queues requests in the case that another request is made before the token is received
- it checks for a token before every request, an external app that uses this can detect this by
catching errors, or explicitly checking `SupersetClient.isAuthorized()`
- supports `GET` and `POST` requests (no `PUT` or `DELETE`)
- timeouts
- query aborts through the `AbortController` API
#### Example usage
```javascript
// appSetup.js
import { SupersetClient } from `@superset-ui/core`;
// or import SupersetClient from `@superset-ui/core/lib|esm/SupersetClient`;
SupersetClient.configure(...clientConfig);
SupersetClient.init(); // CSRF auth, can also chain `.configure().init();
// anotherFile.js
import { SupersetClient } from `@superset-ui/core`;
SupersetClient.post(...requestConfig)
.then(({ request, json }) => ...)
.catch((error) => ...);
```
#### API
##### Client Configuration
The following flags can be passed in the client config call
`SupersetClient.configure(...clientConfig);`
- `protocol = 'http'`
- `host`
- `headers`
- `credentials = 'same-origin'` (set to `include` for non-Superset apps)
- `mode = 'same-origin'` (set to `cors` for non-Superset apps)
- `timeout`
##### Per-request Configuration
The following flags can be passed on a per-request call `SupersetClient.get/post(...requestConfig);`
- `url` or `endpoint`
- `headers`
- `body`
- `timeout`
- `signal` (for aborting, from `const { signal } = (new AbortController())`)
- for `POST` requests
- `postPayload` (key values are added to a `new FormData()`)
- `stringify` whether to call `JSON.stringify` on `postPayload` values
##### Request aborting
Per-request aborting is implemented through the `AbortController` API:
```javascript
import { SupersetClient } from '@superset-ui/core';
import AbortController from 'abortcontroller-polyfill';
const controller = new AbortController();
const { signal } = controller;
SupersetClient.get({ ..., signal }).then(...).catch(...);
if (IWantToCancelForSomeReason) {
signal.abort(); // Promise is rejected, request `catch` is invoked
}
```
### Development
`@data-ui/build-config` is used to manage the build configuration for this package including babel
builds, jest testing, eslint, and prettier.

View File

@ -0,0 +1,73 @@
{
"name": "@superset-ui/core",
"version": "0.0.0",
"description": "Superset UI core 🤖",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"scripts": {
"build:cjs": "beemo babel ./src --out-dir lib/ --minify",
"build:esm": "beemo babel ./src --out-dir esm/ --esm --minify",
"build": "yarn run build:cjs && yarn run build:esm",
"dev": "beemo babel --watch ./src --out-dir esm/ --esm",
"jest": "beemo jest --color --coverage",
"eslint": "beemo eslint \"./{src,test}/**/*.{js,jsx,json,md}\"",
"lint": "yarn run prettier --write && yarn run eslint --fix",
"test": "yarn run jest",
"prettier": "beemo prettier \"./{src,test}/**/*.{js,jsx,json,md}\"",
"sync:gitignore": "beemo sync-dotfiles --filter=gitignore",
"prepublish": "yarn run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui.git"
},
"keywords": [
"superset",
"client",
"core",
"data"
],
"author": "",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui#readme",
"devDependencies": {
"@data-ui/build-config": "0.0.10",
"fetch-mock": "^6.5.2"
},
"dependencies": {
"url-search-params-polyfill": "^4.0.1",
"whatwg-fetch": "^2.0.4"
},
"beemo": {
"module": "@data-ui/build-config",
"drivers": [
"babel",
"eslint",
{
"driver": "jest",
"env": {
"NODE_ENV": "test"
}
},
"prettier"
],
"eslint": {
"rules": {
"prefer-promise-reject-errors": "off"
}
},
"jest": {
"testPathIgnorePatterns": [
"node_modules"
]
}
}
}

View File

@ -0,0 +1,149 @@
import callApi from './callApi';
class SupersetClient {
constructor(config) {
const {
protocol = 'http',
host = '',
headers = {},
mode = 'same-origin',
timeout,
credentials,
} = config;
this.headers = headers;
this.host = host;
this.mode = mode;
this.timeout = timeout;
this.protocol = protocol;
this.credentials = credentials;
this.csrfToken = null;
this.didAuthSuccessfully = false;
this.csrfPromise = null;
}
isAuthenticated() {
return this.didAuthSuccessfully;
}
init() {
return this.getCSRFToken();
}
getCSRFToken() {
// If we can request this resource successfully, it means that the user has
// authenticated. If not we throw an error prompting to authenticate.
this.csrfPromise = callApi({
credentials: this.credentials,
headers: {
...this.headers,
},
method: 'GET',
mode: this.mode,
timeout: this.timeout,
url: this.getUrl({ endpoint: 'superset/csrf_token/', host: this.host }),
}).then(response => {
if (response.json) {
this.csrfToken = response.json.csrf_token;
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
this.didAuthSuccessfully = !!this.csrfToken;
}
if (!this.csrfToken) {
return Promise.reject({ error: 'Failed to fetch CSRF token' });
}
return response;
});
return this.csrfPromise;
}
getUrl({ host = '', endpoint = '' }) {
const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash
return `${this.protocol}://${cleanHost}/${endpoint[0] === '/' ? endpoint.slice(1) : endpoint}`;
}
ensureAuth() {
return (
this.csrfPromise ||
Promise.reject({
error: `SupersetClient has no CSRF token, ensure it is initialized or
try logging into the Superset instance at ${this.getUrl('/login')}`,
})
);
}
get({ host, url, endpoint, mode, credentials, headers, body, timeout, signal }) {
return this.ensureAuth().then(() =>
callApi({
body,
credentials: credentials || this.credentials,
headers: { ...this.headers, ...headers },
method: 'GET',
mode: mode || this.mode,
signal,
timeout: timeout || this.timeout,
url: url || this.getUrl({ endpoint, host: host || this.host }),
}),
);
}
post({
host,
endpoint,
url,
mode,
credentials,
headers,
postPayload,
timeout,
signal,
stringify,
}) {
return this.ensureAuth().then(() =>
callApi({
credentials: credentials || this.credentials,
headers: { ...this.headers, ...headers },
method: 'POST',
mode: mode || this.mode,
postPayload,
signal,
stringify,
timeout: timeout || this.timeout,
url: url || this.getUrl({ endpoint, host: host || this.host }),
}),
);
}
}
let singletonClient;
function hasInstance() {
if (!singletonClient) {
throw new Error('You must call SupersetClient.configure(...) before calling other methods');
}
return true;
}
const PublicAPI = {
configure: config => {
singletonClient = new SupersetClient(config || {});
return singletonClient;
},
get: (...args) => hasInstance() && singletonClient.get(...args),
init: () => hasInstance() && singletonClient.init(),
isAuthenticated: () => hasInstance() && singletonClient.isAuthenticated(),
post: (...args) => hasInstance() && singletonClient.post(...args),
reAuthenticate: () => hasInstance() && singletonClient.getCSRFToken(),
reset: () => {
singletonClient = null;
},
};
export { SupersetClient };
export default PublicAPI;

View File

@ -0,0 +1,50 @@
import 'whatwg-fetch';
import 'url-search-params-polyfill';
const DEFAULT_HEADERS = null;
// This function fetches an API response and returns the corresponding json
export default function callApi({
url,
method = 'GET', // GET, POST, PUT, DELETE
mode = 'same-origin', // no-cors, cors, same-origin
cache = 'default', // default, no-cache, reload, force-cache, only-if-cached
credentials = 'same-origin', // include, same-origin, omit
headers: partialHeaders,
body,
postPayload,
stringify = true,
redirect = 'follow', // manual, follow, error
timeoutId,
signal, // used for aborting
}) {
let request = {
body,
cache,
credentials,
headers: { ...DEFAULT_HEADERS, ...partialHeaders },
method,
mode,
redirect,
signal,
};
if (method === 'POST' && typeof postPayload === 'object') {
// using FormData has the effect that Content-Type header is set to `multipart/form-data`,
// not e.g., 'application/x-www-form-urlencoded'
const formData = new FormData();
Object.keys(postPayload).forEach(key => {
const value = postPayload[key];
if (typeof value !== 'undefined') {
formData.append(key, stringify ? JSON.stringify(postPayload[key]) : postPayload[key]);
}
});
request = {
...request,
body: formData,
};
}
return fetch(url, request); // eslint-disable-line compat/compat
}

View File

@ -0,0 +1,14 @@
import callApi from './callApi';
import rejectAfterTimeout from './rejectAfterTimeout';
import parseResponse from './parseResponse';
export default function callApiAndParseWithTimeout({ timeout, ...rest }) {
const apiPromise = callApi(rest);
const racedPromise =
typeof timeout === 'number' && timeout > 0
? Promise.race([rejectAfterTimeout(timeout), apiPromise])
: apiPromise;
return parseResponse(racedPromise);
}

View File

@ -0,0 +1 @@
export { default } from './callApiAndParseWithTimeout';

View File

@ -0,0 +1,26 @@
export default function parseResponse(apiPromise) {
return apiPromise.then(apiResponse =>
// first try to parse as json, and fall back to text (e.g., in the case of HTML stacktrace)
// cannot fall back to .text() without cloning the response because body is single-use
apiResponse
.clone()
.json()
.catch(() => /* jsonParseError */ apiResponse.text().then(textPayload => ({ textPayload })))
.then(maybeJson => ({
json: maybeJson.textPayload ? undefined : maybeJson,
response: apiResponse,
text: maybeJson.textPayload,
}))
.then(({ response, json, text }) => {
if (!response.ok) {
return Promise.reject({
error: response.error || (json && json.error) || text || 'An error occurred',
status: response.status,
statusText: response.statusText,
});
}
return typeof text === 'undefined' ? { json, response } : { response, text };
}),
);
}

View File

@ -0,0 +1,13 @@
// returns a Promise that rejects after the specified timeout
export default function rejectAfterTimeout(timeout) {
return new Promise((resolve, reject) => {
setTimeout(
() =>
reject({
error: 'Request timed out',
statusText: 'timeout',
}),
timeout,
);
});
}

View File

@ -0,0 +1,2 @@
export { default as callApi } from './callApi';
export { default as SupersetClient } from './SupersetClient';

View File

@ -0,0 +1,471 @@
/* eslint promise/no-callback-in-promise: 'off' */
import fetchMock from 'fetch-mock';
import PublicAPI, { SupersetClient } from '../src/SupersetClient';
import throwIfCalled from './utils/throwIfCalled';
import { LOGIN_GLOB } from './fixtures/constants';
describe('SupersetClient', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' });
});
afterAll(fetchMock.restore);
afterEach(PublicAPI.reset);
describe('Public API', () => {
it('exposes reset, configure, init, get, post, isAuthenticated, and reAuthenticate methods', () => {
expect(PublicAPI.configure).toEqual(expect.any(Function));
expect(PublicAPI.init).toEqual(expect.any(Function));
expect(PublicAPI.get).toEqual(expect.any(Function));
expect(PublicAPI.post).toEqual(expect.any(Function));
expect(PublicAPI.isAuthenticated).toEqual(expect.any(Function));
expect(PublicAPI.reAuthenticate).toEqual(expect.any(Function));
expect(PublicAPI.reset).toEqual(expect.any(Function));
});
it('throws if you call init, get, post, isAuthenticated, or reAuthenticate before configure', () => {
expect(PublicAPI.init).toThrow();
expect(PublicAPI.get).toThrow();
expect(PublicAPI.post).toThrow();
expect(PublicAPI.isAuthenticated).toThrow();
expect(PublicAPI.reAuthenticate).toThrow();
expect(PublicAPI.configure).not.toThrow();
});
// this also tests that the ^above doesn't throw if configure is called appropriately
it('calls appropriate SupersetClient methods when configured', () => {
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
const initSpy = jest.spyOn(SupersetClient.prototype, 'init');
const getSpy = jest.spyOn(SupersetClient.prototype, 'get');
const postSpy = jest.spyOn(SupersetClient.prototype, 'post');
const authenticatedSpy = jest.spyOn(SupersetClient.prototype, 'isAuthenticated');
const csrfSpy = jest.spyOn(SupersetClient.prototype, 'getCSRFToken');
PublicAPI.configure({});
PublicAPI.init();
expect(csrfSpy).toHaveBeenCalledTimes(1);
PublicAPI.get({ url: mockGetUrl });
PublicAPI.post({ url: mockPostUrl });
PublicAPI.isAuthenticated();
PublicAPI.reAuthenticate({});
expect(initSpy).toHaveBeenCalledTimes(1);
expect(getSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledTimes(1);
expect(authenticatedSpy).toHaveBeenCalledTimes(1);
expect(csrfSpy).toHaveBeenCalledTimes(2); // from init() + reAuthenticate()
initSpy.mockRestore();
getSpy.mockRestore();
postSpy.mockRestore();
authenticatedSpy.mockRestore();
csrfSpy.mockRestore();
fetchMock.reset();
});
});
describe('SupersetClient', () => {
describe('CSRF', () => {
afterEach(fetchMock.reset);
it('calls superset/csrf_token/ upon initialization', done => {
expect.assertions(1);
const client = new SupersetClient({});
client
.init()
.then(() => {
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
return done();
})
.catch(throwIfCalled);
});
it('isAuthenticated() returns true if there is a token and false if not', done => {
expect.assertions(2);
const client = new SupersetClient({});
expect(client.isAuthenticated()).toBe(false);
client
.init()
.then(() => {
expect(client.isAuthenticated()).toBe(true);
return done();
})
.catch(throwIfCalled);
});
it('init() throws if superset/csrf_token/ returns an error', done => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, () => Promise.reject({ status: 403 }), {
overwriteRoutes: true,
});
const client = new SupersetClient({});
client
.init()
.then(throwIfCalled)
.catch(error => {
expect(error.status).toBe(403);
// reset
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
return done();
});
});
it('init() throws if superset/csrf_token/ does not return a token', done => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
const client = new SupersetClient({});
client
.init()
.then(throwIfCalled)
.catch(error => {
expect(error).toBeDefined();
// reset
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
return done();
});
});
});
describe('CSRF queuing', () => {
it(`client.ensureAuth() returns a promise that rejects init() has not been called`, done => {
expect.assertions(2);
const client = new SupersetClient({});
client
.ensureAuth()
.then(throwIfCalled)
.catch(error => {
expect(error).toEqual(expect.objectContaining({ error: expect.any(String) }));
expect(client.didAuthSuccessfully).toBe(false);
return done();
});
});
it('client.ensureAuth() returns a promise that resolves if client.init() resolves successfully', done => {
expect.assertions(1);
const client = new SupersetClient({});
client
.init()
.then(() =>
client
.ensureAuth()
.then(throwIfCalled)
.catch(() => {
expect(client.didAuthSuccessfully).toBe(true);
return done();
}),
)
.catch(throwIfCalled);
});
it(`client.ensureAuth() returns a promise that rejects if init() is unsuccessful`, done => {
const rejectValue = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
expect.assertions(3);
const client = new SupersetClient({});
client
.init()
.then(throwIfCalled)
.catch(error => {
expect(error).toEqual(expect.objectContaining(rejectValue));
return client
.ensureAuth()
.then(throwIfCalled)
.catch(error2 => {
expect(error2).toEqual(expect.objectContaining(rejectValue));
expect(client.didAuthSuccessfully).toBe(false);
// reset
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
return done();
});
});
});
});
describe('requests', () => {
afterEach(fetchMock.reset);
const protocol = 'PROTOCOL';
const host = 'HOST';
const mockGetEndpoint = '/get/url';
const mockPostEndpoint = '/post/url';
const mockGetUrl = `${protocol}://${host}${mockGetEndpoint}`;
const mockPostUrl = `${protocol}://${host}${mockPostEndpoint}`;
fetchMock.get(mockGetUrl, 'Ok');
fetchMock.post(mockPostUrl, 'Ok');
it('checks for authentication before every get and post request', done => {
expect.assertions(3);
const authSpy = jest.spyOn(SupersetClient.prototype, 'ensureAuth');
const client = new SupersetClient({ protocol, host });
client
.init()
.then(() =>
Promise.all([client.get({ url: mockGetUrl }), client.post({ url: mockPostUrl })])
.then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(authSpy).toHaveBeenCalledTimes(2);
authSpy.mockRestore();
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
it('sets protocol, host, headers, mode, and credentials from config', done => {
expect.assertions(3);
const clientConfig = {
host,
protocol,
mode: 'a la mode',
credentials: 'mad cred',
headers: { my: 'header' },
};
const client = new SupersetClient(clientConfig);
client
.init()
.then(() =>
client
.get({ url: mockGetUrl })
.then(() => {
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(clientConfig.mode);
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.headers).toEqual(expect.objectContaining(clientConfig.headers));
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
describe('GET', () => {
it('makes a request using url or endpoint', done => {
expect.assertions(1);
const client = new SupersetClient({ protocol, host });
client
.init()
.then(() =>
Promise.all([
client.get({ url: mockGetUrl }),
client.get({ endpoint: mockGetEndpoint }),
])
.then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
it('allows overriding host, headers, mode, and credentials per-request', done => {
expect.assertions(3);
const clientConfig = {
host,
protocol,
mode: 'a la mode',
credentials: 'mad cred',
headers: { my: 'header' },
};
const overrideConfig = {
host: 'override_host',
mode: 'override mode',
credentials: 'override credentials',
headers: { my: 'override', another: 'header' },
};
const client = new SupersetClient(clientConfig);
client
.init()
.then(() =>
client
.get({ url: mockGetUrl, ...overrideConfig })
.then(() => {
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers),
);
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
});
describe('POST', () => {
it('makes a request using url or endpoint', done => {
expect.assertions(1);
const client = new SupersetClient({ protocol, host });
client
.init()
.then(() =>
Promise.all([
client.post({ url: mockPostUrl }),
client.post({ endpoint: mockPostEndpoint }),
])
.then(() => {
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
it('allows overriding host, headers, mode, and credentials per-request', done => {
const clientConfig = {
host,
protocol,
mode: 'a la mode',
credentials: 'mad cred',
headers: { my: 'header' },
};
const overrideConfig = {
host: 'override_host',
mode: 'override mode',
credentials: 'override credentials',
headers: { my: 'override', another: 'header' },
};
const client = new SupersetClient(clientConfig);
client
.init()
.then(() =>
client
.post({ url: mockPostUrl, ...overrideConfig })
.then(() => {
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1];
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers),
);
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
it('passes postPayload key,values in the body', done => {
expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClient({ protocol, host });
client
.init()
.then(() =>
client
.post({ url: mockPostUrl, postPayload })
.then(() => {
const formData = fetchMock.calls(mockPostUrl)[0][1].body;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.keys(postPayload).forEach(key => {
expect(formData.get(key)).toBe(JSON.stringify(postPayload[key]));
});
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
it('respects the stringify parameter for postPayload key,values', done => {
expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClient({ protocol, host });
client
.init()
.then(() =>
client
.post({ url: mockPostUrl, postPayload, stringify: false })
.then(() => {
const formData = fetchMock.calls(mockPostUrl)[0][1].body;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.keys(postPayload).forEach(key => {
expect(formData.get(key)).toBe(String(postPayload[key]));
});
return done();
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
});
});
});
});
});

View File

@ -0,0 +1,156 @@
/* eslint promise/no-callback-in-promise: 'off' */
import fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
describe('callApi()', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' });
});
afterAll(fetchMock.restore);
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockErrorUrl = '/mock/error/url';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
afterEach(fetchMock.reset);
describe('request config', () => {
it('calls the right url with the specified method', done => {
expect.assertions(2);
Promise.all([
callApi({ url: mockGetUrl, method: 'GET' }),
callApi({ url: mockPostUrl, method: 'POST' }),
])
.then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
return done();
})
.catch(throwIfCalled);
});
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', done => {
expect.assertions(8);
const mockRequest = {
url: mockGetUrl,
mode: 'my-mode',
cache: 'cash money',
credentials: 'mad cred',
headers: {
custom: 'header',
},
redirect: 'no thanks',
signal: () => {},
body: 'BODY',
};
callApi(mockRequest)
.then(() => {
const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1];
expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache);
expect(fetchParams.credentials).toBe(mockRequest.credentials);
expect(fetchParams.headers).toEqual(expect.objectContaining(mockRequest.headers));
expect(fetchParams.redirect).toBe(mockRequest.redirect);
expect(fetchParams.signal).toBe(mockRequest.signal);
expect(fetchParams.body).toBe(mockRequest.body);
return done();
})
.catch(throwIfCalled);
});
});
describe('POST requests', () => {
it('encodes key,value pairs from postPayload', done => {
expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 };
callApi({ url: mockPostUrl, method: 'POST', postPayload })
.then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const { body } = fetchParams;
Object.keys(postPayload).forEach(key => {
expect(body.get(key)).toBe(JSON.stringify(postPayload[key]));
});
return done();
})
.catch(throwIfCalled);
});
// the reason for this is to omit strings like 'undefined' from making their way to the backend
it('omits key,value pairs from postPayload that have undefined values', done => {
expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined };
callApi({ url: mockPostUrl, method: 'POST', postPayload })
.then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const { body } = fetchParams;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
return done();
})
.catch(throwIfCalled);
});
it('respects the stringify flag in POST requests', done => {
const postPayload = {
string: 'value',
number: 1237,
array: [1, 2, 3],
object: { a: 'a', 1: 1 },
null: null,
emptyString: '',
};
expect.assertions(1 + 2 * Object.keys(postPayload).length);
Promise.all([
callApi({ url: mockPostUrl, method: 'POST', postPayload }),
callApi({ url: mockPostUrl, method: 'POST', postPayload, stringify: false }),
])
.then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(2);
const stringified = calls[0][1].body;
const unstringified = calls[1][1].body;
Object.keys(postPayload).forEach(key => {
expect(stringified.get(key)).toBe(JSON.stringify(postPayload[key]));
expect(unstringified.get(key)).toBe(String(postPayload[key]));
});
return done();
})
.catch(throwIfCalled);
});
});
});

View File

@ -0,0 +1,102 @@
/* eslint promise/no-callback-in-promise: 'off' */
import fetchMock from 'fetch-mock';
import callApiAndParseWithTimeout from '../../src/callApi/callApiAndParseWithTimeout';
// we import these via * so that we can spy on the 'default' property of the object
import * as callApi from '../../src/callApi/callApi';
import * as parseResponse from '../../src/callApi/parseResponse';
import * as rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
describe('callApiAndParseWithTimeout()', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' });
});
afterAll(fetchMock.restore);
const mockGetUrl = '/mock/get/url';
const mockGetPayload = { get: 'payload' };
fetchMock.get(mockGetUrl, mockGetPayload);
afterEach(fetchMock.reset);
describe('callApi', () => {
it('calls callApi()', () => {
const callApiSpy = jest.spyOn(callApi, 'default');
callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET' });
expect(callApiSpy).toHaveBeenCalledTimes(1);
callApiSpy.mockClear();
});
});
describe('parseResponse', () => {
it('calls parseResponse()', () => {
const parseSpy = jest.spyOn(parseResponse, 'default');
callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET' });
expect(parseSpy).toHaveBeenCalledTimes(1);
parseSpy.mockClear();
});
});
describe('timeout', () => {
it('does not create a rejection timer if no timeout passed', () => {
const rejectionSpy = jest.spyOn(rejectAfterTimeout, 'default');
callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET' });
expect(rejectionSpy).toHaveBeenCalledTimes(0);
rejectionSpy.mockClear();
});
it('creates a rejection timer if a timeout passed', () => {
jest.useFakeTimers(); // prevents the timeout from rejecting + failing test
const rejectionSpy = jest.spyOn(rejectAfterTimeout, 'default');
callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET', timeout: 10 });
expect(rejectionSpy).toHaveBeenCalledTimes(1);
rejectionSpy.mockClear();
});
it('rejects if the request exceeds the timeout', done => {
expect.assertions(4);
jest.useFakeTimers();
const mockTimeoutUrl = '/mock/timeout/url';
const unresolvingPromise = new Promise(() => {});
fetchMock.get(mockTimeoutUrl, () => unresolvingPromise);
callApiAndParseWithTimeout({ url: mockTimeoutUrl, method: 'GET', timeout: 1 })
.then(throwIfCalled)
.catch(timeoutError => {
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
expect(Object.keys(timeoutError)).toEqual(
expect.arrayContaining(['error', 'statusText']),
);
expect(timeoutError.statusText).toBe('timeout');
return done();
});
jest.runOnlyPendingTimers();
});
it('resolves if the request does not exceed the timeout', done => {
expect.assertions(1);
jest.useFakeTimers();
callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET', timeout: 100 })
.then(response => {
expect(response.json).toEqual(expect.objectContaining(mockGetPayload));
return done();
})
.catch(throwIfCalled);
});
});
});

View File

@ -0,0 +1,84 @@
/* eslint promise/no-callback-in-promise: 'off' */
import fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi';
import parseResponse from '../../src/callApi/parseResponse';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
describe('parseResponse()', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' });
});
afterAll(fetchMock.restore);
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockErrorUrl = '/mock/error/url';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
afterEach(fetchMock.reset);
it('returns a Promise', () => {
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
const parsedResponsePromise = parseResponse(apiPromise);
expect(parsedResponsePromise).toEqual(expect.any(Promise));
});
it('resolves to { json, response } if the request succeeds', done => {
expect.assertions(3);
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
parseResponse(apiPromise)
.then(args => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(Object.keys(args)).toEqual(expect.arrayContaining(['response', 'json']));
expect(args.json).toEqual(expect.objectContaining(mockGetPayload));
return done();
})
.catch(throwIfCalled);
});
it('resolves to { text, response } if the request succeeds with text response', done => {
expect.assertions(3);
const mockTextUrl = '/mock/text/url';
const mockTextResponse =
'<html><head></head><body>I could be a stack trace or something</body></html>';
fetchMock.get(mockTextUrl, mockTextResponse);
const apiPromise = callApi({ url: mockTextUrl, method: 'GET' });
parseResponse(apiPromise)
.then(args => {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(Object.keys(args)).toEqual(expect.arrayContaining(['response', 'text']));
expect(args.text).toBe(mockTextResponse);
return done();
})
.catch(throwIfCalled);
});
it('rejects if the request throws', done => {
expect.assertions(3);
callApi({ url: mockErrorUrl, method: 'GET' })
.then(throwIfCalled)
.catch(error => {
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
expect(error.status).toBe(mockErrorPayload.status);
expect(error.statusText).toBe(mockErrorPayload.statusText);
return done();
});
});
});

View File

@ -0,0 +1,20 @@
/* eslint promise/no-callback-in-promise: 'off' */
import rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import throwIfCalled from '../utils/throwIfCalled';
describe('rejectAfterTimeout()', () => {
it('returns a promise that rejects after the specified timeout', done => {
expect.assertions(1);
jest.useFakeTimers();
rejectAfterTimeout(10)
.then(throwIfCalled)
.catch(() => {
expect(setTimeout).toHaveBeenCalledTimes(1);
return done();
});
jest.runOnlyPendingTimers();
});
});

View File

@ -0,0 +1 @@
export const LOGIN_GLOB = 'glob:*superset/csrf_token/*'; // eslint-disable-line import/prefer-default-export

View File

@ -0,0 +1,3 @@
export default function throwIfCalled() {
throw new Error('Unexpected call to throwIfCalled()');
}