Merge branch 'master' of github.com:spotify/backstage into ndudnik/use-catalog-backend

# Conflicts:
#	plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
#	plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
#	plugins/catalog/src/components/ComponentPage/ComponentPage.tsx
#	plugins/catalog/src/data/mock-factory.ts
#	plugins/catalog/src/plugin.ts
This commit is contained in:
Nikita Nek Dudnik
2020-05-28 14:28:00 +02:00
200 changed files with 2982 additions and 644 deletions
+10 -4
View File
@@ -25,10 +25,16 @@
"morgan": "^1.10.0",
"winston": "^3.2.1",
"yn": "^4.0.0",
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"@types/passport": "1.0.3",
"@types/passport-google-oauth20": "2.0.3"
"passport": "^0.4.1",
"passport-google-oauth20": "^2.0.0",
"passport-oauth2-refresh": "^2.0.0",
"passport-oauth2": "^1.5.0",
"cookie-parser": "^1.4.5",
"@types/passport-oauth2-refresh": "^1.1.1",
"@types/passport": "^1.0.3",
"@types/passport-google-oauth20": "^2.0.3",
"@types/cookie-parser": "^1.4.2",
"@types/passport-oauth2": "^1.4.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
@@ -14,10 +14,15 @@
* limitations under the License.
*/
import { GoogleAuthProvider } from './provider';
import {
GoogleAuthProvider,
THOUSAND_DAYS_MS,
TEN_MINUTES_MS,
} from './provider';
import passport from 'passport';
import express from 'express';
import * as utils from './../utils';
import refresh from 'passport-oauth2-refresh';
const googleAuthProviderConfig = {
provider: 'google',
@@ -51,11 +56,16 @@ describe('GoogleAuthProvider', () => {
});
describe('start authentication handler', () => {
const mockResponse: any = ({} as unknown) as express.Response;
const mockResponse = ({
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const mockNext: express.NextFunction = jest.fn();
it('should initiate authenticate request with provided scopes', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {
scope: 'a,b',
},
@@ -74,11 +84,36 @@ describe('GoogleAuthProvider', () => {
scope: 'a,b',
accessType: 'offline',
prompt: 'consent',
state: expect.any(String),
});
});
it('should set a nonce cookie', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {
scope: 'a,b',
},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-nonce',
expect.any(String),
expect.objectContaining({
maxAge: TEN_MINUTES_MS,
path: `/auth/${googleAuthProviderConfig.provider}/handler`,
}),
);
});
it('should throw error if no scopes provided', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {},
} as unknown) as express.Request;
@@ -92,11 +127,14 @@ describe('GoogleAuthProvider', () => {
});
describe('logout handler', () => {
const mockRequest = ({} as unknown) as express.Request;
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
it('should perform logout and respond with 200', () => {
const mockResponse: any = ({
send: jest.fn(),
cookie: jest.fn(),
} as unknown) as express.Response;
const googleAuthProvider = new GoogleAuthProvider(
@@ -110,23 +148,83 @@ describe('GoogleAuthProvider', () => {
googleAuthProvider.logout(mockRequest, mockResponse);
expect(spyResponse).toBeCalledTimes(1);
expect(spyResponse).toBeCalledWith('logout!');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('redirect frame handler', () => {
const mockRequest = ({} as unknown) as express.Request;
const mockResponse: any = ({
send: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const mockNext: express.NextFunction = jest.fn();
it('should call authenticate and post a response ', () => {
it('should call authenticate and post a response', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(null, { refreshToken: 'REFRESH_TOKEN' });
return jest.fn();
});
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({
path: '/auth/google',
sameSite: 'none',
httpOnly: true,
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('should respond with a error message if no refresh token returned', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(null, {});
return jest.fn();
});
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const googleAuthProvider = new GoogleAuthProvider(
@@ -134,10 +232,100 @@ describe('GoogleAuthProvider', () => {
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
const callbackFunc = spyPassport.mock.calls[0][1] as Function;
callbackFunc();
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledWith(mockResponse, {
type: 'auth-result',
error: new Error('Missing refresh token'),
});
});
it('should respond with a error message if auth failed', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(new Error('TokenError'), null);
return jest.fn();
});
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledWith(mockResponse, {
type: 'auth-result',
error: new Error('Google auth failed, Error: TokenError'),
});
});
it('should respond with a error message if cookie nonce is missing', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: {},
query: { state: 'NONCE' },
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
it('should respond with a error message if state nonce is missing', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
it('should respond with a error message if nonce mismatch', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCA' },
query: { state: 'NONCEB' },
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Invalid nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
@@ -159,4 +347,176 @@ describe('GoogleAuthProvider', () => {
}).toThrow();
});
});
describe('refresh token handler', () => {
const mockResponse = ({
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
describe('no refresh token cookie', () => {
it('should respond with a 401', () => {
const mockRequest = ({
cookies: jest.fn(),
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing session cookie');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
describe('refresh token cookie, no scope', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
it('should request for a new access token and fail if no access token returned', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, undefined, undefined, {});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Failed to refresh access token',
);
});
it('should request for a new access token and return 401 if any error', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb({ error: 'ERROR' }, undefined, undefined, {});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Failed to refresh access token',
);
});
it('should fetch and return a new access token', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, 'ACCESS_TOKEN', undefined, {
expires_in: 'EXPIRES_IN',
id_token: 'ID_TOKEN',
});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith({
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 'EXPIRES_IN',
scope: undefined,
});
});
});
describe('refresh token cookie and scope', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {
scope: 'a,b',
},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
it('should fetch and return a new access token with scopes', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, 'ACCESS_TOKEN', undefined, {
expires_in: 'EXPIRES_IN',
id_token: 'ID_TOKEN',
scope: 'a,b',
});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{ scope: 'a,b' },
expect.any(Function),
);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith({
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 'EXPIRES_IN',
scope: 'a,b',
});
});
it('ensures x-requested-with header', () => {
const mockHeaderRequest = ({
header: () => 'TEST',
} as unknown) as express.Request;
googleAuthProvider.refresh(mockHeaderRequest, mockResponse);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Invalid X-Requested-With header',
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
});
});
@@ -15,16 +15,20 @@
*/
import passport from 'passport';
import express from 'express';
import express, { CookieOptions } from 'express';
import crypto from 'crypto';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import refresh from 'passport-oauth2-refresh';
import {
AuthProvider,
AuthProviderRouteHandlers,
AuthProviderConfig,
} from './../types';
import { postMessageResponse } from './../utils';
import { postMessageResponse, ensuresXRequestedWith } from './../utils';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export class GoogleAuthProvider
implements AuthProvider, AuthProviderRouteHandlers {
private readonly providerConfig: AuthProviderConfig;
@@ -37,6 +41,19 @@ export class GoogleAuthProvider
res: express.Response,
next: express.NextFunction,
) {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}/handler`,
httpOnly: true,
};
res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options);
const scope = req.query.scope?.toString() ?? '';
if (!scope) {
throw new InputError('missing scope parameter');
@@ -45,6 +62,7 @@ export class GoogleAuthProvider
scope,
accessType: 'offline',
prompt: 'consent',
state: nonce,
})(req, res, next);
}
@@ -53,16 +71,106 @@ export class GoogleAuthProvider
res: express.Response,
next: express.NextFunction,
) {
return passport.authenticate('google', (_, user) => {
postMessageResponse(res, {
const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
return res.status(401).send('Missing nonce');
}
if (cookieNonce !== stateNonce) {
return res.status(401).send('Invalid nonce');
}
return passport.authenticate('google', (err, user) => {
if (err) {
return postMessageResponse(res, {
type: 'auth-result',
error: new Error(`Google auth failed, ${err}`),
});
}
const { refreshToken } = user;
if (!refreshToken) {
return postMessageResponse(res, {
type: 'auth-result',
error: new Error('Missing refresh token'),
});
}
delete user.refreshToken;
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}`,
httpOnly: true,
};
res.cookie(
`${this.providerConfig.provider}-refresh-token`,
refreshToken,
options,
);
return postMessageResponse(res, {
type: 'auth-result',
payload: user,
});
})(req, res, next);
}
async logout(_req: express.Request, res: express.Response) {
res.send('logout!');
async logout(req: express.Request, res: express.Response) {
if (!ensuresXRequestedWith(req)) {
return res.status(401).send('Invalid X-Requested-With header');
}
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}`,
httpOnly: true,
};
res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
return res.send('logout!');
}
async refresh(req: express.Request, res: express.Response) {
if (!ensuresXRequestedWith(req)) {
return res.status(401).send('Invalid X-Requested-With header');
}
const refreshToken =
req.cookies[`${this.providerConfig.provider}-refresh-token`];
if (!refreshToken) {
return res.status(401).send('Missing session cookie');
}
const scope = req.query.scope?.toString() ?? '';
const refreshTokenRequestParams = scope ? { scope } : {};
return refresh.requestNewAccessToken(
this.providerConfig.provider,
refreshToken,
refreshTokenRequestParams,
(err, accessToken, _refreshToken, params) => {
if (err || !accessToken) {
return res.status(401).send('Failed to refresh access token');
}
return res.send({
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
});
},
);
}
strategy(): passport.Strategy {
@@ -81,6 +189,8 @@ export class GoogleAuthProvider
idToken: params.id_token,
accessToken,
refreshToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
});
},
);
+4 -4
View File
@@ -20,11 +20,11 @@ import { ProviderFactories } from './factories';
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
const router = Router();
router.get('/start', provider.start);
router.get('/handler/frame', provider.frameHandler);
router.get('/logout', provider.logout);
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.get('/logout', provider.logout.bind(provider));
if (provider.refresh) {
router.get('/refreshToken', provider.refresh);
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
+12 -3
View File
@@ -58,16 +58,25 @@ export type AuthProviderFactory = {
new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
};
export type AuthInfo = {
profile: passport.Profile;
export type AuthInfoBase = {
accessToken: string;
idToken?: string;
expiresInSeconds?: number;
scope: string;
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile: passport.Profile;
};
export type AuthInfoPrivate = AuthInfoWithProfile & {
refreshToken: string;
};
export type AuthResponse =
| {
type: 'auth-result';
payload: AuthInfo;
payload: AuthInfoBase | AuthInfoWithProfile;
}
| {
type: 'auth-result';
@@ -39,3 +39,12 @@ export const postMessageResponse = (
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
+1 -1
View File
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -17,6 +17,9 @@
import express from 'express';
import Router from 'express-promise-router';
import passport from 'passport';
import cookieParser from 'cookie-parser';
import refresh from 'passport-oauth2-refresh';
import OAuth2Strategy from 'passport-oauth2';
import { Logger } from 'winston';
import { providers } from './../providers/config';
import { makeProvider } from '../providers';
@@ -39,6 +42,9 @@ export async function createRouter(
);
logger.info(`Configuring provider: ${providerId}`);
passport.use(strategy);
if (strategy instanceof OAuth2Strategy) {
refresh.use(strategy);
}
providerRouters[providerId] = providerRouter;
}
@@ -52,6 +58,7 @@ export async function createRouter(
router.use(passport.initialize());
router.use(passport.session());
router.use(cookieParser());
for (const providerId in providerRouters) {
if (providerRouters.hasOwnProperty(providerId)) {
+3 -1
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -37,9 +38,10 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/lodash": "^4.14.151",
"@types/uuid": "^7.0.3",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"tsc-watch": "^4.2.3"
},
"files": [
@@ -14,32 +14,47 @@
* limitations under the License.
*/
import { NotFoundError } from '@backstage/backend-common';
import { Database } from '../database';
import { DescriptorEnvelope } from '../ingestion/types';
import { EntitiesCatalog } from './types';
import { EntitiesCatalog, EntityFilters } from './types';
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
constructor(private readonly database: Database) {}
async entities(): Promise<DescriptorEnvelope[]> {
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
const items = await this.database.transaction(tx =>
this.database.entities(tx),
this.database.entities(tx, filters),
);
return items.map(i => i.entity);
}
async entity(
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
);
return matches.length ? matches[0].entity : undefined;
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
): Promise<DescriptorEnvelope | undefined> {
const item = await this.database.transaction(tx =>
this.database.entity(tx, kind, name, namespace),
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [
{ key: 'kind', values: [kind] },
{ key: 'name', values: [name] },
{
key: 'namespace',
values:
!namespace || namespace === 'default'
? [null, 'default']
: [namespace],
},
]),
);
if (!item) {
throw new NotFoundError('Entity cannot be found');
}
return item.entity;
return matches.length ? matches[0].entity : undefined;
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
import knex from 'knex';
import path from 'path';
import { Database } from '../database';
import { ReaderOutput } from '../ingestion/types';
import { getVoidLogger } from '@backstage/backend-common';
describe('DatabaseLocationsCatalog', () => {
const database = knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
let db: Database;
let catalog: DatabaseLocationsCatalog;
const mockLocationReader = {
read: async (type: string, target: string): Promise<ReaderOutput[]> => {
if (type !== 'valid_type') {
throw new Error(`Unknown location type ${type}`);
}
if (target === 'valid_target') {
return Promise.resolve([{ type: 'data', data: {} }]);
}
throw new Error(
`Can't read location at ${target} with error: Something is broken`,
);
},
};
beforeEach(async () => {
await database.migrate.latest({
directory: path.resolve(__dirname, '../database/migrations'),
loadExtensions: ['.ts'],
});
db = new Database(database, getVoidLogger());
catalog = new DatabaseLocationsCatalog(db, mockLocationReader);
});
it('resolves to location with id', async () => {
return expect(
catalog.addLocation({ type: 'valid_type', target: 'valid_target' }),
).resolves.toEqual({
id: expect.anything(),
type: 'valid_type',
target: 'valid_target',
});
});
it('rejects for invalid type', async () => {
const type = 'invalid_type';
return expect(
catalog.addLocation({ type, target: 'valid_target' }),
).rejects.toThrow(/Unknown location type/);
});
it('rejects for unreadable target ', async () => {
const target = 'invalid_target';
return expect(
catalog.addLocation({ type: 'valid_type', target }),
).rejects.toThrow(
`Can't read location at ${target} with error: Something is broken`,
);
});
});
@@ -16,11 +16,24 @@
import { Database } from '../database';
import { AddLocation, Location, LocationsCatalog } from './types';
import { LocationReader } from '../ingestion';
export class DatabaseLocationsCatalog implements LocationsCatalog {
constructor(private readonly database: Database) {}
constructor(
private readonly database: Database,
private readonly reader: LocationReader,
) {}
async addLocation(location: AddLocation): Promise<Location> {
const outputs = await this.reader.read(location.type, location.target);
outputs.forEach(output => {
if (output.type === 'error') {
throw new Error(
`Can't read location at ${location.target}, ${output.error}`,
);
}
});
const added = await this.database.addLocation(location);
return added;
}
@@ -35,7 +48,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async location(id: string): Promise<Location> {
const item = await this.location(id);
const item = await this.database.location(id);
return item;
}
}
@@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
return lodash.cloneDeep(this._entities);
}
async entity(
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
const item = this._entities.find(e => uid === e.metadata?.uid);
if (!item) {
throw new NotFoundError('Entity cannot be found');
}
return lodash.cloneDeep(item);
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
+11 -4
View File
@@ -18,15 +18,22 @@ import * as yup from 'yup';
import { DescriptorEnvelope } from '../ingestion';
//
// Items
// Entities
//
export type EntityFilter = {
key: string;
values: (string | null)[];
};
export type EntityFilters = EntityFilter[];
export type EntitiesCatalog = {
entities(): Promise<DescriptorEnvelope[]>;
entity(
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
entityByName(
kind: string,
name: string,
namespace: string | undefined,
name: string,
): Promise<DescriptorEnvelope | undefined>;
};
@@ -21,6 +21,7 @@ import {
} from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { DescriptorEnvelope } from '../ingestion';
import { Database } from './Database';
import {
AddDatabaseLocation,
@@ -242,4 +243,112 @@ describe('Database', () => {
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new Database(database, getVoidLogger());
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
const e2: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
spec: { c: null },
};
await catalog.transaction(async tx => {
await catalog.addEntity(tx, { entity: e1 });
await catalog.addEntity(tx, { entity: e2 });
});
const result = await catalog.transaction(async tx =>
catalog.entities(tx, []),
);
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
{ locationId: undefined, entity: expect.objectContaining(e1) },
{ locationId: undefined, entity: expect.objectContaining(e2) },
]),
);
});
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
kind: 'b',
spec: { c: 'some' },
},
{
apiVersion: 'a',
kind: 'b',
spec: { c: null },
},
];
await catalog.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
}
});
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
{ key: 'kind', values: ['b'] },
{ key: 'spec.c', values: ['some'] },
]),
),
).resolves.toEqual([
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
]);
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
kind: 'b',
spec: { c: 'some' },
},
{
apiVersion: 'a',
kind: 'b',
spec: { c: null },
},
];
await catalog.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
}
});
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
{ key: 'kind', values: ['b'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
);
expect(rows.length).toEqual(3);
expect(rows).toEqual(
expect.arrayContaining([
{
locationId: undefined,
entity: expect.objectContaining(entities[0]),
},
{
locationId: undefined,
entity: expect.objectContaining(entities[1]),
},
{
locationId: undefined,
entity: expect.objectContaining(entities[2]),
},
]),
);
});
});
});
@@ -23,6 +23,7 @@ import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntityFilters } from '../catalog';
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
import { buildEntitySearch } from './search';
import {
@@ -186,11 +187,12 @@ export class Database {
}
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = Object.assign({}, newEntity.metadata, {
newEntity.metadata = {
...newEntity.metadata,
uid: generateUid(),
etag: generateEtag(),
generation: 1,
});
};
const newRow = toEntityRow(request.locationId, newEntity);
await tx<DbEntitiesRow>('entities').insert(newRow);
@@ -275,11 +277,12 @@ export class Database {
? oldRow.generation
: oldRow.generation + 1;
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = Object.assign({}, request.entity.metadata, {
newEntity.metadata = {
...newEntity.metadata,
uid: oldRow.id,
etag: newEtag,
generation: newGeneration,
});
};
// Preserve annotations that were set on the old version of the entity,
// unless the new version overwrites them
@@ -309,10 +312,30 @@ export class Database {
return { locationId: request.locationId, entity: newEntity };
}
async entities(tx: Knex.Transaction<any, any>): Promise<DbEntityResponse[]> {
const rows = await tx<DbEntitiesRow>('entities')
async entities(
tx: Knex.Transaction<any, any>,
filters?: EntityFilters,
): Promise<DbEntityResponse[]> {
let builder = tx<DbEntitiesRow>('entities');
for (const [index, filter] of (filters ?? []).entries()) {
builder = builder
.leftOuterJoin(`entities_search as t${index}`, function join() {
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
`t${index}.value`,
filter.values.filter(x => x),
);
if (filter.values.some(x => !x)) {
this.orOnNull(`t${index}.value`);
}
})
.where(`t${index}.key`, '=', filter.key);
}
const rows = await builder
.orderBy('namespace', 'name')
.select();
.select('entities.*')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
}
@@ -336,9 +359,7 @@ export class Database {
async addLocation(location: AddDatabaseLocation): Promise<DbLocationsRow> {
return await this.database.transaction<DbLocationsRow>(async tx => {
const existingLocation = await tx<DbLocationsRow>('locations')
.where({
target: location.target,
})
.where({ target: location.target })
.select();
if (existingLocation?.[0]) {
@@ -15,6 +15,7 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import Knex from 'knex';
import {
ComponentDescriptor,
DescriptorParser,
@@ -24,7 +25,6 @@ import {
import { Database } from './Database';
import { DatabaseManager } from './DatabaseManager';
import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types';
import Knex from 'knex';
describe('DatabaseManager', () => {
describe('refreshLocations', () => {
@@ -0,0 +1,198 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
import { DescriptorEnvelope } from '../ingestion';
import { createRouter } from './router';
class MockEntitiesCatalog implements EntitiesCatalog {
entities = jest.fn();
entityByUid = jest.fn();
entityByName = jest.fn();
}
class MockLocationsCatalog implements LocationsCatalog {
addLocation = jest.fn();
removeLocation = jest.fn();
locations = jest.fn();
location = jest.fn();
}
describe('createRouter', () => {
describe('entities', () => {
it('happy path: lists entities', async () => {
const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
const catalog = new MockEntitiesCatalog();
catalog.entities.mockResolvedValueOnce(entities);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities');
expect(response.status).toEqual(200);
expect(response.body).toEqual(entities);
});
it('parses single and multiple request parameters and passes them down', async () => {
const catalog = new MockEntitiesCatalog();
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
expect(response.status).toEqual(200);
expect(catalog.entities).toHaveBeenCalledWith([
{ key: 'a', values: ['1', null, '3'] },
{ key: 'b', values: ['4'] },
{ key: 'c', values: [null] },
]);
});
});
describe('entityByUid', () => {
it('can fetch entity by uid', async () => {
const entity: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
},
};
const catalog = new MockEntitiesCatalog();
catalog.entityByUid.mockResolvedValue(entity);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
const catalog = new MockEntitiesCatalog();
catalog.entityByUid.mockResolvedValue(undefined);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/uid/);
});
});
describe('entityByName', () => {
it('can fetch entity by name', async () => {
const entity: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
};
const catalog = new MockEntitiesCatalog();
catalog.entityByName.mockResolvedValue(entity);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-name/b/d/c');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
const catalog = new MockEntitiesCatalog();
catalog.entityByName.mockResolvedValue(undefined);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-name//b/d/c');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/name/);
});
});
describe('locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
const catalog = new MockLocationsCatalog();
catalog.locations.mockResolvedValueOnce(locations);
const router = await createRouter({
locationsCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/locations');
expect(response.status).toEqual(200);
expect(response.body).toEqual(locations);
});
it('rejects malformed locations', async () => {
const location = ({
id: 'a',
typez: 'b',
target: 'c',
} as unknown) as Location;
const catalog = new MockLocationsCatalog();
const router = await createRouter({
locationsCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).post('/locations').send(location);
expect(response.status).toEqual(400);
});
});
});
+58 -6
View File
@@ -14,12 +14,14 @@
* limitations under the License.
*/
import { errorHandler, InputError } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
addLocationSchema,
EntitiesCatalog,
EntityFilters,
LocationsCatalog,
} from '../catalog';
import { validateRequestBody } from './util';
@@ -34,17 +36,43 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog } = options;
const router = Router();
router.use(express.json());
if (entitiesCatalog) {
// Entities
router.get('/entities', async (_req, res) => {
const entities = await entitiesCatalog.entities();
res.status(200).send(entities);
});
router
.get('/entities', async (req, res) => {
const filters = translateQueryToEntityFilters(req);
const entities = await entitiesCatalog.entities(filters);
res.status(200).send(entities);
})
.get('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
const entity = await entitiesCatalog.entityByUid(uid);
if (!entity) {
res.status(404).send(`No entity with uid ${uid}`);
}
res.status(200).send(entity);
})
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
const { kind, namespace, name } = req.params;
const entity = await entitiesCatalog.entityByName(
kind,
name,
namespace,
);
if (!entity) {
res
.status(404)
.send(
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
);
}
res.status(200).send(entity);
});
}
// Locations
if (locationsCatalog) {
router
.post('/locations', async (req, res) => {
@@ -68,5 +96,29 @@ export async function createRouter(
});
}
router.use(errorHandler());
return router;
}
function translateQueryToEntityFilters(
request: express.Request,
): EntityFilters {
const filters: EntityFilters = [];
for (const [key, valueOrValues] of Object.entries(request.query)) {
const values = Array.isArray(valueOrValues)
? valueOrValues
: [valueOrValues];
if (values.some(v => typeof v !== 'string')) {
throw new InputError('Complex query parameters are not supported');
}
filters.push({
key,
values: values.map(v => v || null) as string[],
});
}
return filters;
}
-37
View File
@@ -157,40 +157,3 @@ export type KindParser = {
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export class ParserError extends Error {
constructor(message?: string, private _entityName?: string | undefined) {
super(message);
}
get entityName() {
return this._entityName;
}
}
export type ReaderOutput =
| { type: 'error'; error: Error }
| { type: 'data'; data: object };
export type LocationReader = {
/**
* Reads the contents of a single location.
*
* @param type The type of location to read
* @param target The location target (type-specific)
* @returns The parsed contents, as an array of unverified descriptors or
* errors where the individual documents could not be parsed.
* @throws An error if the location as a whole could not be read
*/
read(type: string, target: string): Promise<ReaderOutput[]>;
};
export type LocationSource = {
/**
* Reads the contents of a single location.
*
* @param target The location target to read
* @returns The parsed contents, as an array of unverified descriptors
* @throws An error if the location target could not be read
*/
read(target: string): Promise<ReaderOutput[]>;
};
@@ -0,0 +1,131 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
},
{
id: 'second',
label: 'Second Label',
},
],
},
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
const [group] = mockGroups;
for (const item of group.items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('should render the count in each item', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
count: 100,
},
{
id: 'second',
label: 'Second Label',
count: 400,
},
],
},
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
const [group] = mockGroups;
for (const item of group.items) {
expect(await findByText(item.count!.toString())).toBeInTheDocument();
}
});
it('should fire the callback when an item is clicked', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
count: 100,
},
{
id: 'second',
label: 'Second Label',
count: 400,
},
],
},
];
const onSelectedChangeHandler = jest.fn();
const { findByText } = render(
wrapInThemedTestApp(
<CatalogFilter
groups={mockGroups}
onSelectedChange={onSelectedChangeHandler}
/>,
),
);
const item = mockGroups[0].items[0];
const element = await findByText(item.label);
fireEvent.click(element);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Card,
List,
ListItemIcon,
ListItemText,
MenuItem,
Typography,
Theme,
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
export type CatalogFilterItem = {
id: string;
label: string;
icon?: IconComponent;
count?: number;
loading?: boolean;
};
export type CatalogFilterGroup = {
name: string;
items: CatalogFilterItem[];
};
export type CatalogFilterProps = {
groups: CatalogFilterGroup[];
selectedId?: string;
onSelectedChange?: (item: CatalogFilterItem) => void;
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
listIcon: {
minWidth: 30,
color: theme.palette.text.primary,
},
menuItem: {
minHeight: theme.spacing(6),
},
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
menuTitle: {
fontWeight: 500,
},
}));
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
groups,
selectedId,
onSelectedChange,
}) => {
const classes = useStyles();
return (
<Card className={classes.root}>
{groups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => onSelectedChange?.(item)}
selected={item.id === selectedId}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1" className={classes.menuTitle}>
{item.label}
</Typography>
</ListItemText>
{item.count}
</MenuItem>
))}
</List>
</Card>
</React.Fragment>
))}
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogFilter } from './CatalogFilter';
@@ -25,6 +25,9 @@ const errorApi = { post: () => {} };
const catalogApi = { getEntities: () => Promise.resolve([{ kind: '' }]) };
describe('CatalogPage', () => {
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const rendered = render(
wrapInTheme(
@@ -18,6 +18,7 @@ import React, { FC } from 'react';
import {
Content,
ContentHeader,
DismissableBanner,
Header,
HomepageTimer,
SupportButton,
@@ -27,7 +28,21 @@ import {
} from '@backstage/core';
import { useAsync } from 'react-use';
import CatalogTable from '../CatalogTable/CatalogTable';
import { Button } from '@material-ui/core';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import { filterGroups, defaultFilter } from '../../data/filters';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
}));
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
@@ -35,6 +50,15 @@ import { envelopeToComponent } from '../../data/utils';
const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
defaultFilter,
);
const onFilterSelected = React.useCallback(
selected => setSelectedFilter(selected),
[],
);
const styles = useStyles();
return (
<Page theme={pageTheme.home}>
@@ -42,17 +66,43 @@ const CatalogPage: FC<{}> = () => {
<HomepageTimer />
</Header>
<Content>
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" style={{ fontSize: '125%' }}>
👋🏼
</span>{' '}
Welcome to Backstage, we are happy to have you. Start by checking
out our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
/>
<ContentHeader title="Services">
<Button variant="contained" color="primary" href="/create">
Create Service
</Button>
<SupportButton>All your components</SupportButton>
</ContentHeader>
<CatalogTable
components={(value && value.map(envelopeToComponent)) || []}
loading={loading}
error={error}
/>
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
groups={filterGroups}
selectedId={selectedFilter.id}
onSelectedChange={onFilterSelected}
/>
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={(value && value.map(envelopeToComponent)) || []}
loading={loading}
error={error}
/>
</div>
</Content>
</Page>
);
@@ -20,15 +20,17 @@ import CatalogTable from './CatalogTable';
import { Component } from '../../data/component';
const components: Component[] = [
{ name: 'component1' },
{ name: 'component2' },
{ name: 'component3' },
{ name: 'component1', kind: 'Component', description: 'Placeholder' },
{ name: 'component2', kind: 'Component', description: 'Placeholder' },
{ name: 'component3', kind: 'Component', description: 'Placeholder' },
];
describe('CatalogTable component', () => {
it('should render loading when loading prop it set to true', async () => {
const rendered = render(
wrapInThemedTestApp(<CatalogTable components={[]} loading />),
wrapInThemedTestApp(
<CatalogTable titlePreamble="Owned" components={[]} loading />,
),
);
const progress = await rendered.findByTestId('progress');
expect(progress).toBeInTheDocument();
@@ -38,6 +40,7 @@ describe('CatalogTable component', () => {
const rendered = render(
wrapInThemedTestApp(
<CatalogTable
titlePreamble="Owned"
components={[]}
loading={false}
error={{ code: 'error' }}
@@ -53,9 +56,16 @@ describe('CatalogTable component', () => {
it('should display component names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInThemedTestApp(
<CatalogTable components={components} loading={false} />,
<CatalogTable
titlePreamble="Owned"
components={components}
loading={false}
/>,
),
);
expect(
await rendered.findByText(`Owned (${components.length})`),
).toBeInTheDocument();
expect(await rendered.findByText('component1')).toBeInTheDocument();
expect(await rendered.findByText('component2')).toBeInTheDocument();
expect(await rendered.findByText('component3')).toBeInTheDocument();
@@ -39,6 +39,7 @@ const columns: TableColumn[] = [
type CatalogTableProps = {
components: Component[];
titlePreamble: string;
loading: boolean;
error?: any;
};
@@ -46,6 +47,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
components,
loading,
error,
titlePreamble,
}) => {
if (loading) {
return <Progress />;
@@ -63,7 +65,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
<Table
columns={columns}
options={{ paging: false }}
title={`Owned (${(components && components.length) || 0})`}
title={`${titlePreamble} (${(components && components.length) || 0})`}
data={components}
/>
);
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
count: 123,
icon: SettingsIcon,
},
{
id: 'starred',
label: 'Starred',
count: 10,
icon: StarIcon,
},
],
},
{
name: 'Spotify',
items: [
{
id: 'all',
label: 'All Services',
count: 123,
},
],
},
];
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ComponentFactory } from './component';
import mock from './mock-factory-data.json';
const ARTIFICIAL_TIMEOUT = 800;
let inMemoryStore = [...mock];
export const MockComponentFactory: ComponentFactory = {
getAllComponents(): Promise<Component[]> {
return new Promise(resolve =>
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
);
},
getComponentByName(name: string): Promise<Component | undefined> {
return new Promise((resolve, reject) =>
setTimeout(() => {
const mockComponent = inMemoryStore.find(
component => component.name === name,
);
if (mockComponent) return resolve(mockComponent);
return reject({ code: 'Component not found!' });
}, ARTIFICIAL_TIMEOUT),
);
},
removeComponentByName(name: string): Promise<boolean> {
return new Promise(resolve =>
setTimeout(() => {
inMemoryStore = inMemoryStore.filter(
component => component.name !== name,
);
resolve(true);
}, ARTIFICIAL_TIMEOUT),
);
},
};
+1 -1
View File
@@ -21,7 +21,7 @@ import ComponentPage from './components/ComponentPage/ComponentPage';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.registerRoute('/catalog', CatalogPage);
router.registerRoute('/', CatalogPage);
router.registerRoute('/catalog/:name/', ComponentPage);
},
});
@@ -22,7 +22,7 @@ import SettingsIcon from '@material-ui/icons/Settings';
import { useSettings } from '../../state';
export type Props = { title?: string };
export const PluginHeader: FC<Props> = ({ title = 'Circle CI' }) => {
export const PluginHeader: FC<Props> = ({ title = 'CircleCI' }) => {
const [, { showSettings }] = useSettings();
const location = useLocation();
const notRoot = !location.pathname.match(/\/circleci\/?$/);
@@ -80,7 +80,7 @@ const Settings = () => {
value={token}
fullWidth
variant="outlined"
onChange={(e) => setToken(e.target.value)}
onChange={e => setToken(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -90,7 +90,7 @@ const Settings = () => {
label="Owner"
variant="outlined"
value={owner}
onChange={(e) => setOwner(e.target.value)}
onChange={e => setOwner(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -100,7 +100,7 @@ const Settings = () => {
fullWidth
variant="outlined"
value={repo}
onChange={(e) => setRepo(e.target.value)}
onChange={e => setRepo(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -35,7 +35,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</IconLink>
</Box>
);
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
neutral: {},
failed: {
position: 'relative',
@@ -50,8 +50,8 @@ export const ActionOutput: FC<{
const [messages, setMessages] = useState([]);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((actionOutput) => {
.then(res => res.json())
.then(actionOutput => {
if (typeof actionOutput !== 'undefined') {
setMessages(
actionOutput.map(({ message }: { message: string }) => message),
@@ -26,7 +26,7 @@ export const useAsyncPolling = (
while (isPolling.current === true) {
await pollingFn();
await new Promise((resolve) => setTimeout(resolve, interval));
await new Promise(resolve => setTimeout(resolve, interval));
}
};
+1 -1
View File
@@ -29,7 +29,7 @@ export function useSettings() {
if (
stateFromStorage &&
Object.keys(stateFromStorage).some(
(k) => (settings as any)[k] !== stateFromStorage[k],
k => (settings as any)[k] !== stateFromStorage[k],
)
)
dispatch({
+1 -1
View File
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -49,7 +49,7 @@ describe('AuditView', () => {
apis = ApiRegistry.from([
[lighthouseApiRef, new LighthouseRestApi('https://lighthouse')],
]);
id = websiteResponse.audits.find((a) => a.status === 'COMPLETED')
id = websiteResponse.audits.find(a => a.status === 'COMPLETED')
?.id as string;
useParams.mockReturnValue({ id });
});
@@ -101,7 +101,7 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
websiteResponse.audits.forEach((a) => {
websiteResponse.audits.forEach(a => {
expect(
rendered.queryByText(formatTime(a.timeCreated)),
).toBeInTheDocument();
@@ -119,14 +119,14 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
const audit = websiteResponse.audits.find((a) => a.id === id) as Audit;
const audit = websiteResponse.audits.find(a => a.id === id) as Audit;
const auditElement = rendered.getByText(formatTime(audit.timeCreated));
expect(auditElement.parentElement?.parentElement?.className).toContain(
'selected',
);
const notSelectedAudit = websiteResponse.audits.find(
(a) => a.id !== id,
a => a.id !== id,
) as Audit;
const notSelectedAuditElement = rendered.getByText(
formatTime(notSelectedAudit.timeCreated),
@@ -147,7 +147,7 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
websiteResponse.audits.forEach((a) => {
websiteResponse.audits.forEach(a => {
expect(
rendered.getByText(formatTime(a.timeCreated)).parentElement
?.parentElement,
@@ -186,7 +186,7 @@ describe('AuditView', () => {
describe.skip('when a loading audit is accessed', () => {
it('shows a loading view', async () => {
id = websiteResponse.audits.find((a) => a.status === 'RUNNING')
id = websiteResponse.audits.find(a => a.status === 'RUNNING')
?.id as string;
useParams.mockReturnValueOnce({ id });
@@ -206,7 +206,7 @@ describe('AuditView', () => {
describe.skip('when a failed audit is accessed', () => {
it('shows an error message', async () => {
id = websiteResponse.audits.find((a) => a.status === 'FAILED')
id = websiteResponse.audits.find(a => a.status === 'FAILED')
?.id as string;
useParams.mockReturnValueOnce({ id });
@@ -53,7 +53,7 @@ describe('CreateAudit', () => {
let errorApi: ErrorApi;
beforeEach(() => {
errorApi = { post: jest.fn() };
errorApi = { post: jest.fn(), error$: jest.fn() };
apis = ApiRegistry.from([
[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
[errorApiRef, errorApi],
@@ -165,7 +165,7 @@ describe('CreateAudit', () => {
fireEvent.click(rendered.getByText(/Create Audit/));
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
await new Promise((r) => setTimeout(r, 0));
await new Promise(r => setTimeout(r, 0));
expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error));
});
@@ -28,7 +28,7 @@ import { BackstageTheme } from '@backstage/theme';
import { Progress } from '@backstage/core';
import { ComponentIdValidators } from '../../util/validate';
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
@@ -40,6 +40,7 @@ const ScaffolderPage: React.FC<{}> = () => {
return (
<Page theme={pageTheme.home}>
<Header
pageTitleOverride="Create a new component"
title={
<>
Create a new component <Lifecycle alpha shorthand />{' '}
+1 -1
View File
@@ -1,3 +1,3 @@
# sentry-backend
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
@@ -22,7 +22,7 @@ import { BackstageTheme } from '@backstage/theme';
function stripText(text: string, maxLength: number) {
return text.length > maxLength ? `${text.substr(0, maxLength)}...` : text;
}
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
minWidth: 260,
position: 'relative',
@@ -24,16 +24,16 @@ import { ErrorGraph } from '../ErrorGraph/ErrorGraph';
const columns: TableColumn[] = [
{
title: 'Error',
render: (data) => <ErrorCell sentryIssue={data as SentryIssue} />,
render: data => <ErrorCell sentryIssue={data as SentryIssue} />,
},
{
title: 'Graph',
render: (data) => <ErrorGraph sentryIssue={data as SentryIssue} />,
render: data => <ErrorGraph sentryIssue={data as SentryIssue} />,
},
{
title: 'First seen',
field: 'firstSeen',
render: (data) => {
render: data => {
const { firstSeen } = data as SentryIssue;
return format(firstSeen);
},
@@ -41,7 +41,7 @@ const columns: TableColumn[] = [
{
title: 'Last seen',
field: 'lastSeen',
render: (data) => {
render: data => {
const { lastSeen } = data as SentryIssue;
return format(lastSeen);
},
+1 -1
View File
@@ -33,7 +33,7 @@ function getMockIssues(number: number): SentryIssue[] {
}
export class MockSentryApi implements SentryApi {
fetchIssues(): Promise<SentryIssue[]> {
return new Promise((resolve) => {
return new Promise(resolve => {
setTimeout(() => resolve(getMockIssues(14)), 800);
});
}
+4 -1
View File
@@ -5,7 +5,10 @@
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
@@ -105,14 +105,14 @@ export default class Radar extends React.Component {
static adjustEntries(entries, activeEntry, quadrants, rings, radius) {
let seed = 42;
entries.forEach((entry, idx) => {
const quadrant = quadrants.find((q) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object'
? entry.quadrant.id
: entry.quadrant;
return q.id === match;
});
const ring = rings.find((r) => {
const ring = rings.find(r => {
const match =
typeof entry.ring === 'object' ? entry.ring.id : entry.ring;
return r.id === match;
@@ -190,7 +190,7 @@ export default class Radar extends React.Component {
return (
<svg
ref={(node) => {
ref={node => {
this.node = node;
}}
width={width}
@@ -205,7 +205,7 @@ export default class Radar extends React.Component {
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry}
onEntryMouseEnter={(entry) => this._setActiveEntry(entry)}
onEntryMouseEnter={entry => this._setActiveEntry(entry)}
onEntryMouseLeave={() => this._clearActiveEntry()}
/>
</svg>
@@ -49,16 +49,16 @@ class RadarBubble extends React.PureComponent {
this._updatePosition();
}
_setRect = (rect) => {
_setRect = rect => {
this.rect = rect;
};
_setNode = (node) => {
_setNode = node => {
this.node = node;
};
_setText = (text) => {
_setText = text => {
this.text = text;
};
_setPath = (path) => {
_setPath = path => {
this.path = path;
};
@@ -84,7 +84,7 @@ class RadarGrid extends React.PureComponent {
/>,
];
const ringNodes = rings.map((r) => r.outerRadius).map(makeRingNode);
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
return axisNodes.concat(ringNodes);
}
@@ -88,7 +88,7 @@ class RadarLegend extends React.PureComponent {
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map((ring) =>
{rings.map(ring =>
RadarLegend._renderRing(
ring,
RadarLegend._getSegment(segments, quadrant, ring),
@@ -117,7 +117,7 @@ class RadarLegend extends React.PureComponent {
<p>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map((entry) => {
{entries.map(entry => {
let node = <span className={classes.entry}>{entry.title}</span>;
if (entry.url) {
@@ -175,7 +175,7 @@ class RadarLegend extends React.PureComponent {
return (
<g>
{quadrants.map((quadrant) =>
{quadrants.map(quadrant =>
RadarLegend._renderQuadrant(
segments,
quadrant,
@@ -46,16 +46,16 @@ export default class RadarPlot extends React.PureComponent {
rings={rings}
entries={entries}
onEntryMouseEnter={
onEntryMouseEnter && ((entry) => onEntryMouseEnter(entry))
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
}
onEntryMouseLeave={
onEntryMouseLeave && ((entry) => onEntryMouseLeave(entry))
onEntryMouseLeave && (entry => onEntryMouseLeave(entry))
}
/>
<g transform={`translate(${width / 2}, ${height / 2})`}>
<RadarGrid radius={radius} rings={rings} />
<RadarFooter x={-0.5 * width} y={0.5 * height} />
{entries.map((entry) => (
{entries.map(entry => (
<RadarEntry
key={entry.id}
x={entry.x}
+1 -1
View File
@@ -20,7 +20,7 @@ import WelcomePage from './components/WelcomePage';
export const plugin = createPlugin({
id: 'welcome',
register({ router, featureFlags }) {
router.registerRoute('/', WelcomePage);
router.registerRoute('/welcome', WelcomePage);
featureFlags.register('enable-welcome-box');
},