frontend/packages: removed shared
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
// TODO: move these into their respective cases: core or plugins depending on usage
|
||||
|
||||
export const GOOGLE_LOGGED_IN = 'GOOGLE_LOGGED_IN';
|
||||
export const GOOGLE_LOGGED_OUT = 'GOOGLE_LOGGED_OUT';
|
||||
|
||||
export const GHE_LOGGED_IN = 'GHE_LOGGED_IN';
|
||||
export const GHE_LOGGED_OUT = 'GHE_LOGGED_OUT';
|
||||
|
||||
// gcp-project-creator actions
|
||||
export const GCP_PROJECT_FETCHED_DATA = 'GCP_PROJECT_FETCHED_DATA';
|
||||
export const GCP_PROJECT_FAILED = 'GCP_PROJECT_FAILED';
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"elasticSearch": {
|
||||
"data": [
|
||||
{
|
||||
"__typename": "Component",
|
||||
"id": "backstage-backend",
|
||||
"componentType": "service"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"name": "metadata",
|
||||
"buckets": [{ "key": "data-endpoints", "docCount": 10 }]
|
||||
},
|
||||
{
|
||||
"name": "lifecycle.keyword",
|
||||
"buckets": [
|
||||
{ "key": "production", "docCount": 5 },
|
||||
{ "key": "experimental", "docCount": 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import backstage_backend_json from './backstage-backend.json';
|
||||
|
||||
export function getOpenProxyHost() {
|
||||
return 'mocked-proxy';
|
||||
}
|
||||
|
||||
export default {
|
||||
client: {
|
||||
query: jest.fn().mockImplementation(() => {
|
||||
return new Promise(resolve => {
|
||||
resolve(backstage_backend_json);
|
||||
});
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
throw new Error('This file is not used');
|
||||
@@ -1,68 +0,0 @@
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { execute, makePromise } from 'apollo-link';
|
||||
import { createHttpLink } from 'apollo-link-http';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import axios from 'axios';
|
||||
// Needed for PhantomJS, which does not have fetch (should be a null operation on browsers)
|
||||
import 'whatwg-fetch';
|
||||
import { urls } from 'shared/apis/baseUrls';
|
||||
import FeatureFlags from 'shared/apis/featureFlags/featureFlags';
|
||||
import { getGraphqlBackendRegion } from 'core/app/AppBar/LoggedIn/OverrideRegion';
|
||||
import { GoogleAnalyticsEvent, sendGAEvent } from 'shared/apis/events';
|
||||
|
||||
export function getGraphQlApiHost() {
|
||||
if (FeatureFlags.getItem('graphql-backend-region-switcher')) {
|
||||
const region = getGraphqlBackendRegion();
|
||||
return `http://backstage-backend.services.${region}.spotify.net/graphql`;
|
||||
}
|
||||
const backstageOpenProxyHost = urls.openProxy;
|
||||
return process.env.REACT_APP_GRAPHQL_API || `${backstageOpenProxyHost}/api/backend/graphql`;
|
||||
}
|
||||
|
||||
function getClientOptions() {
|
||||
if (FeatureFlags.getItem('graphql-backend-no-cache')) {
|
||||
return {
|
||||
watchQuery: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'ignore',
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const graphqlLink = createHttpLink({
|
||||
uri: getGraphQlApiHost(),
|
||||
});
|
||||
|
||||
export const graphqlClient = new ApolloClient({
|
||||
link: graphqlLink,
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: getClientOptions(),
|
||||
});
|
||||
|
||||
export function graphqlRequest(query, variables) {
|
||||
return makePromise(
|
||||
execute(graphqlLink, {
|
||||
query,
|
||||
variables,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function evictGraphqlCacheEntity(entity) {
|
||||
sendGAEvent(
|
||||
'GraphqlEviction',
|
||||
GoogleAnalyticsEvent.IMPRESSION,
|
||||
'Evict graphql cache for type',
|
||||
entity,
|
||||
'tools',
|
||||
null,
|
||||
);
|
||||
return axios.post(`${getGraphQlApiHost()}/evict/${entity}`);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { getGraphQlApiHost, graphqlRequest } from './graphqlClient';
|
||||
|
||||
describe('graphqlClient', () => {
|
||||
it('should have correct uri for tests', () => {
|
||||
expect(getGraphQlApiHost()).toBe('https://backstage-proxy.spotify.net/api/backend/graphql');
|
||||
});
|
||||
|
||||
it('should fail to execute a malformed query directly', async () => {
|
||||
expect(() => graphqlRequest('durr')).toThrow(/^Invalid AST Node/);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { urls } from './baseUrls';
|
||||
|
||||
describe('baseUrls', () => {
|
||||
it('urls should be base urls', () => {
|
||||
Object.values(urls).forEach(url => {
|
||||
expect(typeof url).toBe('string');
|
||||
expect(url).toMatch(/^https?:\/\/(?:[a-z.-]+)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import Api from 'shared/pluginApi/Api';
|
||||
|
||||
const BACKSTAGE_PROXY_URL = 'https://backstage-proxy.spotify.net';
|
||||
const BACKSTAGE_PUBLIC_URL = 'https://backstage.spotify.net';
|
||||
|
||||
export const urls: Urls = {
|
||||
get public() {
|
||||
return BACKSTAGE_PUBLIC_URL;
|
||||
},
|
||||
get proxy() {
|
||||
return BACKSTAGE_PROXY_URL;
|
||||
},
|
||||
get openProxy() {
|
||||
if (window.location.hostname === 'backstage.spotify.net') {
|
||||
return BACKSTAGE_PUBLIC_URL;
|
||||
}
|
||||
// If we're not on the public URL we'll assume local dev/e2e and use the internal proxy, since GLB/IAP authentication won't work.
|
||||
return BACKSTAGE_PROXY_URL;
|
||||
},
|
||||
get sysmodel() {
|
||||
return 'https://sysmodel.spotify.net';
|
||||
},
|
||||
get ghe() {
|
||||
return 'https://ghe.spotify.net';
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A collection of common urls used in the backstage-frontend.
|
||||
*
|
||||
* Import and use directly.
|
||||
*
|
||||
* ```typescript
|
||||
* import { urls } from 'shared/apis/baseUrls';
|
||||
*
|
||||
* fetch(`${urls.proxy}/api/my-backend/my-api`)
|
||||
* ```
|
||||
*/
|
||||
export type Urls = {
|
||||
/** Public URL that Backstage is served from. */
|
||||
public: string;
|
||||
/** Internal Backstage proxy URL. */
|
||||
proxy: string;
|
||||
/**
|
||||
* URL to the Backstage open proxy, which does not require access to the
|
||||
* Spotify network if possible.
|
||||
*/
|
||||
openProxy: string;
|
||||
/** Internal sysmodel URL. */
|
||||
sysmodel: string;
|
||||
/** Internal GHE URL. */
|
||||
ghe: string;
|
||||
};
|
||||
|
||||
export const urlsApi = new Api<Urls>({
|
||||
id: 'urls',
|
||||
title: 'Common URLs',
|
||||
description: 'A collection of common urls used in the backstage-frontend',
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { urls } from './baseUrls';
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { pluralize, UNKNOWN_DISPLAY_VALUE } from 'shared/apis/codehealth/utils';
|
||||
import Link from 'shared/components/Link';
|
||||
import TestStatus from 'plugins/health/components/TestStatus';
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
|
||||
const TOOLTIP_DETAILS = 'Click to view latest invocations';
|
||||
|
||||
export const flakyRateRenderer = ({ value }) => `${(100.0 * value).toFixed(2)}%`;
|
||||
|
||||
export const passRateRenderer = ({ row, value }) => {
|
||||
if (value === Number.MAX_SAFE_INTEGER) {
|
||||
return 'No runs';
|
||||
}
|
||||
const {
|
||||
aggregateCount: { failed, successful },
|
||||
} = row;
|
||||
const cellContent = `${value.toFixed(2)}%`;
|
||||
const successfulString = 'successful';
|
||||
const totalRuns = successful + failed;
|
||||
const runString = pluralize(totalRuns, 'run');
|
||||
const tooltipContent = `${successful} ${successfulString} / ${successful + failed} ${runString} - ${TOOLTIP_DETAILS}`;
|
||||
return (
|
||||
<Tooltip title={tooltipContent}>
|
||||
<span>{cellContent}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const ownerCellRenderer = ({ row }) =>
|
||||
row.owner && row.owner !== 'UNKNOWN' ? (
|
||||
<Link to={`/org/${encodeURIComponent(row.owner)}`}>{row.owner}</Link>
|
||||
) : (
|
||||
UNKNOWN_DISPLAY_VALUE
|
||||
);
|
||||
|
||||
export const statusRenderer = ({ row }) => <TestStatus status={row.status} />;
|
||||
@@ -1,73 +0,0 @@
|
||||
export const UNKNOWN_DISPLAY_VALUE = '(unknown)';
|
||||
|
||||
export const pluralize = (length, targetString) => {
|
||||
if (length !== 1) {
|
||||
targetString += 's';
|
||||
}
|
||||
return targetString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get percentage of passed test runs
|
||||
* @param skipped
|
||||
* @param failed
|
||||
* @param successful
|
||||
* @param flaked
|
||||
* @returns {number} - Percentage
|
||||
*/
|
||||
export const passRate = ({ /* skipped, */ failed, successful /* , flaked */ }) => {
|
||||
const totalRuns = successful + failed;
|
||||
if (totalRuns === 0) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return (successful / totalRuns) * 100;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get average percentage of passed test runs for all tests
|
||||
* @param tests
|
||||
* @returns {number} - Percentage
|
||||
*/
|
||||
export const averagePassRate = tests => {
|
||||
// no tests found for this component
|
||||
if (!tests.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTests = tests.map(test => passRate(test.aggregateCount)).filter(rate => rate !== Number.MAX_SAFE_INTEGER);
|
||||
|
||||
// no valid tests found for this component
|
||||
if (!validTests.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return validTests.reduce((sum, rate) => sum + rate, 0) / validTests.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get flakiness rate
|
||||
* @param skipped
|
||||
* @param failed
|
||||
* @param successful
|
||||
* @param flaked
|
||||
* @param clusterFlaked
|
||||
* @returns {number}
|
||||
*/
|
||||
export const flakinessRate = ({ /* skipped, */ failed, successful, flaked, clusterFlaked }) => {
|
||||
const totalRuns = successful + failed;
|
||||
if (totalRuns > 0) {
|
||||
return (1.0 * Math.max(flaked || 0, clusterFlaked || 0)) / totalRuns;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const durationLabel = durationMs => {
|
||||
const ms = parseInt(durationMs, 10);
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`;
|
||||
} else if (ms < 60000) {
|
||||
return `${(ms / 1000.0).toFixed(2)} sec`;
|
||||
} else {
|
||||
return `${(ms / 60000.0).toFixed(2)} min`;
|
||||
}
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { averagePassRate, durationLabel, pluralize, flakinessRate } from './utils';
|
||||
import { passRateRenderer, flakyRateRenderer, ownerCellRenderer, statusRenderer } from './renderers';
|
||||
import { wrapInTestApp, wrapInThemedTestApp } from 'testUtils';
|
||||
|
||||
describe('codehealth utils', () => {
|
||||
describe('averagePassRate', () => {
|
||||
describe('when there are no tests', () => {
|
||||
it('returns null', () => {
|
||||
expect(averagePassRate([])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are no valid tests', () => {
|
||||
it('returns null', () => {
|
||||
const testRuns = [
|
||||
// This test-run of zeros will cause the passRate() function in utils
|
||||
// to return MAX_SAFE_INTEGER which should cause averagePassRate to
|
||||
// have tests, but no valid tests.
|
||||
createTestRun({ successful: 0, failed: 0 }),
|
||||
];
|
||||
expect(averagePassRate(testRuns)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are tests', () => {
|
||||
it('returns 100% on only succesful', () => {
|
||||
const testRuns = [
|
||||
createTestRun({ successful: 10 }),
|
||||
createTestRun({ successful: 20 }),
|
||||
createTestRun({ successful: 30 }),
|
||||
];
|
||||
expect(averagePassRate(testRuns)).toBe(100);
|
||||
});
|
||||
it('correctly calculates average percentage', () => {
|
||||
const testRuns = [
|
||||
createTestRun({ successful: 150 }), // 100% passed
|
||||
createTestRun({ successful: 10, failed: 10 }), // 50% passed
|
||||
createTestRun({ failed: 30 }), // 0 passed
|
||||
];
|
||||
expect(averagePassRate(testRuns)).toBe(50);
|
||||
});
|
||||
it('ignores entirely skipped tests', () => {
|
||||
const testRuns = [
|
||||
createTestRun({ successful: 50 }),
|
||||
createTestRun({ skipped: 200 }),
|
||||
createTestRun({ failed: 50, skipped: 200 }),
|
||||
];
|
||||
expect(averagePassRate(testRuns)).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
it('calcualtes duration', () => {
|
||||
const ms = durationLabel(200);
|
||||
const sec = durationLabel(2000);
|
||||
const min = durationLabel(120000);
|
||||
expect(ms).toBe('200 ms');
|
||||
expect(sec).toBe('2.00 sec');
|
||||
expect(min).toBe('2.00 min');
|
||||
});
|
||||
it('pluralizes', () => {
|
||||
const singular = pluralize(1, 'run');
|
||||
const plural = pluralize(2, 'run');
|
||||
expect(singular).toBe('run');
|
||||
expect(plural).toBe('runs');
|
||||
});
|
||||
it('calculates flakiness', () => {
|
||||
const noRuns = flakinessRate({ failed: 0, successful: 0, flaked: 0 });
|
||||
const notFlaky = flakinessRate({ failed: 1, successful: 1, flaked: 0 });
|
||||
const flaky = flakinessRate({ failed: 1, successful: 1, flaked: 1 });
|
||||
expect(noRuns).toBe(0);
|
||||
expect(notFlaky).toBe(0);
|
||||
expect(flaky).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderers', () => {
|
||||
it('passRateRenderer', () => {
|
||||
const { getByTitle, getByText } = render(
|
||||
passRateRenderer({ row: createTestRun({ successful: 1, failed: 1 }), value: 50 }),
|
||||
);
|
||||
getByTitle('1 successful / 2 runs - Click to view latest invocations');
|
||||
getByText('50.00%');
|
||||
});
|
||||
it('flakyRateRenderer', () => {
|
||||
const { getByText } = render(flakyRateRenderer({ value: 0.11111 }));
|
||||
getByText('11.11%');
|
||||
});
|
||||
it('ownerCellRenderer', () => {
|
||||
const { getByText } = render(wrapInTestApp(ownerCellRenderer({ row: { owner: 'mockOwner' } })));
|
||||
getByText('mockOwner');
|
||||
});
|
||||
it('ownerCellRenderer', () => {
|
||||
const { getByText } = render(wrapInTestApp(ownerCellRenderer({ row: {} })));
|
||||
getByText('(unknown)');
|
||||
});
|
||||
it('statusRenderer', () => {
|
||||
const { getByText } = render(wrapInThemedTestApp(statusRenderer({ row: { status: 'ok' } })));
|
||||
getByText('OK');
|
||||
});
|
||||
});
|
||||
|
||||
function createTestRun(aggregateCount) {
|
||||
return {
|
||||
aggregateCount: {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
flaked: 0,
|
||||
clusterFlaked: 0,
|
||||
...aggregateCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { env } from './index';
|
||||
|
||||
function runTest(
|
||||
fn: () => boolean,
|
||||
hostname: string | undefined,
|
||||
env: { [key: string]: string } | undefined,
|
||||
expectedSuccess: boolean,
|
||||
) {
|
||||
let oldLocation: PropertyDescriptor | undefined;
|
||||
if (hostname !== undefined) {
|
||||
oldLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
delete window.location;
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: { hostname } });
|
||||
}
|
||||
|
||||
let oldEnv: NodeJS.ProcessEnv | undefined;
|
||||
if (env !== undefined) {
|
||||
oldEnv = { ...process.env };
|
||||
process.env = env;
|
||||
}
|
||||
|
||||
const outcome = fn();
|
||||
if (expectedSuccess) {
|
||||
expect(outcome).toBeTruthy();
|
||||
} else {
|
||||
expect(outcome).toBeFalsy();
|
||||
}
|
||||
|
||||
if (oldLocation !== undefined) {
|
||||
Object.defineProperty(window, 'location', oldLocation!);
|
||||
}
|
||||
|
||||
if (oldEnv !== undefined) {
|
||||
process.env = oldEnv;
|
||||
}
|
||||
}
|
||||
|
||||
describe('env api', () => {
|
||||
it('matches localhost addresses to the development type', () => {
|
||||
runTest(() => env.hostingEnv === 'development', 'localhost', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isDevelopment, 'localhost', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isDevelopment, 'localhosts', { NODE_ENV: 'development' }, true);
|
||||
runTest(() => env.isDevelopment, '[::1]', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isDevelopment, '127.0.0.1', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isDevelopment, '127.0.6.1', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isDevelopment, '0.0.0.0', { NODE_ENV: 'blblbl' }, true);
|
||||
});
|
||||
|
||||
it('matches the docker host address to the staging type', () => {
|
||||
runTest(() => env.hostingEnv === 'staging', '10.99.0.1', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isStaging, '10.99.0.1', { NODE_ENV: 'blblbl' }, true);
|
||||
runTest(() => env.isStaging, 'localhost', { NODE_ENV: 'blblbl' }, false);
|
||||
});
|
||||
|
||||
it('matches the SLINGSHOT_BUILD env var to the staging type', () => {
|
||||
runTest(() => env.hostingEnv === 'staging', undefined, { SLINGSHOT_BUILD: 'dfd' }, true);
|
||||
runTest(() => env.isStaging, undefined, { SLINGSHOT_BUILD: 'dfd' }, true);
|
||||
});
|
||||
});
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import Api from 'shared/pluginApi/Api';
|
||||
|
||||
/** The different types of hosting environment. */
|
||||
export type HostingEnv = 'development' | 'test' | 'staging' | 'production';
|
||||
|
||||
/** Decides whether the given input is a valid HostingEnv. */
|
||||
export function isHostingEnv(s: any): s is HostingEnv {
|
||||
return ['development', 'test', 'staging', 'production'].includes(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties of the current execution environment.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```typescript
|
||||
* import { env } from 'shared/apis/env';
|
||||
* if (env.hostingEnv === 'production') {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type Env = {
|
||||
/** Inspects the type of hosting environment that we are currently in. */
|
||||
hostingEnv: HostingEnv;
|
||||
/** Checks whether we are currently in a development environment. */
|
||||
isDevelopment: boolean;
|
||||
/** Checks whether we are currently in a test environment. */
|
||||
isTest: boolean;
|
||||
/** Checks whether we are currently in a staging environment. */
|
||||
isStaging: boolean;
|
||||
/** Checks whether we are currently in a production environment. */
|
||||
isProduction: boolean;
|
||||
};
|
||||
|
||||
function getHostingEnv() {
|
||||
const { NODE_ENV, REACT_APP_NODE_ENV = false } = process.env;
|
||||
const nodeEnv = REACT_APP_NODE_ENV || NODE_ENV;
|
||||
const hostname = (window && window.location && window.location.hostname) || '';
|
||||
|
||||
// End-to-end tests run on the docker host address
|
||||
if (hostname === '10.99.0.1') {
|
||||
return 'staging';
|
||||
}
|
||||
|
||||
// Slingshot builds are treated as staging as well
|
||||
if (Boolean(process.env.SLINGSHOT_BUILD)) {
|
||||
return 'staging';
|
||||
}
|
||||
|
||||
// If running a prod build locally (rare, but happens)
|
||||
if (
|
||||
nodeEnv === 'production' &&
|
||||
(hostname === 'localhost' ||
|
||||
hostname === '[::1]' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
Boolean(hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)))
|
||||
) {
|
||||
return 'development';
|
||||
}
|
||||
|
||||
if (isHostingEnv(nodeEnv)) {
|
||||
return nodeEnv;
|
||||
}
|
||||
|
||||
return 'development';
|
||||
}
|
||||
|
||||
export const env: Env = {
|
||||
get hostingEnv() {
|
||||
return getHostingEnv();
|
||||
},
|
||||
get isDevelopment() {
|
||||
return getHostingEnv() === 'development';
|
||||
},
|
||||
get isTest() {
|
||||
return getHostingEnv() === 'test';
|
||||
},
|
||||
get isStaging() {
|
||||
return getHostingEnv() === 'staging';
|
||||
},
|
||||
get isProduction() {
|
||||
return getHostingEnv() === 'production';
|
||||
},
|
||||
};
|
||||
|
||||
export const envApi = new Api<Env>({
|
||||
id: 'env',
|
||||
title: 'Properties of the current execution environment',
|
||||
description: 'A collection of properties that are useful to inspect the current execution environment.',
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Drawer, IconButton, List, ListItemSecondaryAction, ListSubheader, withStyles } from '@material-ui/core';
|
||||
import Check from '@material-ui/icons/Check';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import FeaturedPlayListIcon from '@material-ui/icons/FeaturedPlayList';
|
||||
|
||||
import { errorLogClose } from './actions';
|
||||
import ErrorLogEntry from './ErrorLogEntry';
|
||||
|
||||
const closeButtonStyles = {
|
||||
root: {
|
||||
color: 'black',
|
||||
},
|
||||
};
|
||||
|
||||
const drawerStyles = {
|
||||
paperAnchorBottom: {
|
||||
maxHeight: 'auto',
|
||||
height: '45vh',
|
||||
},
|
||||
};
|
||||
|
||||
const listSubheaderStyles = {
|
||||
sticky: {
|
||||
backgroundColor: '#fafafa',
|
||||
},
|
||||
};
|
||||
|
||||
const listStyles = {
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
};
|
||||
|
||||
const CloseButton = withStyles(closeButtonStyles)(({ classes }) => (
|
||||
<IconButton key="close" classes={classes} aria-label="Close" color="inherit">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
));
|
||||
|
||||
const noErrorsStyle = theme => ({
|
||||
errorLogNoErrors: {
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.textVerySubtle,
|
||||
fontSize: '24px',
|
||||
},
|
||||
});
|
||||
|
||||
const errorLogHeaderIconStyles = {
|
||||
root: {
|
||||
marginBottom: '-7px',
|
||||
marginRight: '9px',
|
||||
},
|
||||
};
|
||||
|
||||
const NoErrors = withStyles(noErrorsStyle)(({ classes }) => (
|
||||
<li className={classes.errorLogNoErrors}>
|
||||
<Check style={{ fontSize: 13 }} />
|
||||
All good
|
||||
</li>
|
||||
));
|
||||
|
||||
const ListSubheaderStyled = withStyles(listSubheaderStyles)(ListSubheader);
|
||||
const DrawerStyled = withStyles(drawerStyles)(Drawer);
|
||||
const ListStyled = withStyles(listStyles)(List);
|
||||
const FeaturedPlayListIconStyled = withStyles(errorLogHeaderIconStyles)(FeaturedPlayListIcon);
|
||||
|
||||
const ErrorLogDrawer = ({ errorLog, onClose }) => {
|
||||
const { open, selectedErrorId, errors } = errorLog;
|
||||
|
||||
const errorList = errors.map(error => (
|
||||
<ErrorLogEntry key={error.id} {...error} highlight={selectedErrorId === error.id} />
|
||||
));
|
||||
|
||||
return (
|
||||
<DrawerStyled anchor="bottom" open={open} onClose={onClose} ModalProps={{ 'data-testid': 'error-log-modal' }}>
|
||||
<ListStyled
|
||||
subheader={
|
||||
<ListSubheaderStyled component="div">
|
||||
<FeaturedPlayListIconStyled />
|
||||
ERROR LOG
|
||||
<ListItemSecondaryAction onClick={onClose}>
|
||||
<CloseButton />
|
||||
</ListItemSecondaryAction>
|
||||
</ListSubheaderStyled>
|
||||
}
|
||||
>
|
||||
{errorList && errorList.length ? errorList : <NoErrors />}
|
||||
</ListStyled>
|
||||
</DrawerStyled>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(({ errorLog }) => ({ errorLog }), { onClose: errorLogClose })(ErrorLogDrawer);
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import ErrorLog from './ErrorLog';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import { createStore } from 'core/store';
|
||||
import { errorLogAdd, errorLogOpen, errorLogClose, unselectError } from './actions';
|
||||
|
||||
describe('<ErrorLog />', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<ErrorLog />));
|
||||
expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open and close', () => {
|
||||
const store = createStore();
|
||||
const rendered = render(wrapInThemedTestApp(<Provider store={store} children={<ErrorLog />} />));
|
||||
|
||||
expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument();
|
||||
store.dispatch(errorLogOpen());
|
||||
rendered.getByText('ERROR LOG');
|
||||
rendered.getByText('All good');
|
||||
|
||||
expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe(null);
|
||||
store.dispatch(errorLogClose());
|
||||
expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('should close on click', () => {
|
||||
const store = createStore();
|
||||
const rendered = render(wrapInThemedTestApp(<Provider store={store} children={<ErrorLog />} />));
|
||||
store.dispatch(errorLogOpen());
|
||||
|
||||
expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe(null);
|
||||
fireEvent.click(rendered.getByLabelText('Close'));
|
||||
expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('should add an error to the log without opening', () => {
|
||||
const store = createStore();
|
||||
const rendered = render(wrapInThemedTestApp(<Provider store={store} children={<ErrorLog />} />));
|
||||
|
||||
expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument();
|
||||
store.dispatch(errorLogAdd('msg-a', 'err-b', 'err-id', 'long-msg'));
|
||||
expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('msg-a')).not.toBeInTheDocument();
|
||||
|
||||
store.dispatch(errorLogOpen());
|
||||
rendered.getByText('ERROR LOG');
|
||||
rendered.getByText('msg-a');
|
||||
// only when selected
|
||||
expect(rendered.queryByText('long-msg')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the details of an error when selecting it on open', () => {
|
||||
const store = createStore();
|
||||
const rendered = render(wrapInThemedTestApp(<Provider store={store} children={<ErrorLog />} />));
|
||||
|
||||
store.dispatch(errorLogAdd('msg-a', 'err-b', 'err-id', 'long-msg'));
|
||||
store.dispatch(errorLogOpen('err-id'));
|
||||
|
||||
rendered.getByText('ERROR LOG');
|
||||
rendered.getByText('msg-a');
|
||||
expect(hasCollapsedAncestor(rendered.getByText('long-msg'))).toBe(false);
|
||||
|
||||
store.dispatch(unselectError());
|
||||
|
||||
rendered.getByText('ERROR LOG');
|
||||
rendered.getByText('msg-a');
|
||||
expect(hasCollapsedAncestor(rendered.getByText('long-msg'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function hasCollapsedAncestor(element) {
|
||||
if (!element || element === document.body) {
|
||||
return false;
|
||||
}
|
||||
const isCollapsed = window.getComputedStyle(element).height === '0px';
|
||||
return isCollapsed || hasCollapsedAncestor(element.parentElement);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Collapse,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
withStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import { CopyIcon } from 'shared/icons';
|
||||
|
||||
const errorEntryStyles = theme => ({
|
||||
nested: {
|
||||
flexFlow: 'column nowrap',
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: theme.spacing(9),
|
||||
paddingTop: 0,
|
||||
paddingBottom: theme.spacing(2),
|
||||
},
|
||||
errorDetail: {
|
||||
margin: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
message: {
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.highlight,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: '12px',
|
||||
},
|
||||
});
|
||||
|
||||
const ErrorLogEntry = ({ message, error, timestamp, longMessage, highlight, classes }) => {
|
||||
const [open, setOpen] = React.useState(highlight);
|
||||
const clipboardInputRef = React.useRef(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setOpen(highlight);
|
||||
}, [highlight]);
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(open => !open);
|
||||
};
|
||||
|
||||
const handleCopyClick = e => {
|
||||
e.stopPropagation();
|
||||
clipboardInputRef.current.select();
|
||||
document.execCommand('copy');
|
||||
};
|
||||
|
||||
const errorForClipboard = JSON.stringify({
|
||||
message,
|
||||
timestamp,
|
||||
error,
|
||||
longMessage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ background: highlight ? '#FFFBCC' : 'transparent' }}>
|
||||
<ListItem onClick={handleClick} className={classes.message}>
|
||||
<ListItemIcon>{open ? <ExpandLess /> : <ExpandMore />}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={message || 'Unknown error'}
|
||||
secondary={timestamp}
|
||||
classes={{ secondary: classes.timestamp }}
|
||||
/>
|
||||
<Tooltip id="tooltip-left" title="Copy to clipboard" placement="left">
|
||||
<ListItemSecondaryAction onClick={handleCopyClick}>
|
||||
<IconButton aria-label="Copy to clipboard">
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</Tooltip>
|
||||
{/* Adding the styles below seems to be the only way to copy text from the input to
|
||||
clipboard and keep the input hidden from the view. */}
|
||||
<input
|
||||
ref={clipboardInputRef}
|
||||
onChange={() => {}}
|
||||
value={errorForClipboard}
|
||||
type="text"
|
||||
style={{ position: 'absolute', top: '-9999px', left: '-9999px' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<Collapse in={open} mountOnEnter timeout="auto">
|
||||
<ListItem dense className={classes.nested}>
|
||||
{longMessage ? <Typography className={classes.errorDetail}>{longMessage}</Typography> : null}
|
||||
<pre className={classes.errorDetail}>{error}</pre>
|
||||
</ListItem>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorLogEntry.propTypes = {
|
||||
error: PropTypes.string.isRequired,
|
||||
message: PropTypes.string,
|
||||
timestamp: PropTypes.string,
|
||||
longMessage: PropTypes.string,
|
||||
highlight: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default withStyles(errorEntryStyles)(ErrorLogEntry);
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import ErrorLogEntry from './ErrorLogEntry';
|
||||
|
||||
describe('<ErrorLogEntry />', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
const rendered = render(<ErrorLogEntry />);
|
||||
rendered.getByText('Unknown error');
|
||||
});
|
||||
|
||||
it('renders with all props', () => {
|
||||
const rendered = render(
|
||||
<ErrorLogEntry message="a" error={new Error('b').toString()} timestamp="c" longMessage="d" />,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('a')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('Error: b')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('c')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('d')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders and expands', () => {
|
||||
const rendered = render(<ErrorLogEntry message="a" error="b" longMessage="d" />);
|
||||
|
||||
expect(rendered.queryByText('d')).not.toBeInTheDocument();
|
||||
fireEvent.click(rendered.getByText('a'));
|
||||
expect(rendered.queryByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies error to clipboard', () => {
|
||||
let selectedText = null;
|
||||
let copiedText = null;
|
||||
|
||||
document.onselect = event => {
|
||||
const { target } = event;
|
||||
selectedText = target.value.slice(target.selectionStart, target.selectionEnd);
|
||||
};
|
||||
|
||||
document.execCommand = command => {
|
||||
if (command === 'copy') {
|
||||
copiedText = selectedText;
|
||||
}
|
||||
};
|
||||
|
||||
const rendered = render(<ErrorLogEntry message="a" error="b" />);
|
||||
expect(copiedText).toBeNull();
|
||||
fireEvent.click(rendered.getByLabelText('Copy to clipboard'));
|
||||
expect(copiedText).toBe(JSON.stringify({ message: 'a', error: 'b' }));
|
||||
|
||||
document.onselect = null;
|
||||
document.execCommand = null;
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as constants from './constants';
|
||||
|
||||
/**
|
||||
* Add new entry to error log
|
||||
* @param {String} message - Human readable error message
|
||||
* @param {String|Object} error - Error message or object
|
||||
* @param {String} errorId - Id of error
|
||||
* @param {String} longFailureMessage - Optional: An extra error message that can be long and contain html
|
||||
*/
|
||||
export const errorLogAdd = (message, error, errorId, longMessage) => dispatch => {
|
||||
dispatch({
|
||||
type: constants.ERROR_LOG_ADD,
|
||||
payload: {
|
||||
message,
|
||||
error: `${error}`,
|
||||
errorId,
|
||||
longMessage,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close error log
|
||||
*/
|
||||
export const errorLogClose = () => dispatch => {
|
||||
dispatch({
|
||||
type: constants.ERROR_LOG_CLOSE,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open snackbar
|
||||
* @param {String} errorId - Optional: Error to highlight
|
||||
*/
|
||||
export const errorLogOpen = errorId => dispatch => {
|
||||
dispatch({
|
||||
type: constants.ERROR_LOG_OPEN,
|
||||
payload: {
|
||||
selectedErrorId: errorId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop highlighting error
|
||||
*/
|
||||
export const unselectError = () => dispatch => {
|
||||
dispatch({
|
||||
type: constants.ERROR_LOG_UNSELECT,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export const ERROR_LOG_ADD = 'ERROR_LOG_ADD';
|
||||
export const ERROR_LOG_OPEN = 'ERROR_LOG_OPEN';
|
||||
export const ERROR_LOG_CLOSE = 'ERROR_LOG_CLOSE';
|
||||
export const ERROR_LOG_UNSELECT = 'ERROR_LOG_UNSELECT';
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as constants from './constants';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* The error log is a runtime log for error details that shouldn't be shown in the UI.
|
||||
* The user can open the error log directly from an error message, or from a button in the app bar.
|
||||
*/
|
||||
|
||||
const defaultState = {
|
||||
open: false,
|
||||
errors: [],
|
||||
selectedErrorId: null,
|
||||
};
|
||||
|
||||
const errorLogReducer = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case constants.ERROR_LOG_ADD: {
|
||||
const newError = {
|
||||
id: action.payload.errorId,
|
||||
timestamp: moment().format('YYYY-MM-DD HH:mm:ss:SSS Z'), // reusable?
|
||||
message: action.payload.message,
|
||||
longMessage: action.payload.longMessage,
|
||||
error: action.payload.error,
|
||||
};
|
||||
|
||||
return { ...state, errors: [newError, ...state.errors] };
|
||||
}
|
||||
case constants.ERROR_LOG_OPEN:
|
||||
return { ...state, open: true, selectedErrorId: action.payload.selectedErrorId };
|
||||
case constants.ERROR_LOG_CLOSE:
|
||||
return { ...state, open: false };
|
||||
case constants.ERROR_LOG_UNSELECT:
|
||||
return { ...state, selectedErrorId: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default errorLogReducer;
|
||||
@@ -1,42 +0,0 @@
|
||||
class GoogleAnalyticsEvent {
|
||||
static LINK_CLICK = 'Link_Click';
|
||||
static BTN_CLICK = 'Button_Click';
|
||||
static TAB_CLICK = 'Tab_Click';
|
||||
static ROW_CLICK = 'Row_Click';
|
||||
static IMPRESSION = 'Impression';
|
||||
static HOVER = 'HOVER';
|
||||
|
||||
static EVENT_TYPES = [
|
||||
GoogleAnalyticsEvent.LINK_CLICK,
|
||||
GoogleAnalyticsEvent.BTN_CLICK,
|
||||
GoogleAnalyticsEvent.TAB_CLICK,
|
||||
GoogleAnalyticsEvent.ROW_CLICK,
|
||||
GoogleAnalyticsEvent.IMPRESSION,
|
||||
GoogleAnalyticsEvent.HOVER,
|
||||
];
|
||||
|
||||
constructor(category, action, label, value, owner, context) {
|
||||
if (GoogleAnalyticsEvent.EVENT_TYPES.indexOf(action) === -1) {
|
||||
throw new Error(
|
||||
`ERROR: Unsupported event action! Valid actions are one of: ${GoogleAnalyticsEvent.EVENT_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
this.category = category;
|
||||
this.action = action;
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
this.owner = owner;
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
export default GoogleAnalyticsEvent;
|
||||
|
||||
export const GA_BACKSTAGE_TRACKER = ['backstageTracker'];
|
||||
export const GA_CROSS_DOMAIN_TRACKER = ['crossDomainTracker'];
|
||||
export const GA_ALL_TRACKERS = ['backstageTracker', 'crossDomainTracker'];
|
||||
export const GA_DEFAULT_PLUGIN_ID = 'backstage';
|
||||
export const GA_DEFAULT_PLUGIN_OWNER = 'tools';
|
||||
export const GA_LABEL_UNKNOWN = 'Unknown';
|
||||
export const GA_DEFAULT_USERNAME_PROPS = { label: 'Username' };
|
||||
export const GA_DEFAULT_OWNER_PROPS = { label: 'Owner' };
|
||||
export const GA_HEADER_LOGO_PROPS = { label: 'BackstageHome' };
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getAllReactComponentAncestors } from 'shared/components/Error/util/react';
|
||||
|
||||
const getGAContext = el => {
|
||||
const componentStack = getAllReactComponentAncestors(el);
|
||||
|
||||
const componentWithContext = componentStack.find(component => {
|
||||
return component.props && component.props.gacontext;
|
||||
});
|
||||
|
||||
return componentWithContext ? componentWithContext.props.gacontext : 'NA';
|
||||
};
|
||||
|
||||
export default getGAContext;
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as GoogleAnalyticsEvent } from 'shared/apis/events/GoogleAnalyticsEvent';
|
||||
export { default as getGAContext } from 'shared/apis/events/getGAContext';
|
||||
export { sendGAEvent } from 'shared/apis/events/sendEvent';
|
||||
export { sendGAOutboundLinkEvent } from 'shared/apis/events/sendEvent';
|
||||
@@ -1,51 +0,0 @@
|
||||
import ReactGA from 'react-ga';
|
||||
import { GA_ALL_TRACKERS } from 'shared/apis/events/GoogleAnalyticsEvent';
|
||||
import { matchPath } from 'react-router';
|
||||
import { env } from 'shared/apis/env';
|
||||
|
||||
// The following is in line with the recommended way of adding GA tracking in a react App
|
||||
// https://github.com/react-ga/react-ga/wiki/React-Router-v4-Redux-Middleware
|
||||
|
||||
export const trackPage = (page, info = {}) => {
|
||||
// Report Google Analytics pageview
|
||||
ReactGA.set({ page, ...info }, GA_ALL_TRACKERS);
|
||||
ReactGA.pageview(page, GA_ALL_TRACKERS);
|
||||
};
|
||||
|
||||
let currentPage = '';
|
||||
|
||||
export const googleAnalytics = store => next => action => {
|
||||
if (env.isProduction) {
|
||||
if (action.type === '@@router/LOCATION_CHANGE') {
|
||||
const reduxStore = store.getState();
|
||||
const route = reduxStore.routes.find(r => matchPath(action.payload.location.pathname, r));
|
||||
|
||||
let pluginInfo = {
|
||||
dimension1: null,
|
||||
dimension2: null,
|
||||
dimension3: null,
|
||||
dimension4: null,
|
||||
};
|
||||
|
||||
if (route && route.pluginOwner && route.pluginOwner.manifest) {
|
||||
const { name, manifest } = route.pluginOwner;
|
||||
const { id, owner, facts } = manifest;
|
||||
|
||||
pluginInfo = {
|
||||
dimension1: owner || null,
|
||||
dimension2: name || null,
|
||||
dimension4: id || null,
|
||||
dimension3: facts ? facts.support_channel : null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextPage = `${action.payload.location.pathname}${action.payload.location.search}`;
|
||||
|
||||
if (currentPage !== nextPage) {
|
||||
currentPage = nextPage;
|
||||
trackPage(nextPage, pluginInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import GoogleAnalyticsEvent, { GA_BACKSTAGE_TRACKER } from 'shared/apis/events/GoogleAnalyticsEvent';
|
||||
import * as ReactGA from 'react-ga';
|
||||
import { env } from 'shared/apis/env';
|
||||
|
||||
const sendEvent = event => {
|
||||
if (event instanceof GoogleAnalyticsEvent) {
|
||||
const { category, action, label, value, owner, context } = event;
|
||||
|
||||
ReactGA.event(
|
||||
{
|
||||
category: category,
|
||||
action: action,
|
||||
label: label,
|
||||
value: value,
|
||||
dimension1: owner,
|
||||
dimension2: context,
|
||||
},
|
||||
GA_BACKSTAGE_TRACKER,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendGAEvent = (eventCategory, eventAction, eventLabel, eventValue, eventOwner, eventContext) => {
|
||||
if (env.isProduction) {
|
||||
let gaEvent = new GoogleAnalyticsEvent(
|
||||
eventCategory,
|
||||
eventAction,
|
||||
eventLabel,
|
||||
eventValue,
|
||||
eventOwner,
|
||||
eventContext,
|
||||
);
|
||||
|
||||
// Send click event
|
||||
sendEvent(gaEvent);
|
||||
}
|
||||
};
|
||||
|
||||
function isAbsoluteUrl(url) {
|
||||
return /^([a-z]+:\/\/|\/\/)/i.test(url);
|
||||
}
|
||||
|
||||
export const sendGAOutboundLinkEvent = to => {
|
||||
// Send a page view if outbound link
|
||||
if (to && isAbsoluteUrl(to)) {
|
||||
ReactGA.set({ page: to }, GA_BACKSTAGE_TRACKER);
|
||||
ReactGA.pageview(to, GA_BACKSTAGE_TRACKER);
|
||||
}
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { registerFeatureFlag } from 'shared/apis/featureFlags/featureFlagsActions';
|
||||
import FeatureFlags from 'shared/apis/featureFlags/featureFlags';
|
||||
|
||||
let testFeatureFlag = 'test';
|
||||
|
||||
describe('featureFlags', () => {
|
||||
it('when no feature flag is registered', () => {
|
||||
expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false);
|
||||
});
|
||||
|
||||
it('when feature flag is registered', () => {
|
||||
registerFeatureFlag(testFeatureFlag);
|
||||
expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false);
|
||||
});
|
||||
|
||||
it('when feature flag is enabled', () => {
|
||||
FeatureFlags.enable(testFeatureFlag);
|
||||
expect(FeatureFlags.getItem(testFeatureFlag)).toBe(true);
|
||||
});
|
||||
|
||||
it('when feature flag is disabled', () => {
|
||||
FeatureFlags.disable(testFeatureFlag);
|
||||
expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Class which aims to be compliant with the feature flags functionality in
|
||||
* the angular codebase of system-z. See src/legacy/_modules/systemZCore/modules/featureFlags/featureFlagsProvider.js
|
||||
*/
|
||||
|
||||
// Polyfill needed for protractor on jenkins to work
|
||||
import 'url-search-params-polyfill';
|
||||
|
||||
type FlagObject = { [key: string]: boolean };
|
||||
type ChangeHandler = (flagValue: boolean) => void;
|
||||
|
||||
class FeatureFlags {
|
||||
private static instance: FeatureFlags;
|
||||
private changeHandlers: Map<string, ChangeHandler> = new Map();
|
||||
private featureFlags: FlagObject = {};
|
||||
|
||||
constructor() {
|
||||
if (FeatureFlags.instance) {
|
||||
return FeatureFlags.instance;
|
||||
}
|
||||
this.readFlagsFromStorage();
|
||||
this.processFeatureFlagsFromLocationSearch();
|
||||
FeatureFlags.instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feature flag
|
||||
*/
|
||||
public getItem(flag: string): boolean {
|
||||
return !!this.featureFlags[flag];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature flags
|
||||
*/
|
||||
public getFlags(): FlagObject {
|
||||
return Object.assign({}, this.featureFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a changeHandler that will be invoked when a flag changes
|
||||
*/
|
||||
public onChange(flagName: string, changeHandler: ChangeHandler) {
|
||||
this.changeHandlers.set(flagName, changeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a feature flag to true
|
||||
*/
|
||||
public enable(flag: string) {
|
||||
this.featureFlags[flag] = true;
|
||||
this.callChangeHandler(flag);
|
||||
this.writeFlagsToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the entire opbject
|
||||
*/
|
||||
public storeAll(flags: FlagObject) {
|
||||
const changedFlags = Object.keys(flags).filter(flagName => !!this.featureFlags[flagName] !== !!flags[flagName]);
|
||||
this.featureFlags = flags;
|
||||
changedFlags.forEach(flag => this.callChangeHandler(flag));
|
||||
this.writeFlagsToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a feature flag to false
|
||||
*/
|
||||
public disable(flag: string) {
|
||||
this.featureFlags[flag] = false;
|
||||
this.callChangeHandler(flag);
|
||||
this.writeFlagsToStorage();
|
||||
}
|
||||
|
||||
private callChangeHandler(flag: string) {
|
||||
const handler = this.changeHandlers.get(flag);
|
||||
if (handler) {
|
||||
handler(!!this.featureFlags[flag]);
|
||||
}
|
||||
}
|
||||
|
||||
private readFlagsFromStorage() {
|
||||
const flagsString = localStorage.getItem('featureFlags');
|
||||
if (!flagsString) {
|
||||
this.featureFlags = {};
|
||||
} else {
|
||||
try {
|
||||
this.featureFlags = JSON.parse(flagsString);
|
||||
let tmpFlags: FlagObject = {};
|
||||
// Backstage angular stores an object as a key which messes up the flags
|
||||
Object.entries(this.featureFlags)
|
||||
.filter(([key]) => key !== '[object Object]')
|
||||
.forEach(([key, value]) => {
|
||||
tmpFlags[key] = value;
|
||||
});
|
||||
this.featureFlags = tmpFlags;
|
||||
} catch (e) {
|
||||
console.warn('Feature flags parse error. Initializing new empty storage.');
|
||||
localStorage.removeItem('featureFlags');
|
||||
this.featureFlags = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeFlagsToStorage() {
|
||||
// Filter out false flags as in angular system-z
|
||||
const trimmedFlags: FlagObject = Object.entries(this.featureFlags)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((agg, [key, value]) => {
|
||||
agg[key] = value;
|
||||
return agg;
|
||||
}, {} as FlagObject);
|
||||
|
||||
const flagsString = JSON.stringify(trimmedFlags);
|
||||
window.localStorage.setItem('featureFlags', flagsString);
|
||||
this.readFlagsFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Will modify the feature flags according to the location.search
|
||||
* functionality as in the angular system-z.
|
||||
* valid parameters are *flag*, *flagsOn* and *flagsOff:
|
||||
*/
|
||||
private processFeatureFlagsFromLocationSearch() {
|
||||
const tmpFeatureFlags = Object.assign({}, this.featureFlags);
|
||||
|
||||
const params = new URLSearchParams(document.location.search.substring(1));
|
||||
const flagsOff = params.getAll('flagsOff');
|
||||
flagsOff.forEach(flag => {
|
||||
tmpFeatureFlags[flag] = false;
|
||||
});
|
||||
|
||||
const flagsOn = params.getAll('flagsOn').concat(params.getAll('flags'));
|
||||
flagsOn.forEach(flag => {
|
||||
tmpFeatureFlags[flag] = true;
|
||||
});
|
||||
this.featureFlags = tmpFeatureFlags;
|
||||
this.writeFlagsToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
let _instance = new FeatureFlags();
|
||||
|
||||
export default _instance;
|
||||
@@ -1 +0,0 @@
|
||||
export declare function registerFeatureFlag(flag: string): void;
|
||||
@@ -1,13 +0,0 @@
|
||||
import store from 'core/store';
|
||||
import { REGISTER_FEATURE_FLAG } from 'shared/apis/featureFlags/featureFlagsConstants';
|
||||
|
||||
/**
|
||||
* Register a feature flag that should be available for the app and visible in settings
|
||||
* @param flag the feature flag
|
||||
*/
|
||||
export function registerFeatureFlag(flag) {
|
||||
store.dispatch({
|
||||
type: REGISTER_FEATURE_FLAG,
|
||||
payload: flag,
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const REGISTER_FEATURE_FLAG = 'REGISTER_FEATURE_FLAG';
|
||||
@@ -1,12 +0,0 @@
|
||||
import { REGISTER_FEATURE_FLAG } from 'shared/apis/featureFlags/featureFlagsConstants';
|
||||
|
||||
const featureFlagsReducer = (state = [], action) => {
|
||||
switch (action.type) {
|
||||
case REGISTER_FEATURE_FLAG:
|
||||
return [...state, action.payload];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default featureFlagsReducer;
|
||||
@@ -1,99 +0,0 @@
|
||||
import FirestoreDocFactory from './FirestoreDocFactory';
|
||||
import { withLogCollector } from 'testUtils';
|
||||
import appAuth from 'core/app/auth';
|
||||
|
||||
jest.mock('core/app/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
const successAuthMock = async () => ({
|
||||
firebaseAuth: {
|
||||
customToken: 'my-token',
|
||||
},
|
||||
});
|
||||
|
||||
const missingUserAuthMock = async () => ({
|
||||
firebaseAuth: {
|
||||
customToken: 'missing-user',
|
||||
},
|
||||
});
|
||||
|
||||
const errorAuthMock = async () => {
|
||||
throw new Error('NOPE');
|
||||
};
|
||||
|
||||
jest.mock('firebase/app', () => ({
|
||||
initializeApp: () => ({
|
||||
auth: () => ({
|
||||
signOut: () => {},
|
||||
signInWithCredential: async ({ id, access }) => ({ user: { uid: `uid:${id}:${access}` } }),
|
||||
signInWithCustomToken: async token => ({
|
||||
user: token === 'missing-user' ? undefined : { uid: `token:${token}` },
|
||||
}),
|
||||
}),
|
||||
firestore: () => ({
|
||||
enablePersistence() {},
|
||||
collection: collectionId => ({
|
||||
doc: docId => `doc:${collectionId}/${docId}`,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
auth: {
|
||||
GoogleAuthProvider: {
|
||||
credential: (id, access) => ({ id, access }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const config = {
|
||||
apiKey: '123',
|
||||
projectId: 'abc',
|
||||
};
|
||||
|
||||
describe('FirestoreDocFactory', () => {
|
||||
it('should use either google token or custom token for auth', async () => {
|
||||
appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { googleAccessToken: 'google-token' } });
|
||||
await FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-1').getDoc();
|
||||
|
||||
appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { customToken: 'my-token' } });
|
||||
await FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-2').getDoc();
|
||||
|
||||
appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { noToken: 'nope' } });
|
||||
const logs = await withLogCollector(async () => {
|
||||
await expect(FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-3').getDoc()).rejects.toThrow(
|
||||
'no token available',
|
||||
);
|
||||
});
|
||||
|
||||
expect(logs.error).toEqual(['Firebase auth failed, Error: no token available']);
|
||||
});
|
||||
|
||||
it('should get doc', async () => {
|
||||
appAuth.auth.mockImplementation(successAuthMock);
|
||||
|
||||
const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test2');
|
||||
expect(factory.getDoc()).resolves.toBe('doc:users/token:my-token');
|
||||
});
|
||||
|
||||
it('should not provide a doc if auth fails', async () => {
|
||||
appAuth.auth.mockImplementation(errorAuthMock);
|
||||
|
||||
const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test4');
|
||||
|
||||
const logs = await withLogCollector(['error'], async () => {
|
||||
await expect(factory.getDoc()).rejects.toThrow('NOPE');
|
||||
});
|
||||
expect(logs.error).toEqual(['Firebase auth failed, Error: NOPE']);
|
||||
});
|
||||
|
||||
it('should not provide a doc if user is missing from session', async () => {
|
||||
appAuth.auth.mockImplementation(missingUserAuthMock);
|
||||
|
||||
const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test5');
|
||||
|
||||
const logs = await withLogCollector(['error'], async () => {
|
||||
await expect(factory.getDoc()).rejects.toThrow('received null user from signIn');
|
||||
});
|
||||
expect(logs.error).toEqual(['Firebase auth failed, Error: received null user from signIn']);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import firebase from 'firebase/app';
|
||||
import 'firebase/auth';
|
||||
import 'firebase/firestore';
|
||||
import { AppAuth } from 'core/app/auth';
|
||||
|
||||
export type DocumentReference = firebase.firestore.DocumentReference;
|
||||
|
||||
function defer<T>() {
|
||||
let resolve: (t: T) => void;
|
||||
let reject: (e: Error) => void;
|
||||
const promise = new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
return { promise, resolve: resolve!, reject: reject! };
|
||||
}
|
||||
|
||||
// The purpose of this class is to provide a Firestore instance that is
|
||||
// authenticated with Backstage's existing google auth. It also recreates
|
||||
// and removes the Firestore instance if the google session changes (login/logout).
|
||||
export default class FirestoreDocFactory {
|
||||
private deferredDoc = defer<DocumentReference>();
|
||||
|
||||
static createWithAppAuth(appAuth: AppAuth, options: Object, appName: string): FirestoreDocFactory {
|
||||
const app = firebase.initializeApp(options, appName);
|
||||
const docFactory = new FirestoreDocFactory(app);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authInit = await appAuth.auth();
|
||||
|
||||
const { googleAccessToken, customToken } = authInit.firebaseAuth;
|
||||
// Firebase auth with google credentials is much faster, so use if available.
|
||||
if (googleAccessToken) {
|
||||
const credential = firebase.auth.GoogleAuthProvider.credential(null, googleAccessToken);
|
||||
const session = await app.auth().signInWithCredential(credential);
|
||||
await docFactory.init(session);
|
||||
} else if (customToken) {
|
||||
const session = await app.auth().signInWithCustomToken(customToken);
|
||||
await docFactory.init(session);
|
||||
} else {
|
||||
throw new Error('no token available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Firebase auth failed, ${error}`);
|
||||
docFactory.setError(error);
|
||||
}
|
||||
})();
|
||||
|
||||
return docFactory;
|
||||
}
|
||||
|
||||
constructor(private readonly app: firebase.app.App) {}
|
||||
|
||||
getDoc(): Promise<DocumentReference> {
|
||||
return this.deferredDoc.promise;
|
||||
}
|
||||
|
||||
private async init(session: firebase.auth.UserCredential) {
|
||||
if (!session.user) {
|
||||
throw new Error('received null user from signIn');
|
||||
}
|
||||
const firestore = this.app.firestore();
|
||||
await firestore.enablePersistence({ synchronizeTabs: true });
|
||||
const doc = firestore.collection('users').doc(session.user.uid);
|
||||
|
||||
this.deferredDoc.resolve(doc);
|
||||
}
|
||||
|
||||
private setError(error: Error) {
|
||||
this.deferredDoc.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useFirestore, FirestoreProvider } from './FirestoreProvider';
|
||||
import { render } from '@testing-library/react';
|
||||
import getFirestoreApi from './getFirestoreApi';
|
||||
|
||||
jest.mock('./getFirestoreApi');
|
||||
|
||||
const mockedGetFirestoreApi = getFirestoreApi as jest.MockedFunction<typeof getFirestoreApi>;
|
||||
|
||||
describe('FirestoreProvider', () => {
|
||||
it('should provide a firestore api', () => {
|
||||
const mockApi = { text: 'firestore' };
|
||||
|
||||
const MyComponent = () => {
|
||||
const api = useFirestore() as any;
|
||||
return <div>this is the api: {api.text}</div>;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<FirestoreProvider api={mockApi as any}>
|
||||
<MyComponent />
|
||||
</FirestoreProvider>,
|
||||
);
|
||||
|
||||
rendered.getByText('this is the api: firestore');
|
||||
});
|
||||
|
||||
it('should fall back to fetching default firestore api instance', () => {
|
||||
const mockApi = { text: 'default-firestore' };
|
||||
mockedGetFirestoreApi.mockReturnValue(mockApi as any);
|
||||
|
||||
const MyComponent = () => {
|
||||
const api = useFirestore() as any;
|
||||
return <div>this is the api: {api.text}</div>;
|
||||
};
|
||||
|
||||
const rendered = render(<MyComponent />);
|
||||
|
||||
rendered.getByText('this is the api: default-firestore');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import React, { FC, createContext, useContext } from 'react';
|
||||
import FirestoreStorage from './FirestoreStorage';
|
||||
import { FirestoreApi } from './types';
|
||||
import getFirestoreApi from './getFirestoreApi';
|
||||
|
||||
const Context = createContext<FirestoreApi | undefined>(undefined);
|
||||
|
||||
type ProviderProps = {
|
||||
api: FirestoreStorage;
|
||||
};
|
||||
|
||||
export const FirestoreProvider: FC<ProviderProps> = ({ api, children }) => {
|
||||
return <Context.Provider value={api} children={children} />;
|
||||
};
|
||||
|
||||
export function useFirestore(): FirestoreApi {
|
||||
let firestoreApi = useContext(Context);
|
||||
if (!firestoreApi) {
|
||||
firestoreApi = getFirestoreApi();
|
||||
}
|
||||
return firestoreApi;
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
import firebase from 'firebase/app';
|
||||
import FirestoreDocFactory from './FirestoreDocFactory';
|
||||
import FirestoreStorage from './FirestoreStorage';
|
||||
|
||||
type DocumentData = firebase.firestore.DocumentData;
|
||||
type DocumentSnapshot = firebase.firestore.DocumentSnapshot;
|
||||
type DocumentReference = firebase.firestore.DocumentReference;
|
||||
type CollectionReference = firebase.firestore.CollectionReference;
|
||||
|
||||
function createQueryMock(result: DocumentSnapshot[]) {
|
||||
const calls: any[] = [];
|
||||
|
||||
const pushFn = (name: string) => (...args: any[]) => {
|
||||
calls.push([name, ...args]);
|
||||
return mock;
|
||||
};
|
||||
|
||||
const mock = {
|
||||
calls,
|
||||
limit: pushFn('limit'),
|
||||
limitToLast: pushFn('limitToLast'),
|
||||
orderBy: pushFn('orderBy'),
|
||||
startAfter: pushFn('startAfter'),
|
||||
startAt: pushFn('startAt'),
|
||||
endAt: pushFn('endAt'),
|
||||
endBefore: pushFn('endBefore'),
|
||||
where: pushFn('where'),
|
||||
get: jest.fn().mockReturnValue({
|
||||
docs: result,
|
||||
}),
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('FirestoreStorage', () => {
|
||||
it('should be created using doc factory', async () => {
|
||||
const mockDoc = {} as any;
|
||||
|
||||
jest.spyOn(FirestoreDocFactory, 'createWithAppAuth').mockReturnValue({ getDoc: async () => mockDoc } as any);
|
||||
const storage = FirestoreStorage.create({} as any);
|
||||
|
||||
await expect((storage as any).rootDoc).resolves.toBe(mockDoc);
|
||||
});
|
||||
|
||||
it('should get the root doc', async () => {
|
||||
const mockDoc = {
|
||||
get: async () => mockSnapshot as DocumentSnapshot,
|
||||
path: 'users/root',
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'root',
|
||||
ref: mockDoc,
|
||||
exists: true,
|
||||
data: () => ({ hello: 'world' } as DocumentData),
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(mockDoc));
|
||||
await expect(storage.get('/')).resolves.toEqual({
|
||||
data: { hello: 'world' },
|
||||
exists: true,
|
||||
id: 'root',
|
||||
path: '/',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get a doc in a collection', async () => {
|
||||
const mockDoc = {
|
||||
get: async () => mockSnapshot as DocumentSnapshot,
|
||||
path: 'users/root/col/doc',
|
||||
} as DocumentReference;
|
||||
const mockCollection = {
|
||||
path: 'users/root/col',
|
||||
doc: (path: string) => path === 'doc' && mockDoc,
|
||||
} as CollectionReference;
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path === 'col' && mockCollection,
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'doc',
|
||||
ref: mockDoc,
|
||||
exists: true,
|
||||
data: () => ({ hello: 'world' } as DocumentData),
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.get('/col/doc')).resolves.toEqual({
|
||||
data: { hello: 'world' },
|
||||
exists: true,
|
||||
id: 'doc',
|
||||
path: '/col/doc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get missing doc', async () => {
|
||||
const mockDoc = {
|
||||
get: async () => (mockSnapshot as unknown) as DocumentSnapshot,
|
||||
path: 'users/root/col/doc',
|
||||
} as DocumentReference;
|
||||
const mockCollection = {
|
||||
path: 'users/root/col',
|
||||
doc: (path: string) => path === 'doc' && mockDoc,
|
||||
} as CollectionReference;
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path === 'col' && mockCollection,
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'doc',
|
||||
ref: mockDoc,
|
||||
exists: false,
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.get('/col/doc')).resolves.toEqual({
|
||||
data: undefined,
|
||||
exists: false,
|
||||
id: 'doc',
|
||||
path: '/col/doc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail to get docs and cols with invalid paths', async () => {
|
||||
const storage = new FirestoreStorage(undefined as any);
|
||||
|
||||
await expect(storage.get('')).rejects.toThrow("Storage document path must start with '/', got ''");
|
||||
await expect(storage.get('col')).rejects.toThrow("Storage document path must start with '/', got 'col'");
|
||||
await expect(storage.get('/col/doc/')).rejects.toThrow(
|
||||
"Storage document path must not end with '/', got '/col/doc/'",
|
||||
);
|
||||
await expect(storage.get('/col')).rejects.toThrow(
|
||||
"Storage document path must have an even number of path components, got '/col'",
|
||||
);
|
||||
await expect(storage.get('/col/doc/col2')).rejects.toThrow(
|
||||
"Storage document path must have an even number of path components, got '/col/doc/col2'",
|
||||
);
|
||||
|
||||
await expect(storage.query({ path: '' })).rejects.toThrow("Storage collection path must start with '/', got ''");
|
||||
await expect(storage.query({ path: 'col/' })).rejects.toThrow(
|
||||
"Storage collection path must start with '/', got 'col/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col' })).rejects.toThrow(
|
||||
"Storage collection path must end with '/', got '/col'",
|
||||
);
|
||||
await expect(storage.query({ path: '/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col/doc/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/col/doc/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col/doc/col2/doc2/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/col/doc/col2/doc2/'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a doc to a collection', async () => {
|
||||
const mockDoc2 = {
|
||||
path: 'users/root/col/doc/col2/new-doc',
|
||||
} as DocumentReference;
|
||||
const mockAdd = jest.fn().mockImplementation(() => mockDoc2);
|
||||
const mockCollection2 = {
|
||||
path: 'users/root/col/doc/col2',
|
||||
add: mockAdd as (data: any) => Promise<DocumentReference>,
|
||||
} as CollectionReference;
|
||||
const mockDoc = {
|
||||
path: 'users/root/col/new-doc',
|
||||
collection: (path: string) => path === 'col2' && mockCollection2,
|
||||
} as DocumentReference;
|
||||
const mockCollection = {
|
||||
path: 'users/root/col',
|
||||
doc: (path: string) => path === 'doc' && mockDoc,
|
||||
} as CollectionReference;
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path === 'col' && mockCollection,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.add('/col/doc/col2/', { hello: 'world' })).resolves.toBe('/col/doc/col2/new-doc');
|
||||
expect(mockAdd).toHaveBeenCalledWith({ hello: 'world' });
|
||||
});
|
||||
|
||||
it('should set data in a doc', async () => {
|
||||
const mockSet = jest.fn();
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
set: mockSet as any,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.set('/', { foo: 'bar' })).resolves.toBeUndefined();
|
||||
expect(mockSet).toHaveBeenCalledWith({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('should update data in a doc', async () => {
|
||||
const mockUpdate = jest.fn();
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
update: mockUpdate as any,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.update('/', { foo: 'bar' })).resolves.toBeUndefined();
|
||||
expect(mockUpdate).toHaveBeenCalledWith({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('should merge data into a doc', async () => {
|
||||
const mockSet = jest.fn();
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
set: mockSet as any,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.merge('/', { foo: 'bar' })).resolves.toBeUndefined();
|
||||
expect(mockSet).toHaveBeenCalledWith({ foo: 'bar' }, { merge: true });
|
||||
});
|
||||
|
||||
it('should delete a doc', async () => {
|
||||
const mockDelete = jest.fn();
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
delete: mockDelete as any,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.delete('/')).resolves.toBeUndefined();
|
||||
expect(mockDelete).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should query a collection with no params', async () => {
|
||||
const queryMock = createQueryMock([
|
||||
{
|
||||
id: 'doc1',
|
||||
ref: { path: 'users/root/col/doc1' } as DocumentReference,
|
||||
exists: true,
|
||||
data: () => ({ foo: 'bar' } as DocumentData),
|
||||
} as DocumentSnapshot,
|
||||
]);
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.query({ path: '/col/' })).resolves.toEqual({
|
||||
docs: [
|
||||
{
|
||||
id: 'doc1',
|
||||
path: '/col/doc1',
|
||||
exists: true,
|
||||
data: { foo: 'bar' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(queryMock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('should query a collection with limit', async () => {
|
||||
const queryMock = createQueryMock([]);
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(storage.query({ path: '/col/', limit: 10 })).resolves.toEqual({
|
||||
docs: [],
|
||||
});
|
||||
expect(queryMock.calls).toEqual([['limit', 10]]);
|
||||
});
|
||||
|
||||
it('should query a collection with many params', async () => {
|
||||
const queryMock = {
|
||||
...createQueryMock([]),
|
||||
doc: (path: string) => ({ fakeDoc: path }),
|
||||
};
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
limit: 10,
|
||||
startAt: '/col/bar',
|
||||
startAfter: '/col/bar2',
|
||||
endAt: '/col/foo',
|
||||
endBefore: '/col/foo2',
|
||||
orderBy: { field: 'my.counter', direction: 'desc', startAfter: '2', endBefore: '10' },
|
||||
where: { field: 'type', op: '==', value: 'correct' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
docs: [],
|
||||
});
|
||||
expect(queryMock.calls).toEqual([
|
||||
['startAt', { fakeDoc: 'bar' }],
|
||||
['startAfter', { fakeDoc: 'bar2' }],
|
||||
['endAt', { fakeDoc: 'foo' }],
|
||||
['endBefore', { fakeDoc: 'foo2' }],
|
||||
['where', 'type', '==', 'correct'],
|
||||
['orderBy', 'my.counter', 'desc'],
|
||||
['startAfter', '2'],
|
||||
['endBefore', '10'],
|
||||
['limit', 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should query with multiple orderBy clauses', async () => {
|
||||
const queryMock = createQueryMock([]);
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: ['foo', 'bar'], direction: 'asc', startAt: 'a', endAt: 'z' },
|
||||
{ field: 'b', startAt: 1, endAt: 10 },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ docs: [] });
|
||||
expect(queryMock.calls).toEqual([
|
||||
['orderBy', new firebase.firestore.FieldPath('foo', 'bar'), 'asc'],
|
||||
['orderBy', 'b', undefined],
|
||||
['startAt', 'a', 1],
|
||||
['endAt', 'z', 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fail query with gap in order delimiters', async () => {
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (createQueryMock([]) as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [{ field: 'a' }, { field: 'b', startAt: 1 }],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, startAt option must appear in all previous orderBy clauses');
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: 'a', endAt: 1 },
|
||||
{ field: 'b', startAfter: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, startAfter option must appear in all previous orderBy clauses');
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: 'a', startAfter: 1 },
|
||||
{ field: 'b', endAt: 1 },
|
||||
{ field: 'b', endBefore: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, endAt option must appear in all previous orderBy clauses');
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: 'a', startAt: 1 },
|
||||
{ field: 'b', endBefore: 0 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, endBefore option must appear in all previous orderBy clauses');
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: 'a', startAt: 1, startAfter: 2, endBefore: 0 },
|
||||
{ field: 'b', startAt: null },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, only one of startAt and startAfter can be used in all orderBy clauses');
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path: '/col/',
|
||||
orderBy: [
|
||||
{ field: 'a', endAt: null, endBefore: null },
|
||||
{ field: 'b', endBefore: 0 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Invalid storage query, only one of endAt and endBefore can be used in all orderBy clauses');
|
||||
});
|
||||
|
||||
it('should observe a document', async () => {
|
||||
let resolveListener: any;
|
||||
let listenerPromise = new Promise<any>(resolve => {
|
||||
resolveListener = resolve;
|
||||
});
|
||||
|
||||
const mockDoc = {
|
||||
path: 'users/root',
|
||||
onSnapshot: ({ next }: any) => {
|
||||
resolveListener(next);
|
||||
},
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'root',
|
||||
ref: mockDoc,
|
||||
exists: true,
|
||||
data: () => ({ foo: 'bar' } as DocumentData),
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(mockDoc));
|
||||
const subscriber = {
|
||||
next: jest.fn(),
|
||||
error: jest.fn(),
|
||||
complete: jest.fn(),
|
||||
};
|
||||
const observable = storage.observe('/');
|
||||
observable.subscribe(subscriber);
|
||||
|
||||
const listener = await listenerPromise;
|
||||
|
||||
expect(subscriber.next).not.toHaveBeenCalled();
|
||||
listener(mockSnapshot);
|
||||
expect(subscriber.next).toHaveBeenCalledWith({ id: 'root', path: '/', exists: true, data: { foo: 'bar' } });
|
||||
});
|
||||
|
||||
it('should observe and listen separately for each subscriber', async () => {
|
||||
const onSnapshot = jest.fn().mockImplementation(({ next }) => {
|
||||
next?.(mockSnapshot);
|
||||
return () => {};
|
||||
});
|
||||
const mockDoc = {
|
||||
path: 'users/root',
|
||||
onSnapshot: onSnapshot as any,
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'root',
|
||||
ref: mockDoc,
|
||||
exists: true,
|
||||
data: () => ({ foo: 'bar' } as DocumentData),
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(mockDoc));
|
||||
|
||||
const out = await new Promise(resolve => {
|
||||
const subscription = storage.observe('/').subscribe({});
|
||||
storage
|
||||
.observe('/')
|
||||
.subscribe({})
|
||||
.unsubscribe();
|
||||
storage.observe('/').subscribe({
|
||||
next: val => {
|
||||
subscription.unsubscribe();
|
||||
resolve(val);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(out).toEqual({ id: 'root', path: '/', exists: true, data: { foo: 'bar' } });
|
||||
expect(onSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should forward root doc errors in observe', async () => {
|
||||
const storage = new FirestoreStorage(Promise.reject(new Error('NOPE')));
|
||||
|
||||
const out = await new Promise(resolve => {
|
||||
storage.observe('/').subscribe({
|
||||
error: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
expect(out).toEqual(new Error('NOPE'));
|
||||
});
|
||||
|
||||
it('should forward errors in observe', async () => {
|
||||
const onSnapshot = jest.fn().mockImplementation(({ error }) => error(new Error('NOPE')));
|
||||
const mockDoc = {
|
||||
path: 'users/root',
|
||||
onSnapshot: onSnapshot as any,
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(mockDoc));
|
||||
|
||||
const error = await new Promise(resolve => {
|
||||
storage.observe('/').subscribe({
|
||||
error: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
expect(error).toEqual(new Error('NOPE'));
|
||||
});
|
||||
|
||||
it('should observe a query', async () => {
|
||||
const onSnapshot = jest.fn().mockImplementation(({ next }) => {
|
||||
next?.({ docs: [mockSnapshot] });
|
||||
return () => {};
|
||||
});
|
||||
const queryMock = { ...createQueryMock([]), onSnapshot };
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
const mockSnapshot = {
|
||||
id: 'root',
|
||||
ref: rootDoc,
|
||||
exists: true,
|
||||
data: () => ({ foo: 'bar' } as DocumentData),
|
||||
};
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
|
||||
const result = await new Promise<any>(resolve => {
|
||||
const subscriber = storage.observeQuery({ path: '/col/' }).subscribe({});
|
||||
storage
|
||||
.observeQuery({ path: '/col/' })
|
||||
.subscribe({})
|
||||
.unsubscribe();
|
||||
storage.observeQuery({ path: '/col/' }).subscribe({
|
||||
next: value => {
|
||||
resolve(value);
|
||||
subscriber.unsubscribe();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.docs).toEqual([{ id: 'root', path: '/', exists: true, data: { foo: 'bar' } }]);
|
||||
expect(onSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should forward root doc errors in observe query', async () => {
|
||||
const storage = new FirestoreStorage(Promise.reject(new Error('NOPE')));
|
||||
|
||||
const out = await new Promise(resolve => {
|
||||
storage.observeQuery({ path: '/col/' }).subscribe({
|
||||
error: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
expect(out).toEqual(new Error('NOPE'));
|
||||
});
|
||||
|
||||
it('should forward errors in observe query', async () => {
|
||||
const onSnapshot = jest.fn().mockImplementation(({ error }) => error(new Error('NOPE')));
|
||||
const queryMock = { ...createQueryMock([]), onSnapshot };
|
||||
const rootDoc = {
|
||||
path: 'users/root',
|
||||
collection: (path: string) => path && (queryMock as any),
|
||||
} as DocumentReference;
|
||||
|
||||
const storage = new FirestoreStorage(Promise.resolve(rootDoc));
|
||||
|
||||
const error = await new Promise(resolve => {
|
||||
storage.observeQuery({ path: '/col/' }).subscribe({
|
||||
error: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
expect(error).toEqual(new Error('NOPE'));
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import Observable from 'zen-observable';
|
||||
import firebase from 'firebase/app';
|
||||
import chunk from 'lodash/chunk';
|
||||
import castArray from 'lodash/castArray';
|
||||
import FirestoreDocFactory from './FirestoreDocFactory';
|
||||
import {
|
||||
JsonPrimitive,
|
||||
DocData,
|
||||
DocumentPath,
|
||||
CollectionPath,
|
||||
Doc,
|
||||
FieldPath,
|
||||
FirestoreApi,
|
||||
Query,
|
||||
QueryResult,
|
||||
} from './types';
|
||||
import { AppAuth } from 'core/app/auth';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyD9lgzKmd9jliPcCH1petc7HiB67jlh1GQ',
|
||||
authDomain: 'spotify.com',
|
||||
projectId: 'spotify-backstage',
|
||||
};
|
||||
|
||||
type DocumentSnapshot = firebase.firestore.DocumentSnapshot;
|
||||
type DocumentReference = firebase.firestore.DocumentReference;
|
||||
type CollectionReference = firebase.firestore.CollectionReference;
|
||||
type FirestoreQuery = firebase.firestore.Query;
|
||||
|
||||
function mkFieldPath(path: FieldPath): string | firebase.firestore.FieldPath {
|
||||
if (Array.isArray(path)) {
|
||||
return new firebase.firestore.FieldPath(...path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export default class FirestoreStorage implements FirestoreApi {
|
||||
static create(appAuth: AppAuth): FirestoreStorage {
|
||||
const factory = FirestoreDocFactory.createWithAppAuth(appAuth, firebaseConfig, 'default');
|
||||
return new FirestoreStorage(factory.getDoc());
|
||||
}
|
||||
|
||||
constructor(private readonly rootDoc: Promise<DocumentReference>) {}
|
||||
|
||||
async get<T extends DocData = DocData>(path: DocumentPath): Promise<Doc<T>> {
|
||||
const doc = await this.getDocRef(await this.rootDoc, path);
|
||||
const snapshot = await doc.get();
|
||||
|
||||
return this.transformSnapshot<T>(snapshot, await this.rootDoc);
|
||||
}
|
||||
|
||||
observe<T extends DocData = DocData>(path: DocumentPath): Observable<Doc<T>> {
|
||||
const docPromise = this.rootDoc.then(rootDoc => {
|
||||
return Promise.all([this.getDocRef(rootDoc, path), this.rootDoc]);
|
||||
});
|
||||
|
||||
return new Observable(subscriber => {
|
||||
let unsubscribe: () => void;
|
||||
let didUnsubscribe = false;
|
||||
|
||||
docPromise.then(
|
||||
([doc, rootDoc]) => {
|
||||
if (didUnsubscribe) {
|
||||
return;
|
||||
}
|
||||
unsubscribe = doc.onSnapshot({
|
||||
next: snapshot => {
|
||||
subscriber.next(this.transformSnapshot<T>(snapshot, rootDoc));
|
||||
},
|
||||
error(error) {
|
||||
subscriber.error(error);
|
||||
},
|
||||
});
|
||||
},
|
||||
error => subscriber.error(error),
|
||||
);
|
||||
|
||||
return () => {
|
||||
didUnsubscribe = true;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async query<T extends DocData = DocData>(query: Query): Promise<QueryResult<T>> {
|
||||
const collectionRef = await this.getColRef(query.path);
|
||||
const rootDoc = await this.rootDoc;
|
||||
const queryRef = this.prepareQuery(rootDoc, collectionRef, query);
|
||||
|
||||
const result = await queryRef.get();
|
||||
const docs = result.docs.map(snapshot => this.transformSnapshot<T>(snapshot, rootDoc));
|
||||
|
||||
return { docs };
|
||||
}
|
||||
|
||||
observeQuery<T extends DocData = DocData>(query: Query): Observable<QueryResult<T>> {
|
||||
const collectionRefPromise = Promise.all([this.getColRef(query.path), this.rootDoc]);
|
||||
|
||||
return new Observable(subscriber => {
|
||||
let unsubscribe: () => void;
|
||||
let didUnsubscribe = false;
|
||||
|
||||
collectionRefPromise.then(
|
||||
([collectionRef, rootDoc]) => {
|
||||
if (didUnsubscribe) {
|
||||
return;
|
||||
}
|
||||
unsubscribe = this.prepareQuery(rootDoc, collectionRef, query).onSnapshot({
|
||||
next: result => {
|
||||
const docs = result.docs.map(snapshot => this.transformSnapshot<T>(snapshot, rootDoc));
|
||||
subscriber.next({ docs });
|
||||
},
|
||||
error(error) {
|
||||
subscriber.error(error);
|
||||
},
|
||||
});
|
||||
},
|
||||
error => subscriber.error(error),
|
||||
);
|
||||
|
||||
return () => {
|
||||
didUnsubscribe = true;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async add(path: CollectionPath, data: DocData): Promise<DocumentPath> {
|
||||
const colRef = await this.getColRef(path);
|
||||
const newDoc = await colRef.add(data);
|
||||
const rootDoc = await this.rootDoc;
|
||||
return newDoc.path.replace(rootDoc.path, '');
|
||||
}
|
||||
|
||||
async set(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = await this.getDocRef(await this.rootDoc, path);
|
||||
return doc.set(data);
|
||||
}
|
||||
|
||||
async update(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = await this.getDocRef(await this.rootDoc, path);
|
||||
return doc.update(data);
|
||||
}
|
||||
|
||||
async merge(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = await this.getDocRef(await this.rootDoc, path);
|
||||
return doc.set(data, { merge: true });
|
||||
}
|
||||
|
||||
async delete(path: DocumentPath): Promise<void> {
|
||||
const doc = await this.getDocRef(await this.rootDoc, path);
|
||||
return doc.delete();
|
||||
}
|
||||
|
||||
private prepareQuery(rootDoc: DocumentReference, ref: FirestoreQuery, query: Query): FirestoreQuery {
|
||||
if (query.startAt) {
|
||||
ref = ref.startAt(this.getDocRef(rootDoc, query.startAt));
|
||||
}
|
||||
if (query.startAfter) {
|
||||
ref = ref.startAfter(this.getDocRef(rootDoc, query.startAfter));
|
||||
}
|
||||
if (query.endAt) {
|
||||
ref = ref.endAt(this.getDocRef(rootDoc, query.endAt));
|
||||
}
|
||||
if (query.endBefore) {
|
||||
ref = ref.endBefore(this.getDocRef(rootDoc, query.endBefore));
|
||||
}
|
||||
|
||||
if (query.where) {
|
||||
for (const where of castArray(query.where)) {
|
||||
ref = ref.where(mkFieldPath(where.field), where.op, where.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.orderBy) {
|
||||
const startAt: JsonPrimitive[] = [];
|
||||
const startAfter: JsonPrimitive[] = [];
|
||||
const endAt: JsonPrimitive[] = [];
|
||||
const endBefore: JsonPrimitive[] = [];
|
||||
|
||||
castArray(query.orderBy).forEach((orderBy, index) => {
|
||||
ref = ref.orderBy(mkFieldPath(orderBy.field), orderBy.direction);
|
||||
|
||||
if (orderBy.startAt !== undefined) {
|
||||
if (startAt.length < index) {
|
||||
throw new TypeError('Invalid storage query, startAt option must appear in all previous orderBy clauses');
|
||||
}
|
||||
startAt.push(orderBy.startAt);
|
||||
}
|
||||
if (orderBy.startAfter !== undefined) {
|
||||
if (startAfter.length < index) {
|
||||
throw new TypeError('Invalid storage query, startAfter option must appear in all previous orderBy clauses');
|
||||
}
|
||||
startAfter.push(orderBy.startAfter);
|
||||
}
|
||||
if (orderBy.endAt !== undefined) {
|
||||
if (endAt.length < index) {
|
||||
throw new TypeError('Invalid storage query, endAt option must appear in all previous orderBy clauses');
|
||||
}
|
||||
endAt.push(orderBy.endAt);
|
||||
}
|
||||
if (orderBy.endBefore !== undefined) {
|
||||
if (endBefore.length < index) {
|
||||
throw new TypeError('Invalid storage query, endBefore option must appear in all previous orderBy clauses');
|
||||
}
|
||||
endBefore.push(orderBy.endBefore);
|
||||
}
|
||||
});
|
||||
|
||||
if (startAt.length && startAfter.length) {
|
||||
throw new TypeError(
|
||||
'Invalid storage query, only one of startAt and startAfter can be used in all orderBy clauses',
|
||||
);
|
||||
}
|
||||
if (endAt.length && endBefore.length) {
|
||||
throw new TypeError(
|
||||
'Invalid storage query, only one of endAt and endBefore can be used in all orderBy clauses',
|
||||
);
|
||||
}
|
||||
if (startAt.length) {
|
||||
ref = ref.startAt(...startAt);
|
||||
}
|
||||
if (startAfter.length) {
|
||||
ref = ref.startAfter(...startAfter);
|
||||
}
|
||||
if (endAt.length) {
|
||||
ref = ref.endAt(...endAt);
|
||||
}
|
||||
if (endBefore.length) {
|
||||
ref = ref.endBefore(...endBefore);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.limit && query.limit > 0) {
|
||||
ref = ref.limit(query.limit);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
private transformSnapshot<T extends DocData>(snapshot: DocumentSnapshot, rootDoc: DocumentReference): Doc<T> {
|
||||
const { id, exists } = snapshot;
|
||||
const path = snapshot.ref.path.replace(rootDoc.path, '') || '/';
|
||||
|
||||
if (exists) {
|
||||
const data = snapshot.data() as T;
|
||||
return { id, path, exists, data };
|
||||
}
|
||||
|
||||
return { id, path, exists, data: undefined };
|
||||
}
|
||||
|
||||
private getDocRef(rootDoc: DocumentReference, path: DocumentPath): DocumentReference {
|
||||
if (!path.startsWith('/')) {
|
||||
throw new TypeError(`Storage document path must start with '/', got '${path}'`);
|
||||
}
|
||||
if (path !== '/' && path.endsWith('/')) {
|
||||
throw new TypeError(`Storage document path must not end with '/', got '${path}'`);
|
||||
}
|
||||
const parts = path
|
||||
.slice(1)
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (parts.length % 2 !== 0) {
|
||||
throw new TypeError(`Storage document path must have an even number of path components, got '${path}'`);
|
||||
}
|
||||
|
||||
const doc = chunk(parts, 2).reduce((doc, [colPath, docPath]) => {
|
||||
return doc.collection(colPath).doc(docPath);
|
||||
}, rootDoc);
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async getColRef(path: CollectionPath): Promise<CollectionReference> {
|
||||
if (!path.startsWith('/')) {
|
||||
throw new TypeError(`Storage collection path must start with '/', got '${path}'`);
|
||||
}
|
||||
if (!path.endsWith('/')) {
|
||||
throw new TypeError(`Storage collection path must end with '/', got '${path}'`);
|
||||
}
|
||||
const [rootColPath, ...parts] = path.slice(1, -1).split('/');
|
||||
if (parts.length % 2 !== 0 || !rootColPath) {
|
||||
throw new TypeError(`Storage collection path must have an odd number of path components, got '${path}'`);
|
||||
}
|
||||
const rootCol = (await this.rootDoc).collection(rootColPath);
|
||||
|
||||
const col = chunk(parts, 2).reduce((col, [docPath, colPath]) => {
|
||||
return col.doc(docPath).collection(colPath);
|
||||
}, rootCol);
|
||||
|
||||
return col;
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import MockFirestoreStorage from './MockFirestoreStorage';
|
||||
|
||||
describe('MockFirestoreStorage', () => {
|
||||
it('should be constructed', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
expect(storage).toBeDefined();
|
||||
|
||||
storage.set('/', { hello: 'world' });
|
||||
await expect(storage.get('/')).resolves.toMatchObject({ data: { hello: 'world' } });
|
||||
});
|
||||
|
||||
it('should observe a doc', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
const next = jest.fn();
|
||||
const next2 = jest.fn();
|
||||
|
||||
const subscription = storage.observe('/foo/bar').subscribe(next);
|
||||
storage.observe('/foo/baz').subscribe(next2);
|
||||
|
||||
await storage.set('/foo/bar', { foo: 'bar' });
|
||||
expect(next).toHaveBeenNthCalledWith(1, { id: 'bar', path: '/foo/bar', exists: false, data: undefined });
|
||||
expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { foo: 'bar' } });
|
||||
await storage.update('/foo/bar', { foo: 1 });
|
||||
expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { foo: 1 } });
|
||||
await storage.merge('/foo/bar', { bar: 2 });
|
||||
expect(next).toHaveBeenLastCalledWith({
|
||||
id: 'bar',
|
||||
path: '/foo/bar',
|
||||
exists: true,
|
||||
data: { foo: 1, bar: 2 },
|
||||
});
|
||||
|
||||
await expect(storage.get('/foo/bar')).resolves.toEqual({
|
||||
id: 'bar',
|
||||
path: '/foo/bar',
|
||||
exists: true,
|
||||
data: { foo: 1, bar: 2 },
|
||||
});
|
||||
await storage.delete('/foo/bar');
|
||||
expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: false, data: undefined });
|
||||
await expect(storage.get('/foo/bar')).resolves.toEqual({
|
||||
id: 'bar',
|
||||
path: '/foo/bar',
|
||||
exists: false,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
await expect(storage.update('/foo/bar', { bar: 2 })).rejects.toThrow(
|
||||
new Error('Update failed, no document found at /foo/bar'),
|
||||
);
|
||||
await storage.merge('/foo/bar', { bar: 2 });
|
||||
expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { bar: 2 } });
|
||||
|
||||
next.mockClear();
|
||||
subscription.unsubscribe();
|
||||
await storage.set('/foo/bar', { bar: 3 });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
expect(next2).toHaveBeenCalledTimes(1);
|
||||
expect(next2).toHaveBeenCalledWith({ id: 'baz', path: '/foo/baz', exists: false, data: undefined });
|
||||
});
|
||||
|
||||
it('should observe a query', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
const next1 = jest.fn();
|
||||
const next2 = jest.fn();
|
||||
const next3 = jest.fn();
|
||||
|
||||
const subscription1 = storage.observeQuery({ path: '/foo/', limit: 2 }).subscribe(next1);
|
||||
storage.observeQuery({ path: '/foo/', orderBy: { field: 'x', direction: 'asc', startAfter: 1 } }).subscribe(next2);
|
||||
|
||||
const path = await storage.add('/foo/', { x: 1 });
|
||||
const id = path.replace(/.*\//, '');
|
||||
expect(next1).toHaveBeenNthCalledWith(1, { docs: [] });
|
||||
expect(next2).toHaveBeenNthCalledWith(1, { docs: [] });
|
||||
expect(next1).toHaveBeenNthCalledWith(2, {
|
||||
docs: [{ id, path, exists: true, data: { x: 1 } }],
|
||||
});
|
||||
expect(next2).toHaveBeenNthCalledWith(2, { docs: [] });
|
||||
await storage.set('/foo/bar', { x: 2 });
|
||||
expect(next1).toHaveBeenLastCalledWith({
|
||||
docs: [
|
||||
{ id, path, exists: true, data: { x: 1 } },
|
||||
{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } },
|
||||
],
|
||||
});
|
||||
expect(next2).toHaveBeenLastCalledWith({
|
||||
docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }],
|
||||
});
|
||||
await storage.delete(path);
|
||||
expect(next1).toHaveBeenLastCalledWith({
|
||||
docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }],
|
||||
});
|
||||
expect(next2).toHaveBeenLastCalledWith({
|
||||
docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }],
|
||||
});
|
||||
await storage.set('/foo/foo', { x: 1 });
|
||||
const subscription3 = storage
|
||||
.observeQuery({ path: '/foo/', where: { field: 'x', op: '==', value: 1 } })
|
||||
.subscribe(next3);
|
||||
await storage.set('/foo/baz', { x: 3 });
|
||||
expect(next3).toHaveBeenCalledTimes(2);
|
||||
expect(next3).toHaveBeenNthCalledWith(1, { docs: [{ id: 'foo', path: '/foo/foo', exists: true, data: { x: 1 } }] });
|
||||
subscription3.unsubscribe();
|
||||
await storage.set('/foo/wut', { x: 4 });
|
||||
expect(next3).toHaveBeenCalledTimes(2);
|
||||
expect(next1).toHaveBeenLastCalledWith({
|
||||
docs: [
|
||||
{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } },
|
||||
{ id: 'foo', path: '/foo/foo', exists: true, data: { x: 1 } },
|
||||
],
|
||||
});
|
||||
expect(next2).toHaveBeenLastCalledWith({
|
||||
docs: [
|
||||
{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } },
|
||||
{ id: 'baz', path: '/foo/baz', exists: true, data: { x: 3 } },
|
||||
{ id: 'wut', path: '/foo/wut', exists: true, data: { x: 4 } },
|
||||
],
|
||||
});
|
||||
|
||||
next1.mockClear();
|
||||
next2.mockClear();
|
||||
|
||||
subscription1.unsubscribe();
|
||||
await storage.delete('/foo/wut');
|
||||
|
||||
expect(next1).not.toHaveBeenCalled();
|
||||
expect(next2).toHaveBeenCalledWith({
|
||||
docs: [
|
||||
{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } },
|
||||
{ id: 'baz', path: '/foo/baz', exists: true, data: { x: 3 } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should make some queries', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
|
||||
const item1 = { data: { x: 1, y: 'foo', z: 1 }, exists: true, path: '/foo/1', id: '1' };
|
||||
const item2 = { data: { x: 2, y: 'bar', z: 1 }, exists: true, path: '/foo/2', id: '2' };
|
||||
const item3 = { data: { x: 3, y: 'baz', z: 2 }, exists: true, path: '/foo/3', id: '3' };
|
||||
const item4 = { data: { x: 4, y: 'lol', z: 2 }, exists: true, path: '/foo/4', id: '4' };
|
||||
const item5 = { data: { x: 5, y: 'wut', z: 2 }, exists: true, path: '/foo/5', id: '5' };
|
||||
|
||||
await Promise.all([item1, item2, item3, item4, item5].map(({ path, data }) => storage.set(path, data)));
|
||||
|
||||
const path = '/foo/';
|
||||
|
||||
await expect(storage.query({ path })).resolves.toEqual({ docs: [item1, item2, item3, item4, item5] });
|
||||
|
||||
await expect(storage.query({ path, orderBy: { field: 'y' } })).resolves.toEqual({
|
||||
docs: [item2, item3, item1, item4, item5],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, orderBy: { field: 'x', startAt: 3 } })).resolves.toEqual({
|
||||
docs: [item3, item4, item5],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, orderBy: { field: 'x', endBefore: 2 } })).resolves.toEqual({
|
||||
docs: [item1],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, orderBy: { field: 'x', endBefore: 2, direction: 'desc' } })).resolves.toEqual({
|
||||
docs: [item5, item4, item3],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, orderBy: { field: 'x', startAfter: 2, endAt: 4 } })).resolves.toEqual({
|
||||
docs: [item3, item4],
|
||||
});
|
||||
|
||||
await expect(
|
||||
storage.query({ path, orderBy: { field: 'x', startAfter: 2, endAt: 4, direction: 'desc' } }),
|
||||
).resolves.toEqual({ docs: [] });
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path,
|
||||
orderBy: [
|
||||
{ field: 'z', direction: 'desc', startAt: 2 },
|
||||
{ field: 'x', direction: 'asc' },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ docs: [item3, item4, item5, item1, item2] });
|
||||
|
||||
await expect(storage.query({ path, where: { field: 'x', op: '<', value: 2 } })).resolves.toEqual({
|
||||
docs: [item1],
|
||||
});
|
||||
|
||||
await expect(
|
||||
storage.query({
|
||||
path,
|
||||
where: [
|
||||
{ field: 'x', op: '>=', value: 2 },
|
||||
{ field: 'x', op: '<=', value: 4 },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ docs: [item2, item3, item4] });
|
||||
|
||||
await expect(storage.query({ path, where: [{ field: 'z', op: '==', value: 1 }] })).resolves.toEqual({
|
||||
docs: [item1, item2],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, where: [{ field: 'z', op: '>', value: 6 }] })).resolves.toEqual({
|
||||
docs: [],
|
||||
});
|
||||
|
||||
await expect(storage.query({ path, startAt: '/foo/4' })).resolves.toEqual({ docs: [item4, item5] });
|
||||
await expect(storage.query({ path, startAfter: '/foo/4' })).resolves.toEqual({ docs: [item5] });
|
||||
await expect(storage.query({ path, endAt: '/foo/4' })).resolves.toEqual({ docs: [item1, item2, item3, item4] });
|
||||
await expect(storage.query({ path, endBefore: '/foo/4' })).resolves.toEqual({ docs: [item1, item2, item3] });
|
||||
|
||||
await expect(storage.query({ path, endBefore: '/foo/4', limit: 2 })).resolves.toEqual({ docs: [item1, item2] });
|
||||
|
||||
await expect(storage.query({ path, where: [{ field: 'z', op: 'derp' as '>', value: 6 }] })).rejects.toThrow(
|
||||
"Invalid where filter op 'derp'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle some more things', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
|
||||
await expect(storage.add('/foo/bar/baz/', { x: 1 })).resolves.toMatch(/^\/foo\/bar\/baz\/[a-z0-9]+$/);
|
||||
|
||||
await expect(storage.get('')).rejects.toThrow("Storage document path must start with '/', got ''");
|
||||
await expect(storage.get('col')).rejects.toThrow("Storage document path must start with '/', got 'col'");
|
||||
await expect(storage.get('/col/doc/')).rejects.toThrow(
|
||||
"Storage document path must not end with '/', got '/col/doc/'",
|
||||
);
|
||||
await expect(storage.get('/col')).rejects.toThrow(
|
||||
"Storage document path must have an even number of path components, got '/col'",
|
||||
);
|
||||
await expect(storage.get('/col/doc/col2')).rejects.toThrow(
|
||||
"Storage document path must have an even number of path components, got '/col/doc/col2'",
|
||||
);
|
||||
|
||||
await expect(storage.query({ path: '' })).rejects.toThrow("Storage collection path must start with '/', got ''");
|
||||
await expect(storage.query({ path: 'col/' })).rejects.toThrow(
|
||||
"Storage collection path must start with '/', got 'col/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col' })).rejects.toThrow(
|
||||
"Storage collection path must end with '/', got '/col'",
|
||||
);
|
||||
await expect(storage.query({ path: '/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col/doc/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/col/doc/'",
|
||||
);
|
||||
await expect(storage.query({ path: '/col/doc/col2/doc2/' })).rejects.toThrow(
|
||||
"Storage collection path must have an odd number of path components, got '/col/doc/col2/doc2/'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
import Observable from 'zen-observable';
|
||||
import get from 'lodash/get';
|
||||
import chunk from 'lodash/chunk';
|
||||
import merge from 'lodash/merge';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import castArray from 'lodash/castArray';
|
||||
import { DocData, DocumentPath, CollectionPath, Doc, FirestoreApi, Query, QueryResult } from './types';
|
||||
|
||||
class MockDocRef {
|
||||
exists = false;
|
||||
|
||||
private docData?: DocData;
|
||||
private readonly collections = new Map<string, MockCollectionRef>();
|
||||
|
||||
constructor(readonly path: string) {}
|
||||
|
||||
get id() {
|
||||
return this.path.replace(/.*\//, '');
|
||||
}
|
||||
|
||||
collection(path: string) {
|
||||
let col = this.collections.get(path);
|
||||
if (!col) {
|
||||
col = new MockCollectionRef(`${this.path}/${path}`);
|
||||
this.collections.set(path, col);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
data(): DocData | undefined {
|
||||
return this.docData && JSON.parse(JSON.stringify(this.docData));
|
||||
}
|
||||
|
||||
set(data: DocData) {
|
||||
this.exists = true;
|
||||
this.docData = data;
|
||||
}
|
||||
|
||||
delete() {
|
||||
this.exists = false;
|
||||
this.docData = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class MockCollectionRef {
|
||||
private readonly docs = new Map<string, MockDocRef>();
|
||||
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
doc(path: string) {
|
||||
let doc = this.docs.get(path);
|
||||
if (!doc) {
|
||||
doc = new MockDocRef(`${this.path}/${path}`);
|
||||
this.docs.set(path, doc);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
query(query: Query, rootPath: string): MockDocRef[] {
|
||||
let entries = Array.from(this.docs.entries()).filter(([, doc]) => doc.exists);
|
||||
|
||||
for (const filter of castArray(query.where || [])) {
|
||||
entries = entries.filter(([, doc]) => {
|
||||
const value = get(doc.data(), filter.field);
|
||||
const notNull = value !== null && filter.value !== null;
|
||||
switch (filter.op) {
|
||||
case '<':
|
||||
return notNull && value < filter.value!;
|
||||
case '<=':
|
||||
return notNull && value <= filter.value!;
|
||||
case '==':
|
||||
return value === filter.value;
|
||||
case '>':
|
||||
return notNull && value > filter.value!;
|
||||
case '>=':
|
||||
return notNull && value >= filter.value!;
|
||||
default:
|
||||
throw new Error(`Invalid where filter op '${filter.op}'`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sorters = castArray(query.orderBy || []).slice();
|
||||
for (const sorter of sorters.reverse()) {
|
||||
entries = orderBy(entries, ([, doc]) => get(doc.data(), sorter.field), [sorter.direction || 'asc']);
|
||||
|
||||
const asc = sorter.direction !== 'desc';
|
||||
if (sorter.startAt) {
|
||||
entries = entries.filter(([, doc]) => {
|
||||
const data = get(doc.data(), sorter.field);
|
||||
return asc ? data >= sorter.startAt! : data <= sorter.startAt!;
|
||||
});
|
||||
}
|
||||
if (sorter.startAfter) {
|
||||
entries = entries.filter(([, doc]) => {
|
||||
const data = get(doc.data(), sorter.field);
|
||||
return asc ? data > sorter.startAfter! : data < sorter.startAfter!;
|
||||
});
|
||||
}
|
||||
if (sorter.endAt) {
|
||||
entries = entries.filter(([, doc]) => {
|
||||
const data = get(doc.data(), sorter.field);
|
||||
return asc ? data <= sorter.endAt! : data >= sorter.endAt!;
|
||||
});
|
||||
}
|
||||
if (sorter.endBefore) {
|
||||
entries = entries.filter(([, doc]) => {
|
||||
const data = get(doc.data(), sorter.field);
|
||||
return asc ? data < sorter.endBefore! : data > sorter.endBefore!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (query.startAt) {
|
||||
entries = entries.filter(([, { path }]) => path >= rootPath + query.startAt!);
|
||||
}
|
||||
if (query.startAfter) {
|
||||
entries = entries.filter(([, { path }]) => path > rootPath + query.startAfter!);
|
||||
}
|
||||
if (query.endAt) {
|
||||
entries = entries.filter(([, { path }]) => path <= rootPath + query.endAt!);
|
||||
}
|
||||
if (query.endBefore) {
|
||||
entries = entries.filter(([, { path }]) => path < rootPath + query.endBefore!);
|
||||
}
|
||||
|
||||
if (query.limit !== undefined) {
|
||||
entries = entries.slice(0, query.limit);
|
||||
}
|
||||
|
||||
return entries.map(([, doc]) => doc);
|
||||
}
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
path: string;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
export default class MockFirestoreStorage implements FirestoreApi {
|
||||
private readonly rootDoc = new MockDocRef('users/me');
|
||||
private readonly listeners = new Set<Listener>();
|
||||
|
||||
async get<T extends DocData = DocData>(path: DocumentPath): Promise<Doc<T>> {
|
||||
return this.transformSnapshot<T>(this.getDocRef(path));
|
||||
}
|
||||
|
||||
observe<T extends DocData = DocData>(path: DocumentPath): Observable<Doc<T>> {
|
||||
return new Observable(subscriber => {
|
||||
const doc = this.getDocRef(path);
|
||||
|
||||
subscriber.next(this.transformSnapshot<T>(doc));
|
||||
|
||||
const listener = {
|
||||
path,
|
||||
onUpdate: () => subscriber.next(this.transformSnapshot<T>(doc)),
|
||||
};
|
||||
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async query<T extends DocData = DocData>(query: Query): Promise<QueryResult<T>> {
|
||||
const colRef = this.getColRef(query.path);
|
||||
const docs = colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot<T>(snapshot));
|
||||
return { docs };
|
||||
}
|
||||
|
||||
observeQuery<T extends DocData = DocData>(query: Query): Observable<QueryResult<T>> {
|
||||
return new Observable(subscriber => {
|
||||
const colRef = this.getColRef(query.path);
|
||||
|
||||
subscriber.next({
|
||||
docs: colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot<T>(snapshot)),
|
||||
});
|
||||
|
||||
const listener = {
|
||||
path: query.path,
|
||||
onUpdate: () => {
|
||||
subscriber.next({
|
||||
docs: colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot<T>(snapshot)),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async add(path: CollectionPath, data: DocData): Promise<DocumentPath> {
|
||||
const id = Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10);
|
||||
|
||||
const newDoc = this.getColRef(path).doc(id);
|
||||
newDoc.set(data);
|
||||
await this.notify(newDoc);
|
||||
return newDoc.path.replace(this.rootDoc.path, '');
|
||||
}
|
||||
|
||||
async set(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = this.getDocRef(path);
|
||||
doc.set(data);
|
||||
await this.notify(doc);
|
||||
}
|
||||
|
||||
async update(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = this.getDocRef(path);
|
||||
if (!doc.exists) {
|
||||
const error = new Error(`Update failed, no document found at ${path}`);
|
||||
error.name = 'NotFoundError';
|
||||
throw error;
|
||||
}
|
||||
doc.set(data);
|
||||
await this.notify(doc);
|
||||
}
|
||||
|
||||
async merge(path: DocumentPath, data: DocData): Promise<void> {
|
||||
const doc = this.getDocRef(path);
|
||||
const existingData = doc.data();
|
||||
if (!existingData) {
|
||||
doc.set(data);
|
||||
} else {
|
||||
doc.set(merge({}, existingData, data));
|
||||
}
|
||||
await this.notify(doc);
|
||||
}
|
||||
|
||||
async delete(path: DocumentPath): Promise<void> {
|
||||
const doc = this.getDocRef(path);
|
||||
doc.delete();
|
||||
await this.notify(doc);
|
||||
}
|
||||
|
||||
private async notify(doc: MockDocRef) {
|
||||
const notifyPath = doc.path.replace(this.rootDoc.path, '') || '/';
|
||||
await Promise.resolve();
|
||||
|
||||
const parentPath = notifyPath.replace(/[^\/]+$/, '');
|
||||
|
||||
this.listeners.forEach(({ path, onUpdate }) => {
|
||||
if (path === notifyPath || path === parentPath) {
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private transformSnapshot<T extends DocData>(snapshot: MockDocRef): Doc<T> {
|
||||
const { id, path, exists } = snapshot;
|
||||
return {
|
||||
id,
|
||||
path: path.replace(this.rootDoc.path, '') || '/',
|
||||
exists,
|
||||
data: exists ? (snapshot.data() as T) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getDocRef(path: DocumentPath): MockDocRef {
|
||||
if (!path.startsWith('/')) {
|
||||
throw new TypeError(`Storage document path must start with '/', got '${path}'`);
|
||||
}
|
||||
if (path !== '/' && path.endsWith('/')) {
|
||||
throw new TypeError(`Storage document path must not end with '/', got '${path}'`);
|
||||
}
|
||||
const parts = path
|
||||
.slice(1)
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (parts.length % 2 !== 0) {
|
||||
throw new TypeError(`Storage document path must have an even number of path components, got '${path}'`);
|
||||
}
|
||||
|
||||
return chunk(parts, 2).reduce((doc, [colPath, docPath]) => {
|
||||
return doc.collection(colPath).doc(docPath);
|
||||
}, this.rootDoc);
|
||||
}
|
||||
|
||||
private getColRef(path: CollectionPath): MockCollectionRef {
|
||||
if (!path.startsWith('/')) {
|
||||
throw new TypeError(`Storage collection path must start with '/', got '${path}'`);
|
||||
}
|
||||
if (!path.endsWith('/')) {
|
||||
throw new TypeError(`Storage collection path must end with '/', got '${path}'`);
|
||||
}
|
||||
const [rootColPath, ...parts] = path.slice(1, -1).split('/');
|
||||
if (parts.length % 2 !== 0 || !rootColPath) {
|
||||
throw new TypeError(`Storage collection path must have an odd number of path components, got '${path}'`);
|
||||
}
|
||||
const rootCol = this.rootDoc.collection(rootColPath);
|
||||
|
||||
return chunk(parts, 2).reduce((col, [docPath, colPath]) => {
|
||||
return col.doc(docPath).collection(colPath);
|
||||
}, rootCol);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import MockFirestoreStorage from './MockFirestoreStorage';
|
||||
import FirestoreStorage from './FirestoreStorage';
|
||||
|
||||
describe('getFirestoreApi', () => {
|
||||
it('should get a mock implementation in tests', () => {
|
||||
jest.isolateModules(() => {
|
||||
const getFirestoreApi = require('./getFirestoreApi').default;
|
||||
const api = getFirestoreApi();
|
||||
expect(api).toBeInstanceOf(MockFirestoreStorage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the same instance', () => {
|
||||
jest.isolateModules(() => {
|
||||
const getFirestoreApi = require('./getFirestoreApi').default;
|
||||
const api1 = getFirestoreApi();
|
||||
const api2 = getFirestoreApi();
|
||||
expect(api1).toBe(api2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a real FirestoreStorage in production', () => {
|
||||
jest.isolateModules(() => {
|
||||
const mockInstance = {};
|
||||
jest.spyOn(FirestoreStorage, 'create').mockReturnValue(mockInstance as any);
|
||||
|
||||
process.env.NODE_ENV = 'production';
|
||||
const getFirestoreApi = require('./getFirestoreApi').default;
|
||||
const api1 = getFirestoreApi();
|
||||
const api2 = getFirestoreApi();
|
||||
expect(api1).toBe(mockInstance);
|
||||
expect(api2).toBe(mockInstance);
|
||||
expect(FirestoreStorage.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import FirestoreStorage from './FirestoreStorage';
|
||||
import appAuth from 'core/app/auth';
|
||||
|
||||
// Lazy initialization to avoid side effects on module load in tests
|
||||
const getFirestoreApi = (() => {
|
||||
let storage: FirestoreStorage | undefined;
|
||||
|
||||
return () => {
|
||||
if (!storage) {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
storage = new (require('./MockFirestoreStorage').default)();
|
||||
} else {
|
||||
storage = FirestoreStorage.create(appAuth);
|
||||
}
|
||||
}
|
||||
return storage!;
|
||||
};
|
||||
})();
|
||||
|
||||
export default getFirestoreApi;
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './types';
|
||||
export * from './FirestoreProvider';
|
||||
export { default as FirestoreStorage } from './FirestoreStorage';
|
||||
export { default as getFirestoreApi } from './getFirestoreApi';
|
||||
@@ -1,91 +0,0 @@
|
||||
import Observable from 'zen-observable';
|
||||
import Api from 'shared/pluginApi/Api';
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
export interface JsonObject {
|
||||
[x: string]: JsonValue;
|
||||
}
|
||||
export interface JsonArray extends Array<JsonValue> {}
|
||||
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
|
||||
|
||||
export type DocData = { [key in string]: JsonValue };
|
||||
|
||||
export type CollectionPath = string;
|
||||
export type DocumentPath = string;
|
||||
|
||||
export type FieldPath = string | string[];
|
||||
|
||||
export type OrderBy = {
|
||||
field: FieldPath;
|
||||
// Sort order, defaults to ascending.
|
||||
direction?: 'asc' | 'desc';
|
||||
startAt?: JsonPrimitive;
|
||||
startAfter?: JsonPrimitive;
|
||||
endAt?: JsonPrimitive;
|
||||
endBefore?: JsonPrimitive;
|
||||
};
|
||||
|
||||
export type WhereFilter = {
|
||||
field: FieldPath;
|
||||
op: '<' | '<=' | '==' | '>' | '>=';
|
||||
value: JsonPrimitive;
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
path: CollectionPath;
|
||||
startAt?: DocumentPath;
|
||||
startAfter?: DocumentPath;
|
||||
endAt?: DocumentPath;
|
||||
endBefore?: DocumentPath;
|
||||
limit?: number;
|
||||
orderBy?: OrderBy | OrderBy[];
|
||||
where?: WhereFilter | WhereFilter[];
|
||||
};
|
||||
|
||||
// export type Mutation =
|
||||
// | {
|
||||
// op: 'add';
|
||||
// path: CollectionPath;
|
||||
// data: DocData;
|
||||
// }
|
||||
// | {
|
||||
// op: 'set' | 'update' | 'merge';
|
||||
// path: DocumentPath;
|
||||
// data: DocData;
|
||||
// }
|
||||
// | {
|
||||
// op: 'delete';
|
||||
// path: DocumentPath;
|
||||
// };
|
||||
|
||||
export type Doc<T extends DocData = DocData> = {
|
||||
id: string;
|
||||
path: DocumentPath;
|
||||
exists: boolean;
|
||||
data: T | undefined;
|
||||
};
|
||||
|
||||
export type QueryResult<T extends DocData = DocData> = {
|
||||
docs: Doc<T>[];
|
||||
};
|
||||
|
||||
export type FirestoreApi = {
|
||||
get<T extends DocData = DocData>(path: DocumentPath): Promise<Doc<T>>;
|
||||
observe<T extends DocData = DocData>(path: DocumentPath): Observable<Doc<T>>;
|
||||
query<T extends DocData = DocData>(query: Query): Promise<QueryResult<T>>;
|
||||
observeQuery<T extends DocData = DocData>(query: Query): Observable<QueryResult<T>>;
|
||||
add(path: CollectionPath, data: DocData): Promise<DocumentPath>;
|
||||
set(path: DocumentPath, data: DocData): Promise<void>;
|
||||
update(path: DocumentPath, data: DocData): Promise<void>;
|
||||
merge(path: DocumentPath, data: DocData): Promise<void>;
|
||||
delete(path: DocumentPath): Promise<void>;
|
||||
// write(mutation: Mutation | Mutation[]): Promise<void>;
|
||||
};
|
||||
|
||||
export const firestoreApiRef = new Api<FirestoreApi>({
|
||||
id: 'firestore',
|
||||
title: 'Firestore',
|
||||
description: 'Api for talking to user-scoped Firebase database',
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
import GheClient from './GheClient';
|
||||
|
||||
describe('gheClient', () => {
|
||||
afterEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should fetch file content', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 });
|
||||
await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toEqual({
|
||||
content: 'abc',
|
||||
});
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should fetch raw file content', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 });
|
||||
await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toMatchObject({
|
||||
content: 'abc',
|
||||
});
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should fetch raw file content with no encoding', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ encoding: 'utf-8', content: 'abc' }), { status: 200 });
|
||||
await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toMatchObject({
|
||||
content: 'abc',
|
||||
});
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/contents/c');
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should fetch raw file content from ref', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 });
|
||||
await expect(
|
||||
GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c', sha: 'abc123' }),
|
||||
).resolves.toMatchObject({
|
||||
content: 'abc',
|
||||
});
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/contents/c?ref=abc123');
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should fetch from any api', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ some: 'data' }), { status: 200 });
|
||||
await expect(GheClient.fromAccessToken('abc').request('/my-path')).resolves.toEqual({ some: 'data' });
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/my-path');
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should handle failture', async () => {
|
||||
fetch.mockResponseOnce('{}', { status: 401, statusText: 'NOPE' });
|
||||
await expect(GheClient.fromAccessToken('abc').request('/my-path')).rejects.toMatchObject({
|
||||
message: 'Failed to do authenticated GHE request: 401 NOPE',
|
||||
status: 401,
|
||||
statusText: 'NOPE',
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch a commit', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ sha: 'abc123' }), { status: 200 });
|
||||
await expect(GheClient.fromAccessToken('abc').getCommit({ org: 'a', repo: 'b', sha: 'abc123' })).resolves.toEqual({
|
||||
sha: 'abc123',
|
||||
});
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/commits/abc123');
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc');
|
||||
});
|
||||
|
||||
it('should create a commit', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
|
||||
jest
|
||||
.spyOn(gheClient, 'request')
|
||||
.mockResolvedValueOnce({ tree: { sha: 'parent-tree-sha' } })
|
||||
.mockResolvedValueOnce({ sha: 'new-tree-sha' })
|
||||
.mockResolvedValueOnce({ sha: 'new-commit-sha' });
|
||||
|
||||
const commit = await gheClient.createCommit({
|
||||
org: 'org',
|
||||
repo: 'repo',
|
||||
message: 'my-message',
|
||||
parentCommitSha: 'parent-commit-sha',
|
||||
changes: [{ path: 'file.txt', content: 'my-content' }],
|
||||
});
|
||||
expect(commit.sha).toBe('new-commit-sha');
|
||||
|
||||
expect(gheClient.request).toHaveBeenCalledTimes(3);
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(1, '/repos/org/repo/git/commits/parent-commit-sha');
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(2, '/repos/org/repo/git/trees', 'POST', {
|
||||
base_tree: 'parent-tree-sha',
|
||||
tree: [{ path: 'file.txt', content: 'my-content', mode: '100644', type: 'blob' }],
|
||||
});
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(3, '/repos/org/repo/git/commits', 'POST', {
|
||||
parents: ['parent-commit-sha'],
|
||||
message: 'my-message',
|
||||
tree: 'new-tree-sha',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a commit with binary and text data', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
|
||||
jest
|
||||
.spyOn(gheClient, 'request')
|
||||
.mockResolvedValueOnce({ tree: { sha: 'parent-tree-sha' } })
|
||||
.mockResolvedValueOnce({ sha: 'new-blob-sha' })
|
||||
.mockResolvedValueOnce({ sha: 'new-tree-sha' })
|
||||
.mockResolvedValueOnce({ sha: 'new-commit-sha' });
|
||||
|
||||
const commit = await gheClient.createCommit({
|
||||
org: 'org',
|
||||
repo: 'repo',
|
||||
message: 'my-message',
|
||||
parentCommitSha: 'parent-commit-sha',
|
||||
changes: [
|
||||
{
|
||||
path: 'file.txt',
|
||||
content: 'my-content',
|
||||
},
|
||||
{
|
||||
path: 'binary.txt',
|
||||
content: new Uint8Array([0x41, 0x42, 0x43]),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(commit.sha).toBe('new-commit-sha');
|
||||
|
||||
expect(gheClient.request).toHaveBeenCalledTimes(4);
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(1, '/repos/org/repo/git/commits/parent-commit-sha');
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(2, '/repos/org/repo/git/blobs', 'POST', {
|
||||
content: btoa(String.fromCharCode(0x41, 0x42, 0x43)),
|
||||
encoding: 'base64',
|
||||
});
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(3, '/repos/org/repo/git/trees', 'POST', {
|
||||
base_tree: 'parent-tree-sha',
|
||||
tree: [
|
||||
{
|
||||
path: 'file.txt',
|
||||
content: 'my-content',
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
},
|
||||
{
|
||||
path: 'binary.txt',
|
||||
sha: 'new-blob-sha',
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(gheClient.request).toHaveBeenNthCalledWith(4, '/repos/org/repo/git/commits', 'POST', {
|
||||
parents: ['parent-commit-sha'],
|
||||
message: 'my-message',
|
||||
tree: 'new-tree-sha',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a PR for a branch', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
|
||||
jest.spyOn(gheClient, 'request').mockResolvedValueOnce({ number: 3 });
|
||||
|
||||
const { number } = await gheClient.createPullRequestWithBranch({
|
||||
org: 'org',
|
||||
repo: 'repo',
|
||||
title: 'My PR',
|
||||
body: 'my pr body',
|
||||
branch: 'my-branch',
|
||||
});
|
||||
expect(number).toBe(3);
|
||||
|
||||
expect(gheClient.request).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.request).toHaveBeenCalledWith('/repos/org/repo/pulls', 'POST', {
|
||||
title: 'My PR',
|
||||
body: 'my pr body',
|
||||
base: 'master',
|
||||
head: 'my-branch',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a PR with contents', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
const opts = { org: 'org', repo: 'repo' };
|
||||
|
||||
jest.spyOn(gheClient, 'getSha').mockResolvedValueOnce('base-sha');
|
||||
jest.spyOn(gheClient, 'createCommit').mockResolvedValueOnce({ sha: 'new-commit-sha' });
|
||||
jest.spyOn(gheClient, 'getUserInfo').mockResolvedValueOnce({ login: 'my-user' });
|
||||
jest.spyOn(gheClient, 'createBranch').mockResolvedValueOnce();
|
||||
jest.spyOn(gheClient, 'createPullRequestWithBranch').mockResolvedValueOnce({ number: 4 });
|
||||
|
||||
const { number } = await gheClient.createPullRequest({
|
||||
...opts,
|
||||
title: 'My PR',
|
||||
body: 'my pr body',
|
||||
base: 'my-master',
|
||||
changes: [
|
||||
{
|
||||
path: 'file.txt',
|
||||
content: 'my-contents',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(number).toBe(4);
|
||||
|
||||
expect(gheClient.getSha).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.getSha).toHaveBeenCalledWith({ ...opts, ref: 'heads/my-master' });
|
||||
expect(gheClient.createCommit).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.createCommit).toHaveBeenCalledWith({
|
||||
...opts,
|
||||
parentCommitSha: 'base-sha',
|
||||
message: 'My PR',
|
||||
changes: [
|
||||
{
|
||||
path: 'file.txt',
|
||||
content: 'my-contents',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(gheClient.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.createBranch).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.createBranch).toHaveBeenCalledWith({
|
||||
...opts,
|
||||
branch: expect.stringMatching(/^my-user\/patch-[a-z0-9]{4}$/),
|
||||
sha: 'new-commit-sha',
|
||||
});
|
||||
expect(gheClient.createPullRequestWithBranch).toHaveBeenCalledTimes(1);
|
||||
expect(gheClient.createPullRequestWithBranch).toHaveBeenCalledWith({
|
||||
...opts,
|
||||
base: 'my-master',
|
||||
branch: gheClient.createBranch.mock.calls[0][0].branch,
|
||||
title: 'My PR',
|
||||
body: 'my pr body',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get user info', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
|
||||
fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { status: 200 });
|
||||
await expect(gheClient.getUserInfo('abc')).resolves.toEqual({ id: 'my-user' });
|
||||
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token access-token');
|
||||
});
|
||||
|
||||
it('should handle errors when getting user info', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
|
||||
fetch.mockResponseOnce(JSON.stringify({ message: 'user not found' }), { status: 404, statusText: 'NOPE' });
|
||||
await expect(gheClient.getUserInfo('abc')).rejects.toMatchObject({
|
||||
name: 'GheError',
|
||||
message: 'Failed to do authenticated GHE request: 404 NOPE',
|
||||
status: 404,
|
||||
statusText: 'NOPE',
|
||||
body: {
|
||||
message: 'user not found',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkScopes', () => {
|
||||
afterEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should be true if all scopes are present in headers', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), {
|
||||
headers: { 'X-OAuth-Scopes': 'abc, def, ghe' },
|
||||
status: 200,
|
||||
});
|
||||
await expect(gheClient.verifyUserHasScopes(['abc', 'def'])).resolves.toEqual(true);
|
||||
});
|
||||
it('should be false if some scopes are missing in headers', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), {
|
||||
headers: { 'X-OAuth-Scopes': 'abc, def' },
|
||||
status: 200,
|
||||
});
|
||||
await expect(gheClient.verifyUserHasScopes(['abc', 'def', 'ghe'])).resolves.toEqual(false);
|
||||
});
|
||||
it('should be false if some scopes are missing in headers but are substrings of others', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), {
|
||||
headers: { 'X-OAuth-Scopes': 'abc, defg' },
|
||||
status: 200,
|
||||
});
|
||||
await expect(gheClient.verifyUserHasScopes(['abc', 'ef'])).resolves.toEqual(false);
|
||||
});
|
||||
it('should be false there are no scopes', async () => {
|
||||
const gheClient = GheClient.fromAccessToken('access-token');
|
||||
fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), {
|
||||
status: 200,
|
||||
});
|
||||
await expect(gheClient.verifyUserHasScopes(['abc', 'def', 'ghe'])).resolves.toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { urls } from 'shared/apis/baseUrls';
|
||||
import {
|
||||
GheApi,
|
||||
UserInfoResponse,
|
||||
CommitResponse,
|
||||
FileResponse,
|
||||
PullRequestResponse,
|
||||
GetCommitOptions,
|
||||
GetShaOptions,
|
||||
GetFileOptions,
|
||||
CreateBranchOptions,
|
||||
CreateCommitOptions,
|
||||
CreatePullRequestWithBranchOptions,
|
||||
GetPullRequestOptions,
|
||||
CreatePullRequestOptions,
|
||||
isGheError,
|
||||
} from './types';
|
||||
|
||||
async function blobToBase64(blob: Blob | File): Promise<string> {
|
||||
const reader = new FileReader();
|
||||
const readerPromise = new Promise((resolve, reject) => {
|
||||
reader.onload = () => resolve();
|
||||
reader.onerror = error => reject(error);
|
||||
reader.onabort = () => reject(new Error('file reader was aborted'));
|
||||
});
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
await readerPromise;
|
||||
|
||||
const dataUri = reader.result as string;
|
||||
const base64Data = dataUri.replace(/data:[^;]+;base64,/, '');
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
export default class GheClient implements GheApi {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchOptions: object;
|
||||
|
||||
static fromAccessToken(accessToken: string) {
|
||||
return new GheClient(urls.ghe, accessToken);
|
||||
}
|
||||
|
||||
constructor(baseUrl: string, accessToken: string) {
|
||||
this.baseUrl = `${baseUrl}/api/v3`;
|
||||
this.fetchOptions = {
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${accessToken}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getUserInfo = async (): Promise<UserInfoResponse> => {
|
||||
return this.request('/user');
|
||||
};
|
||||
|
||||
verifyUserHasScopes = async (requiredScopes: string[]): Promise<boolean> => {
|
||||
const res = await fetch(`${this.baseUrl}/user`, {
|
||||
...this.fetchOptions,
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await this.handleError(res);
|
||||
} else {
|
||||
const scopes = res.headers.get('X-OAuth-Scopes');
|
||||
if (!scopes) {
|
||||
return false;
|
||||
}
|
||||
const scopesList = scopes.split(',').map(scope => scope.trim());
|
||||
if (requiredScopes.every(scope => scopesList.includes(scope))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
getCommit = async ({ org, repo, sha }: GetCommitOptions): Promise<CommitResponse> => {
|
||||
return this.request(`/repos/${org}/${repo}/commits/${sha}`);
|
||||
};
|
||||
|
||||
getSha = async ({ org, repo, ref = 'heads/master' }: GetShaOptions): Promise<string> => {
|
||||
const data = await this.request(`/repos/${org}/${repo}/git/refs/${ref}`);
|
||||
return data.object.sha;
|
||||
};
|
||||
|
||||
getFile = async ({ repo, org, path, sha = undefined }: GetFileOptions): Promise<FileResponse> => {
|
||||
const body = await this.request(`/repos/${org}/${repo}/contents/${path}${sha ? `?ref=${sha}` : ''}`);
|
||||
|
||||
const { content, encoding, ...rest } = body;
|
||||
|
||||
let plainContent = content;
|
||||
if (encoding === 'base64') {
|
||||
// escape + decodeURIComponent avoids character encoding issues since the contents are utf-8
|
||||
plainContent = decodeURIComponent(escape(atob(content)));
|
||||
}
|
||||
|
||||
return { content: plainContent, ...rest };
|
||||
};
|
||||
|
||||
/** Creates a new branch pointing to a commit sha. Will fail with a ConflictError if the branch already exists. */
|
||||
private createBranch = async ({ org, repo, branch, sha }: CreateBranchOptions): Promise<void> => {
|
||||
try {
|
||||
await this.request(`/repos/${org}/${repo}/git/refs`, 'POST', {
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGheError(error) && error.body.message === 'Reference already exists') {
|
||||
const error = new Error(`Branch '${branch}' already exists`);
|
||||
error.name = 'ConflictError';
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new commit given a set of changes to files in the repo and a parent commit.
|
||||
*
|
||||
* This will throw an error with the name `"EmptyCommitError"` if the commit doesn't have any changes.
|
||||
*/
|
||||
private createCommit = async ({
|
||||
org,
|
||||
repo,
|
||||
parentCommitSha,
|
||||
message,
|
||||
changes,
|
||||
}: CreateCommitOptions): Promise<CommitResponse> => {
|
||||
const { tree: parentTree } = await this.request(`/repos/${org}/${repo}/git/commits/${parentCommitSha}`);
|
||||
|
||||
// Iterate through changes and upload all binary data as blobs
|
||||
const blobUpdates = await Promise.all(
|
||||
changes.map(async ({ path, content }) => {
|
||||
if (typeof content === 'string') {
|
||||
// Strings just work, character encoding doesn't seem to be an issue
|
||||
return { path, content, mode: '100644', type: 'blob' };
|
||||
}
|
||||
|
||||
if (content instanceof ArrayBuffer || ArrayBuffer.isView(content)) {
|
||||
content = new Blob([content], { type: 'application/octet-stream' });
|
||||
}
|
||||
|
||||
if (!(content instanceof Blob)) {
|
||||
throw new TypeError(`change content must be a string, typed array, or blob, got ${content}`);
|
||||
}
|
||||
|
||||
const { sha } = await this.request(`/repos/${org}/${repo}/git/blobs`, 'POST', {
|
||||
content: await blobToBase64(content),
|
||||
encoding: 'base64',
|
||||
});
|
||||
|
||||
return { path, sha, mode: '100644', type: 'blob' };
|
||||
}),
|
||||
);
|
||||
|
||||
const tree = await this.request(`/repos/${org}/${repo}/git/trees`, 'POST', {
|
||||
base_tree: parentTree.sha,
|
||||
tree: blobUpdates,
|
||||
});
|
||||
|
||||
if (parentTree.sha === tree.sha) {
|
||||
const error = new Error('Commit is empty');
|
||||
error.name = 'EmptyCommitError';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const commit = await this.request(`/repos/${org}/${repo}/git/commits`, 'POST', {
|
||||
parents: [parentCommitSha],
|
||||
message,
|
||||
tree: tree.sha,
|
||||
});
|
||||
|
||||
return commit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a pull request given a feature branch.
|
||||
* This is a lower level than createPullRequest where you need to supply the branch with changes yourself.
|
||||
*/
|
||||
private createPullRequestWithBranch = async ({
|
||||
org,
|
||||
repo,
|
||||
base = 'master',
|
||||
title,
|
||||
body = undefined,
|
||||
branch,
|
||||
}: CreatePullRequestWithBranchOptions): Promise<PullRequestResponse> => {
|
||||
return this.request(`/repos/${org}/${repo}/pulls`, 'POST', { base, head: branch, title, body });
|
||||
};
|
||||
|
||||
getPullRequest = async ({ org, repo, number }: GetPullRequestOptions): Promise<PullRequestResponse> => {
|
||||
return this.request(`/repos/${org}/${repo}/pulls/${number}`);
|
||||
};
|
||||
|
||||
createPullRequest = async ({
|
||||
org,
|
||||
repo,
|
||||
base = 'master',
|
||||
title,
|
||||
body = undefined,
|
||||
parentCommitSha = undefined,
|
||||
changes,
|
||||
}: CreatePullRequestOptions): Promise<PullRequestResponse> => {
|
||||
if (!parentCommitSha) {
|
||||
parentCommitSha = await this.getSha({ org, repo, ref: `heads/${base}` });
|
||||
}
|
||||
|
||||
const commit = await this.createCommit({ org, repo, parentCommitSha, message: title, changes });
|
||||
|
||||
const { login } = await this.getUserInfo();
|
||||
|
||||
// Keep trying to create a branch until we find one that doesn't exist
|
||||
for (;;) {
|
||||
const suffix = Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6);
|
||||
let branch = `${login}/patch-${suffix}`;
|
||||
|
||||
try {
|
||||
await this.createBranch({ org, repo, branch, sha: commit.sha });
|
||||
} catch (error) {
|
||||
if (error.name === 'ConflictError') {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await this.createPullRequestWithBranch({ org, repo, base, branch, title, body });
|
||||
}
|
||||
};
|
||||
|
||||
request = async (
|
||||
path: string,
|
||||
method: 'GET' | 'PATCH' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
body?: object,
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...this.fetchOptions,
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await this.handleError(res);
|
||||
}
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
private async handleError(res: Response) {
|
||||
const error: any = new Error(`Failed to do authenticated GHE request: ${res.status} ${res.statusText}`);
|
||||
error.name = 'GheError';
|
||||
error.status = res.status;
|
||||
error.statusText = res.statusText;
|
||||
error.body = await res.json();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as GheClient } from './GheClient';
|
||||
export * from './types';
|
||||
@@ -1,263 +0,0 @@
|
||||
import { Api } from 'shared/pluginApi';
|
||||
|
||||
/**
|
||||
* This API provides a way to talk to the GitHub Enterprise API, for both reading and writing to repos.
|
||||
*
|
||||
* A GHE Client instance is acquired using the `withGheAuth` HOC:
|
||||
*
|
||||
* ```typescript
|
||||
* const MyComponent = ({gheApi}) => {
|
||||
* console.log('gheApi', gheApi);
|
||||
*
|
||||
* return ...;
|
||||
* }
|
||||
*
|
||||
* export default withGheAuth()(MyComponent);
|
||||
* ```
|
||||
*
|
||||
* See https://backstage.spotify.net/docs/backstage-frontend/apis/#ghe-api for more examples.
|
||||
*/
|
||||
export type GheApi = {
|
||||
/** Get information about the currently logged in user. */
|
||||
getUserInfo(): Promise<UserInfoResponse>;
|
||||
|
||||
/** Verify that the user has authenticated with scopes. */
|
||||
verifyUserHasScopes(scope: string[]): Promise<boolean>;
|
||||
|
||||
/** Get the commit information for a commit sha. */
|
||||
getCommit(options: GetCommitOptions): Promise<CommitResponse>;
|
||||
|
||||
/** Get the commit sha of a ref. */
|
||||
getSha(options: GetShaOptions): Promise<string>;
|
||||
|
||||
/** Fetches a single file (blob) from a commit, including it's contents. Defaults to the tip of the default branch. */
|
||||
getFile(options: GetFileOptions): Promise<FileResponse>;
|
||||
|
||||
/**
|
||||
* Get a pull request by it's number in a repo.
|
||||
*/
|
||||
getPullRequest(options: GetPullRequestOptions): Promise<PullRequestResponse>;
|
||||
|
||||
/**
|
||||
* Create a pull request given a set of changes to files in the repo.
|
||||
* This method takes care of creating a branch and commit for you.
|
||||
*
|
||||
* The generated branch will be named using the template <username>/patch-<random-chars>
|
||||
*
|
||||
* This will throw an error with the name `"EmptyCommitError"` if the PR would've been created without any changes.
|
||||
*/
|
||||
createPullRequest(options: CreatePullRequestOptions): Promise<PullRequestResponse>;
|
||||
|
||||
/** Sends a request to GHE v3 rest API, e.g. /user, defaults to GET with empty body. */
|
||||
request(path: string, method?: 'GET' | 'PATCH' | 'POST' | 'PUT' | 'DELETE', body?: object): Promise<any>;
|
||||
};
|
||||
|
||||
export const gheApiToken = new Api<GheApi>({
|
||||
id: 'ghe',
|
||||
title: 'GHE',
|
||||
description: 'Enables you to talk to GHE, both reading repo contents and creating PRs',
|
||||
});
|
||||
|
||||
/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/users/#get-a-single-user */
|
||||
export type UserInfoResponse = {
|
||||
// The username of the user.
|
||||
login: string;
|
||||
|
||||
// The email of the user.
|
||||
email: string;
|
||||
};
|
||||
|
||||
/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/git/commits/#get-a-commit */
|
||||
export type CommitResponse = {
|
||||
// The sha of the commit.
|
||||
sha: string;
|
||||
|
||||
// The commit message.
|
||||
message: string;
|
||||
|
||||
// The tree (working dir contents) of the commit.
|
||||
tree: { sha: string };
|
||||
|
||||
// The parents of the commits.
|
||||
parents: Array<{ sha: string }>;
|
||||
|
||||
// The commit author.
|
||||
author: { date: string; name: string; email: string };
|
||||
|
||||
// The committer.
|
||||
committer: { date: string; name: string; email: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* These are the most interesting fields of the response.
|
||||
*
|
||||
* For the full response, see https://developer.github.com/v3/repos/contents/#get-contents
|
||||
*
|
||||
* This type differs from the API response in that content is always plain text (base64 decoded), and the encoding field is removed.
|
||||
*/
|
||||
export type FileResponse = {
|
||||
// Size of the file in bytes.
|
||||
size: number;
|
||||
|
||||
// Name of the file without the path.
|
||||
name: string;
|
||||
|
||||
// Path to the file.
|
||||
path: string;
|
||||
|
||||
// Contents of the file.
|
||||
content: string;
|
||||
|
||||
// Sha of the file (blob) contents.
|
||||
sha: string;
|
||||
|
||||
// Link to the file in GHE.
|
||||
html_url: string;
|
||||
|
||||
// Download URL for the file.
|
||||
download_url: string;
|
||||
};
|
||||
|
||||
/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/pulls/#get-a-single-pull-request */
|
||||
export type PullRequestResponse = {
|
||||
// The PR #
|
||||
number: number;
|
||||
|
||||
// Link to the PR in GHE
|
||||
html_url: string;
|
||||
|
||||
// Title of the PR
|
||||
title: string;
|
||||
// Body of the PR
|
||||
body: string;
|
||||
|
||||
// The state of the PR.
|
||||
state: 'open' | 'closed';
|
||||
|
||||
// Time the PR was created at, e.g. '2011-01-26T19:01:12Z'
|
||||
created_at: string;
|
||||
|
||||
// Number of commits in the PR.
|
||||
commits: number;
|
||||
|
||||
// Lines added by the PR.
|
||||
additions: number;
|
||||
// Lines removed by the PR.
|
||||
deletions: number;
|
||||
// Number of files changed by the PR.
|
||||
changed_files: number;
|
||||
};
|
||||
|
||||
export type BaseOptions = {
|
||||
// The org, e.g. 'backstage'
|
||||
org: string;
|
||||
|
||||
// The repo, e.g. 'backstage-frontend'
|
||||
repo: string;
|
||||
};
|
||||
|
||||
export type GetCommitOptions = BaseOptions & {
|
||||
// The sha of the commit.
|
||||
sha: string;
|
||||
};
|
||||
|
||||
export type GetShaOptions = BaseOptions & {
|
||||
// The ref the fetch the commit sha for, defaults to 'heads/master'
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type GetFileOptions = BaseOptions & {
|
||||
// The path to the file. e.g. 'docs/README.md'
|
||||
path: string;
|
||||
|
||||
// The commit sha to get the file from, defaults to master.
|
||||
sha?: string;
|
||||
};
|
||||
|
||||
export type CreateBranchOptions = BaseOptions & {
|
||||
// The name of the branch.
|
||||
branch: string;
|
||||
|
||||
// The commit sha that the branch should point to.
|
||||
sha: string;
|
||||
};
|
||||
|
||||
export type CreateCommitOptions = BaseOptions & {
|
||||
// The sha of the parent commit that this commit should be created on top of.
|
||||
parentCommitSha: string;
|
||||
|
||||
// The commit message.
|
||||
message: string;
|
||||
|
||||
// A list of changes to include in this commit.
|
||||
changes: Change[];
|
||||
};
|
||||
|
||||
export type CreatePullRequestWithBranchOptions = BaseOptions & {
|
||||
// The base branch to send the PR towards, defaults to 'master'.
|
||||
base?: string;
|
||||
|
||||
// The title of the PR.
|
||||
title: string;
|
||||
|
||||
// The body of the PR.
|
||||
body?: string;
|
||||
|
||||
// The name of the branch to create a PR for.
|
||||
branch: string;
|
||||
};
|
||||
|
||||
export type GetPullRequestOptions = BaseOptions & {
|
||||
// The PR #
|
||||
number: number;
|
||||
};
|
||||
|
||||
export type CreatePullRequestOptions = BaseOptions & {
|
||||
// The base branch to send the PR towards, defaults to 'master'.
|
||||
base?: string;
|
||||
|
||||
// The title of the PR.
|
||||
title: string;
|
||||
|
||||
// The body of the PR.
|
||||
body?: string;
|
||||
|
||||
// If this is specified it will be used as the parent commit sha, instead of
|
||||
// using the tip of the base branch.
|
||||
//
|
||||
// It is best to specify this if it is available, since you might override
|
||||
// recent changes to master otherwise. And it should always be used when
|
||||
// writing to files that are modified frequently.
|
||||
parentCommitSha?: string;
|
||||
|
||||
// A list of changes that will be included in the PR.
|
||||
changes: Change[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Change describes a modification to a file in a repo.
|
||||
*
|
||||
* If the file doesn't exist it will be created, and if it exists it will be overwritten.
|
||||
*
|
||||
* Change is used by createCommit and createPullRequest to describe changes it should include.
|
||||
*/
|
||||
export type Change = {
|
||||
// The path to the file to change.
|
||||
path: string;
|
||||
|
||||
// The new contents of the file.
|
||||
content: string | ArrayBuffer | ArrayBufferView | Blob | File;
|
||||
};
|
||||
|
||||
export type GheError = Error & {
|
||||
name: 'GheError';
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function isGheError(error: Error): error is GheError {
|
||||
return error.name === 'GheError';
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { initiateLogin } from 'shared/apis/gheAuth/gheAuth';
|
||||
|
||||
const OAuthGheSignInRequestPage = ({ targetUrl }) => (
|
||||
<Button color="primary" onClick={() => initiateLogin(targetUrl)}>
|
||||
Sign in
|
||||
</Button>
|
||||
);
|
||||
|
||||
OAuthGheSignInRequestPage.propTypes = {
|
||||
targetUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default OAuthGheSignInRequestPage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import OAuthGheSignInRequestPage from './OAuthGheSignInRequestPage';
|
||||
import { buildComponentInApp } from 'testUtils';
|
||||
import { validateLoginState } from './gheAuth';
|
||||
|
||||
describe('<OAuthGheSignInRequestPage />', () => {
|
||||
it('should render and initiate login', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http', host: 'localhost' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const rendered = buildComponentInApp(OAuthGheSignInRequestPage)
|
||||
.withTheme()
|
||||
.render({ targetUrl: '/here' });
|
||||
|
||||
fireEvent.click(rendered.getByText('Sign in'));
|
||||
const [, state] = window.location.match(/&state=(.*?)&/);
|
||||
expect(validateLoginState(state)).toBe('/here');
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import qs from 'qs';
|
||||
import { connect } from 'react-redux';
|
||||
import { GheClient } from 'shared/apis/ghe';
|
||||
import { getNewAccessToken, validateLoginState } from 'shared/apis/gheAuth/gheAuth';
|
||||
import Progress from 'shared/components/Progress';
|
||||
import * as gheAuthActions from 'shared/apis/gheAuth/actions';
|
||||
|
||||
const OAuthGheSignInResponsePage = ({ dispatch }) => {
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { code, state } = qs.parse(window.location.search.slice(1));
|
||||
const originalUrl = validateLoginState(state);
|
||||
|
||||
if (!code || !state || !originalUrl) {
|
||||
setError(new Error('Illegal request'));
|
||||
} else {
|
||||
getNewAccessToken(code, state)
|
||||
.then(accessToken => {
|
||||
return GheClient.fromAccessToken(accessToken)
|
||||
.getUserInfo()
|
||||
.then(userInfo => dispatch(gheAuthActions.gheLoggedIn(accessToken, userInfo, originalUrl)));
|
||||
})
|
||||
.catch(setError);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
return error ? error.toString() : <Progress />;
|
||||
};
|
||||
|
||||
export default connect()(OAuthGheSignInResponsePage);
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import OAuthGheSignInResponsePage from './OAuthGheSignInResponsePage';
|
||||
import { buildComponentInApp } from 'testUtils';
|
||||
import { initiateLogin } from './gheAuth';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore } from 'core/store';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { ConnectedRouter } from 'connected-react-router';
|
||||
import * as gheAuthActions from 'shared/apis/gheAuth/actions';
|
||||
import * as gheAuthSelectors from 'shared/apis/gheAuth/selectors';
|
||||
|
||||
describe('<OAuthGheSignInResponsePage />', () => {
|
||||
let store = null;
|
||||
let WrappedComponent = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/ghe/login'] });
|
||||
store = createStore(history);
|
||||
|
||||
WrappedComponent = () => (
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<Switch>
|
||||
<Route path="/ghe/login" component={OAuthGheSignInResponsePage} />
|
||||
<Route path="/here">after login</Route>
|
||||
<Route path="/">logged in</Route>
|
||||
</Switch>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should render and initiate login', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http', host: 'localhost' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
initiateLogin('/here');
|
||||
|
||||
const search = window.location.slice(window.location.indexOf('?'));
|
||||
window.location = { search: `${search}&code=123` };
|
||||
|
||||
fetch.mockResponses(
|
||||
[JSON.stringify({ access_token: 'abc' }), { status: 200 }],
|
||||
[JSON.stringify({ id: 'mock-user' }), { status: 200 }],
|
||||
);
|
||||
|
||||
expect(gheAuthSelectors.getUser(store.getState())).toBeNull();
|
||||
|
||||
const rendered = buildComponentInApp(WrappedComponent)
|
||||
.withTheme()
|
||||
.render({ targetUrl: '/here' });
|
||||
|
||||
rendered.getByTestId('progress');
|
||||
await rendered.findByText('after login');
|
||||
|
||||
expect(fetch.mock.calls[0][1].body).toMatch(/"code":"123"/);
|
||||
expect(gheAuthSelectors.getUser(store.getState())).toEqual({ accessToken: 'abc', userInfo: { id: 'mock-user' } });
|
||||
|
||||
store.dispatch(gheAuthActions.gheLogOut());
|
||||
|
||||
expect(gheAuthSelectors.getUser(store.getState())).toBeNull();
|
||||
});
|
||||
|
||||
it('should render error if login fails', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http', host: 'localhost' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
initiateLogin('/here');
|
||||
|
||||
const search = window.location.slice(window.location.indexOf('?'));
|
||||
window.location = { search: `${search}&code=123` };
|
||||
|
||||
fetch.mockResponse(JSON.stringify({ error: 'NOPE' }), { status: 401 });
|
||||
|
||||
const rendered = buildComponentInApp(WrappedComponent)
|
||||
.withTheme()
|
||||
.render({ targetUrl: '/here' });
|
||||
|
||||
rendered.getByTestId('progress');
|
||||
await rendered.findByText('Error: Failed to authorize: 401 Unauthorized');
|
||||
});
|
||||
|
||||
it('should render error if precondition check fails', () => {
|
||||
const rendered = buildComponentInApp(WrappedComponent)
|
||||
.withTheme()
|
||||
.render({ targetUrl: '/here' });
|
||||
|
||||
rendered.getByText('Error: Illegal request');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import * as actionConstants from 'shared/actions/actionConstants';
|
||||
import { removeCachedGheAccessToken, setCachedGheAccessToken } from 'shared/apis/gheAuth/gheAuth';
|
||||
|
||||
export function gheLoggedIn(accessToken, userInfo, originalUrl) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actionConstants.GHE_LOGGED_IN,
|
||||
payload: { accessToken, userInfo },
|
||||
});
|
||||
setCachedGheAccessToken(accessToken);
|
||||
if (originalUrl) {
|
||||
dispatch(push(originalUrl));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function gheLogOut() {
|
||||
return dispatch => {
|
||||
removeCachedGheAccessToken();
|
||||
dispatch({
|
||||
type: actionConstants.GHE_LOGGED_OUT,
|
||||
});
|
||||
// TODO: Show success notification
|
||||
};
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { urls } from 'shared/apis/baseUrls';
|
||||
import { env } from 'shared/apis/env';
|
||||
|
||||
/**
|
||||
* Checks whether a hostname represents a Slingshot host, and in that case returns
|
||||
* information about the slingshot build.
|
||||
*
|
||||
* @param {string} host - The hostname to check.
|
||||
* @returns {{}} - An object with the slingshot build `id` and `region`, both as strings.
|
||||
*/
|
||||
function getSlingshotInfo(host) {
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = host.match(/^backstage-backstage-frontend-([0-9]+)\.services\.([a-z0-9]+)\.spotify\.net$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, id, region] = match;
|
||||
return { id, region };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a hostname/IP represents the local host.
|
||||
*
|
||||
* @param host The host name or IP to inspect, with or without port number
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalHost(host) {
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!host.match(/^(0\.0\.0\.0|127\.0\.0\.1|localhost)(:\d+)?$/);
|
||||
}
|
||||
|
||||
const PRODUCTION_CLIENT_ID = '354bc7c49b64bf7d7bc8';
|
||||
const DEVELOPMENT_CLIENT_ID = 'b444743194e5f57b7f17';
|
||||
const SLINGSHOT_CLIENT_IDS = {
|
||||
'0': 'ae3c6b52a22ff5509fb7',
|
||||
'1': '5787beb3ee9c0f9dfc7d',
|
||||
'2': 'fafdcf9f860dc63ff86d',
|
||||
'3': '73521c219b3c62af996b',
|
||||
'4': 'd92e272e2c2c8b89f9c5',
|
||||
'5': '344acf7e426ad4b32eb5',
|
||||
'6': '724521bc9238bcf8e63f',
|
||||
'7': 'fd0eb21789e76e74f48a',
|
||||
'8': '4134c26941c896dae0c6',
|
||||
'9': '0409e26dfe87f78e96f5',
|
||||
'10': '2ba99717f2513cba2208',
|
||||
'11': '88a70d171a2f53e18966',
|
||||
'12': '8647a90097c26fb68698',
|
||||
'13': '0a69388c960f6987e6cd',
|
||||
'14': 'b2fe4cd9e61c3e71ddb5',
|
||||
'15': 'b50841e2e6dd50b4a3dc',
|
||||
'16': '1a37e7489299c5c8c6fd',
|
||||
'17': '626a5554b095de95561b',
|
||||
'18': 'c98129a1cdf02a4af285',
|
||||
'19': '97062a432db9972b6f80',
|
||||
};
|
||||
|
||||
// TODO: non-prefixed keys are deprecated, remove once this has been live long enough that ppl have refreshed their tokens
|
||||
const TOKEN_KEY = 'gheAccessToken';
|
||||
const STATE_KEY = 'gheOauthState';
|
||||
const REDIRECT_URL_KEY = 'gheOauthRedirectUrl';
|
||||
|
||||
function storeValue(key, value) {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function getValue(key) {
|
||||
const value = window.localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : value;
|
||||
}
|
||||
|
||||
function removeValue(key) {
|
||||
return window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
function getClientId(host) {
|
||||
if (isLocalHost(host)) {
|
||||
return DEVELOPMENT_CLIENT_ID;
|
||||
}
|
||||
const slingshotInfo = getSlingshotInfo(host);
|
||||
if (slingshotInfo) {
|
||||
return SLINGSHOT_CLIENT_IDS[slingshotInfo.id];
|
||||
}
|
||||
|
||||
return PRODUCTION_CLIENT_ID;
|
||||
}
|
||||
|
||||
function gheOAuthConfig() {
|
||||
const { protocol, host } = window.location;
|
||||
|
||||
return {
|
||||
// Access tokens could in theory be fetched directly from GHE, but the call requires a
|
||||
// client_secret which we don't want to distribute to browsers. We do that specific call
|
||||
// through the proxy instead which has the ability to decorate it with the correct client
|
||||
// secret.
|
||||
accessTokenEndpoint: `${urls.proxy}/api/backend/ghe-proxy/login/oauth/access_token`,
|
||||
// This is where the user is redirected initially, to authorize our application to talk to GHE.
|
||||
authorizationEndpoint: 'https://ghe.spotify.net/login/oauth/authorize',
|
||||
// This is where the user shall be redirected back, after completing the above authorization.
|
||||
redirectUri: `${protocol}//${host}/oauth/ghe`,
|
||||
// The root of the GHE API.
|
||||
apiEndpoint: `${urls.ghe}/api/v3`,
|
||||
// The ID of the identity that our application wants to use when performing operations in GHE.
|
||||
clientId: getClientId(host),
|
||||
};
|
||||
}
|
||||
|
||||
// Store a GHE access token. This lets us act on the user's behalf across sessions, without them
|
||||
// re-authorizing us over and over.
|
||||
export function setCachedGheAccessToken(accessToken) {
|
||||
storeValue('accessToken', accessToken);
|
||||
storeValue(TOKEN_KEY, accessToken);
|
||||
}
|
||||
|
||||
// Attempt to fetch a stored GHE access token.
|
||||
export function getCachedGheAccessToken() {
|
||||
return getValue(TOKEN_KEY) || getValue('accessToken');
|
||||
}
|
||||
|
||||
// Forget a stored GHE access token.
|
||||
export function removeCachedGheAccessToken() {
|
||||
const removed = removeValue('accessToken');
|
||||
return removeValue(TOKEN_KEY) || removed;
|
||||
}
|
||||
|
||||
function randomBase64String(length) {
|
||||
if (env.isTest) {
|
||||
// window.crypto is not available in jsdom, get rid of this when it is https://github.com/jsdom/jsdom/issues/1612
|
||||
return Math.random().toString(36);
|
||||
}
|
||||
const randomValues = crypto.getRandomValues(new Uint8Array((length * 1.2 + 10) | 0));
|
||||
const string = btoa(String.fromCharCode(...randomValues)).replace(/[+\/=]/g, '');
|
||||
return string.slice(0, length);
|
||||
}
|
||||
|
||||
/*
|
||||
* Initiates a new GHE login cycle, by redirecting the user to the GHE authorization page. When the
|
||||
* user has consented, they will be redirected back to the redirect_uri together with a temporary
|
||||
* code that they generate, and the state that we generate in here.
|
||||
*/
|
||||
export function initiateLogin(targetUrl) {
|
||||
const oauthState = randomBase64String(20);
|
||||
const config = gheOAuthConfig();
|
||||
storeValue('oauthState', oauthState);
|
||||
storeValue('oauthRedirectUrl', targetUrl);
|
||||
storeValue(STATE_KEY, oauthState); // Store for validation later (see below)
|
||||
storeValue(REDIRECT_URL_KEY, targetUrl);
|
||||
window.location = [
|
||||
config.authorizationEndpoint,
|
||||
`?client_id=${config.clientId}`,
|
||||
`&redirect_uri=${config.redirectUri}`,
|
||||
`&state=${oauthState}`,
|
||||
'&scope=user,admin:org,repo,gist',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/*
|
||||
* When the user has authorized us and the redirect has happened back to our site, we need to check
|
||||
* that the state parameter we got back was the one that we generated earlier. This protects against
|
||||
* some forms of attack. Returns null on failure, or the desired target URL if successful.
|
||||
*/
|
||||
export function validateLoginState(state) {
|
||||
const originalState = getValue(STATE_KEY) || getValue('oauthState');
|
||||
removeValue('oauthState');
|
||||
removeValue(STATE_KEY);
|
||||
if (state !== originalState) {
|
||||
return null;
|
||||
} else {
|
||||
return getValue(REDIRECT_URL_KEY) || getValue('oauthRedirectUrl');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* When a valid login redirect has happened, we take the code and state and send those again to GHE,
|
||||
* in order to get a long lived access token. This token is tied to the current user, and can be
|
||||
* supplied in future API calls.
|
||||
*/
|
||||
export async function getNewAccessToken(code, state) {
|
||||
const config = gheOAuthConfig();
|
||||
|
||||
const response = await fetch(config.accessTokenEndpoint, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
state: state,
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to authorize: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token || data.error) {
|
||||
throw new Error(`Failed to authorize: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import {
|
||||
initiateLogin,
|
||||
validateLoginState,
|
||||
getNewAccessToken,
|
||||
setCachedGheAccessToken,
|
||||
getCachedGheAccessToken,
|
||||
removeCachedGheAccessToken,
|
||||
isLocalHost,
|
||||
} from './gheAuth';
|
||||
|
||||
describe('isLocalHost', () => {
|
||||
it('handles anomalous input', () => {
|
||||
expect(isLocalHost('')).toBe(false);
|
||||
expect(isLocalHost(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('deals with it', () => {
|
||||
expect(isLocalHost('localhost')).toBe(true);
|
||||
expect(isLocalHost('localhost:3000')).toBe(true);
|
||||
expect(isLocalHost('localhosts')).toBe(false);
|
||||
expect(isLocalHost('127.0.0.1')).toBe(true);
|
||||
expect(isLocalHost('0.0.0.0')).toBe(true);
|
||||
expect(isLocalHost('google.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gheClient', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http', host: 'localhost' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should initiate login', () => {
|
||||
initiateLogin('/here');
|
||||
|
||||
// Remove once removed
|
||||
const oldState = JSON.parse(window.localStorage.getItem('oauthState'));
|
||||
expect(oldState.length).toBeGreaterThan(8);
|
||||
expect(JSON.parse(window.localStorage.getItem('oauthRedirectUrl'))).toBe('/here');
|
||||
expect(window.location).toMatch(`state=${oldState}`);
|
||||
|
||||
const state = JSON.parse(window.localStorage.getItem('gheOauthState'));
|
||||
expect(state.length).toBeGreaterThan(8);
|
||||
expect(JSON.parse(window.localStorage.getItem('gheOauthRedirectUrl'))).toBe('/here');
|
||||
expect(window.location).toMatch(`state=${state}`);
|
||||
});
|
||||
|
||||
it('should validate login state', () => {
|
||||
initiateLogin('/here');
|
||||
|
||||
expect(validateLoginState(JSON.parse(window.localStorage.getItem('oauthState')))).toBe('/here');
|
||||
expect(validateLoginState(JSON.parse(window.localStorage.getItem('gheOauthState')))).toBe('/here');
|
||||
});
|
||||
|
||||
it('should detect invalid login state', () => {
|
||||
initiateLogin('/here');
|
||||
|
||||
expect(validateLoginState('not-the-state')).toBeNull();
|
||||
});
|
||||
|
||||
it('should be able to cache access token', () => {
|
||||
expect(getCachedGheAccessToken()).toBeNull();
|
||||
setCachedGheAccessToken('abc');
|
||||
expect(getCachedGheAccessToken()).toBe('abc');
|
||||
removeCachedGheAccessToken();
|
||||
expect(getCachedGheAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('should get access token', async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify({ access_token: 'abc' }), { status: 200 });
|
||||
await expect(getNewAccessToken('my-code', 'my-state')).resolves.toBe('abc');
|
||||
});
|
||||
|
||||
it('should fail to get access token', async () => {
|
||||
fetch.mockRejectOnce(new Error('it failed'));
|
||||
await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({ message: 'it failed' });
|
||||
|
||||
fetch.mockResponseOnce(JSON.stringify({ access_token: 'abc' }), { status: 401, statusText: 'NOPE' });
|
||||
await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({
|
||||
message: 'Failed to authorize: 401 NOPE',
|
||||
});
|
||||
|
||||
fetch.mockResponseOnce(JSON.stringify({ error: 'fail' }), { status: 200 });
|
||||
await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({
|
||||
message: 'Failed to authorize: {"error":"fail"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as actionConstants from 'shared/actions/actionConstants';
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
const gheAuthReducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case actionConstants.GHE_LOGGED_IN:
|
||||
return _gheLoggedIn(state, action);
|
||||
case actionConstants.GHE_LOGGED_OUT:
|
||||
return _gheLoggedOut(state, action);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default gheAuthReducer;
|
||||
|
||||
// Private methods below
|
||||
|
||||
const _gheLoggedIn = (state, action) => {
|
||||
return { ...state, user: action.payload };
|
||||
};
|
||||
|
||||
const _gheLoggedOut = state => {
|
||||
return { ...state, user: null };
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const getUser = state => state.gheAuth.user;
|
||||
@@ -1,150 +0,0 @@
|
||||
import GoogleAuth from './GoogleAuth';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
const theFuture = new Date(Date.now() + 3600000);
|
||||
const thePast = new Date(Date.now() - 10);
|
||||
|
||||
describe('GoogleAuth', () => {
|
||||
it('should save result form createSession', async () => {
|
||||
const createSession = jest.fn().mockResolvedValue({ expiresAt: theFuture });
|
||||
const refreshSession = jest.fn().mockResolvedValue(undefined);
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
await googleAuth.getSession({});
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
|
||||
await googleAuth.getSession({});
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ask consent only if scopes have changed', async () => {
|
||||
const createSession = jest.fn();
|
||||
const refreshSession = jest.fn().mockResolvedValue(undefined);
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockResolvedValue({ scopes: GoogleScopes.from('a'), expiresAt: theFuture });
|
||||
await googleAuth.getSession({ scope: 'a' });
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
|
||||
await googleAuth.getSession({ scope: 'a' });
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
|
||||
await googleAuth.getSession({ scope: 'b' });
|
||||
expect(createSession).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should check for session expiry', async () => {
|
||||
const createSession = jest.fn();
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValue({});
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockResolvedValue({ scopes: GoogleScopes.from('a'), expiresAt: thePast });
|
||||
|
||||
await googleAuth.getSession({ scope: 'a' });
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
|
||||
await googleAuth.getSession({ scope: 'a' });
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle user closed popup', async () => {
|
||||
const createSession = jest.fn();
|
||||
const refreshSession = jest.fn().mockResolvedValue(undefined);
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockRejectedValueOnce(new Error('some error'));
|
||||
await expect(googleAuth.getSession({ scope: 'a' })).rejects.toThrow('some error');
|
||||
});
|
||||
|
||||
it('should logout and reload', async () => {
|
||||
jest.spyOn(window.location, 'reload').mockImplementation();
|
||||
const removeSession = jest.fn();
|
||||
const googleAuth = new GoogleAuth({ removeSession } as any);
|
||||
|
||||
await googleAuth.logout();
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
expect(removeSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get refreshed access token', async () => {
|
||||
const refreshSession = jest.fn().mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getAccessToken()).toBe('access-token');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should get refreshed id token', async () => {
|
||||
const refreshSession = jest.fn().mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken()).toBe('id-token');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should get optional id token', async () => {
|
||||
const refreshSession = jest.fn().mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not get optional id token', async () => {
|
||||
const refreshSession = jest.fn().mockResolvedValue(undefined);
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken({ optional: true })).toBe('');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share popup closed errors', async () => {
|
||||
const error = new Error('NOPE');
|
||||
error.name = 'RejectedError';
|
||||
const createSession = jest.fn().mockRejectedValue(error);
|
||||
const refreshSession = jest.fn().mockResolvedValue({
|
||||
accessToken: 'access-token',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.from('not-enough'),
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
|
||||
await expect(googleAuth.getAccessToken()).resolves.toBe('access-token');
|
||||
|
||||
const promise1 = googleAuth.getAccessToken('more');
|
||||
const promise2 = googleAuth.getAccessToken('more');
|
||||
await expect(promise1).rejects.toBe(error);
|
||||
await expect(promise2).rejects.toBe(error);
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
expect(createSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait for all session refreshes', async () => {
|
||||
const initialSession = { idToken: 'token1', expiresAt: theFuture };
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initialSession)
|
||||
.mockResolvedValue({ idToken: 'token2', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
// Grab the expired session first
|
||||
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
|
||||
initialSession.expiresAt = thePast;
|
||||
|
||||
const promise1 = googleAuth.getIdToken();
|
||||
const promise2 = googleAuth.getIdToken();
|
||||
const promise3 = googleAuth.getIdToken();
|
||||
await expect(promise1).resolves.toBe('token2');
|
||||
await expect(promise2).resolves.toBe('token2');
|
||||
await expect(promise3).resolves.toBe('token2');
|
||||
expect(refreshSession).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import Api from 'shared/pluginApi/Api';
|
||||
import { OAuthScopes } from '../oauth/types';
|
||||
import { AuthHelper, googleAuthHelper } from './GoogleAuthHelper';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleAuthApi, GoogleSession, IdTokenOptions } from './types';
|
||||
|
||||
class GoogleAuth implements GoogleAuthApi {
|
||||
private currentSession: GoogleSession | undefined;
|
||||
private newSessionPromise: Promise<GoogleSession | undefined> | undefined;
|
||||
|
||||
constructor(private readonly helper: AuthHelper) {}
|
||||
|
||||
async getAccessToken(scope?: string | string[]) {
|
||||
const session = await this.getSession({ optional: false, scope });
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
async getIdToken({ optional }: IdTokenOptions = {}) {
|
||||
const session = await this.getSession({ optional: optional || false });
|
||||
if (session) {
|
||||
return session.idToken;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getSession(options: { optional: false; scope?: string | string[] }): Promise<GoogleSession>;
|
||||
async getSession(options: { optional?: boolean; scope?: string | string[] }): Promise<GoogleSession | undefined>;
|
||||
async getSession(options: { optional?: boolean; scope?: string | string[] }): Promise<GoogleSession | undefined> {
|
||||
if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) {
|
||||
if (!this.sessionWillExpire(this.currentSession!)) {
|
||||
return this.currentSession!;
|
||||
}
|
||||
|
||||
// Is a session refresh already in progress? If so, at this point we know that
|
||||
// that refresh was for a set of scopes that is at least as large as what we need.
|
||||
// So just return that ongoing refresh promise, it'll suffice for the user's needs.
|
||||
while (this.newSessionPromise) {
|
||||
const newSession = await this.newSessionPromise;
|
||||
if (newSession) {
|
||||
return newSession;
|
||||
}
|
||||
}
|
||||
|
||||
this.newSessionPromise = this.helper.refreshSession();
|
||||
try {
|
||||
const newSession = await this.newSessionPromise;
|
||||
this.currentSession = newSession;
|
||||
return newSession;
|
||||
} finally {
|
||||
this.newSessionPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.newSessionPromise) {
|
||||
try {
|
||||
await this.newSessionPromise;
|
||||
} catch (error) {
|
||||
if (error.name === 'RejectedError') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.getSession(options);
|
||||
}
|
||||
|
||||
// The user may still have a valid refresh token in their cookies. Attempt to
|
||||
// initiate a fresh session through the backend using that refresh token.
|
||||
if (!this.currentSession) {
|
||||
try {
|
||||
// This is an "optional" session request, meaning it will return undefined if we don't have a session.
|
||||
this.newSessionPromise = this.helper.refreshSession(true);
|
||||
try {
|
||||
const newSession = await this.newSessionPromise;
|
||||
this.currentSession = newSession;
|
||||
} finally {
|
||||
this.newSessionPromise = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Initial session request failed, ${error}`);
|
||||
}
|
||||
|
||||
// We may not have received a session in the above request, and missing session won't throw
|
||||
if (this.currentSession) {
|
||||
// The session might not have the scopes requested so go back and check again
|
||||
return this.getSession(options);
|
||||
}
|
||||
|
||||
// If we continue here we will show a popup, so exit if this is an optional session request.
|
||||
if (options.optional) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.newSessionPromise = this.helper.createSession(this.getExtendedScope(options.scope));
|
||||
try {
|
||||
const newSession = await this.newSessionPromise;
|
||||
this.currentSession = newSession;
|
||||
return newSession;
|
||||
} finally {
|
||||
this.newSessionPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.helper.removeSession();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private sessionExistsAndHasScope(session: GoogleSession | undefined, scope?: string | string[]): boolean {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
if (!scope) {
|
||||
return true;
|
||||
}
|
||||
return session.scopes.hasScopes(scope);
|
||||
}
|
||||
|
||||
private sessionWillExpire(session: GoogleSession) {
|
||||
const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000;
|
||||
return expiresInSec < 60 * 5;
|
||||
}
|
||||
|
||||
private getExtendedScope(scope?: string | string[]) {
|
||||
let newScope: OAuthScopes = GoogleScopes.default();
|
||||
if (this.currentSession) {
|
||||
newScope = this.currentSession.scopes;
|
||||
}
|
||||
if (scope) {
|
||||
newScope = newScope.extend(scope);
|
||||
}
|
||||
return newScope.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export const googleAuthApiToken = new Api<GoogleAuthApi>({
|
||||
id: 'googleAuth',
|
||||
title: 'Google Auth',
|
||||
description: 'Provides Google tokens and manages a Google session',
|
||||
});
|
||||
|
||||
export const googleAuth = new GoogleAuth(googleAuthHelper);
|
||||
|
||||
export default GoogleAuth;
|
||||
@@ -1,185 +0,0 @@
|
||||
import GoogleAuthBarrier, { useGetGoogleAccessToken, useGetGoogleIdToken } from './GoogleAuthBarrier';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { withLogCollector } from 'testUtils';
|
||||
import { googleAuth } from './GoogleAuth';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { useInterval, useUpdate } from 'react-use';
|
||||
|
||||
describe('GoogleAuthBarrier', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('does not explode', () => {
|
||||
jest.spyOn(googleAuth, 'getSession').mockResolvedValueOnce({
|
||||
accessToken: 'mockAccessToken',
|
||||
idToken: 'mockIdToken',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const rendered = render(
|
||||
<GoogleAuthBarrier>
|
||||
<div>hej</div>
|
||||
</GoogleAuthBarrier>,
|
||||
);
|
||||
expect(rendered.queryByText('hej')).not.toBeInTheDocument();
|
||||
rendered.getByTestId('progress');
|
||||
});
|
||||
|
||||
it('should fail if no context is available', () => {
|
||||
const MyAccessComponent = () => {
|
||||
const getToken = useGetGoogleAccessToken();
|
||||
return <span>access token is {getToken()}</span>;
|
||||
};
|
||||
const MyIdComponent = () => {
|
||||
const getToken = useGetGoogleIdToken();
|
||||
return <span>id token is {getToken()}</span>;
|
||||
};
|
||||
|
||||
const accessLogs = withLogCollector(['error'], () => {
|
||||
expect(() => render(<MyAccessComponent />)).toThrowError(/You can only use this hook inside a GoogleAuthBarrier/);
|
||||
});
|
||||
expect(accessLogs.error.length).toBe(2);
|
||||
expect(accessLogs.error[0]).toMatch(
|
||||
/^Error: Uncaught \[Error: You can only use this hook inside a GoogleAuthBarrier/,
|
||||
);
|
||||
expect(accessLogs.error[1]).toMatch(/^The above error occurred in the <MyAccessComponent> component/);
|
||||
|
||||
const idLogs = withLogCollector(['error'], () => {
|
||||
expect(() => render(<MyIdComponent />)).toThrowError(/You can only use this hook inside a GoogleAuthBarrier/);
|
||||
});
|
||||
expect(idLogs.error.length).toBe(2);
|
||||
expect(idLogs.error[0]).toMatch(/^Error: Uncaught \[Error: You can only use this hook inside a GoogleAuthBarrier/);
|
||||
expect(idLogs.error[1]).toMatch(/^The above error occurred in the <MyIdComponent> component/);
|
||||
});
|
||||
|
||||
it('should make tokens available', async () => {
|
||||
const MyAccessComponent = () => {
|
||||
const getToken = useGetGoogleAccessToken();
|
||||
return <span>access token is {getToken()}</span>;
|
||||
};
|
||||
const MyIdComponent = () => {
|
||||
const getToken = useGetGoogleIdToken();
|
||||
return <span>id token is {getToken()}</span>;
|
||||
};
|
||||
|
||||
jest.spyOn(googleAuth, 'getSession').mockResolvedValueOnce({
|
||||
accessToken: 'mockAccessToken',
|
||||
idToken: 'mockIdToken',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const rendered = render(
|
||||
<GoogleAuthBarrier>
|
||||
<MyAccessComponent />
|
||||
<MyIdComponent />
|
||||
</GoogleAuthBarrier>,
|
||||
);
|
||||
|
||||
await rendered.findByText('access token is mockAccessToken');
|
||||
await rendered.findByText('id token is mockIdToken');
|
||||
});
|
||||
|
||||
it('handles errors and retries', async () => {
|
||||
const getSession = jest
|
||||
.spyOn(googleAuth, 'getSession')
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce({
|
||||
accessToken: 'mockAccessToken',
|
||||
idToken: 'mockIdToken',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const rendered = render(
|
||||
<GoogleAuthBarrier>
|
||||
<div>hej</div>
|
||||
</GoogleAuthBarrier>,
|
||||
);
|
||||
|
||||
const button = await rendered.findByText('Retry');
|
||||
expect(getSession).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(button);
|
||||
await rendered.findByText('hej');
|
||||
expect(getSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('issues refreshes', async () => {
|
||||
jest
|
||||
.spyOn(googleAuth, 'getSession')
|
||||
.mockResolvedValueOnce({
|
||||
accessToken: 'mockAccessToken1',
|
||||
idToken: 'mockIdToken1',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
accessToken: 'mockAccessToken2',
|
||||
idToken: 'mockIdToken2',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const Component = () => {
|
||||
const getToken = useGetGoogleAccessToken();
|
||||
const update = useUpdate();
|
||||
useInterval(update, 5);
|
||||
return <span>{getToken()}</span>;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<GoogleAuthBarrier refreshInterval={100}>
|
||||
<Component />
|
||||
</GoogleAuthBarrier>,
|
||||
);
|
||||
|
||||
await rendered.findByText('mockAccessToken1');
|
||||
|
||||
await rendered.findByText('mockAccessToken2');
|
||||
});
|
||||
|
||||
it('should show error if refresh is rejected', async () => {
|
||||
const rejectError = new Error('NOPE');
|
||||
rejectError.name = 'PopupClosedError';
|
||||
|
||||
jest
|
||||
.spyOn(googleAuth, 'getSession')
|
||||
.mockResolvedValueOnce({
|
||||
accessToken: 'mockAccessToken1',
|
||||
idToken: 'mockIdToken1',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
})
|
||||
.mockRejectedValueOnce(rejectError)
|
||||
.mockResolvedValue({
|
||||
accessToken: 'mockAccessToken2',
|
||||
idToken: 'mockIdToken2',
|
||||
scopes: GoogleScopes.from('mockscope'),
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const Component = () => {
|
||||
const getToken = useGetGoogleAccessToken();
|
||||
const update = useUpdate();
|
||||
useInterval(update, 10);
|
||||
return <span>{getToken()}</span>;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<GoogleAuthBarrier refreshInterval={50}>
|
||||
<Component />
|
||||
</GoogleAuthBarrier>,
|
||||
);
|
||||
|
||||
await rendered.findByText('mockAccessToken1');
|
||||
|
||||
await rendered.findByText('Google auth failed, PopupClosedError: NOPE');
|
||||
|
||||
fireEvent.click(rendered.getByText('Retry'));
|
||||
|
||||
await rendered.findByText('mockAccessToken2');
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { createContext, FC, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { GoogleSession } from './types';
|
||||
import { googleAuth } from './GoogleAuth';
|
||||
import Progress from 'shared/components/Progress';
|
||||
|
||||
const DEFAULT_INTERVAL = 60 * 1000;
|
||||
|
||||
type Props = {
|
||||
scope?: string | string[];
|
||||
refreshInterval?: number;
|
||||
};
|
||||
|
||||
const Context = createContext<(() => GoogleSession) | undefined>(undefined);
|
||||
|
||||
export const useGetGoogleAccessToken = (): (() => String) => {
|
||||
const getSession = useContext(Context);
|
||||
if (!getSession) {
|
||||
throw new Error('You can only use this hook inside a GoogleAuthBarrier');
|
||||
}
|
||||
|
||||
return useCallback(() => getSession().accessToken, [getSession]);
|
||||
};
|
||||
|
||||
export const useGetGoogleIdToken = (): (() => String) => {
|
||||
const getSession = useContext(Context);
|
||||
if (!getSession) {
|
||||
throw new Error('You can only use this hook inside a GoogleAuthBarrier');
|
||||
}
|
||||
|
||||
return useCallback(() => getSession().idToken, [getSession]);
|
||||
};
|
||||
|
||||
const GoogleAuthBarrier: FC<Props> = ({ scope, refreshInterval = DEFAULT_INTERVAL, children }) => {
|
||||
const ref = useRef<GoogleSession>();
|
||||
const [state, setState] = useState({ loading: true, error: null });
|
||||
const [retryAttempts, setRetryAttempts] = useState(0);
|
||||
const getSession = useCallback(() => ref.current!, []);
|
||||
const api = googleAuth;
|
||||
const scopeString = Array.isArray(scope) ? scope.join(' ') : scope;
|
||||
|
||||
const handleRetry = () => {
|
||||
setState({ loading: true, error: null });
|
||||
setRetryAttempts(x => x + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timer | undefined;
|
||||
|
||||
const initialGet = async () => {
|
||||
try {
|
||||
const googleSession = await api.getSession({ optional: false, scope: scopeString });
|
||||
ref.current = googleSession;
|
||||
setState({ loading: false, error: null });
|
||||
|
||||
timeoutId = setTimeout(refresh, refreshInterval);
|
||||
} catch (error) {
|
||||
setState({ loading: false, error });
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const googleSession = await api.getSession({ optional: false, scope: scopeString });
|
||||
ref.current = googleSession;
|
||||
timeoutId = setTimeout(refresh, refreshInterval);
|
||||
} catch (error) {
|
||||
if (error.name === 'PopupClosedError') {
|
||||
ref.current = undefined;
|
||||
setState({ loading: false, error });
|
||||
} else {
|
||||
timeoutId = setTimeout(refresh, 5 * 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialGet();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [retryAttempts, refreshInterval, api, scopeString]);
|
||||
|
||||
if (state.loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
if (state.error) {
|
||||
return (
|
||||
<div>
|
||||
Google auth failed, {String(state.error)} <button onClick={handleRetry}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Context.Provider value={getSession} children={children} />;
|
||||
};
|
||||
|
||||
export default GoogleAuthBarrier;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { fireEvent, render, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Observable from 'zen-observable';
|
||||
import GoogleAuthDialog from './GoogleAuthDialog';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleSession } from './types';
|
||||
import { PendingRequest } from '../oauth/OAuthPendingRequests';
|
||||
import { BasicOAuthScopes } from '../oauth/BasicOAuthScopes';
|
||||
import { OAuthScopes } from '../oauth/types';
|
||||
|
||||
const mockPending = {
|
||||
scopes: BasicOAuthScopes.from('a b'),
|
||||
resolve: jest.fn(),
|
||||
reject: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSession: GoogleSession = {
|
||||
scopes: GoogleScopes.from('profile'),
|
||||
idToken: 'i',
|
||||
accessToken: 'a',
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
|
||||
describe('GoogleAuthDialog', () => {
|
||||
it('should render without exploding', () => {
|
||||
const rendered = render(
|
||||
<GoogleAuthDialog scopesRequest$={new Observable(() => {})} onRequestConsent={jest.fn()} />,
|
||||
);
|
||||
expect(rendered).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger the popup flow', async () => {
|
||||
let subscriber: ZenObservable.SubscriptionObserver<PendingRequest<GoogleSession> | undefined>;
|
||||
const onRequestConsent: (scopes: OAuthScopes) => Promise<GoogleSession> = () => Promise.resolve(mockSession);
|
||||
const onRequestConsentSpy = jest.fn(onRequestConsent);
|
||||
const rendered = render(
|
||||
<GoogleAuthDialog
|
||||
scopesRequest$={
|
||||
new Observable(s => {
|
||||
subscriber = s;
|
||||
})
|
||||
}
|
||||
onRequestConsent={onRequestConsentSpy}
|
||||
/>,
|
||||
);
|
||||
|
||||
subscriber!.next(mockPending);
|
||||
await rendered.findByText('Google Auth Required');
|
||||
fireEvent.click(rendered.getByText('Continue'));
|
||||
subscriber!.next(undefined);
|
||||
await waitForElementToBeRemoved(() => rendered.getByText('Google Auth Required'));
|
||||
|
||||
expect(onRequestConsentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onRequestConsentSpy.mock.calls[0]![0].toString()).toBe('a b');
|
||||
expect(mockPending.resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle rejection', async () => {
|
||||
let subscriber: ZenObservable.SubscriptionObserver<PendingRequest<GoogleSession> | undefined>;
|
||||
const onRequestConsent = jest.fn();
|
||||
const rendered = render(
|
||||
<GoogleAuthDialog
|
||||
scopesRequest$={
|
||||
new Observable(s => {
|
||||
subscriber = s;
|
||||
})
|
||||
}
|
||||
onRequestConsent={onRequestConsent}
|
||||
/>,
|
||||
);
|
||||
|
||||
subscriber!.next(mockPending);
|
||||
await rendered.findByText('Google Auth Required');
|
||||
fireEvent.click(rendered.getByText('Reject'));
|
||||
|
||||
expect(onRequestConsent).not.toHaveBeenCalled();
|
||||
expect(mockPending.reject).toHaveBeenCalledTimes(1);
|
||||
expect(mockPending.reject.mock.calls[0][0].name).toBe('PopupClosedError');
|
||||
});
|
||||
|
||||
it('should show consent errors', async () => {
|
||||
let subscriber: ZenObservable.SubscriptionObserver<PendingRequest<GoogleSession> | undefined>;
|
||||
const onRequestConsent = jest.fn(() => Promise.reject(new Error('BOOM')));
|
||||
const rendered = render(
|
||||
<GoogleAuthDialog
|
||||
scopesRequest$={
|
||||
new Observable(s => {
|
||||
subscriber = s;
|
||||
})
|
||||
}
|
||||
onRequestConsent={onRequestConsent}
|
||||
/>,
|
||||
);
|
||||
|
||||
subscriber!.next(mockPending);
|
||||
await rendered.findByText('Google Auth Required');
|
||||
fireEvent.click(rendered.getByText('Continue'));
|
||||
await rendered.findByText('BOOM');
|
||||
|
||||
expect(onRequestConsent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from '@material-ui/core';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
import { PendingRequest } from 'shared/apis/oauth/OAuthPendingRequests';
|
||||
import { OAuthScopes } from 'shared/apis/oauth/types';
|
||||
import Button from 'shared/components/Button';
|
||||
import Observable from 'zen-observable';
|
||||
import { googleAuthHelper } from './GoogleAuthHelper';
|
||||
import { googleAuthPendingRequests } from './GoogleAuthPendingRequests';
|
||||
import { GoogleSession } from './types';
|
||||
|
||||
type Props = {
|
||||
scopesRequest$: Observable<PendingRequest<GoogleSession>>;
|
||||
onRequestConsent: (scopes: OAuthScopes) => Promise<GoogleSession>;
|
||||
};
|
||||
|
||||
const defaultProps: Props = {
|
||||
scopesRequest$: googleAuthPendingRequests.pending(),
|
||||
onRequestConsent: scopes => googleAuthHelper.showPopup(scopes.toString()),
|
||||
};
|
||||
|
||||
const GoogleAuthDialog: FC<Props> = props => {
|
||||
const { scopesRequest$, onRequestConsent } = { ...defaultProps, ...props };
|
||||
const [error, setError] = useState<Error>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scopesRequest = useObservable(scopesRequest$);
|
||||
|
||||
const handleContinue = async () => {
|
||||
const currentRequest = scopesRequest;
|
||||
|
||||
if (currentRequest && currentRequest.scopes) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const session = await onRequestConsent(currentRequest.scopes);
|
||||
currentRequest.resolve(session);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
const error = new Error('Google auth failed, the user rejected');
|
||||
error.name = 'PopupClosedError';
|
||||
scopesRequest!.reject(error);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(scopesRequest && scopesRequest.scopes)}>
|
||||
<DialogTitle>Google Auth Required</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Some content on this page requires you to authenticate with Google.</DialogContentText>
|
||||
{error && <Typography color="error">{error.message || 'An unspecified error occurred'}</Typography>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleReject}>Reject</Button>
|
||||
<Button onClick={handleContinue} disabled={busy} color="primary">
|
||||
Continue
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleAuthDialog;
|
||||
@@ -1,212 +0,0 @@
|
||||
import GoogleAuthHelper from './GoogleAuthHelper';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
const anyFetch = fetch as any;
|
||||
|
||||
const pendingRequests = {
|
||||
request: jest.fn(),
|
||||
resolve: jest.fn(),
|
||||
reject: jest.fn(),
|
||||
pending: jest.fn(),
|
||||
};
|
||||
|
||||
describe('GoogleAuthHelper', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should refresh a session', async () => {
|
||||
anyFetch.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
idToken: 'mock-id-token',
|
||||
accessToken: 'mock-access-token',
|
||||
scopes: 'a b c',
|
||||
expiresInSeconds: '60',
|
||||
}),
|
||||
);
|
||||
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
const session = await helper.refreshSession();
|
||||
expect(session.idToken).toBe('mock-id-token');
|
||||
expect(session.accessToken).toBe('mock-access-token');
|
||||
expect(session.scopes.hasScopes('a b c')).toBe(true);
|
||||
expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000);
|
||||
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000);
|
||||
});
|
||||
|
||||
it('should handle failure to refresh session', async () => {
|
||||
anyFetch.mockRejectOnce(new Error('Network NOPE'));
|
||||
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: true, pendingRequests });
|
||||
await expect(helper.refreshSession()).rejects.toThrow('Auth refresh request failed, Error: Network NOPE');
|
||||
});
|
||||
|
||||
it('should handle failure response when refreshing session', async () => {
|
||||
anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' });
|
||||
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
await expect(helper.refreshSession()).rejects.toThrow('Auth refresh request failed with status NOPE');
|
||||
});
|
||||
|
||||
it('should fail if popup could not be shown', async () => {
|
||||
pendingRequests.request.mockRejectedValueOnce(new Error('BAH'));
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
await expect(helper.createSession('a b')).rejects.toThrow('BAH');
|
||||
});
|
||||
|
||||
it('should show an auth popup', async () => {
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
|
||||
const openSpy = jest.spyOn(window, 'open');
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const popupMock = { closed: false };
|
||||
|
||||
openSpy.mockReturnValue(popupMock as Window);
|
||||
pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString()));
|
||||
|
||||
const sessionPromise = helper.createSession('a b');
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(openSpy.mock.calls[0][0]).toBe(
|
||||
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
|
||||
);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
|
||||
|
||||
await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting');
|
||||
|
||||
listener({} as MessageEvent);
|
||||
|
||||
await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting');
|
||||
|
||||
// None of these should be accepted
|
||||
listener({ source: popupMock } as MessageEvent);
|
||||
listener({ origin: 'my-origin' } as MessageEvent);
|
||||
listener({ data: { type: 'oauth-result' } } as MessageEvent);
|
||||
listener({ source: popupMock, origin: 'my-origin', data: {} } as MessageEvent);
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: { type: 'not-oauth-result', payload: {} },
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting');
|
||||
|
||||
// This should be accepted as a valid sessions response
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'oauth-result',
|
||||
payload: { accessToken: 'my-access-token', idToken: 'my-id-token', expiresInSeconds: 5, scopes: 'a b' },
|
||||
},
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(sessionPromise).resolves.toEqual({
|
||||
idToken: 'my-id-token',
|
||||
accessToken: 'my-access-token',
|
||||
scopes: expect.any(GoogleScopes),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should forward slingshot info', async () => {
|
||||
const helper = new GoogleAuthHelper({
|
||||
clientId: 'mock-id',
|
||||
apiOrigin: 'my-origin',
|
||||
dev: false,
|
||||
slingshotInfo: {
|
||||
id: 101,
|
||||
site: 'narnia',
|
||||
},
|
||||
pendingRequests,
|
||||
});
|
||||
|
||||
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
|
||||
pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString()));
|
||||
|
||||
await expect(helper.createSession('a b')).rejects.toThrow('Failed to open google login popup.');
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(openSpy.mock.calls[0][0]).toBe(
|
||||
'my-origin/api/backend/auth/start?slingshot=101%3Anarnia&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if popup returns error', async () => {
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
|
||||
const openSpy = jest.spyOn(window, 'open');
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const popupMock = { closed: false };
|
||||
|
||||
openSpy.mockReturnValue(popupMock as Window);
|
||||
pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString()));
|
||||
|
||||
const sessionPromise = helper.createSession('a b');
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
|
||||
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'oauth-result',
|
||||
payload: {
|
||||
error: {
|
||||
message: 'NOPE',
|
||||
name: 'NopeError',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(sessionPromise).rejects.toThrow({
|
||||
name: 'NopeError',
|
||||
message: 'NOPE',
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fail if popup is closed', async () => {
|
||||
const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests });
|
||||
|
||||
const openSpy = jest.spyOn(window, 'open');
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const popupMock = { closed: false };
|
||||
|
||||
openSpy.mockReturnValue(popupMock as Window);
|
||||
pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString()));
|
||||
|
||||
const sessionPromise = helper.createSession('a b');
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
setTimeout(() => {
|
||||
popupMock.closed = true;
|
||||
}, 150);
|
||||
await expect(sessionPromise).rejects.toThrow('Google login failed, popup was closed');
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
import { urls } from 'shared/apis/baseUrls';
|
||||
import { env } from 'shared/apis/env';
|
||||
import { OAuthPendingRequestsApi } from 'shared/apis/oauth/OAuthPendingRequests';
|
||||
import { CLIENT_ID_DEV, getClientId, getSlingshotInfo, SlingshotInfo } from './clientIds';
|
||||
import { googleAuthPendingRequests } from './GoogleAuthPendingRequests';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import MockAuthHelper from './MockAuthHelper';
|
||||
import { GoogleSession } from './types';
|
||||
|
||||
const API_PATH = '/api/backend/auth';
|
||||
|
||||
type Options = {
|
||||
clientId: string;
|
||||
slingshotInfo?: SlingshotInfo;
|
||||
apiOrigin: string;
|
||||
dev: boolean;
|
||||
pendingRequests: OAuthPendingRequestsApi<GoogleSession>;
|
||||
};
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scopes: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export type AuthHelper = {
|
||||
refreshSession(optional?: false): Promise<GoogleSession>;
|
||||
refreshSession(optional: true): Promise<GoogleSession | undefined>;
|
||||
removeSession(): Promise<void>;
|
||||
createSession(scope: string): Promise<GoogleSession>;
|
||||
showPopup(scope: string): Promise<GoogleSession>;
|
||||
};
|
||||
|
||||
class GoogleAuthHelper implements AuthHelper {
|
||||
static create() {
|
||||
const clientId = getClientId();
|
||||
const slingshotInfo = getSlingshotInfo();
|
||||
|
||||
return new GoogleAuthHelper({
|
||||
clientId,
|
||||
slingshotInfo,
|
||||
apiOrigin: urls.openProxy,
|
||||
dev: clientId === CLIENT_ID_DEV,
|
||||
pendingRequests: googleAuthPendingRequests,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async refreshSession(optional?: false): Promise<GoogleSession>;
|
||||
async refreshSession(optional: true): Promise<GoogleSession | undefined>;
|
||||
async refreshSession(optional?: boolean): Promise<GoogleSession | undefined> {
|
||||
const res = await fetch(this.buildUrl('/token', { optional }), {
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(error => {
|
||||
throw new Error(`Auth refresh request failed, ${error}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error: any = new Error(`Auth refresh request failed with status ${res.statusText}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const authInfo = await res.json();
|
||||
|
||||
if (optional && authInfo.error) {
|
||||
return undefined;
|
||||
}
|
||||
return GoogleAuthHelper.convertAuthInfo(authInfo);
|
||||
}
|
||||
|
||||
async removeSession(): Promise<void> {
|
||||
const res = await fetch(this.buildUrl('/logout'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Logout request failed with status ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(scope: string): Promise<GoogleSession> {
|
||||
return this.options.pendingRequests.request(GoogleScopes.from(scope));
|
||||
}
|
||||
|
||||
async showPopup(scope: string): Promise<GoogleSession> {
|
||||
const { slingshotInfo } = this.options;
|
||||
const slingshot = slingshotInfo && `${slingshotInfo.id}:${slingshotInfo.site}`;
|
||||
|
||||
const popupUrl = this.buildUrl('/start', { slingshot, scope });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const width = 450;
|
||||
const height = 730;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const popup = window.open(
|
||||
popupUrl,
|
||||
'google-login',
|
||||
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
|
||||
);
|
||||
|
||||
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
|
||||
reject(new Error('Failed to open google login popup.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!env.isTest) {
|
||||
window.focus();
|
||||
}
|
||||
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
if (event.source !== popup) {
|
||||
return;
|
||||
}
|
||||
if (event.origin !== this.options.apiOrigin) {
|
||||
return;
|
||||
}
|
||||
const { data } = event;
|
||||
if (data.type !== 'oauth-result') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.payload.error) {
|
||||
const error = new Error(data.payload.error.message);
|
||||
error.name = data.payload.error.name;
|
||||
// TODO: proper error type
|
||||
// error.extra = data.payload.error.extra;
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(GoogleAuthHelper.convertAuthInfo(data.payload));
|
||||
}
|
||||
done();
|
||||
};
|
||||
|
||||
const done = () => {
|
||||
window.removeEventListener('message', messageListener);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
const error = new Error('Google login failed, popup was closed');
|
||||
error.name = 'PopupClosedError';
|
||||
reject(error);
|
||||
done();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('message', messageListener);
|
||||
});
|
||||
}
|
||||
|
||||
private buildUrl(path: string, query?: { [key: string]: string | boolean | undefined }): string {
|
||||
const queryString = this.buildQueryString({ ...query, dev: this.options.dev });
|
||||
|
||||
return `${this.options.apiOrigin}${API_PATH}${path}${queryString}`;
|
||||
}
|
||||
|
||||
private buildQueryString(query?: { [key: string]: string | boolean | undefined }): string {
|
||||
if (!query) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryString = Object.entries<string | boolean | undefined>(query)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
} else if (value) {
|
||||
return encodeURIComponent(key);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
|
||||
if (!queryString) {
|
||||
return '';
|
||||
}
|
||||
return `?${queryString}`;
|
||||
}
|
||||
|
||||
private static convertAuthInfo(authInfo: GoogleAuthResponse): GoogleSession {
|
||||
return {
|
||||
idToken: authInfo.idToken,
|
||||
accessToken: authInfo.accessToken,
|
||||
scopes: GoogleScopes.from(authInfo.scopes),
|
||||
expiresAt: new Date(Date.now() + authInfo.expiresInSeconds * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const googleAuthHelper = env.isTest ? new MockAuthHelper() : GoogleAuthHelper.create();
|
||||
|
||||
export default GoogleAuthHelper;
|
||||
@@ -1,4 +0,0 @@
|
||||
import { OAuthPendingRequests } from 'shared/apis/oauth/OAuthPendingRequests';
|
||||
import { GoogleSession } from './types';
|
||||
|
||||
export const googleAuthPendingRequests = new OAuthPendingRequests<GoogleSession>();
|
||||
@@ -1,62 +0,0 @@
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
const PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
describe('GoogleScopes', () => {
|
||||
it('should be created from scopes', () => {
|
||||
const scopes = GoogleScopes.from('a openid b profile');
|
||||
expect(scopes.toString()).toBe(`${PREFIX}a openid ${PREFIX}b ${PREFIX}userinfo.profile`);
|
||||
});
|
||||
|
||||
it('should be created with default scopes', () => {
|
||||
expect(GoogleScopes.default().toString()).toBe(`openid ${PREFIX}userinfo.email ${PREFIX}userinfo.profile`);
|
||||
});
|
||||
|
||||
it('should have or not have scopes', () => {
|
||||
const scopes = GoogleScopes.from(`a b ${PREFIX}c`);
|
||||
expect(scopes.hasScopes('a')).toBe(true);
|
||||
expect(scopes.hasScopes('a b')).toBe(true);
|
||||
expect(scopes.hasScopes('b')).toBe(true);
|
||||
expect(scopes.hasScopes('b c')).toBe(true);
|
||||
expect(scopes.hasScopes('a b c')).toBe(true);
|
||||
expect(scopes.hasScopes(`a b ${PREFIX}c`)).toBe(true);
|
||||
expect(scopes.hasScopes(`a ${PREFIX}b c`)).toBe(true);
|
||||
expect(scopes.hasScopes('a b c d')).toBe(false);
|
||||
expect(scopes.hasScopes('d')).toBe(false);
|
||||
expect(scopes.hasScopes('')).toBe(true);
|
||||
expect(scopes.hasScopes('abc')).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}a`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle scope shorthands correctly', () => {
|
||||
const scopes = GoogleScopes.default();
|
||||
expect(scopes.hasScopes('email')).toBe(true);
|
||||
expect(scopes.hasScopes('profile')).toBe(true);
|
||||
expect(scopes.hasScopes('openid')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.email')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.profile')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.openid')).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.email`)).toBe(true);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.profile`)).toBe(true);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.openid`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}email`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}profile`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}openid`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be extended', () => {
|
||||
const scopes = GoogleScopes.from('a b');
|
||||
expect(scopes.extend('')).not.toBe(scopes);
|
||||
expect(scopes.extend('d').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}d`);
|
||||
expect(scopes.extend('profile').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}userinfo.profile`);
|
||||
expect(scopes.extend('d profile').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`);
|
||||
expect(scopes.extend(`${PREFIX}d profile`).toString()).toBe(
|
||||
`${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
expect(scopes.extend('a').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b a').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b a b a a').toString()).toBe(scopes.toString());
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { BasicOAuthScopes } from 'shared/apis/oauth/BasicOAuthScopes';
|
||||
import { OAuthScopeLike } from '../oauth/types';
|
||||
|
||||
// https://www.googleapis.com/auth/userinfo.profile
|
||||
// https://www.googleapis.com/auth/userinfo.email
|
||||
// openid
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
export default class GoogleScopes extends BasicOAuthScopes {
|
||||
static from(scope: OAuthScopeLike): GoogleScopes {
|
||||
return new GoogleScopes(new Set(BasicOAuthScopes.asStrings(scope, GoogleScopes.canonicalScope)));
|
||||
}
|
||||
|
||||
static default(): GoogleScopes {
|
||||
return new GoogleScopes(new Set(['openid', `${SCOPE_PREFIX}userinfo.email`, `${SCOPE_PREFIX}userinfo.profile`]));
|
||||
}
|
||||
|
||||
static empty(): GoogleScopes {
|
||||
return new GoogleScopes(new Set());
|
||||
}
|
||||
|
||||
constructor(scopes: Set<string>) {
|
||||
super(scopes, GoogleScopes.canonicalScope);
|
||||
}
|
||||
|
||||
private static canonicalScope(scope: string): string {
|
||||
if (scope === 'openid') {
|
||||
return scope;
|
||||
}
|
||||
|
||||
if (scope === 'profile' || scope === 'email') {
|
||||
return `${SCOPE_PREFIX}userinfo.${scope}`;
|
||||
}
|
||||
|
||||
if (scope.startsWith(SCOPE_PREFIX)) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
return `${SCOPE_PREFIX}${scope}`;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import MockAuthHelper, { mockIdToken, mockAccessToken } from './MockAuthHelper';
|
||||
|
||||
describe('MockAuthHelper', () => {
|
||||
it('should return mock tokens', async () => {
|
||||
const helper = new MockAuthHelper();
|
||||
await expect(helper.createSession()).resolves.toEqual({
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: expect.any(Date),
|
||||
scopes: expect.any(GoogleScopes),
|
||||
});
|
||||
await expect(helper.refreshSession()).resolves.toEqual({
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: expect.any(Date),
|
||||
scopes: expect.any(GoogleScopes),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleSession } from './types';
|
||||
import { AuthHelper } from './GoogleAuthHelper';
|
||||
|
||||
export const mockIdToken = 'mock-id-token';
|
||||
export const mockAccessToken = 'mock-access-token';
|
||||
|
||||
const defaultMockSession: GoogleSession = {
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: new Date(),
|
||||
scopes: GoogleScopes.default(),
|
||||
};
|
||||
|
||||
export default class MockAuthHelper implements AuthHelper {
|
||||
constructor(private readonly mockSession: GoogleSession = defaultMockSession) {}
|
||||
|
||||
async refreshSession() {
|
||||
return this.mockSession;
|
||||
}
|
||||
|
||||
async removeSession() {}
|
||||
|
||||
async createSession() {
|
||||
return this.mockSession;
|
||||
}
|
||||
|
||||
async showPopup(scope: string) {
|
||||
return {
|
||||
scopes: GoogleScopes.from(scope),
|
||||
idToken: 'i',
|
||||
accessToken: 'a',
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { env } from 'shared/apis/env';
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Client IDs
|
||||
* https://console.cloud.google.com/apis/credentials?folder=&organizationId=642708779950&orgonly=true&project=xpn-system-z-1&supportedpurview=organizationId
|
||||
*/
|
||||
export const CLIENT_ID_DEV = '820235932049-nc367ocqr8rfknrjrd71cfog3figgt1s.apps.googleusercontent.com'; // oauth client allowing JavaScript origin http://localhost:5678 and slingshot domains
|
||||
export const CLIENT_ID_PROD = '820235932049-g88s8ltjd53af38go61db8debamdlh7d.apps.googleusercontent.com'; // oauth client allowing JavaScript origin from trusted domains.
|
||||
|
||||
export function getClientId() {
|
||||
if (env.isDevelopment || env.isStaging) {
|
||||
return CLIENT_ID_DEV;
|
||||
}
|
||||
return CLIENT_ID_PROD;
|
||||
}
|
||||
|
||||
export type SlingshotInfo = {
|
||||
id: number;
|
||||
site: string;
|
||||
};
|
||||
|
||||
export function getSlingshotInfo(): undefined | SlingshotInfo {
|
||||
const { hostname } = location;
|
||||
if (!hostname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = hostname.match(/^backstage-backstage-frontend-([0-9]+)\.services\.([a-z0-9]+)\.spotify\.net$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, id, site] = match;
|
||||
return { id: Number(id), site };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './types';
|
||||
export { default as GoogleAuthBarrier, useGetGoogleAccessToken, useGetGoogleIdToken } from './GoogleAuthBarrier';
|
||||
export { default as GoogleAuth } from './GoogleAuth';
|
||||
import { googleAuth as googleAuthInstance } from './GoogleAuth';
|
||||
import { GoogleAuthApi } from './types';
|
||||
export const googleAuth = googleAuthInstance as GoogleAuthApi;
|
||||
@@ -1,78 +0,0 @@
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
/**
|
||||
* This api provides access to Google OAuth credentials. It lets you request access tokens,
|
||||
* which can be used to act on behalf of the user when talking to Google APIs. It also supplies
|
||||
* ID Tokens, which can be passed to backend services to prove the user's identity.
|
||||
*
|
||||
* The API can be called directly to get access and ID tokens, which will cause a modal dialog
|
||||
* to show up if the user is not yet signed in.
|
||||
*
|
||||
* For more fine grained control of where the sign in prompt is shown, it is possible to use
|
||||
* the GoogleAuthBarrier components, which ensures that all components rendered inside it
|
||||
* have synchronous access to both access and ID tokens.
|
||||
*
|
||||
* For full examples, see https://backstage.spotify.net/docs/backstage-frontend/apis/#google-auth-api
|
||||
*/
|
||||
export type GoogleAuthApi = {
|
||||
/**
|
||||
* Requests a Google OAuth ID Token, optionally with a set of scopes. The scopes allow you to access
|
||||
* google APIs on behalf of the user. A full list of scopes can be found at https://developers.google.com/identity/protocols/googlescopes.
|
||||
*
|
||||
* Be sure to include all required scopes when requesting an access token. When testing your implementation
|
||||
* it is best to log out the Backstage Google session and then visit your plugin page directly, as
|
||||
* you might already have some required scopes in your existing session. Not requesting the correct
|
||||
* scopes can lead to 403 or other authorization errors, which can be tricky to debug.
|
||||
*
|
||||
* This method is cheap and should be called each time an access token is used. Do not for example
|
||||
* store the access token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new access token for each request.
|
||||
*
|
||||
* If the user has not yet logged in to Google inside Backstage, a dialog window will be shown
|
||||
* that prompts the user to log in, and the returned promise will not resolve until the user has
|
||||
* successfully logged in.
|
||||
*
|
||||
* The returned promise can be rejected, but only if the user rejects the login request. If the
|
||||
* login fails because the user fails to log in to their google account, the dialog will simply
|
||||
* remain and ask them to try again, and the promise will still be pending.
|
||||
*/
|
||||
getAccessToken(scope?: string | string[]): Promise<string>;
|
||||
|
||||
/**
|
||||
* Requests a Google OAuth ID Token.
|
||||
*
|
||||
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*
|
||||
* This method is cheap and should be called each time an ID token is used. Do not for example
|
||||
* store the id token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new id token for each request.
|
||||
*
|
||||
* If the user has not yet logged in to Google inside Backstage, a dialog window will be shown
|
||||
* that prompts the user to log in, and the returned promise will not resolve until the user has
|
||||
* successfully logged in.
|
||||
*
|
||||
* The returned promise can be rejected, but only if the user rejects the login request. If the
|
||||
* login fails because the user fails to log in to their google account, the dialog will simply
|
||||
* remain and ask them to try again, and the promise will still be pending.
|
||||
*/
|
||||
getIdToken(options?: IdTokenOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Logs out the user's Google session. This will reload the page.
|
||||
*/
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
export type GoogleSession = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: GoogleScopes;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type IdTokenOptions = {
|
||||
// If this is set to true, the user will not be prompted to log in,
|
||||
// and an empty id token will be returned if there is no existing session.
|
||||
optional?: boolean;
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/* global gapi */
|
||||
|
||||
import { googleAuth } from 'shared/apis/googleAuthV2';
|
||||
|
||||
// Using the Google API Client Library for JavaScript
|
||||
// https://github.com/google/google-api-javascript-client
|
||||
|
||||
export const googleClientApi = {
|
||||
async load({ api, version, scope }) {
|
||||
await gapi.client.load(api, version);
|
||||
const accessToken = await googleAuth.getAccessToken(scope);
|
||||
gapi.client.setToken({ access_token: accessToken });
|
||||
},
|
||||
analytics: {
|
||||
getGaData(query) {
|
||||
return gapi.client.analytics.data.ga.get(query);
|
||||
},
|
||||
},
|
||||
compute: {
|
||||
getUrlMaps(project) {
|
||||
return gapi.client.compute.urlMaps.list({
|
||||
project,
|
||||
});
|
||||
},
|
||||
getFwdRules(project, filter = null) {
|
||||
return gapi.client.compute.globalForwardingRules.list({
|
||||
project,
|
||||
filter,
|
||||
});
|
||||
},
|
||||
getHttpProxies(project, filter = null) {
|
||||
return gapi.client.compute.targetHttpProxies.list({
|
||||
project,
|
||||
filter,
|
||||
});
|
||||
},
|
||||
getHttpsProxies(project, filter = null) {
|
||||
return gapi.client.compute.targetHttpsProxies.list({
|
||||
project,
|
||||
filter,
|
||||
});
|
||||
},
|
||||
getBackends(project, filter = null) {
|
||||
return gapi.client.compute.backendServices.list({
|
||||
project,
|
||||
filter,
|
||||
});
|
||||
},
|
||||
getBackendHealth(project, backendService, group) {
|
||||
return gapi.client.compute.backendServices.getHealth({
|
||||
project,
|
||||
backendService,
|
||||
resource: {
|
||||
group,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import 'highlight.js/styles/atom-one-dark.css';
|
||||
|
||||
let library;
|
||||
function loadLibrary() {
|
||||
if (library) {
|
||||
return Promise.resolve(library);
|
||||
}
|
||||
const name = str => m => [str, m.default || m];
|
||||
|
||||
// Full path specs are needed for supported languages so that webpack knows how to properly bundle this library.
|
||||
return Promise.all([
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/highlight.js'),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/bash.js').then(name('bash')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/cpp.js').then(name('cpp')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/dockerfile.js').then(name('docker')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/go.js').then(name('go')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/groovy.js').then(name('groovy')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/java.js').then(name('java')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/javascript.js').then(name('javascript')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/json.js').then(name('json')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/kotlin.js').then(name('kotlin')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/markdown.js').then(name('markdown')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/objectivec.js').then(name('objectivec')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/python.js').then(name('python')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/ruby.js').then(name('ruby')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/scala.js').then(name('scala')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/swift.js').then(name('swift')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/typescript.js').then(name('typescript')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/xml.js').then(name('xml')),
|
||||
import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/yaml.js').then(name('yaml')),
|
||||
]).then(([highlight, ...langs]) => {
|
||||
langs.forEach(([moduleName, module]) => {
|
||||
highlight.registerLanguage(moduleName, module);
|
||||
});
|
||||
library = highlight;
|
||||
return highlight;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a file extension, repo name, and array of code lines, return a Promise resolving
|
||||
* to an array of formatted lines with html/css formatting.
|
||||
*
|
||||
* @param {String} fileExtension The extension of the source file
|
||||
* @param {String} repo The name of the code repository
|
||||
* @param {Array<String>} lines The source code lines
|
||||
*
|
||||
* @returns {Promise<Array<String>>} Promise of formatted lines
|
||||
*
|
||||
* @see http://highlightjs.readthedocs.io/en/latest/api.html#highlight-name-value-ignore-illegals-continuation
|
||||
*/
|
||||
export function highlightLines(fileExtension, repo, lines) {
|
||||
return loadLibrary().then(highlight => {
|
||||
const formattedLines = [];
|
||||
let state = null;
|
||||
|
||||
let fileformat = fileExtension;
|
||||
// edge case for .h fileExtension
|
||||
if (fileExtension === 'h') {
|
||||
if (repo === 'client-core') {
|
||||
fileformat = 'cpp';
|
||||
} else {
|
||||
fileformat = 'objectivec';
|
||||
}
|
||||
}
|
||||
|
||||
// edge case for .m fileExtension
|
||||
if (fileExtension === 'm') {
|
||||
fileformat = 'objectivec';
|
||||
}
|
||||
|
||||
// make sure tsx and jsx are interpreted correctly
|
||||
if (fileExtension === 'tsx') {
|
||||
fileformat = 'typescript';
|
||||
}
|
||||
if (fileExtension === 'jsx') {
|
||||
fileformat = 'javascript';
|
||||
}
|
||||
if (fileExtension === 'kt') {
|
||||
fileformat = 'kotlin';
|
||||
}
|
||||
|
||||
lines.forEach(line => {
|
||||
const result = highlight.highlight(fileformat, line, true, state);
|
||||
state = result.top;
|
||||
formattedLines.push(highlight.fixMarkup(result.value));
|
||||
});
|
||||
return formattedLines;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a block of code, return a formatted string with the code highlighted, using language detection
|
||||
*
|
||||
* @param {String} block The block of code.
|
||||
*
|
||||
* @returns {Promise<Void>} Promise resolving when highlighting is complete.
|
||||
*/
|
||||
export const highlightBlock = block => loadLibrary().then(highlight => highlight.highlightBlock(block));
|
||||
|
||||
/**
|
||||
* This React component should maintain a compatible API with the "react-highlight" module. Using our own
|
||||
* module ensures that we can
|
||||
* 1) Only load the languages we really need
|
||||
* 2) Configure the webpack async chunk in one place (the loadLibrary() function).
|
||||
*/
|
||||
export class Highlight extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
highlighted: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { language, children: body } = this.props;
|
||||
highlightLines(language, null, body.split('\n')).then(lines => {
|
||||
this.setState({
|
||||
highlighted: lines,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<pre>
|
||||
<code className="hljs" style={{ borderRadius: 5 }} dangerouslySetInnerHTML={this.buildMarkup()} />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
buildMarkup() {
|
||||
return {
|
||||
__html: this.state.highlighted.join('<br />'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Jira utilities for merging results
|
||||
*/
|
||||
|
||||
class JiraUtils {
|
||||
static instance;
|
||||
|
||||
constructor() {
|
||||
if (JiraUtils.instance) {
|
||||
return JiraUtils.instance;
|
||||
}
|
||||
this.instance = this;
|
||||
}
|
||||
|
||||
makeJiraIssueURL = (projectID, issueType) => {
|
||||
if (!projectID || !issueType) {
|
||||
throw Error('Input projectID and issueType needs to be set');
|
||||
}
|
||||
if (issueType !== 1 && issueType !== 2) {
|
||||
throw Error('issueType must be either 1 (bug) or 2 (feature request)');
|
||||
}
|
||||
return `https://jira.spotify.net/secure/CreateIssueDetails!init.jspa?priority=3&issuetype=${issueType}&pid=${projectID}`;
|
||||
};
|
||||
|
||||
genIssuesWithStatusFromFilterNameMap(issueMap, filtername, statusText) {
|
||||
let issues = {};
|
||||
if (issueMap.hasOwnProperty(filtername)) {
|
||||
issueMap[filtername].forEach(issue => {
|
||||
let newIssue = Object.assign({ status: statusText }, issue);
|
||||
issues[newIssue.id] = newIssue;
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
mergeFilterNameIssues(filterResults) {
|
||||
// Extract the results to keys from the results
|
||||
let filterMap = {};
|
||||
filterResults.forEach(filter => {
|
||||
filterMap[filter.filter] = filter.issues;
|
||||
});
|
||||
|
||||
// Extract and enhance the warning issues
|
||||
const warningIssues = this.genIssuesWithStatusFromFilterNameMap(filterMap, 'APP_RELEASE_RADAR', 'yellow');
|
||||
// Extract and enhance the error issues
|
||||
const errorIssues = this.genIssuesWithStatusFromFilterNameMap(filterMap, 'APP_IMMEDIATE_ACTION', 'red');
|
||||
// Merge the two with overwriting the left objec keys with the right
|
||||
const combined = Object.assign({}, warningIssues, errorIssues);
|
||||
|
||||
// flatten to an array
|
||||
const issues = Object.values(combined);
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
let _instance = new JiraUtils();
|
||||
|
||||
export default _instance;
|
||||
@@ -1,30 +0,0 @@
|
||||
import JiraUtils from 'shared/apis/jira/jiraUtils';
|
||||
|
||||
describe('JiraUtils', () => {
|
||||
it('throws proper error when required input is missing', () => {
|
||||
expect(() => {
|
||||
JiraUtils.makeJiraIssueURL();
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('throws proper error when one required input is missing', () => {
|
||||
expect(() => {
|
||||
JiraUtils.makeJiraIssueURL(1222);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('throws proper error when one input is wrong', () => {
|
||||
expect(() => {
|
||||
JiraUtils.makeJiraIssueURL(1222, 0);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('does not throw error when input is valid', () => {
|
||||
expect(() => {
|
||||
JiraUtils.makeJiraIssueURL(1222, 1);
|
||||
}).not.toThrowError();
|
||||
expect(() => {
|
||||
JiraUtils.makeJiraIssueURL(1222, 2);
|
||||
}).not.toThrowError();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import MenuItem from 'shared/apis/menu/MenuItem';
|
||||
|
||||
import { PluginBase } from 'shared/pluginApi';
|
||||
|
||||
import FeatureFlags from 'shared/apis/featureFlags/featureFlags';
|
||||
|
||||
export default class BackstageMenuItem extends MenuItem {
|
||||
type = 'Link';
|
||||
|
||||
constructor(ownerPlugin, id, title, options = {}, parent = undefined) {
|
||||
super(id, title, options, parent);
|
||||
|
||||
if (!(ownerPlugin instanceof PluginBase)) {
|
||||
console.error(ownerPlugin);
|
||||
throw new Error('BackstageMenuItem: ownerPlugin must extend PluginBase');
|
||||
}
|
||||
|
||||
this.ownerPlugin = ownerPlugin;
|
||||
|
||||
this.type = this.options.type || this.type;
|
||||
}
|
||||
|
||||
get visible() {
|
||||
if (this.ownerPlugin.manifest.featureFlag) {
|
||||
return super.visible && FeatureFlags.getItem(this.ownerPlugin.manifest.featureFlag);
|
||||
}
|
||||
|
||||
return super.visible;
|
||||
}
|
||||
}
|
||||
@@ -1,953 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import BackstageMenuItem from 'shared/apis/menu/BackstageMenuItem';
|
||||
|
||||
import { AppIcon, GroupIcon, ServiceIcon, StorageIcon } from 'shared/icons';
|
||||
import DataIcon from '@material-ui/icons/DataUsage';
|
||||
import ReliabilityIcon from '@material-ui/icons/Stars';
|
||||
import ComplianceIcon from '@material-ui/icons/Business';
|
||||
import CodeIcon from '@material-ui/icons/Code';
|
||||
import FeatureFlags from 'shared/apis/featureFlags/featureFlags';
|
||||
|
||||
// Explore icons.
|
||||
import ProgrammingPlatformIcon from 'shared/assets/icons/explore/pp.png';
|
||||
import TechLearningIcon from 'shared/assets/icons/explore/techlearning.png';
|
||||
import SearchIcon from 'shared/assets/icons/explore/search.png';
|
||||
import MessagingIcon from 'shared/assets/icons/explore/journey.png';
|
||||
import EncoreIcon from 'shared/assets/icons/explore/encore.png';
|
||||
import SecurityIcon from 'shared/assets/icons/explore/SecurityTribe.png';
|
||||
import Tc4bIcon from 'shared/assets/icons/explore/tc4b.jpg';
|
||||
import MLIcon from 'shared/assets/icons/explore/ml.png';
|
||||
|
||||
// Client SDK icons.
|
||||
import SDKGenericIcon from 'shared/assets/icons/explore/sdk.png';
|
||||
import BetamaxIcon from 'shared/assets/icons/explore/betamax.png';
|
||||
import ConnectivitySDKIcon from 'shared/assets/icons/explore/connectivity_logo_black.png';
|
||||
|
||||
// Infrastructure and Tooling.
|
||||
import PlatformDefaultIcon from 'shared/assets/icons/explore/tool-logo.svg';
|
||||
import DataInfrastructureIcon from 'shared/assets/icons/explore/di.png';
|
||||
import DataMonitoringIcon from 'shared/assets/icons/explore/datamonitoring.png';
|
||||
import TingleIcon from 'shared/assets/icons/explore/tingle.png';
|
||||
import TechDocsIcon from 'shared/assets/icons/explore/techdocs.png';
|
||||
import SlingshotIcon from 'shared/assets/icons/explore/slingshot.png';
|
||||
import ScioIcon from 'shared/assets/icons/explore/scio.png';
|
||||
import StackOverflowEnterpriseIcon from 'shared/assets/icons/explore/soe.png';
|
||||
import JukeboxIcon from 'shared/assets/icons/explore/jukebox.png';
|
||||
import KubeflowIcon from 'shared/assets/icons/explore/kubeflow.png';
|
||||
import KlioIcon from 'shared/assets/icons/explore/klio.png';
|
||||
import GrpcIcon from 'shared/assets/icons/explore/spoticakes.png';
|
||||
import BigQueryIcon from 'shared/assets/icons/explore/bigquery.png';
|
||||
import ScienceBoxIcon from 'shared/assets/icons/explore/sciencebox.png';
|
||||
import TableauIcon from 'shared/assets/icons/explore/tableau.png';
|
||||
import QlikSenseIcon from 'shared/assets/icons/explore/qliksense.png';
|
||||
import MobiusIcon from 'shared/assets/icons/explore/mobius-logo.png';
|
||||
import DistributedTracingIcon from 'shared/assets/icons/explore/lightstep.png';
|
||||
import GrafanaIcon from 'shared/assets/icons/explore/grafana.png';
|
||||
import LuigiIcon from 'shared/assets/icons/explore/luigi.png';
|
||||
import CassetteIcon from 'shared/assets/icons/explore/cassette-logo.png';
|
||||
import SecurityTribeIcon from 'shared/assets/icons/explore/SecurityTribe.png';
|
||||
import ArtifactoryIcon from 'shared/assets/icons/explore/artifactory.png';
|
||||
import BackstageIcon from 'shared/assets/icons/explore/backstage-platform.png';
|
||||
import ITGCtrackerIcon from 'shared/assets/icons/explore/itgc_tracker.svg';
|
||||
import BigtableIcon from 'shared/assets/icons/explore/bigtable.png';
|
||||
|
||||
export function setupMenu(pluginManager) {
|
||||
const rp = pluginManager.rootPlugin;
|
||||
|
||||
const menuRoot = (pluginManager.menu = new BackstageMenuItem(rp, 'root', ''));
|
||||
|
||||
menuRoot.add(new BackstageMenuItem(rp, 'tools', 'Tools'));
|
||||
|
||||
menuRoot.add(
|
||||
new BackstageMenuItem(rp, 'explore', 'Explore', {
|
||||
searchable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* This is a non-searchable menu item that is used by plugins to add links to the search.
|
||||
*/
|
||||
menuRoot.add(new BackstageMenuItem(rp, 'general', 'General'));
|
||||
|
||||
//-------------------------------------
|
||||
// Explore
|
||||
//-------------------------------------
|
||||
// Platforms
|
||||
menuRoot.getByIdPath('explore').add(new BackstageMenuItem(rp, 'platform', 'Platforms', { searchable: false }));
|
||||
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'metadata', 'Content Metadata', {
|
||||
img: 'https://cdn2.techadvisor.co.uk/cmsdata/reviews/3620240/Spotify-Menu.png',
|
||||
url: '/docs/metadata',
|
||||
desc:
|
||||
"The source for all of Spotify's ingested, curated and refined content metadata to create a catalog that both listeners and creators expect.",
|
||||
newsTag: 'contentmetadata',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'pzn', 'Personalization', {
|
||||
img:
|
||||
'https://domain.me/wp-content/uploads/2016/10/ME-blog-cover-Personalization-The-Future-of-Marketing-okt-2016.jpg',
|
||||
url: 'https://confluence.spotify.net/display/PZNPL/Product+Pages',
|
||||
desc: 'Understand the ways content on Spotify can be similar to other content',
|
||||
newsTag: 'personalization',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'messaging', 'Quicksilver Messaging', {
|
||||
img: MessagingIcon,
|
||||
fit: 'contain',
|
||||
url: 'https://confluence.spotify.net/display/quicksilver/Quicksilver+Messaging+Platform',
|
||||
desc:
|
||||
'Quicksilver is the internal messaging tool used to send in-app, email and push messages to Spotify listeners.',
|
||||
newsTag: 'quicksilver',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'pp', 'Programming', {
|
||||
img: ProgrammingPlatformIcon,
|
||||
url: 'https://docs.google.com/document/d/1JlTWLPF39uDKMOWKrjzQ4_BaVa6X28B9_vTVaNbOxEM/edit#',
|
||||
desc:
|
||||
'Content aggregation, curation and personalization of views. Tools for curating playlists and programming which playlists go where.',
|
||||
newsTag: 'programming',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'security', 'Security', {
|
||||
img: SecurityIcon, // or external URL.
|
||||
fit: 'contain',
|
||||
url: '/docs/security',
|
||||
desc: 'Security guidelines you should take into account when building and reviewing your service.',
|
||||
newsTag: 'security',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'encore', 'Encore', {
|
||||
img: EncoreIcon,
|
||||
url: 'https://encore.spotify.net/',
|
||||
desc:
|
||||
'Our new approach to design systems. It’s everything you need to build beautiful, scalable apps that look and feel like Spotify.',
|
||||
newsTag: 'encore',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'storage', 'Storage', {
|
||||
img:
|
||||
'https://storage.googleapis.com/gweb-cloudblog-publish/images/google-cloud-storage-pub-sub3a60.max-700x700.PNG',
|
||||
url: '/docs/storage',
|
||||
desc:
|
||||
'Enabling squads to focus on their vision by providing reliable, easy to use storage solutions that grow with Spotify.',
|
||||
newsTag: 'storage',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'search', 'Search', {
|
||||
img: SearchIcon,
|
||||
url: 'https://confluence.spotify.net/display/JAM/Search+Product+Area',
|
||||
desc: 'Enables a frictionless path from intent expressed through text, to great sessions and the right entities.',
|
||||
newsTag: 'search',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'connect', 'Spotify Connect', {
|
||||
img: 'https://assets.sbnation.com/assets/3147739/spotify-connect-devices1_560.jpg',
|
||||
url: 'https://www.spotify.com/connect/',
|
||||
desc: 'Listen on your speakers or TV, using the Spotify app as a remote.',
|
||||
newsTag: 'connect',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'tl', 'Tech Learning', {
|
||||
img: TechLearningIcon,
|
||||
url: '/docs/tech-learning',
|
||||
desc: 'Educational programs to grow the breadth and depth of Spotify employees’ technical skill sets.',
|
||||
newsTag: 'techlearning',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'web-api', 'Spotify Web API', {
|
||||
img: 'https://developer.spotify.com/assets/WebAPI_intro.png',
|
||||
url: 'https://developer.spotify.com/documentation/web-api/',
|
||||
desc:
|
||||
'Spotify Web API endpoints return JSON metadata about music artists, albums, and tracks, directly from the Spotify Data Catalogue.',
|
||||
newsTag: 'webapi',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'research', 'Research', {
|
||||
img: 'https://s3.envato.com/files/2709ab7e-dd8f-4243-983d-82a5c763e46a/inline_image_preview.jpg',
|
||||
url: 'https://research.spotify.com',
|
||||
desc: "Extending the State of the Art in Technologies related to Spotify's Products",
|
||||
newsTag: 'research',
|
||||
}),
|
||||
);
|
||||
if (FeatureFlags.getItem('machine-learning')) {
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'ml', 'Machine Learning', {
|
||||
img: MLIcon,
|
||||
url: '/machine-learning',
|
||||
lifecycle: 'Alpha',
|
||||
desc: 'Spotify’s ML Platform to discover, share, and manage your ML work.',
|
||||
newsTag: 'ml',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (FeatureFlags.getItem('sciencebox-cloud-notebook-creator')) {
|
||||
menuRoot.getByIdPath('explore.platform').add(
|
||||
new BackstageMenuItem(rp, 'sciencebox', 'ScienceBox Cloud', {
|
||||
img: ScienceBoxIcon,
|
||||
url: '/sciencebox-cloud',
|
||||
lifecycle: 'Alpha',
|
||||
desc: "Spotify's Platform to simplify your data analysis workflow",
|
||||
newsTag: 'scienceboxcloud',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Client SDKs
|
||||
menuRoot.getByIdPath('explore').add(new BackstageMenuItem(rp, 'sdk', 'Client SDKs', { searchable: false }));
|
||||
menuRoot.getByIdPath('explore.sdk').add(
|
||||
new BackstageMenuItem(rp, 'betamax', 'Betamax', {
|
||||
img: BetamaxIcon,
|
||||
url: '/docs/betamax-sdk',
|
||||
desc: 'The Betamax SDK enables Spotify to create reliable and creative video experiences our users love.',
|
||||
newsTag: 'betamax',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.sdk').add(
|
||||
new BackstageMenuItem(rp, 'gabito', 'Event Delivery', {
|
||||
img: 'https://spotifylabscom.files.wordpress.com/2016/03/gabo-system-design-2x.png',
|
||||
url: '/docs/gabito-docs/',
|
||||
desc:
|
||||
'Event Delivery (Gabito) is a reliable, high throughput, highly available, cost efficient event delivery system infrastructure.',
|
||||
newsTag: 'eventdelivery',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.sdk').add(
|
||||
new BackstageMenuItem(rp, 'rcs', 'Remote Configuration', {
|
||||
img: SDKGenericIcon,
|
||||
url: '/docs/remote-configuration/',
|
||||
desc:
|
||||
'Remote configuration is a configuration platform for dynamic configuration assignment to a given service or client.',
|
||||
newsTag: 'remoteconfig',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.sdk').add(
|
||||
new BackstageMenuItem(rp, 'connectivity-sdk', 'Connectivity', {
|
||||
img: ConnectivitySDKIcon,
|
||||
url: '/docs/connectivity-sdk-docs/',
|
||||
desc:
|
||||
'The Connectivity SDK is a self-contained and complete solution for working with mobile and desktop apps, enabling authenticated networking to the Spotify backend as well as general networking for other internet services, such as CDNs.',
|
||||
newsTag: 'connectivity',
|
||||
}),
|
||||
);
|
||||
|
||||
// Infrastructure and tooling
|
||||
menuRoot
|
||||
.getByIdPath('explore')
|
||||
.add(new BackstageMenuItem(rp, 'infra', 'Infrastructure & Tooling', { searchable: false }));
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'tingle', 'Tingle', {
|
||||
img: TingleIcon,
|
||||
url: '/docs/tingle',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'mobile'],
|
||||
desc: "Tingle is Spotify's centralized CI/CD system for backend, data and web-services.",
|
||||
newsTag: 'tingle',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'gke', 'GKE', {
|
||||
img: 'https://miro.medium.com/max/1200/1*_saMmI_5Kse6rqZPkiekfg.png',
|
||||
url: '/docs/gke',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend'],
|
||||
desc: 'Managed tool for deploying containerized applications in a developer and cost efficient way.',
|
||||
newsTag: 'gke',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'grpc', 'gRPC', {
|
||||
img: GrpcIcon,
|
||||
url: '/docs/grpc',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend'],
|
||||
desc: 'An open source RPC framework.',
|
||||
newsTag: 'grpc',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'techdocs', 'TechDocs', {
|
||||
img: TechDocsIcon,
|
||||
url: '/docs/docs',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'data-science', 'mobile', 'ml'],
|
||||
desc: 'TechDocs is the way to write technical documentation at Spotify.',
|
||||
newsTag: 'techdocs',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'scio', 'Scio', {
|
||||
img: ScioIcon,
|
||||
url: 'https://spotify.github.io/scio/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: 'An Open Source Scala API for Apache Beam and Google Cloud Dataflow.',
|
||||
newsTag: 'scio',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'slingshot', 'Slingshot', {
|
||||
img: SlingshotIcon,
|
||||
url: '/docs/slingshot/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['web'],
|
||||
desc: 'Provides temporary review instances as a result of a Tingle Review Build.',
|
||||
newsTag: 'slingshot',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'teamcity', 'TeamCity', {
|
||||
img: 'https://www.nclouds.com/blog/wp-content/uploads/2017/04/teamcity-post-banner.jpg',
|
||||
url: 'https://teamcity.spotify.net',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc: 'CI platform for mobile and desktop clients.',
|
||||
newsTag: 'teamcity',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'data-monitoring', 'Data Monitoring', {
|
||||
img: DataMonitoringIcon,
|
||||
url: '/data-monitoring',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['data'],
|
||||
desc: 'Dashboarding tool to monitor the health of your data',
|
||||
newsTag: 'datamonitoring',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'data-discovery', 'Data Discovery', {
|
||||
img: DataInfrastructureIcon,
|
||||
url: '/data',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: 'Experience for finding high quality data in Spotify ecosystem.',
|
||||
newsTag: 'datadiscovery',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'lexikon', 'Lexikon', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://lexikon.spotify.net/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data-science'],
|
||||
desc: "Spotify's data and knowledge management solution.",
|
||||
newsTag: 'lexikon',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'ti', 'Tech Insights', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/tech-insights',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['backend', 'web', 'data', 'data-science', 'mobile'],
|
||||
desc: 'Get a overview of the Spotify tech landscape and its health.',
|
||||
newsTag: 'techinsights',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'soe', 'Stack Overflow Enterprise', {
|
||||
img: StackOverflowEnterpriseIcon,
|
||||
url: 'https://spotify.stackenterprise.co/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'data-science', 'mobile'],
|
||||
desc: "Spotify's main technical support tool",
|
||||
newsTag: 'soe',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'jukebox', 'Jukebox', {
|
||||
img: JukeboxIcon,
|
||||
url: '/docs/jukebox/',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data', 'data-science', 'ml'],
|
||||
desc: 'Components enabling ML Feature Stores',
|
||||
newsTag: 'jukebox',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'kubeflow', 'Kubeflow', {
|
||||
img: KubeflowIcon,
|
||||
url: '/docs/spotify-kubeflow/',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data', 'data-science', 'ml'],
|
||||
desc: 'Components enabling rapid ML model iteration',
|
||||
newsTag: 'kubeflow',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'klio', 'Klio', {
|
||||
img: KlioIcon,
|
||||
url: '/docs/klio-docs/',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data', 'data-science', 'ml'],
|
||||
desc: 'Python API for Apache Beam and Google Cloud Dataflow',
|
||||
newsTag: 'klio',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'tc4x', 'Test Certified', {
|
||||
img: Tc4bIcon,
|
||||
url: '/docs/tc4x-docs',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'mobile'],
|
||||
desc:
|
||||
'Test Certified Programs for all disciplines, which help guide you to high levels of quality and confidence with your products.',
|
||||
newsTag: 'testcertified',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'xclogparser', 'XCLogParser', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://github.com/spotify/XCLogParser',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['mobile'],
|
||||
desc: 'Xcode Log Parsing and Reporting tools that gives insight on iOS Build performance.',
|
||||
newsTag: 'xclogparser',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'androidstudiotuner', 'Android Studio Tuner', {
|
||||
img: PlatformDefaultIcon,
|
||||
url:
|
||||
'https://backstage.spotify.net/docs/client-golden-path/part-1-configuring-your-local-development-environment/10-configuring-android-locally/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc: 'Tool that optimizes Android Studio performance and development environment settings',
|
||||
newsTag: 'androidstudiotuner',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'remotebuild', 'Remote Build', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/client-android/remote-build/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc:
|
||||
'A tool that does quick remote builds of the Android Music Client that is then synced to your local development machine',
|
||||
newsTag: 'remotebuild',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'qliksense', 'Qlik Sense', {
|
||||
img: QlikSenseIcon,
|
||||
url:
|
||||
'https://backstage.spotify.net/docs/data-science-golden-path/part-3-creating-your-first-qlik-sense-app/1-introduction/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: "Spotify's visualisation toolkit.",
|
||||
newsTag: 'qliksense',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'tableau', 'Tableau', {
|
||||
img: TableauIcon,
|
||||
url:
|
||||
'https://backstage.spotify.net/docs/data-science-golden-path/part-2-creating-your-first-tableau-dashboard/1-introduction/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: "Spotify's visualisation toolkit.",
|
||||
newsTag: 'tableau',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'scienceboxclassic', 'Science Box Classic', {
|
||||
img: ScienceBoxIcon,
|
||||
url: 'https://confluence.spotify.net/display/SB/Science+Box',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: "Spotify's data science toolkit.",
|
||||
newsTag: 'scienceboxclassic',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'bigquery', 'BigQuery', {
|
||||
img: BigQueryIcon,
|
||||
url: 'https://console.cloud.google.com/bigquery',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: "Google's cloud-based web service for interactive SQL queries against big data sets.",
|
||||
newsTag: 'bigquery',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'bigqueryrunner', 'BigQuery Runner', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://confluence.spotify.net/display/BBQ/BQ+Runner+Documentation',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: 'A tool that allows you to run queries on a schedule.',
|
||||
newsTag: 'bqrunner',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'bigqueryload', 'BigQuery Load', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://confluence.spotify.net/display/BBQ/BQ+Load+User+Manual',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data', 'data-science'],
|
||||
desc: 'A tool that allows you to load data from Google Cloud Storage to BigQuery on a schedule.',
|
||||
newsTag: 'bqload',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'elitzur', 'Elitzur', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://backstage.spotify.net/docs/elitzur/',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['data'],
|
||||
desc: 'Library to validate dataset column values using custom-types at run-time in Scio Pipelines.',
|
||||
newsTag: 'elitzur',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'contours', 'Contours', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://backstage.spotify.net/docs/data-profiling-docs/',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data'],
|
||||
desc: 'Library to generate descriptive statistics of each field in your dataset.',
|
||||
newsTag: 'contours',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'iceluigi', 'Ice-Luigi', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://ghe.spotify.net/datainfra/ice-luigi',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data'],
|
||||
desc: 'Luigi tasks and targets to make it easier to test Data Pipelines.',
|
||||
newsTag: 'iceluigi',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'ratatool', 'Ratatool', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://github.com/spotify/ratatool/',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['data'],
|
||||
desc: 'Tool for random data sampling and generation.',
|
||||
newsTag: 'ratatool',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'mobius', 'Mobius', {
|
||||
img: MobiusIcon,
|
||||
url: '/docs/mobius-docs/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc: 'Mobius is a functional reactive framework for managing state evolution and side-effects.',
|
||||
newsTag: 'mobius',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'hubsrenderer', ' HubsRenderer', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/hubs-renderer-docs/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc:
|
||||
'HubsRenderer is Spotify’s component-driven UI framework. We use it to build, tweak, and ship user interface features in new or existing apps. It also makes it easy to build backend-driven UIs.',
|
||||
newsTag: 'hubsrenderer',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'crashview', 'Crash View', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/crash-stack-documentation',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['mobile'],
|
||||
desc: "Crash View is Spotify's tool to find your crashes in the main music app.",
|
||||
newsTag: 'crashview',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'experimentation', 'Experimentation Platform', {
|
||||
img: PlatformDefaultIcon,
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['backend', 'web', 'data', 'data-science', 'mobile'],
|
||||
url: '/docs/experimentation-platform-docs/',
|
||||
desc:
|
||||
'The experimentation platform enables users to quickly iterate through new ideas or improvements, learn from them to adopt better solutions',
|
||||
newsTag: 'experimentation',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'grafana', 'Grafana', {
|
||||
img: GrafanaIcon,
|
||||
url: '/docs/monitoring/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web'],
|
||||
desc: 'Observability platform frontend for graphing and time series analytics, in real time.',
|
||||
newsTag: 'grafana',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'distributedtracing', 'Distributed Tracing', {
|
||||
img: DistributedTracingIcon,
|
||||
url: '/docs/tracing/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'mobile'],
|
||||
desc: 'Observability platform tool to instrument and trace requests at scale, in real time. ',
|
||||
newsTag: 'tracing',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'spotifystatus', 'Spotity Status', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://status.spotify.net',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'mobile'],
|
||||
desc:
|
||||
'The starting place for incidents. Know at a glance whether there are known problems affecting Spotify and how those incidents are progressing towards resolution.',
|
||||
newsTag: 'spotifystatus',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'styx', 'Styx', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://ghe.spotify.net/datainfra/styx',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data'],
|
||||
desc:
|
||||
'A service for scheduling the execution of docker containers used to periodically trigger batch data workflows.',
|
||||
newsTag: 'styx',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'luigi', 'Luigi', {
|
||||
img: LuigiIcon,
|
||||
url: 'https://github.com/spotify/luigi',
|
||||
lifecycle: 'GA',
|
||||
domains: ['data'],
|
||||
desc: 'An open source Python library for defining tasks and their dependencies within a data workflow.',
|
||||
newsTag: 'luigi',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'artifactory', 'Artifactory', {
|
||||
img: ArtifactoryIcon,
|
||||
url: '/docs/artifactory/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'web', 'data', 'mobile'],
|
||||
desc: 'Store and manage your binaries.',
|
||||
newsTag: 'artifactory',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'cloudefficiency', 'Cost Efficiency', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/gcp-efficiency-score-service',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['backend'],
|
||||
desc:
|
||||
'Tool that calculates cost efficiency based on utilization of cloud resources and provides recommendations for better efficiency.',
|
||||
newsTag: 'cost',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'cassette-ios', 'Cassette (iOS)', {
|
||||
img: CassetteIcon,
|
||||
url: 'https://backstage.spotify.net/docs/client-ios-docs/Writing-Cassette-Tests/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc: 'Cassette is a framework for writing fast and stable integration tests',
|
||||
newsTag: 'casetteios',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'cassette-android', 'Cassette (Android)', {
|
||||
img: CassetteIcon,
|
||||
url: 'https://backstage.spotify.net/docs/client-android/03-testing/03-cassette-tests/overview/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc: 'Cassette is a framework for writing fast and stable integration tests',
|
||||
newsTag: 'casetteandroid',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'gabito', 'Gabito', {
|
||||
img: DataInfrastructureIcon,
|
||||
url: '/docs/gabito-docs',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['data', 'data-science', 'backend', 'web', 'mobile'],
|
||||
desc:
|
||||
'Instrumentation SDKs and Event Delivery Infrastructure. Reliable, high throughput, highly available and cost efficient',
|
||||
newsTag: 'gabito',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'safetynet', 'Safetynet', {
|
||||
img: SecurityTribeIcon,
|
||||
url: '/docs/security/safetynet/',
|
||||
lifecycle: 'Beta',
|
||||
domains: ['backend', 'web', 'data'],
|
||||
desc: "Safetynet is Security's product for detecting and alerting on malicious activity in our environment.",
|
||||
newsTag: 'safetynet',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'client-performance-dashboards', 'Client Performance Dashboards', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://arewefastyet.spotify.net/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['mobile'],
|
||||
desc:
|
||||
'A series of interactive dashboards for digging into Client Performance data to find out how fast and efficient our products are.',
|
||||
newsTag: 'perfdashboards',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'remote-admin', 'Remote Admin', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: 'https://remoteadmin.spotifyinternal.com/',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'mobile'],
|
||||
desc:
|
||||
'A backend tool which enables live traffic visualization for selected users. Has additional debugging functionality for Spotify Connect.',
|
||||
newsTag: 'remoteadmin',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'padlock', 'Padlock', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/padlock',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'data'],
|
||||
desc: 'Padlock is a key management service that helps service-owners respect users privacy.',
|
||||
newsTag: 'padlock',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'scio-anonym', 'Scio Anonym', {
|
||||
img: PlatformDefaultIcon,
|
||||
url: '/docs/padlock/adoption/scio/scio_anonym',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['data'],
|
||||
desc: 'Scio Anonym is a library for producing and consuming personal data in Scio pipelines.',
|
||||
newsTag: 'scioanonym',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'backstage', 'Backstage Platform', {
|
||||
img: BackstageIcon,
|
||||
url: '/docs/backstage',
|
||||
desc: 'The Spotify platform for internal tooling. Information targeted to contributors.',
|
||||
domains: ['web', 'backend'],
|
||||
newsTag: 'backstagedevs',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'itgctracker', 'ITGC Tracker', {
|
||||
img: ITGCtrackerIcon,
|
||||
url: '/itgc/tracker',
|
||||
lifecycle: 'Alpha',
|
||||
domains: ['backend', 'itgc'],
|
||||
desc:
|
||||
'The ITGC tracker allows you to visualize the state of all components in scope for ITGC or awating to be reviewed.',
|
||||
newsTag: 'itgctrcker',
|
||||
}),
|
||||
);
|
||||
|
||||
menuRoot.getByIdPath('explore.infra').add(
|
||||
new BackstageMenuItem(rp, 'bigtable', 'Bigtable', {
|
||||
img: BigtableIcon,
|
||||
url: '/docs/storage/bigtable',
|
||||
lifecycle: 'GA',
|
||||
domains: ['backend', 'data'],
|
||||
desc:
|
||||
'Bigtable is a high performance cross-region replicated NoSQL database service for large analytical and operational workloads.',
|
||||
newsTag: 'bigtable',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Data
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'data', 'Data', { img: <DataIcon /> }));
|
||||
menuRoot.getByIdPath('tools.data').add(
|
||||
new BackstageMenuItem(rp, 'dataMonitoring', 'Data Monitoring', {
|
||||
url: '/data-monitoring',
|
||||
}),
|
||||
);
|
||||
menuRoot
|
||||
.getByIdPath('tools.data')
|
||||
.add(new BackstageMenuItem(rp, 'realtimeEvents', 'Realtime Events', { url: '/realtime-events' }));
|
||||
menuRoot
|
||||
.getByIdPath('tools.data')
|
||||
.add(new BackstageMenuItem(rp, 'dataAccessStatus', 'Data Access Status', { url: '/data-requests/access/status' }));
|
||||
menuRoot
|
||||
.getByIdPath('tools.data')
|
||||
.add(new BackstageMenuItem(rp, 'alchemy', 'Data Alchemy', { url: 'https://alchemy.spotify.net' }));
|
||||
|
||||
//-------------------------------------
|
||||
// Data Science
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'data-science', 'Data Science', { img: <DataIcon /> }));
|
||||
|
||||
menuRoot.getByIdPath('tools.data-science').add(
|
||||
new BackstageMenuItem(rp, 'qliksense', 'Qlik Sense', {
|
||||
url: 'https://confluence.spotify.net/display/IT/Qlik+sense+access',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.data-science').add(
|
||||
new BackstageMenuItem(rp, 'tableau', 'Tableau Desktop', {
|
||||
url: 'https://confluence.spotify.net/display/IT/Access+to+Tableau+Desktop',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Code
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'code', 'Code', { img: <CodeIcon /> }));
|
||||
menuRoot.getByIdPath('tools.code').add(new BackstageMenuItem(rp, 'ghe', 'GHE', { url: 'https://ghe.spotify.net' }));
|
||||
menuRoot.getByIdPath('tools.code').add(
|
||||
new BackstageMenuItem(rp, 'foss', 'Open Source @ Spotify', {
|
||||
url: 'https://foss.spotify.net',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.code').add(
|
||||
new BackstageMenuItem(rp, 'tingleConsole', 'Tingle Console', {
|
||||
url: 'https://tingle-console.spotify.net/',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.code').add(
|
||||
new BackstageMenuItem(rp, 'tingle-validator', 'Tingle Build Info Validator', {
|
||||
url: 'https://build-info-validator.spotify.net/',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Reliability
|
||||
//-------------------------------------
|
||||
menuRoot
|
||||
.getByIdPath('tools')
|
||||
.add(new BackstageMenuItem(rp, 'reliability', 'Reliability', { img: <ReliabilityIcon /> }));
|
||||
menuRoot.getByIdPath('tools.reliability').add(
|
||||
new BackstageMenuItem(rp, 'grafanaMonitoring', 'Grafana Monitoring', {
|
||||
url: 'https://grafana.spotify.net',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.reliability').add(
|
||||
new BackstageMenuItem(rp, 'distributedTracing', 'Distributed Tracing', {
|
||||
url: 'https://app.lightstep.com/spotify',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Services
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(
|
||||
new BackstageMenuItem(rp, 'services', 'Services', {
|
||||
img: <ServiceIcon />,
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.services').add(
|
||||
new BackstageMenuItem(rp, 'apolloLibrary', 'Apollo library', {
|
||||
url: 'https://developer.spotify.net/products/apollo.html',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.services').add(
|
||||
new BackstageMenuItem(rp, 'serviceDiscovery', 'Service Discovery', {
|
||||
url: 'https://developer.spotify.net/products/nameless.html',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Apps
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(
|
||||
new BackstageMenuItem(rp, 'apps', 'Apps', {
|
||||
img: <AppIcon />,
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.apps').add(
|
||||
new BackstageMenuItem(rp, 'junit', 'Visualization of JUnit test data', {
|
||||
url: 'https://odeneye.spotify.net/',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.apps').add(
|
||||
new BackstageMenuItem(rp, 'integrationTesting', 'Integration testing', {
|
||||
url: 'https://developer.spotify.net/products/cassette.html',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.apps').add(
|
||||
new BackstageMenuItem(rp, 'productQuality', 'Product Quality', {
|
||||
url: 'https://product-quality.spotify.net/',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Compliance
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(
|
||||
new BackstageMenuItem(rp, 'compliance', 'Compliance', {
|
||||
img: <ComplianceIcon />,
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.compliance').add(
|
||||
new BackstageMenuItem(rp, 'itgc', 'ITGC Components', {
|
||||
url: '/itgc/components',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.compliance').add(
|
||||
new BackstageMenuItem(rp, 'activity', 'ITGC Activity', {
|
||||
url: '/itgc/activity',
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.compliance').add(
|
||||
new BackstageMenuItem(rp, 'yopass', 'Yopass - send passwords', {
|
||||
url: 'https://yopass.spotify.net',
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Groups & Access
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(
|
||||
new BackstageMenuItem(rp, 'groups', 'Groups & Access', {
|
||||
img: <GroupIcon />,
|
||||
}),
|
||||
);
|
||||
|
||||
//-------------------------------------
|
||||
// Storage
|
||||
//-------------------------------------
|
||||
menuRoot.getByIdPath('tools').add(
|
||||
new BackstageMenuItem(rp, 'storage', 'Storage', {
|
||||
img: <StorageIcon />,
|
||||
searchable: false,
|
||||
}),
|
||||
);
|
||||
menuRoot.getByIdPath('tools.storage').add(
|
||||
new BackstageMenuItem(rp, 'storage', 'Selection Guide (Beta)', {
|
||||
url: '/storage-selection-guide',
|
||||
}),
|
||||
);
|
||||
|
||||
return menuRoot;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuItem {
|
||||
constructor(id, title, options, parent) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.parent = parent;
|
||||
this.children = [];
|
||||
this.childrenById = {};
|
||||
|
||||
this.options = options;
|
||||
|
||||
this._visible = true;
|
||||
}
|
||||
|
||||
get visibleChildren() {
|
||||
return this.children.filter(child => child.visible);
|
||||
}
|
||||
|
||||
set visible(value) {
|
||||
this._visible = value;
|
||||
}
|
||||
|
||||
get visible() {
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively construct a breadcrumb title path to this node.
|
||||
*
|
||||
* @param delimiter Defaults to ' > '
|
||||
* @returns {string}
|
||||
*/
|
||||
getTitlePath(delimiter = ' > ') {
|
||||
if (this.options.ignoreParentsInTitle) {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
return `${this.parent && this.parent.title ? `${this.parent.title}${delimiter}` : ''}${this.title}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path can be an array of MenuItem ids: ['tools', 'compliance']
|
||||
*
|
||||
* OR
|
||||
*
|
||||
* Path can be a dot-delimited string: 'tools.compliance'
|
||||
*
|
||||
* @param path
|
||||
* @returns {MenuItem}
|
||||
*/
|
||||
getByIdPath(path) {
|
||||
path = path instanceof Array ? path : path.split('.');
|
||||
|
||||
return path.reduce((menuItem, id) => {
|
||||
return menuItem.childrenById[id];
|
||||
}, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a MenuItem instance as a child to this MenuItem.
|
||||
*
|
||||
* @param menuItem
|
||||
* @returns {*}
|
||||
*/
|
||||
add(menuItem) {
|
||||
menuItem.parent = this;
|
||||
|
||||
this.childrenById[menuItem.id] = menuItem;
|
||||
this.children.push(menuItem);
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>{this.title}</div>;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.parent ? `${this.parent.toString()}.${this.id}` : this.id;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { usePagerDutyIncidents } from './pagerDutyIncidents';
|
||||
export { useStackoverflowUnansweredQuestions } from './stackoverflowQuestions';
|
||||
@@ -1,192 +0,0 @@
|
||||
import React from 'react';
|
||||
import gql from 'graphql-tag';
|
||||
import _ from 'lodash';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { useApolloClient } from '@apollo/react-hooks';
|
||||
import { useUser } from 'shared/apis/user';
|
||||
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
|
||||
const query = gql`
|
||||
query($username: String!) {
|
||||
user(username: $username) {
|
||||
components {
|
||||
id
|
||||
pagerDutyService {
|
||||
id
|
||||
...PD
|
||||
}
|
||||
}
|
||||
dataEndpoints {
|
||||
id
|
||||
warningPagerDutyService {
|
||||
...PD
|
||||
}
|
||||
errorPagerDutyService {
|
||||
...PD
|
||||
}
|
||||
}
|
||||
workflows {
|
||||
id
|
||||
component {
|
||||
id
|
||||
pagerDutyService {
|
||||
...PD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment PD on PagerDutyService {
|
||||
id
|
||||
name
|
||||
homepageUrl
|
||||
activeIncidents {
|
||||
id
|
||||
homepageUrl
|
||||
status
|
||||
title
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function getIncidentsFor({ components, dataEndpoints, workflows }: any) {
|
||||
const incidents = [
|
||||
...components.map((c: any) => getServiceIncidents(c.pagerDutyService)),
|
||||
...dataEndpoints.map((d: any) => getServiceIncidents(d.warningPagerDutyService)),
|
||||
...dataEndpoints.map((d: any) => getServiceIncidents(d.errorPagerDutyService)),
|
||||
...workflows.map((w: any) => getServiceIncidents(w.component.pagerDutyService)),
|
||||
];
|
||||
|
||||
return _.chain(incidents)
|
||||
.flatten()
|
||||
.uniqBy('id')
|
||||
.orderBy('createdAt')
|
||||
.value();
|
||||
}
|
||||
|
||||
function getServiceIncidents(service: any) {
|
||||
if (!service) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return service.activeIncidents.map((incident: any) => ({
|
||||
...incident,
|
||||
service,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getPagerDutyIncidents(client: ApolloClient<any>, username: string): Promise<any[]> {
|
||||
try {
|
||||
let { data, errors } = await client.query({ query, variables: { username } });
|
||||
|
||||
if (errors) {
|
||||
console.warn('Failed to fetch PagerDuty incidents', errors);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!data || !data.user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getIncidentsFor(data.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch PagerDuty incidents', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A hook that repeatedly polls the backend for all incident-capable entities, and returns
|
||||
* an array with all active incidents.
|
||||
*/
|
||||
export function usePagerDutyIncidents() {
|
||||
const { id: username } = useUser();
|
||||
const client = useApolloClient();
|
||||
const [incidents, setIncidents] = React.useState<any[]>([]);
|
||||
const timeoutIdRef = React.useRef<NodeJS.Timer>();
|
||||
|
||||
const [broadcastChannel] = React.useState(() => {
|
||||
// Broadcast messages to other channels open on the same origin in any window, not available in Safari.
|
||||
if ((window as any).BroadcastChannel) {
|
||||
return new (window as any).BroadcastChannel('pager-duty-refresh');
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close(); // Cleanup on unmount
|
||||
}
|
||||
},
|
||||
[broadcastChannel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
// Updates incidents, but doesn't replace empty arrays to avoid rerendering
|
||||
const handleNewIncidents = (newIncidents: any[]) => {
|
||||
setIncidents(incidents => {
|
||||
if (newIncidents.length !== 0 || incidents.length !== 0) {
|
||||
return newIncidents;
|
||||
} else {
|
||||
return incidents;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle incidents received from another window, restart the timout with some extra time added
|
||||
// to give time for another update, and a random component for conflict resolution if the window is closed.
|
||||
const handleIncidentsFromOtherTab = ({ data }: any) => {
|
||||
scheduleFetch(1000 + Math.random() * 2000);
|
||||
handleNewIncidents(data.incidents);
|
||||
};
|
||||
|
||||
const fetchIncidents = async () => {
|
||||
const newIncidents = await getPagerDutyIncidents(client, username);
|
||||
|
||||
if (didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast the incidents we fetched to other tabs, so they won't need to fetch themselves.
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.postMessage({ incidents: newIncidents });
|
||||
}
|
||||
handleNewIncidents(newIncidents);
|
||||
};
|
||||
|
||||
const scheduleFetch = (extraWaitMs = 0) => {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
}
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
fetchIncidents();
|
||||
scheduleFetch();
|
||||
}, POLL_INTERVAL_MS + extraWaitMs);
|
||||
};
|
||||
|
||||
// Fetch initial incidents and start fetch loop
|
||||
fetchIncidents();
|
||||
scheduleFetch();
|
||||
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.addEventListener('message', handleIncidentsFromOtherTab);
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
}
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.removeEventListener('message', handleIncidentsFromOtherTab);
|
||||
}
|
||||
};
|
||||
}, [client, username, broadcastChannel]);
|
||||
|
||||
return incidents;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import pluginManagerBootstrap from 'plugins/pluginManagerBootstrap';
|
||||
import StackOverflowClient from 'shared/apis/stackOverflow/StackOverflowClient';
|
||||
import { useUser } from 'shared/apis/user';
|
||||
|
||||
const LAST_READ_KEY = 'notifications.stackoverflowUnanswered.lastReadTimestamp';
|
||||
|
||||
export const useStackoverflowUnansweredQuestions = () => {
|
||||
const [lastRead, setLastRead] = useState(() => Number(localStorage.getItem(LAST_READ_KEY)) || 0);
|
||||
const user = useUser();
|
||||
|
||||
const markRead = useCallback(() => {
|
||||
const now = new Date().getTime();
|
||||
localStorage.setItem(LAST_READ_KEY, String(now));
|
||||
setLastRead(now);
|
||||
}, []);
|
||||
|
||||
const status = useAsync(async () => {
|
||||
const squadIds = user.groups.filter(group => group.type === 'squad').map(s => s.id);
|
||||
const tags = pluginManagerBootstrap.plugins.reduce((tags, plugin) => {
|
||||
if (squadIds.includes(plugin.owner)) tags = tags.concat(plugin.stackoverflowTags);
|
||||
return tags;
|
||||
}, []);
|
||||
if (tags.length) {
|
||||
return await StackOverflowClient.unansweredFor(tags, lastRead);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [lastRead]);
|
||||
|
||||
return {
|
||||
...status,
|
||||
markRead,
|
||||
};
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { OAuthScopes, OAuthScopeLike } from './types';
|
||||
|
||||
export class BasicOAuthScopes implements OAuthScopes {
|
||||
static from(scopes: OAuthScopeLike, normalizer: (scope: string) => string = x => x) {
|
||||
const normalized = BasicOAuthScopes.asStrings(scopes, normalizer);
|
||||
return new BasicOAuthScopes(new Set(normalized), normalizer);
|
||||
}
|
||||
|
||||
constructor(private readonly scopes: Set<string>, private readonly normalizer: (scope: string) => string) {}
|
||||
|
||||
extend(requestedScopes: OAuthScopeLike): OAuthScopes {
|
||||
const newScopes = new Set(this.scopes);
|
||||
BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach(s => newScopes.add(s));
|
||||
return new BasicOAuthScopes(newScopes, this.normalizer);
|
||||
}
|
||||
|
||||
hasScopes(scopes: OAuthScopeLike): boolean {
|
||||
return BasicOAuthScopes.asStrings(scopes, this.normalizer).every(s => this.scopes.has(s));
|
||||
}
|
||||
|
||||
toSet(): Set<string> {
|
||||
return this.scopes;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return Array.from(this.scopes).join(' ');
|
||||
}
|
||||
|
||||
static asStrings(input: OAuthScopeLike, normalizer: (scope: string) => string): string[] {
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(s => normalizer(s));
|
||||
} else if (Array.isArray(input)) {
|
||||
return input.map(s => normalizer(s));
|
||||
} else {
|
||||
return Array.from(input.toSet()).map(s => normalizer(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { wait } from '@testing-library/react';
|
||||
import { OAuthPendingRequests } from './OAuthPendingRequests';
|
||||
import { BasicOAuthScopes } from './BasicOAuthScopes';
|
||||
|
||||
describe('OAuthPendingRequests', () => {
|
||||
it('notifies new observers about current state', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const input = BasicOAuthScopes.from('a b');
|
||||
target.pending().subscribe({ next, error });
|
||||
target.request(input);
|
||||
|
||||
await wait(() => expect(next).toBeCalledTimes(2));
|
||||
expect(next.mock.calls[0][0].scopes).toBeUndefined();
|
||||
expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
|
||||
expect(error.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves requests and notifies observers', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session1');
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session2');
|
||||
|
||||
await expect(request1).resolves.toBe('session1');
|
||||
await expect(request2).resolves.toBe('session1');
|
||||
expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can resolve through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).resolves.toBe('done');
|
||||
expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects requests and notifies observers only once', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
const rejection = new Error('eek');
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.reject(rejection);
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session');
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
await expect(request2).rejects.toBe(rejection);
|
||||
expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can reject through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const rejection = new Error('nope');
|
||||
const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
expect(next).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import Observable from 'zen-observable';
|
||||
import { OAuthScopes } from './types';
|
||||
|
||||
type RequestQueueEntry<ResultType> = {
|
||||
scopes: OAuthScopes;
|
||||
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type PendingRequest<ResultType> = {
|
||||
scopes: OAuthScopes | undefined;
|
||||
resolve: (value: ResultType) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type OAuthPendingRequestsApi<ResultType> = {
|
||||
request(scopes: OAuthScopes): Promise<ResultType>;
|
||||
resolve(scopes: OAuthScopes, result: ResultType): void;
|
||||
reject(error: Error): void;
|
||||
pending(): Observable<PendingRequest<ResultType>>;
|
||||
};
|
||||
|
||||
export class OAuthPendingRequests<ResultType> implements OAuthPendingRequestsApi<ResultType> {
|
||||
private requests: RequestQueueEntry<ResultType>[] = [];
|
||||
private listeners: ZenObservable.SubscriptionObserver<PendingRequest<ResultType>>[] = [];
|
||||
|
||||
request(scopes: OAuthScopes): Promise<ResultType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requests.push({ scopes, resolve, reject });
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach(listener => listener.next(pending));
|
||||
});
|
||||
}
|
||||
|
||||
resolve(scopes: OAuthScopes, result: ResultType): void {
|
||||
this.requests = this.requests.filter(request => {
|
||||
if (scopes.hasScopes(request.scopes)) {
|
||||
request.resolve(result);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach(listener => listener.next(pending));
|
||||
}
|
||||
|
||||
reject(error: Error) {
|
||||
this.requests.forEach(request => request.reject(error));
|
||||
this.requests = [];
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach(listener => listener.next(pending));
|
||||
}
|
||||
|
||||
pending(): Observable<PendingRequest<ResultType>> {
|
||||
return new Observable(subscriber => {
|
||||
this.listeners.push(subscriber);
|
||||
subscriber.next(this.getCurrentPending());
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getCurrentPending(): PendingRequest<ResultType> {
|
||||
const currentScopes =
|
||||
this.requests.length === 0
|
||||
? undefined
|
||||
: this.requests.slice(1).reduce((acc, current) => acc.extend(current.scopes), this.requests[0].scopes);
|
||||
|
||||
return {
|
||||
scopes: currentScopes,
|
||||
resolve: (value: ResultType) => {
|
||||
if (currentScopes) {
|
||||
this.resolve(currentScopes, value);
|
||||
}
|
||||
},
|
||||
reject: (reason: Error) => {
|
||||
if (currentScopes) {
|
||||
this.reject(reason);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export type OAuthScopes = {
|
||||
extend(scopes: OAuthScopeLike): OAuthScopes;
|
||||
hasScopes(scopes: OAuthScopeLike): boolean;
|
||||
toSet(): Set<string>;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
export type OAuthScopeLike =
|
||||
| string /** Space separated scope strings */
|
||||
| string[] /** Array of individual scope strings */
|
||||
| OAuthScopes;
|
||||
@@ -1,234 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AppIcon,
|
||||
AppFeatureIcon,
|
||||
CdnIcon,
|
||||
DataEndpointsIcon,
|
||||
GcpProjectIcon,
|
||||
GroupIcon,
|
||||
LibraryIcon,
|
||||
OtherIcon,
|
||||
ServiceIcon,
|
||||
SystemIcon,
|
||||
TechDocsIcon,
|
||||
UserIcon,
|
||||
WebsiteIcon,
|
||||
WorkflowIcon,
|
||||
SdkIcon,
|
||||
MachineLearningIcon,
|
||||
} from 'shared/icons';
|
||||
import Link from 'shared/components/Link';
|
||||
import { theme } from 'core/app/PageThemeProvider';
|
||||
import FeatureFlags from '../featureFlags/featureFlags';
|
||||
|
||||
// The type that needs to be declared for every component type below.
|
||||
// Indexable will be one of the indexable types below.
|
||||
type SearchTypeDefinition<I extends ComponentResult | ResultTypes[keyof ResultTypes]> = {
|
||||
title: string;
|
||||
icon: JSX.Element;
|
||||
theme?: keyof typeof theme;
|
||||
shorthands: string[];
|
||||
buildUrl(indexable: I): string;
|
||||
buildTitle(indexable: I): string;
|
||||
buildSubtitle(indexable: I): string;
|
||||
};
|
||||
|
||||
// This is the search result item type for generic component-like search results.
|
||||
type ComponentResult = {
|
||||
id: string;
|
||||
description: string;
|
||||
componentType: string;
|
||||
};
|
||||
|
||||
// These are custom types for other indexed resources, keyed by componentType
|
||||
type ResultTypes = {
|
||||
user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
componentType: 'user';
|
||||
};
|
||||
'tech-doc': {
|
||||
title: string;
|
||||
location: string;
|
||||
componentId: string;
|
||||
componentType: 'tech-doc';
|
||||
};
|
||||
'ml-project': {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
componentType: 'ml-project';
|
||||
};
|
||||
};
|
||||
|
||||
// Helps infer the type of all definitions below.
|
||||
// The result is an object type where the key is a union of component type strings
|
||||
// and the value is the type definition for that component type.
|
||||
type Definitions<T extends string> = {
|
||||
[key in T]: SearchTypeDefinition<key extends keyof ResultTypes ? ResultTypes[key] : ComponentResult>;
|
||||
};
|
||||
|
||||
// Typescript needs a function to be able to infer the type of each definition
|
||||
const inferDefinitions = <T extends string>(definitions: Definitions<T>) => definitions;
|
||||
|
||||
export const componentTypes = inferDefinitions({
|
||||
// TechDocs have a custom search results page, so this is only for SearchBox
|
||||
'tech-doc': {
|
||||
title: 'Documentation',
|
||||
icon: <TechDocsIcon />,
|
||||
theme: 'documentation',
|
||||
shorthands: ['doc', 'docs'],
|
||||
buildUrl: ({ location }) => `/docs/${location}`,
|
||||
buildTitle: ({ title, componentId }) => `${componentId} - ${title}`,
|
||||
buildSubtitle: ({ location }) => `docs/${location}`,
|
||||
},
|
||||
service: {
|
||||
title: 'Services',
|
||||
icon: <ServiceIcon data-testid="service" />,
|
||||
shorthands: ['srv'],
|
||||
buildUrl: ({ id }) => `/services/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
dataset: {
|
||||
title: 'Data Endpoints',
|
||||
icon: <DataEndpointsIcon />,
|
||||
theme: 'endpoint',
|
||||
shorthands: ['data'],
|
||||
buildUrl: ({ id }) => `/data-endpoints/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
workflow: {
|
||||
title: 'Workflows',
|
||||
icon: <WorkflowIcon />,
|
||||
shorthands: ['work'],
|
||||
buildUrl: ({ id }) => `/workflows/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
app: {
|
||||
title: 'Apps',
|
||||
icon: <AppIcon />,
|
||||
theme: 'app',
|
||||
shorthands: ['app'],
|
||||
buildUrl: ({ id }) => `/apps/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
'app-feature': {
|
||||
title: 'App Features',
|
||||
icon: <AppFeatureIcon />,
|
||||
theme: 'appFeature',
|
||||
shorthands: ['feat'],
|
||||
buildUrl: ({ id }) => `/app-features/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
website: {
|
||||
title: 'Websites',
|
||||
icon: <WebsiteIcon data-testid="website" />,
|
||||
shorthands: ['web'],
|
||||
buildUrl: ({ id }) => `/websites/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
library: {
|
||||
title: 'Libraries',
|
||||
icon: <LibraryIcon data-testid="library" />,
|
||||
shorthands: ['lib'],
|
||||
buildUrl: ({ id }) => `/libraries/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
'client-sdk': {
|
||||
title: 'Client SDK',
|
||||
icon: <SdkIcon data-testid="sdk" />,
|
||||
shorthands: ['sdk'],
|
||||
buildUrl: ({ id }) => `/client-sdks/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
'gcp-project': {
|
||||
title: 'GCP Projects',
|
||||
icon: <GcpProjectIcon data-testid="gcp-project" />,
|
||||
theme: 'project',
|
||||
shorthands: ['proj'],
|
||||
buildUrl: ({ id }) => `/projects/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
system: {
|
||||
title: 'Systems',
|
||||
icon: <SystemIcon data-testid="system" />,
|
||||
shorthands: ['sys'],
|
||||
buildUrl: ({ id }) => `/system/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
squad: {
|
||||
title: 'Groups & Squads',
|
||||
icon: <GroupIcon data-testid="squad" />,
|
||||
theme: 'org',
|
||||
shorthands: ['group', 'squad'],
|
||||
buildUrl: ({ id }) => `/org/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
user: {
|
||||
title: 'Users',
|
||||
icon: <UserIcon data-testid="user" />,
|
||||
theme: 'tool',
|
||||
shorthands: ['user'],
|
||||
buildUrl: ({ id }) => `/groups/users/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ fullName }) => fullName,
|
||||
},
|
||||
other: {
|
||||
title: 'Other',
|
||||
icon: <OtherIcon data-testid="other" />,
|
||||
shorthands: ['other'],
|
||||
buildUrl: ({ id }) => `/components/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
cdn: {
|
||||
title: 'CDN',
|
||||
icon: <CdnIcon />,
|
||||
shorthands: ['cdn'],
|
||||
buildUrl: ({ id }) => `/components/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
'ml-project': {
|
||||
title: 'ML Projects',
|
||||
icon: <MachineLearningIcon data-testid="ml-project" />,
|
||||
shorthands: ['ml'],
|
||||
buildUrl: ({ id }) => `/machine-learning/projects/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ name }) => name,
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
'sciencebox-project': {
|
||||
title: 'ScienceBox Projects',
|
||||
icon: <OtherIcon />, // We don't have a ScienceBox icon yet.
|
||||
shorthands: ['sb'],
|
||||
buildUrl: ({ id }) => `/sciencebox-projects/${encodeURIComponent(id)}`,
|
||||
buildTitle: ({ id }) => id, // ScienceBox ids are human-readable.
|
||||
buildSubtitle: ({ description }) => description,
|
||||
},
|
||||
});
|
||||
|
||||
if (!FeatureFlags.getItem('machine-learning')) {
|
||||
delete componentTypes['ml-project'];
|
||||
}
|
||||
|
||||
export function ComponentTypeLink({
|
||||
componentType,
|
||||
id,
|
||||
}: {
|
||||
componentType: Exclude<keyof typeof componentTypes, keyof ResultTypes>;
|
||||
id: string;
|
||||
}) {
|
||||
const comp = componentTypes[componentType] !== null ? componentTypes[componentType] : componentTypes['other'];
|
||||
return <Link to={comp.buildUrl({ id } as ComponentResult)}>{id}</Link>;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import bodybuilder from 'bodybuilder';
|
||||
import { pruneFilterObject } from 'plugins/searchPage/components/FacetsFilter';
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 30;
|
||||
const COMPONENT_TYPE_AGG_SIZE = 100;
|
||||
const AGG_SIZE = 300;
|
||||
|
||||
const getFieldWithDataType = name =>
|
||||
['tc4dLevel', 'isGolden', 'isTestRun'].indexOf(name) > -1 ? name : `${name}.keyword`;
|
||||
|
||||
const applyFilters = (query, filters = {}) => {
|
||||
Object.keys(filters).forEach(filterSetId => {
|
||||
const filterValues = Object.keys(filters[filterSetId]);
|
||||
if (filterValues.length) {
|
||||
query.filter('terms', getFieldWithDataType(filterSetId), filterValues);
|
||||
if (filterValues.options) {
|
||||
applyFilters(query, filterValues.options);
|
||||
}
|
||||
}
|
||||
});
|
||||
return query;
|
||||
};
|
||||
|
||||
export const createFilteredQuery = (filters = {}) => {
|
||||
const search = bodybuilder();
|
||||
|
||||
if (Object.keys(filters).length) {
|
||||
search.aggregation('filter', 'componentType.keyword', a => {
|
||||
applyFilters(a, filters);
|
||||
a.aggregation('terms', 'componentType.keyword', { size: COMPONENT_TYPE_AGG_SIZE }, 'metadata');
|
||||
return a;
|
||||
});
|
||||
} else {
|
||||
search.aggregation('terms', 'componentType.keyword', { size: COMPONENT_TYPE_AGG_SIZE }, 'metadata');
|
||||
}
|
||||
|
||||
return search;
|
||||
};
|
||||
|
||||
export const constructDefaultQuery = (term, filters = {}) => {
|
||||
const search = createFilteredQuery(filters);
|
||||
|
||||
if (!term) {
|
||||
return search.query('match_all');
|
||||
}
|
||||
|
||||
return (
|
||||
search
|
||||
.query('multi_match', {
|
||||
query: term,
|
||||
fields: ['_all', 'id^2', 'id.raw^100', 'id.ngram_raw^2', 'tags.raw^5', 'tags.ngram_raw^2'],
|
||||
})
|
||||
.orQuery('match', 'lifecycle', { query: 'production', boost: 3 })
|
||||
.orQuery('match', 'tc4dLevel', { query: '3', boost: 4 })
|
||||
.orQuery('match', 'tc4dLevel', { query: '2', boost: 3 })
|
||||
.orQuery('match', 'tc4dLevel', { query: '1', boost: 2 })
|
||||
.orQuery('match', 'isGolden', { query: 'true', boost: 1 })
|
||||
.orQuery('match', 'resourceId', { query: term, boost: 1 })
|
||||
.notFilter('term', 'componentType.keyword', 'data-endpoint')
|
||||
// The index boost for search-partnership must be wildcarded, if an exact name is provided Elasticsearch throws
|
||||
// an error when the index doesn't exist (in tests, but this also seems like good safety for production).
|
||||
.rawOption('indices_boost', [{ 'search-partnershi*': 10 }])
|
||||
);
|
||||
};
|
||||
|
||||
export const constructDocsQuery = (term, filters = {}) => {
|
||||
const search = createFilteredQuery(filters);
|
||||
|
||||
if (!term) {
|
||||
return search.query('match_all');
|
||||
}
|
||||
|
||||
/* If a search term includes double quotes we would like to give exact matches for that.
|
||||
For example "Find horse ever bus" should return a empty search result list.
|
||||
1. Words should be included in any docs that shows up in search result.
|
||||
2. Words should have the exact same sequence in the document as in the search term to be a valid search result.
|
||||
(as in Googles exact match search) */
|
||||
// TODO: Boosting for exact match
|
||||
|
||||
if (term.match(/"[^"]*"/)) {
|
||||
return search.rawOption('query', {
|
||||
query_string: {
|
||||
query: term,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return search.rawOption('query', {
|
||||
function_score: {
|
||||
query: {
|
||||
multi_match: {
|
||||
query: term,
|
||||
fields: [
|
||||
'_all',
|
||||
'componentId^6',
|
||||
'text^7',
|
||||
'title^2',
|
||||
'title.ngram^1',
|
||||
'location^10',
|
||||
'id^2',
|
||||
'id.raw^100',
|
||||
'id.ngram_raw^2',
|
||||
'tags.raw^5',
|
||||
'tags.ngram_raw^2',
|
||||
],
|
||||
},
|
||||
},
|
||||
functions: [
|
||||
{
|
||||
field_value_factor: {
|
||||
field: 'hitsLast30Days',
|
||||
factor: 1.0,
|
||||
modifier: 'sqrt',
|
||||
missing: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
boost: '5',
|
||||
boost_mode: 'sum',
|
||||
score_mode: 'multiply',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Construct elastic search query from term and filters
|
||||
export const constructQuery = ({
|
||||
aggFields = [],
|
||||
componentType,
|
||||
filters = {},
|
||||
pagination = { pageIndex: 0, size: DEFAULT_PAGE_SIZE },
|
||||
sort = {},
|
||||
query: term = '',
|
||||
}) => {
|
||||
const prunedFilters = pruneFilterObject(filters);
|
||||
const hasFilters = !!Object.keys(prunedFilters).length;
|
||||
let query;
|
||||
|
||||
// TODO: get rid of this piece of tech dept, platformize search
|
||||
if (componentType === 'tech-doc') {
|
||||
query = constructDocsQuery(term, prunedFilters);
|
||||
} else {
|
||||
query = constructDefaultQuery(term, prunedFilters);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
query.from(pagination.pageIndex * Number(pagination.size)).size(Number(pagination.size));
|
||||
|
||||
if (sort.column && sort.column !== 'false') {
|
||||
query.sort([
|
||||
{
|
||||
// tc4d value needs to default to 0 if it is missing for sorting to work
|
||||
[getFieldWithDataType(sort.column)]: {
|
||||
order: sort.direction,
|
||||
...(sort.column === 'tc4dLevel' ? { missing: 0 } : {}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Apply selected filters and component type filter as a post_filter so that aggregation counts
|
||||
// are not affected (for example, filtering on lifecycle 'production' should NOT then show that there are 0 components
|
||||
// with lifecycle 'experimental', as it would if this was not a post_filter).
|
||||
let postFilters = bodybuilder();
|
||||
if (hasFilters) {
|
||||
postFilters = applyFilters(postFilters, prunedFilters);
|
||||
}
|
||||
if (componentType) {
|
||||
postFilters.filter('term', 'componentType.keyword', componentType);
|
||||
}
|
||||
query.rawOption('post_filter', postFilters.getFilter());
|
||||
|
||||
// Add aggregations for each filterable field, to get counts for the terms in that field; the aggregation should have
|
||||
// active filters applied EXCEPT its own, so that other filters affect the counts but selecting the filter doesn't
|
||||
// drop the counts for other values of the same filter to zero.
|
||||
if (aggFields.length) {
|
||||
aggFields.forEach(agg => {
|
||||
const otherFilters = Object.keys(prunedFilters).reduce((acc, key) => {
|
||||
if (key !== agg && Object.keys(prunedFilters[key]).length) {
|
||||
acc[key] = prunedFilters[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(otherFilters).length || componentType) {
|
||||
query.aggregation('filter', getFieldWithDataType(agg), a => {
|
||||
applyFilters(a, otherFilters);
|
||||
a.filter('term', 'componentType.keyword', componentType);
|
||||
|
||||
// Component type filter is standalone and NOT included in the filter object
|
||||
if (componentType) {
|
||||
a.aggregation('terms', getFieldWithDataType(agg), { size: AGG_SIZE });
|
||||
}
|
||||
|
||||
return a;
|
||||
});
|
||||
} else {
|
||||
query.aggregation('terms', getFieldWithDataType(agg), { size: AGG_SIZE });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(query.build());
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { constructDefaultQuery } from './ESQueryBuilder';
|
||||
import axios from 'axios';
|
||||
import { urls } from 'shared/apis/baseUrls';
|
||||
|
||||
async function doEsQuery(term) {
|
||||
const query = constructDefaultQuery(term);
|
||||
const esQuery = `
|
||||
query ElasticSearch($query: String!) {
|
||||
elasticSearch(esQuery: $query) {
|
||||
data {
|
||||
... on Indexable {
|
||||
id
|
||||
componentType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let error;
|
||||
for (let attempts = 0; attempts < 3; attempts++) {
|
||||
try {
|
||||
const res = await axios.post(`${urls.proxy}/api/backend/graphql`, {
|
||||
operationName: 'ElasticSearch',
|
||||
query: esQuery,
|
||||
variables: {
|
||||
query: JSON.stringify(query.build()),
|
||||
},
|
||||
});
|
||||
|
||||
return res.data.data.elasticSearch.data;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
describe('ESQueryBuilder', () => {
|
||||
// TODO: Too flaky, move to script / external service
|
||||
it.skip.each([
|
||||
'sysmodel',
|
||||
'backstage-lb',
|
||||
'backstage-backend',
|
||||
'backstage-frontend',
|
||||
'backstage-e2e-test-data',
|
||||
'backstage-e2e-test-data.TestData',
|
||||
])('find exact match for term - %s', async queryTerm => {
|
||||
const result = await doEsQuery(queryTerm);
|
||||
expect(result[0].id).toBe(queryTerm);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { graphqlRequest } from 'shared/apis/backstage/graphqlClient';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
const defaultExtraFields = `
|
||||
... on Component {
|
||||
description
|
||||
lifecycle
|
||||
owner {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
... on TechDoc {
|
||||
componentId
|
||||
location
|
||||
title
|
||||
text
|
||||
hitsLast30Days
|
||||
}
|
||||
... on Dataset {
|
||||
lifecycle
|
||||
sysmodelComponentId
|
||||
storageType
|
||||
isGolden
|
||||
dataFormat
|
||||
publishFrequency
|
||||
accessPolicy
|
||||
tc4dLevel
|
||||
description
|
||||
isTestRun
|
||||
owner {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
... on Workflow {
|
||||
lifecycle
|
||||
owner {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
... on GoogleCloudPlatformProject {
|
||||
owner {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
... on MLProject {
|
||||
name
|
||||
description
|
||||
}`;
|
||||
|
||||
export function esSearch(filter, options = {}) {
|
||||
const { extraFields = defaultExtraFields } = options;
|
||||
|
||||
const query = gql`
|
||||
query ElasticSearch($query: String!) {
|
||||
elasticSearch(esQuery: $query) {
|
||||
data {
|
||||
__typename
|
||||
... on Indexable {
|
||||
id
|
||||
componentType
|
||||
}
|
||||
${extraFields}
|
||||
}
|
||||
aggregations {
|
||||
name
|
||||
buckets {
|
||||
key
|
||||
docCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return graphqlRequest(query, {
|
||||
query: filter.query,
|
||||
});
|
||||
}
|
||||
|
||||
export default { esSearch };
|
||||
@@ -1,72 +0,0 @@
|
||||
import FirestoreSettingsStore from './FirestoreSettingsStore';
|
||||
import MockFirestoreStorage from 'shared/apis/firestore/MockFirestoreStorage';
|
||||
|
||||
describe('FirestoreSettingsStore', () => {
|
||||
it('should be created', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
const store = new FirestoreSettingsStore(storage);
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
|
||||
it('should forward value to storage', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
const store = new FirestoreSettingsStore(storage);
|
||||
await expect(storage.get('/')).resolves.toMatchObject({ exists: false });
|
||||
await store.set('my.setting', 3);
|
||||
await expect(storage.get('/')).resolves.toEqual({
|
||||
exists: true,
|
||||
data: {
|
||||
settings: { 'my.setting': 3 },
|
||||
},
|
||||
path: '/',
|
||||
id: 'me',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not forward unchanged values', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
storage.set('/', { settings: { 'my.setting': 3 } });
|
||||
jest.spyOn(storage, 'set');
|
||||
|
||||
const store = new FirestoreSettingsStore(storage);
|
||||
expect(storage.set).not.toHaveBeenCalled();
|
||||
await store.set('my.setting', 3);
|
||||
expect(storage.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should subscribe to changes', async () => {
|
||||
const storage = new MockFirestoreStorage();
|
||||
const store = new FirestoreSettingsStore(storage);
|
||||
|
||||
const subscribeFn1 = jest.fn();
|
||||
const unsubscribe1 = store.subscribe('my.setting', subscribeFn1);
|
||||
const subscribeFn2 = jest.fn();
|
||||
const unsubscribe2 = store.subscribe('my.missingSetting', subscribeFn2);
|
||||
|
||||
expect(subscribeFn1).not.toHaveBeenCalled();
|
||||
expect(subscribeFn2).not.toHaveBeenCalled();
|
||||
|
||||
await storage.merge('/', { settings: { 'my.setting': 4 } });
|
||||
expect(subscribeFn1).toHaveBeenCalledTimes(2);
|
||||
expect(subscribeFn1).toHaveBeenLastCalledWith(4);
|
||||
expect(subscribeFn2).toHaveBeenCalledTimes(1);
|
||||
expect(subscribeFn2).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
// unchanged values should be ignored
|
||||
await storage.merge('/', { settings: { 'my.setting': 4 } });
|
||||
expect(subscribeFn1).toHaveBeenCalledTimes(2);
|
||||
expect(subscribeFn2).toHaveBeenCalledTimes(1);
|
||||
|
||||
// unchanged values should be ignored
|
||||
await storage.merge('/', { settings: { 'my.setting': 5 } });
|
||||
expect(subscribeFn1).toHaveBeenCalledTimes(3);
|
||||
expect(subscribeFn2).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe1();
|
||||
unsubscribe2();
|
||||
|
||||
await storage.merge('/', { settings: { 'my.setting': 6 } });
|
||||
expect(subscribeFn1).toHaveBeenCalledTimes(3);
|
||||
expect(subscribeFn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { SettingsStore, SettingValue, SettingListener, Unsubscribe, SettingsData } from './types';
|
||||
import { FirestoreApi, JsonObject } from 'shared/apis/firestore';
|
||||
|
||||
export default class FirestoreSettingsStore implements SettingsStore {
|
||||
private api: FirestoreApi;
|
||||
private data: SettingsData | undefined;
|
||||
private listeners: Map<string, Set<SettingListener>> = new Map();
|
||||
|
||||
constructor(api: FirestoreApi) {
|
||||
this.api = api;
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
this.api.observe<{ settings: SettingsData }>('/').forEach(snapshot => {
|
||||
const data = snapshot.data?.settings;
|
||||
this.data = data;
|
||||
|
||||
this.listeners.forEach((listenerSet, id) => {
|
||||
listenerSet.forEach(listener => {
|
||||
listener(data && data[id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async set<T extends SettingValue>(id: string, value: T): Promise<void> {
|
||||
const snapshot = await this.api.get('/');
|
||||
const data = snapshot.data?.settings || {};
|
||||
const settings = data as JsonObject;
|
||||
if (settings[id] === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[id] = value;
|
||||
await this.api.merge('/', { settings });
|
||||
}
|
||||
|
||||
subscribe<T extends SettingValue>(id: string, listener: SettingListener<T>): Unsubscribe {
|
||||
let currentValue = this.data && (this.data[id] as T);
|
||||
let calledListenerOnce = false;
|
||||
|
||||
if (this.data) {
|
||||
listener(currentValue);
|
||||
calledListenerOnce = true;
|
||||
}
|
||||
|
||||
const internalListener = (newValue: T) => {
|
||||
if (newValue !== currentValue || !calledListenerOnce) {
|
||||
currentValue = newValue;
|
||||
listener(newValue);
|
||||
calledListenerOnce = true;
|
||||
}
|
||||
};
|
||||
|
||||
const listeners = this.listeners.get(id) || new Set();
|
||||
this.listeners.set(id, listeners);
|
||||
listeners.add(internalListener as SettingListener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(internalListener as SettingListener);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SettingListener, Unsubscribe, SettingValue, SettingsData, SettingsStore } from './types';
|
||||
|
||||
export default class MemorySettingsStore implements SettingsStore {
|
||||
private data: SettingsData;
|
||||
private listeners: Map<string, Set<SettingListener>> = new Map();
|
||||
|
||||
constructor(data: SettingsData = {}) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
async set<T extends SettingValue>(id: string, value: T): Promise<void> {
|
||||
this.data[id] = value;
|
||||
|
||||
const listeners = this.listeners.get(id);
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
(listeners as Set<SettingListener>).forEach(listener => {
|
||||
listener(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
subscribe<T extends SettingValue>(id: string, listener: SettingListener<T>): Unsubscribe {
|
||||
let listeners = this.listeners.get(id) || new Set();
|
||||
this.listeners.set(id, listeners);
|
||||
listeners.add(listener as SettingListener);
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
listener(this.data[id] as T);
|
||||
});
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener as SettingListener);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Setting from './Setting';
|
||||
import { SettingsStore } from './types';
|
||||
|
||||
describe('Setting', () => {
|
||||
it('should be created', async () => {
|
||||
const setting = new Setting('my.id', 3 as number, () => undefined);
|
||||
expect(setting.id).toBe('my.id');
|
||||
expect(setting.defaultValue).toBe(3);
|
||||
|
||||
expect(() => {
|
||||
setting.subscribe(() => {});
|
||||
}).toThrowError('no settings store available');
|
||||
|
||||
await expect(setting.set(4)).rejects.toThrowError('no settings store available');
|
||||
});
|
||||
|
||||
it('should be accessible directly', async () => {
|
||||
const mockStore: SettingsStore = {
|
||||
set: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
const setting = new Setting('my.id', 3 as number, () => mockStore);
|
||||
|
||||
const listener = () => {};
|
||||
setting.subscribe(listener);
|
||||
setting.set(4);
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('my.id', 4);
|
||||
expect(mockStore.subscribe).toHaveBeenCalledWith('my.id', listener);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { SettingValue, SettingListener, SettingsStore } from './types';
|
||||
|
||||
export default class Setting<T extends SettingValue> {
|
||||
readonly id: string;
|
||||
readonly defaultValue: T;
|
||||
|
||||
private readonly storeAccessor: () => SettingsStore | undefined;
|
||||
|
||||
constructor(id: string, defaultValue: T, storeAccessor: () => SettingsStore | undefined) {
|
||||
this.id = id;
|
||||
this.defaultValue = defaultValue;
|
||||
this.storeAccessor = storeAccessor;
|
||||
}
|
||||
|
||||
async set(value: T): Promise<void> {
|
||||
const store = this.storeAccessor();
|
||||
if (!store) {
|
||||
throw new Error('no settings store available');
|
||||
}
|
||||
return store.set(this.id, value);
|
||||
}
|
||||
|
||||
subscribe(listener: SettingListener<T>) {
|
||||
const store = this.storeAccessor();
|
||||
if (!store) {
|
||||
throw new Error('no settings store available');
|
||||
}
|
||||
return store.subscribe(this.id, listener);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import SettingCollection from './SettingCollection';
|
||||
import { SettingsStore } from './types';
|
||||
|
||||
describe('SettingCollection', () => {
|
||||
it('should be created', async () => {
|
||||
const collection = new SettingCollection();
|
||||
expect(collection.bind()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register settings', async () => {
|
||||
const collection = new SettingCollection();
|
||||
|
||||
const mySetting = collection.register({ id: 'my.setting', defaultValue: 4 });
|
||||
mySetting.set(5).catch(() => {}); // make sure any number is allowed
|
||||
|
||||
expect(() => {
|
||||
collection.register({ id: 'my.setting', defaultValue: 2 });
|
||||
}).toThrowError('already exists');
|
||||
|
||||
const myOtherSetting = collection.register({ id: 'my.other.setting', defaultValue: false });
|
||||
myOtherSetting.set(true).catch(() => {}); // make sure type allows all boolean values
|
||||
});
|
||||
|
||||
it('should bind a store', async () => {
|
||||
const mockStore: SettingsStore = {
|
||||
set: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
|
||||
const collection = new SettingCollection();
|
||||
|
||||
const mySetting = collection.register({ id: 'my.setting', defaultValue: 'initial-value' });
|
||||
|
||||
collection.bind()(mockStore);
|
||||
|
||||
await mySetting.set('other-value');
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('my.setting', 'other-value');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { SettingConfig, SettingValue, SettingsStore, SettingsStoreContext } from './types';
|
||||
import Setting from './Setting';
|
||||
|
||||
export default class SettingCollection {
|
||||
private readonly settings: Map<string, Setting<SettingValue>> = new Map();
|
||||
private store: SettingsStore | undefined;
|
||||
|
||||
register(config: SettingConfig<boolean>): Setting<boolean>;
|
||||
register(config: SettingConfig<string>): Setting<string>;
|
||||
register(config: SettingConfig<number>): Setting<number>;
|
||||
register<T extends SettingValue>(config: SettingConfig<T>): Setting<T> {
|
||||
if (this.settings.has(config.id)) {
|
||||
throw new Error(`Setting '${config.id}' already exists`);
|
||||
}
|
||||
const setting = new Setting(config.id, config.defaultValue, () => this.store);
|
||||
this.settings.set(config.id, setting);
|
||||
return setting;
|
||||
}
|
||||
|
||||
// Creates a function that is used to bind this settings collection to a settings store,
|
||||
// and returns a context provider for that store.
|
||||
bind(): (store: SettingsStore) => FC<{}> {
|
||||
return store => {
|
||||
this.store = store;
|
||||
|
||||
return ({ children }) => <SettingsStoreContext.Provider value={store} children={children} />;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type SettingValue = boolean | number | string;
|
||||
export type SettingsData = { [id: string]: SettingValue };
|
||||
export type SettingSetFunc<T> = (value: T) => Promise<void>;
|
||||
export type SettingListener<T = SettingValue> = (value: T | undefined) => void;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export type SettingsStore = {
|
||||
set<T extends SettingValue>(id: string, value: T): Promise<void>;
|
||||
subscribe<T extends SettingValue>(id: string, listener: SettingListener<T>): Unsubscribe;
|
||||
};
|
||||
|
||||
export type SettingConfig<T extends SettingValue> = {
|
||||
id: string;
|
||||
defaultValue: T;
|
||||
};
|
||||
|
||||
export const SettingsStoreContext = createContext<SettingsStore | undefined>(undefined);
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import useSetting from './useSetting';
|
||||
import SettingCollection from './SettingCollection';
|
||||
import { SettingsStore, SettingValue } from './types';
|
||||
import Setting from './Setting';
|
||||
import { render } from '@testing-library/react';
|
||||
import MemorySettingsStore from './MemorySettingsStore';
|
||||
import { renderWithEffects } from 'testUtils';
|
||||
|
||||
type TestProps = {
|
||||
setting: Setting<SettingValue>;
|
||||
mock: (ret: ReturnType<typeof useSetting>) => void;
|
||||
};
|
||||
|
||||
const TestComponent: FC<TestProps> = ({ setting, mock }) => {
|
||||
mock(useSetting(setting));
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useSetting', () => {
|
||||
it('should return default value', async () => {
|
||||
const collection = new SettingCollection();
|
||||
|
||||
const mySetting = collection.register({ id: 'my.setting', defaultValue: 4 });
|
||||
|
||||
const mock = jest.fn();
|
||||
render(<TestComponent setting={mySetting} mock={mock} />);
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
expect(mock).toHaveBeenCalledWith([4, undefined, true]);
|
||||
});
|
||||
|
||||
it('should interact with a store', async () => {
|
||||
const mockStore: SettingsStore = {
|
||||
set: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
|
||||
const collection = new SettingCollection();
|
||||
const mySetting = collection.register({ id: 'my.setting', defaultValue: 6 });
|
||||
const Provider = collection.bind()(mockStore);
|
||||
|
||||
const retFn = jest.fn();
|
||||
render(
|
||||
<Provider>
|
||||
<TestComponent setting={mySetting} mock={retFn} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(retFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockStore.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(retFn).toHaveBeenNthCalledWith(1, [6, expect.any(Function), true]);
|
||||
|
||||
const listener = (mockStore.subscribe as jest.Mock).mock.calls[0][1];
|
||||
|
||||
listener(7);
|
||||
expect(retFn).toHaveBeenCalledTimes(2);
|
||||
expect(retFn).toHaveBeenLastCalledWith([7, expect.any(Function), false]);
|
||||
|
||||
listener(8);
|
||||
expect(retFn).toHaveBeenCalledTimes(3);
|
||||
expect(retFn).toHaveBeenLastCalledWith([8, expect.any(Function), false]);
|
||||
|
||||
listener(undefined);
|
||||
expect(retFn).toHaveBeenCalledTimes(4);
|
||||
expect(retFn).toHaveBeenLastCalledWith([6, expect.any(Function), false]);
|
||||
|
||||
const setValue = retFn.mock.calls[0][0][1];
|
||||
expect(mockStore.set).not.toHaveBeenCalled();
|
||||
setValue(9);
|
||||
expect(mockStore.set).toHaveBeenCalledWith('my.setting', 9);
|
||||
});
|
||||
|
||||
it('should interact with memory store', async () => {
|
||||
const store = new MemorySettingsStore();
|
||||
const collection = new SettingCollection();
|
||||
const mySetting = collection.register({ id: 'my.setting', defaultValue: 6 });
|
||||
const Provider = collection.bind()(store);
|
||||
|
||||
const retFn = jest.fn();
|
||||
await renderWithEffects(
|
||||
<Provider>
|
||||
<TestComponent setting={mySetting} mock={retFn} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(retFn).toHaveBeenCalledTimes(2);
|
||||
expect(retFn).toHaveBeenNthCalledWith(1, [6, expect.any(Function), true]);
|
||||
expect(retFn).toHaveBeenNthCalledWith(2, [6, expect.any(Function), false]);
|
||||
|
||||
await store.set('my.setting', 5);
|
||||
|
||||
expect(retFn).toHaveBeenCalledTimes(3);
|
||||
expect(retFn).toHaveBeenLastCalledWith([5, expect.any(Function), false]);
|
||||
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = store.subscribe('my.setting', listener);
|
||||
await Promise.resolve();
|
||||
expect(listener).toHaveBeenCalledWith(5);
|
||||
|
||||
const setValue = retFn.mock.calls[0][0][1];
|
||||
await setValue(4);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenCalledWith(4);
|
||||
expect(retFn).toHaveBeenCalledTimes(4);
|
||||
expect(retFn).toHaveBeenLastCalledWith([4, expect.any(Function), false]);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
await setValue(3);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(retFn).toHaveBeenCalledTimes(5);
|
||||
expect(retFn).toHaveBeenLastCalledWith([3, expect.any(Function), false]);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useContext, useEffect, useReducer } from 'react';
|
||||
import { SettingValue, SettingSetFunc, SettingsStoreContext } from './types';
|
||||
import Setting from './Setting';
|
||||
|
||||
type State<T> = {
|
||||
loading: boolean;
|
||||
value: T | undefined;
|
||||
};
|
||||
|
||||
type Action<T> = { type: 'CLEAR' } | { type: 'SET'; value: T | undefined };
|
||||
|
||||
const initialState = { loading: true, value: undefined };
|
||||
|
||||
function reducer<T>(state: State<T>, action: Action<T>): State<T> {
|
||||
switch (action.type) {
|
||||
case 'CLEAR':
|
||||
if (state.value === undefined) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, value: undefined };
|
||||
case 'SET':
|
||||
return { loading: false, value: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const useSetting = <T extends SettingValue>(
|
||||
setting: Setting<T>,
|
||||
): [T, SettingSetFunc<T>, boolean] | [T, undefined, boolean] => {
|
||||
const settingStore = useContext(SettingsStoreContext);
|
||||
const [state, dispatch] = useReducer<(state: State<T>, action: Action<T>) => State<T>>(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingStore) {
|
||||
dispatch({ type: 'CLEAR' });
|
||||
return;
|
||||
}
|
||||
|
||||
return settingStore.subscribe(setting.id, (value: T | undefined) => {
|
||||
dispatch({ type: 'SET', value });
|
||||
});
|
||||
}, [setting, settingStore]);
|
||||
|
||||
const returnedValue = state.value === undefined ? setting.defaultValue : state.value;
|
||||
|
||||
if (settingStore) {
|
||||
const setValue: SettingSetFunc<T> = async newValue => {
|
||||
await settingStore.set(setting.id, newValue);
|
||||
};
|
||||
|
||||
return [returnedValue, setValue, state.loading];
|
||||
}
|
||||
|
||||
return [returnedValue, undefined, state.loading];
|
||||
};
|
||||
|
||||
export default useSetting;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user