diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..f982aa397d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +💔 Breaking Changes + +🏆 Enhancements + +📜 Documentation + +🐛 Bug Fix + +🏠 Internal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..4ff18fba27 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/README.md b/README.md index e69de29bb2..c88aa50906 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000000..b6cd9a9406 --- /dev/null +++ b/lerna.json @@ -0,0 +1,5 @@ +{ + "lerna": "3.2.1", + "packages": ["packages/*"], + "version": "0.0.0" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..3b89af1d43 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/superset-ui-core/README.md b/packages/superset-ui-core/README.md new file mode 100644 index 0000000000..faddbe759d --- /dev/null +++ b/packages/superset-ui-core/README.md @@ -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. diff --git a/packages/superset-ui-core/package.json b/packages/superset-ui-core/package.json new file mode 100644 index 0000000000..222ed9c012 --- /dev/null +++ b/packages/superset-ui-core/package.json @@ -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" + ] + } + } +} diff --git a/packages/superset-ui-core/src/SupersetClient.js b/packages/superset-ui-core/src/SupersetClient.js new file mode 100644 index 0000000000..aad85a1a3b --- /dev/null +++ b/packages/superset-ui-core/src/SupersetClient.js @@ -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; diff --git a/packages/superset-ui-core/src/callApi/callApi.js b/packages/superset-ui-core/src/callApi/callApi.js new file mode 100644 index 0000000000..ed5dca15ed --- /dev/null +++ b/packages/superset-ui-core/src/callApi/callApi.js @@ -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 +} diff --git a/packages/superset-ui-core/src/callApi/callApiAndParseWithTimeout.js b/packages/superset-ui-core/src/callApi/callApiAndParseWithTimeout.js new file mode 100644 index 0000000000..8f41a8c06a --- /dev/null +++ b/packages/superset-ui-core/src/callApi/callApiAndParseWithTimeout.js @@ -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); +} diff --git a/packages/superset-ui-core/src/callApi/index.js b/packages/superset-ui-core/src/callApi/index.js new file mode 100644 index 0000000000..12d1deb915 --- /dev/null +++ b/packages/superset-ui-core/src/callApi/index.js @@ -0,0 +1 @@ +export { default } from './callApiAndParseWithTimeout'; diff --git a/packages/superset-ui-core/src/callApi/parseResponse.js b/packages/superset-ui-core/src/callApi/parseResponse.js new file mode 100644 index 0000000000..07b680e4b3 --- /dev/null +++ b/packages/superset-ui-core/src/callApi/parseResponse.js @@ -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 }; + }), + ); +} diff --git a/packages/superset-ui-core/src/callApi/rejectAfterTimeout.js b/packages/superset-ui-core/src/callApi/rejectAfterTimeout.js new file mode 100644 index 0000000000..ea728bd97f --- /dev/null +++ b/packages/superset-ui-core/src/callApi/rejectAfterTimeout.js @@ -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, + ); + }); +} diff --git a/packages/superset-ui-core/src/index.js b/packages/superset-ui-core/src/index.js new file mode 100644 index 0000000000..c0a0956e88 --- /dev/null +++ b/packages/superset-ui-core/src/index.js @@ -0,0 +1,2 @@ +export { default as callApi } from './callApi'; +export { default as SupersetClient } from './SupersetClient'; diff --git a/packages/superset-ui-core/test/SupersetClient.test.js b/packages/superset-ui-core/test/SupersetClient.test.js new file mode 100644 index 0000000000..5a95df737e --- /dev/null +++ b/packages/superset-ui-core/test/SupersetClient.test.js @@ -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); + }); + }); + }); + }); +}); diff --git a/packages/superset-ui-core/test/callApi/callApi.test.js b/packages/superset-ui-core/test/callApi/callApi.test.js new file mode 100644 index 0000000000..d4c670cccc --- /dev/null +++ b/packages/superset-ui-core/test/callApi/callApi.test.js @@ -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); + }); + }); +}); diff --git a/packages/superset-ui-core/test/callApi/callApiAndParseWithTimeout.test.js b/packages/superset-ui-core/test/callApi/callApiAndParseWithTimeout.test.js new file mode 100644 index 0000000000..a9006d9b4d --- /dev/null +++ b/packages/superset-ui-core/test/callApi/callApiAndParseWithTimeout.test.js @@ -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); + }); + }); +}); diff --git a/packages/superset-ui-core/test/callApi/parseResponse.test.js b/packages/superset-ui-core/test/callApi/parseResponse.test.js new file mode 100644 index 0000000000..de288a2808 --- /dev/null +++ b/packages/superset-ui-core/test/callApi/parseResponse.test.js @@ -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 = + 'I could be a stack trace or something'; + 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(); + }); + }); +}); diff --git a/packages/superset-ui-core/test/callApi/rejectAfterTimeout.test.js b/packages/superset-ui-core/test/callApi/rejectAfterTimeout.test.js new file mode 100644 index 0000000000..6fc90511d4 --- /dev/null +++ b/packages/superset-ui-core/test/callApi/rejectAfterTimeout.test.js @@ -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(); + }); +}); diff --git a/packages/superset-ui-core/test/fixtures/constants.js b/packages/superset-ui-core/test/fixtures/constants.js new file mode 100644 index 0000000000..4457bc0115 --- /dev/null +++ b/packages/superset-ui-core/test/fixtures/constants.js @@ -0,0 +1 @@ +export const LOGIN_GLOB = 'glob:*superset/csrf_token/*'; // eslint-disable-line import/prefer-default-export diff --git a/packages/superset-ui-core/test/utils/throwIfCalled.js b/packages/superset-ui-core/test/utils/throwIfCalled.js new file mode 100644 index 0000000000..41079b95ee --- /dev/null +++ b/packages/superset-ui-core/test/utils/throwIfCalled.js @@ -0,0 +1,3 @@ +export default function throwIfCalled() { + throw new Error('Unexpected call to throwIfCalled()'); +}