frontend/packages: removed shared

This commit is contained in:
Patrik Oldsberg
2020-02-24 15:29:56 +01:00
parent 803073dacf
commit d1b539bac9
495 changed files with 0 additions and 28802 deletions
@@ -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
View File
@@ -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
View File
@@ -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 }} />
&nbsp;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';
-263
View File
@@ -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. Its 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: 'Spotifys 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 Spotifys 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