Merge branch 'master' of github.com:spotify/backstage into blam/persistant-storage

* 'master' of github.com:spotify/backstage: (97 commits)
  Update development-environment.md
  Make local start of catalog work
  Tweak the router tests, and fix one error
  Forgot one member of the higher order type
  package/app: move app config to yaml
  packages/cli: make cli read app config and inject into APP_CONFIG at compile-time
  packages/core: make AppConfigLoader return an array, and added defaultConfigLoader + tests
  github/workflows: use actions/cache@v2
  github/workflows: split cli build to skip more builds on windows
  plugins/auth-backend: docs for saml-idp
  plugins/auth-backend: added basic saml provider
  plugins/auth-backend: refactor to allow non-oauth providers
  Break out the location refresh loop as well
  plugins: remove mock-idp-backend
  plugins/auth-backend: add script for starting up saml test idp
  plugins/mock-idp-backend: add saml-idp + test command
  plugins/mock-idp-backend: generate some dev certs
  plugins: added initial mock-idp-backend
  Address comments and add tests
  Rename test to clarify what it is doing
  ...
This commit is contained in:
blam
2020-06-03 12:17:38 +02:00
220 changed files with 6640 additions and 6205 deletions
+7
View File
@@ -71,6 +71,13 @@
"pathRewrite": {
"^/circleci/api/": "/"
}
},
"/catalog/api": {
"target": "http://localhost:3003",
"changeOrigin": true,
"pathRewrite": {
"^/catalog/api/": "/"
}
}
}
}
+2 -8
View File
@@ -14,14 +14,9 @@
* limitations under the License.
*/
import {
createApp,
AlertDisplay,
OAuthRequestDialog,
LoginPage,
} from '@backstage/core';
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import Root from './components/Root';
import * as plugins from './plugins';
import apis from './apis';
@@ -40,7 +35,6 @@ const App: FC<{}> = () => (
<OAuthRequestDialog />
<Router>
<Root>
<Route key="login" path="/login" component={LoginPage} exact />
<AppComponent />
</Root>
</Router>
+20
View File
@@ -25,9 +25,11 @@ import {
featureFlagsApiRef,
FeatureFlags,
GoogleAuth,
GithubAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
} from '@backstage/core';
import {
@@ -38,6 +40,7 @@ import {
import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
@@ -63,6 +66,15 @@ builder.add(
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
techRadarApiRef,
new TechRadar({
@@ -71,4 +83,12 @@ builder.add(
}),
);
builder.add(
catalogApiRef,
new CatalogClient({
apiOrigin: 'http://localhost:3000',
basePath: '/catalog/api',
}),
);
export default builder.build() as ApiHolder;
+9 -4
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"build": "tsc",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess nodemon",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"nodemon -r esm\\\"",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
@@ -18,13 +18,15 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@backstage/catalog-model": "^0.1.1-alpha.6",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.6",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"helmet": "^3.22.0",
"knex": "^0.21.1",
@@ -43,6 +45,9 @@
"typescript": "^3.9.2"
},
"nodemonConfig": {
"watch": "./dist"
"watch": [
"./dist",
"node_modules/@backstage*"
]
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-auth-backend';
import { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
}
+27 -10
View File
@@ -21,22 +21,39 @@ import {
DatabaseManager,
DescriptorParsers,
LocationReaders,
IngestionModels,
runPeriodically,
HigherOrderOperations,
} from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
import { EntityPolicies } from '@backstage/catalog-model';
export default async function ({ logger, database }: PluginEnvironment) {
const reader = LocationReaders.create();
const parser = DescriptorParsers.create();
const db = await DatabaseManager.createDatabase(database, logger);
runPeriodically(
() => DatabaseManager.refreshLocations(db, reader, parser, logger),
10000,
export default async function createPlugin({
logger,
database,
}: PluginEnvironment) {
const ingestionModel = new IngestionModels(
new LocationReaders(),
new DescriptorParsers(),
new EntityPolicies(),
);
const db = await DatabaseManager.createDatabase(database, logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db, reader);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
ingestionModel,
logger,
);
return await createRouter({ entitiesCatalog, locationsCatalog, logger });
runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000);
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
}
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-identity-backend';
import { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from '@backstage/plugin-scaffolder-backend';
import type { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
const storage = new DiskStorage({ logger });
const templater = new CookieCutter();
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-sentry-backend';
import { Logger } from 'winston';
export default async function (logger: Logger) {
export default async function createPlugin(logger: Logger) {
return await createRouter(logger);
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+12
View File
@@ -0,0 +1,12 @@
# Catalog Model
Contains the core model types and validators/policies used by the Backstage catalog functionality.
This package will be imported both by the frontend and backend parts of the catalog,
as well as by others that want to consume catalog data.
## Links
- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog)
- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend)
- [The Backstage homepage](https://backstage.io)
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.6",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"lodash": "^4.17.15",
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
"yaml": "^1.9.2"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,90 @@
/*
* 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 {
Entity,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
import { ComponentV1beta1Policy } from './kinds';
import { EntityPolicy } from './types';
import { DefaultNamespaceEntityPolicy } from './entity/policies/DefaultNamespaceEntityPolicy';
// Helper that requires that all of a set of policies can be successfully
// applied
class AllEntityPolicies implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
let result = entity;
for (const policy of this.policies) {
result = await policy.enforce(entity);
}
return result;
}
}
// Helper that requires that at least one of a set of policies can be
// successfully applied
class AnyEntityPolicy implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
for (const policy of this.policies) {
try {
return await policy.enforce(entity);
} catch {
continue;
}
}
throw new Error(`The entity did not match any known policy`);
}
}
export class EntityPolicies implements EntityPolicy {
private readonly policy: EntityPolicy;
static defaultPolicies(): EntityPolicy {
return EntityPolicies.allOf([
EntityPolicies.allOf([
new SchemaValidEntityPolicy(),
new DefaultNamespaceEntityPolicy(),
new NoForeignRootFieldsEntityPolicy(),
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
]);
}
static allOf(policies: EntityPolicy[]): EntityPolicy {
return new AllEntityPolicies(policies);
}
static anyOf(policies: EntityPolicy[]): EntityPolicy {
return new AnyEntityPolicy(policies);
}
constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) {
this.policy = policy;
}
enforce(entity: Entity): Promise<Entity> {
return this.policy.enforce(entity);
}
}
+108
View File
@@ -0,0 +1,108 @@
/*
* 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.
*/
/**
* The format envelope that's common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type Entity = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace + kind pair.
*/
name: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
@@ -0,0 +1,18 @@
/*
* 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 type { Entity, EntityMeta } from './Entity';
export * from './policies';
@@ -0,0 +1,61 @@
/*
* 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 yaml from 'yaml';
import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
describe('DefaultNamespaceEntityPolicy', () => {
let withNamespace: any;
let withoutNamespace: any;
let policy: DefaultNamespaceEntityPolicy;
beforeEach(() => {
withoutNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: my-component-yay
`);
withNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: my-component-yay
namespace: my-home
`);
policy = new DefaultNamespaceEntityPolicy();
});
it('leaves untouched if it already has a namespace', async () => {
const result = policy.enforce(withNamespace);
await expect(result).resolves.toBe(withNamespace);
await expect(result).resolves.toEqual(
expect.objectContaining({
metadata: { name: 'my-component-yay', namespace: 'my-home' },
}),
);
});
it('adds namespace in different object if it did not have one', async () => {
const result = policy.enforce(withoutNamespace);
await expect(result).resolves.not.toBe(withoutNamespace);
await expect(result).resolves.toEqual(
expect.objectContaining({
metadata: { name: 'my-component-yay', namespace: 'default' },
}),
);
});
});
@@ -0,0 +1,38 @@
/*
* 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 lodash from 'lodash';
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
/**
* Sets a default namespace if none was set.
*/
export class DefaultNamespaceEntityPolicy implements EntityPolicy {
private readonly namespace: string;
constructor(namespace: string = 'default') {
this.namespace = namespace;
}
async enforce(entity: Entity): Promise<Entity> {
if (entity.metadata.namespace) {
return entity;
}
return lodash.merge({ metadata: { namespace: this.namespace } }, entity);
}
}
@@ -0,0 +1,105 @@
/*
* 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 yaml from 'yaml';
import { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
describe('FieldFormatEntityPolicy', () => {
let data: any;
let policy: FieldFormatEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new FieldFormatEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad apiVersion', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
data.apiVersion = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad kind', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
data.kind = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('handles missing metadata gracefully', async () => {
delete data.medatata;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('handles missing spec gracefully', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad name', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name.*7/);
data.metadata.name = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/name.*aaaa/);
});
it('rejects bad namespace', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*7/);
data.metadata.namespace = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*aaaa/);
});
it('rejects bad label key', async () => {
data.metadata.labels['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad label value', async () => {
data.metadata.labels.a = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad annotation key', async () => {
data.metadata.annotations['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*a#b/i);
});
it('rejects bad annotation value', async () => {
data.metadata.annotations.a = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*7/i);
});
});
@@ -0,0 +1,88 @@
/*
* 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 { EntityPolicy } from '../../types';
import { makeValidator, Validators } from '../../validation';
import { Entity } from '../Entity';
/**
* Ensures that the format of individual fields of the entity envelope
* is valid.
*
* This does not take into account machine generated fields such as uid, etag
* and generation.
*/
export class FieldFormatEntityPolicy implements EntityPolicy {
private readonly validators: Validators;
constructor(validators: Validators = makeValidator()) {
this.validators = validators;
}
async enforce(entity: Entity): Promise<Entity> {
function require(
field: string,
value: any,
validator: (value: any) => boolean,
) {
if (value === undefined || value === null) {
throw new Error(`${field} must have a value`);
}
let isValid: boolean;
try {
isValid = validator(value);
} catch (e) {
throw new Error(`${field} could not be validated, ${e}`);
}
if (!isValid) {
throw new Error(`${field} "${value}" is not valid`);
}
}
function optional(
field: string,
value: any,
validator: (value: any) => boolean,
) {
return value === undefined || require(field, value, validator);
}
require('apiVersion', entity.apiVersion, this.validators.isValidApiVersion);
require('kind', entity.kind, this.validators.isValidKind);
require('metadata.name', entity.metadata.name, this.validators
.isValidEntityName);
optional(
'metadata.namespace',
entity.metadata.namespace,
this.validators.isValidNamespace,
);
for (const [k, v] of Object.entries(entity.metadata.labels ?? [])) {
require(`labels.${k}`, k, this.validators.isValidLabelKey);
require(`labels.${k}`, v, this.validators.isValidLabelValue);
}
for (const [k, v] of Object.entries(entity.metadata.annotations ?? [])) {
require(`annotations.${k}`, k, this.validators.isValidAnnotationKey);
require(`annotations.${k}`, v, this.validators.isValidAnnotationValue);
}
return entity;
}
}
@@ -0,0 +1,52 @@
/*
* 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 yaml from 'yaml';
import { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
describe('NoForeignRootFieldsEntityPolicy', () => {
let data: any;
let policy: NoForeignRootFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new NoForeignRootFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects unknown root fields', async () => {
data.spec2 = {};
await expect(policy.enforce(data)).rejects.toThrow(/spec2/i);
});
});
@@ -0,0 +1,40 @@
/*
* 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 { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec'];
/**
* Ensures that there are no foreign root fields in the entity.
*/
export class NoForeignRootFieldsEntityPolicy implements EntityPolicy {
private readonly knownFields: string[];
constructor(knownFields: string[] = defaultKnownFields) {
this.knownFields = knownFields;
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of Object.keys(entity)) {
if (!this.knownFields.includes(field)) {
throw new Error(`Unknown field ${field}`);
}
}
return entity;
}
}
@@ -0,0 +1,64 @@
/*
* 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 yaml from 'yaml';
import { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
describe('ReservedFieldsEntityPolicy', () => {
let data: any;
let policy: ReservedFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new ReservedFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects reserved keys in the spec root', async () => {
data.spec.apiVersion = 'a/b';
await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i);
});
it('rejects reserved keys in labels', async () => {
data.metadata.labels.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i);
});
it('rejects reserved keys in annotations', async () => {
data.metadata.annotations.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(
/annotation.*apiVersion/i,
);
});
});
@@ -0,0 +1,66 @@
/*
* 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 { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_RESERVED_ENTITY_FIELDS = [
'apiVersion',
'kind',
'uid',
'etag',
'generation',
'name',
'namespace',
'labels',
'annotations',
'spec',
];
/**
* Ensures that fields are not given certain reserved names.
*/
export class ReservedFieldsEntityPolicy implements EntityPolicy {
private readonly reservedFields: string[];
constructor(fields?: string[]) {
this.reservedFields = [
...(fields ?? []),
...DEFAULT_RESERVED_ENTITY_FIELDS,
];
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of this.reservedFields) {
if (entity.spec?.hasOwnProperty(field)) {
throw new Error(
`The spec may not contain the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata.labels?.hasOwnProperty(field)) {
throw new Error(
`A label may not have the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata.annotations?.hasOwnProperty(field)) {
throw new Error(
`An annotation may not have the field ${field}, because it has reserved meaning`,
);
}
}
return entity;
}
}
@@ -0,0 +1,176 @@
/*
* 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 yaml from 'yaml';
import { Entity } from '../Entity';
import { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
describe('SchemaValidEntityPolicy', () => {
let data: any;
let policy: SchemaValidEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new SchemaValidEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
//
// apiVersion and kind
//
it('rejects wrong root type', async () => {
await expect(policy.enforce((7 as unknown) as Entity)).rejects.toThrow(
/object/,
);
});
it('rejects missing apiVersion', async () => {
delete data.apiVersion;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad apiVersion type', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects missing kind', async () => {
delete data.kind;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('rejects bad kind type', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
//
// metadata
//
it('rejects missing metadata', async () => {
delete data.metadata;
await expect(policy.enforce(data)).rejects.toThrow(/metadata/);
});
it('rejects bad metadata type', async () => {
data.metadata = 7;
await expect(policy.enforce(data)).rejects.toThrow(/metadata/);
});
it('accepts missing uid', async () => {
delete data.metadata.uid;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad uid type', async () => {
data.metadata.uid = 7;
await expect(policy.enforce(data)).rejects.toThrow(/uid/);
});
it('accepts missing etag', async () => {
delete data.metadata.etag;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad etag type', async () => {
data.metadata.etag = 7;
await expect(policy.enforce(data)).rejects.toThrow(/etag/);
});
it('accepts missing generation', async () => {
delete data.metadata.generation;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad generation type', async () => {
data.metadata.generation = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('rejects missing name', async () => {
delete data.metadata.name;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
});
it('rejects bad name type', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
});
it('accepts missing namespace', async () => {
delete data.metadata.namespace;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad namespace type', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace/);
});
it('accepts missing labels', async () => {
delete data.metadata.labels;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad labels type', async () => {
data.metadata.labels = 7;
await expect(policy.enforce(data)).rejects.toThrow(/labels/);
});
it('accepts missing annotations', async () => {
delete data.metadata.annotations;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad annotations type', async () => {
data.metadata.annotations = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotations/);
});
//
// spec
//
it('accepts missing spec', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects non-object spec', async () => {
data.spec = 7;
await expect(policy.enforce(data)).rejects.toThrow(/spec/);
});
});
@@ -0,0 +1,80 @@
/*
* 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 * as yup from 'yup';
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_ENTITY_SCHEMA = yup.object({
apiVersion: yup.string().required(),
kind: yup.string().required(),
metadata: yup
.object({
uid: yup
.string()
.notRequired()
.test(
'metadata.uid',
'The uid must not be empty',
value => value === undefined || value.length > 0,
),
etag: yup
.string()
.notRequired()
.test(
'metadata.etag',
'The etag must not be empty',
value => value === undefined || value.length > 0,
),
generation: yup
.number()
.notRequired()
.test(
'metadata.generation',
'The generation must be an integer greater than zero',
value => value === undefined || (value === (value | 0) && value > 0),
),
name: yup.string().required(),
namespace: yup.string().notRequired(),
labels: yup.object<Record<string, string>>().notRequired(),
annotations: yup.object<Record<string, string>>().notRequired(),
})
.required(),
spec: yup.object({}).notRequired(),
});
/**
* Ensures that the entity spec is valid according to a schema.
*
* This should be the first policy in the list, to ensure that other downstream
* policies can work with a structure that is at least valid in therms of the
* typescript type.
*/
export class SchemaValidEntityPolicy implements EntityPolicy {
private readonly schema: yup.Schema<Entity>;
constructor(schema: yup.Schema<Entity> = DEFAULT_ENTITY_SCHEMA) {
this.schema = schema;
}
async enforce(entity: Entity): Promise<Entity> {
try {
return await this.schema.validate(entity, { strict: true });
} catch (e) {
throw new Error(`Malformed envelope, ${e}`);
}
}
}
@@ -0,0 +1,20 @@
/*
* 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 { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 * from './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export * from './location';
export type { EntityPolicy } from './types';
export * from './validation';
@@ -0,0 +1,63 @@
/*
* 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 * as yup from 'yup';
import type { Entity, EntityMeta } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
export interface ComponentV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
metadata: EntityMeta & {
name: string;
};
spec: {
type: string;
};
}
export class ComponentV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentV1beta1>>({
metadata: yup
.object({
name: yup.string().required(),
})
.required(),
spec: yup
.object({
type: yup.string().required(),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
throw new Error('Unsupported apiVersion / kind');
}
return await this.schema.validate(envelope, { strict: true });
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 type {
ComponentV1beta1,
ComponentV1beta1 as Component,
} from './ComponentV1beta1';
export { ComponentV1beta1Policy } from './ComponentV1beta1';
@@ -0,0 +1,18 @@
/*
* 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 type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
@@ -0,0 +1,24 @@
/*
* 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 type LocationSpec = {
type: string;
target: string;
};
export type Location = {
id: string;
} & LocationSpec;
@@ -0,0 +1,33 @@
/*
* 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 * as yup from 'yup';
import { LocationSpec, Location } from './types';
export const locationSpecSchema = yup
.object<LocationSpec>({
type: yup.string().required(),
target: yup.string().required(),
})
.noUnknown();
export const locationSchema = yup
.object<Location>({
id: yup.string().required(),
type: yup.string().required(),
target: yup.string().required(),
})
.noUnknown();
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { LoginPage } from './LoginPage';
export {};
+32
View File
@@ -0,0 +1,32 @@
/*
* 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 type { Entity } from './entity/Entity';
/**
* A policy for validation or mutation to be applied to entities as they are
* entering the system.
*/
export type EntityPolicy = {
/**
* Applies validation or mutation on an entity.
*
* @param entity The entity, as validated/mutated so far in the policy tree
* @returns The incoming entity, or a mutated version of the same
* @throws An error if the entity should be rejected
*/
enforce(entity: Entity): Promise<Entity>;
};
@@ -0,0 +1,178 @@
/*
* 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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
describe('CommonValidatorFunctions', () => {
describe('isValidPrefixAndOrSuffix', () => {
it('only accepts strings', () => {
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
null,
'/',
() => true,
() => true,
),
).toBe(false);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
7,
'/',
() => true,
() => true,
),
).toBe(false);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
() => 'hello',
'/',
() => true,
() => true,
),
).toBe(false);
});
it('only accepts one or two parts', () => {
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a',
'/',
() => true,
() => true,
),
).toBe(true);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a/b',
'/',
() => true,
() => true,
),
).toBe(true);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a/b/c',
'/',
() => true,
() => true,
),
).toBe(false);
});
it('checks the prefix and suffix', () => {
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a/b',
'/',
() => true,
() => true,
),
).toBe(true);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a/b',
'/',
() => false,
() => true,
),
).toBe(false);
expect(
CommonValidatorFunctions.isValidPrefixAndOrSuffix(
'a/b',
'/',
() => true,
() => false,
),
).toBe(false);
});
});
it.each([
[null, true],
[undefined, false],
[1, true],
['a', true],
[() => 'a', false],
[Symbol('a'), false],
[[], true],
[[1], true],
[[undefined], false],
[{}, true],
[{ a: 1 }, true],
[{ a: undefined }, false],
] as [any, boolean][])(`isJsonSafe %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.isJsonSafe(value)).toBe(result);
});
it.each([
[null, false],
[7, false],
['', false],
['a', true],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', false],
['adam.bertil.caesar', true],
['adam.ber-til.caesar', true],
['adam.-bertil.caesar', false],
['adam.bertil-.caesar', false],
['adam/bertil.caesar', false],
[`a.${'b'.repeat(63)}.c`, true],
[`a.${'b'.repeat(64)}.c`, false],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(61)}`,
true,
],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(62)}`,
false,
],
])(`isValidDnsSubdomain %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.isValidDnsSubdomain(value)).toBe(result);
});
it.each([
[null, false],
[7, false],
['', false],
['a', true],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', false],
[`${'a'.repeat(63)}`, true],
[`${'a'.repeat(64)}`, false],
])(`isValidDnsLabel %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result);
});
it.each([
['', ''],
['a', 'a'],
['a-b', 'ab'],
['-a-b', 'ab'],
['a_b', 'ab'],
[`${'a'.repeat(6000)}`, `${'a'.repeat(6000)}`],
['_:;>!"#€', ''],
])(`normalizeToLowercaseAlphanum %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.normalizeToLowercaseAlphanum(value)).toBe(
result,
);
});
});
@@ -0,0 +1,108 @@
/*
* 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 lodash from 'lodash';
/**
* Contains various helper validation and normalization functions that can be
* composed to form a Validator.
*/
export class CommonValidatorFunctions {
/**
* Checks that the value is on the form <suffix> or <prefix><separator><suffix>, and validates
* those parts separately.
*
* @param value The value to check
* @param separator The separator between parts
* @param isValidPrefix Checks that the part before the separator is valid, if present
* @param isValidSuffix Checks that the part after the separator (or the entire value if there is no separator) is valid
*/
static isValidPrefixAndOrSuffix(
value: any,
separator: string,
isValidPrefix: (value: string) => boolean,
isValidSuffix: (value: string) => boolean,
): boolean {
if (typeof value !== 'string') {
return false;
}
const parts = value.split(separator);
if (parts.length === 1) {
return isValidSuffix(parts[0]);
} else if (parts.length === 2) {
return isValidPrefix(parts[0]) && isValidSuffix(parts[1]);
}
return false;
}
/**
* Checks that the value can be safely transferred as JSON.
*
* @param value The value to check
*/
static isJsonSafe(value: any): boolean {
try {
return lodash.isEqual(value, JSON.parse(JSON.stringify(value)));
} catch {
return false;
}
}
/**
* Checks that the value is a valid DNS subdomain name.
*
* @param value The value to check
* @see https://tools.ietf.org/html/rfc1123
*/
static isValidDnsSubdomain(value: any): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 253 &&
value.split('.').every(CommonValidatorFunctions.isValidDnsLabel)
);
}
/**
* Checks that the value is a valid DNS label.
*
* @param value The value to check
* @see https://tools.ietf.org/html/rfc1123
*/
static isValidDnsLabel(value: any): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 63 &&
/^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value)
);
}
/**
* Normalizes by keeping only a-z, A-Z, and 0-9; and converts to lowercase.
*
* @param value The value to normalize
*/
static normalizeToLowercaseAlphanum(value: string): string {
return value
.split('')
.filter(x => /[a-zA-Z0-9]/.test(x))
.join('')
.toLowerCase();
}
}
@@ -0,0 +1,209 @@
/*
* 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 { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
describe('KubernetesValidatorFunctions', () => {
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', true],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a-b', false],
['a_b', false],
['a.b', false],
['a/a', true],
['a/aAb5C', true],
['a-b.c/v1', true],
['a--b.c/v1', false],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
61,
)}/v1`,
true,
],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
62,
)}/v1`,
false,
],
[`a/${'a'.repeat(63)}`, true],
[`a/${'a'.repeat(64)}`, false],
])(`isValidApiVersion %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidApiVersion(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', true],
['9AZ', false],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a-b', false],
])(`isValidKind %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidKind(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', true],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a/b', false],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', true],
['a.b', true],
])(`isValidObjectName %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidObjectName(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', false],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a/b', false],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', false],
['a.b', false],
])(`isValidNamespace %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidNamespace(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', true],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a/b', true],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', true],
['a.b', true],
['a/a', true],
['a-b.c/a', true],
['a--b.c/a', false],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
61,
)}/a`,
true,
],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
62,
)}/a`,
false,
],
[`a/${'a'.repeat(63)}`, true],
[`a/${'a'.repeat(64)}`, false],
])(`isValidLabelKey %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidLabelKey(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', true],
['a', true],
['AZ09', true],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a/b', false],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', true],
['a.b', true],
])(`isValidLabelValue %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidLabelValue(value)).toBe(matches);
});
it.each([
[7, false],
[null, false],
['', false],
['a', true],
['AZ09', true],
['a'.repeat(63), true],
['a'.repeat(64), false],
['a/b', true],
['a-b', true],
['-a-b', false],
['a-b-', false],
['a--b', false],
['a_b', true],
['a.b', true],
['a/a', true],
['a-b.c/a', true],
['a--b.c/a', false],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
61,
)}/a`,
true,
],
[
`${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(
62,
)}/a`,
false,
],
[`a/${'a'.repeat(63)}`, true],
[`a/${'a'.repeat(64)}`, false],
])(`isValidAnnotationKey %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidAnnotationKey(value)).toBe(
matches,
);
});
it.each([
[7, false],
[null, false],
['', true],
['a', true],
['/'.repeat(6000), true],
])(`isValidAnnotationValue %p ? %p`, (value, matches) => {
expect(KubernetesValidatorFunctions.isValidAnnotationValue(value)).toBe(
matches,
);
});
});
@@ -0,0 +1,86 @@
/*
* 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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
/**
* Contains validation functions that match the Kubernetes spec, usable to
* build a catalog that is compatible with those rule sets.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
*/
export class KubernetesValidatorFunctions {
static isValidApiVersion(value: any): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
CommonValidatorFunctions.isValidDnsSubdomain,
n => n.length >= 1 && n.length <= 63 && /^[a-z0-9A-Z]+$/.test(n),
);
}
static isValidKind(value: any): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 63 &&
/^[a-zA-Z][a-z0-9A-Z]*$/.test(value)
);
}
static isValidObjectName(value: any): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 63 &&
/^[a-z0-9A-Z]+([-_.][a-z0-9A-Z]+)*$/.test(value)
);
}
static isValidNamespace(value: any): boolean {
return CommonValidatorFunctions.isValidDnsLabel(value);
}
static isValidLabelKey(value: any): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
CommonValidatorFunctions.isValidDnsSubdomain,
KubernetesValidatorFunctions.isValidObjectName,
);
}
static isValidLabelValue(value: any): boolean {
return (
value === '' || KubernetesValidatorFunctions.isValidObjectName(value)
);
}
static isValidAnnotationKey(value: any): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
CommonValidatorFunctions.isValidDnsSubdomain,
KubernetesValidatorFunctions.isValidObjectName,
);
}
static isValidAnnotationValue(value: any): boolean {
return typeof value === 'string';
}
}
@@ -0,0 +1,20 @@
/*
* 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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
export { makeValidator } from './makeValidator';
export type { Validators } from './types';
@@ -0,0 +1,38 @@
/*
* 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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
import { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
import { Validators } from './types';
const defaultValidators: Validators = {
isValidApiVersion: KubernetesValidatorFunctions.isValidApiVersion,
isValidKind: KubernetesValidatorFunctions.isValidKind,
isValidEntityName: KubernetesValidatorFunctions.isValidObjectName,
isValidNamespace: KubernetesValidatorFunctions.isValidNamespace,
normalizeEntityName: CommonValidatorFunctions.normalizeToLowercaseAlphanum,
isValidLabelKey: KubernetesValidatorFunctions.isValidLabelKey,
isValidLabelValue: KubernetesValidatorFunctions.isValidLabelValue,
isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey,
isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue,
};
export function makeValidator(overrides: Partial<Validators> = {}): Validators {
return {
...defaultValidators,
...overrides,
};
}
@@ -0,0 +1,27 @@
/*
* 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 type Validators = {
isValidApiVersion(value: any): boolean;
isValidKind(value: any): boolean;
isValidEntityName(value: any): boolean;
isValidNamespace(value: any): boolean;
normalizeEntityName(value: string): string;
isValidLabelKey(value: any): boolean;
isValidLabelValue(value: any): boolean;
isValidAnnotationKey(value: any): boolean;
isValidAnnotationValue(value: any): boolean;
};
+1 -1
View File
@@ -32,7 +32,7 @@ module.exports = {
ecmaVersion: 2018,
sourceType: 'module',
},
ignorePatterns: ['**/dist/**', '**/build/**'],
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
+1 -1
View File
@@ -39,7 +39,7 @@ module.exports = {
version: 'detect',
},
},
ignorePatterns: ['**/dist/**', '**/build/**'],
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
+26 -6
View File
@@ -1,17 +1,37 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"exclude": ["**/*.test.*"],
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"experimentalDecorators": false,
"forceConsistentCasingInFileNames": true,
"importHelpers": false,
"incremental": true,
"target": "ES2019",
"isolatedModules": true,
"jsx": "react",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
"module": "ESNext",
"moduleResolution": "node",
"noEmit": false,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"pretty": true,
"removeComments": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
"sourceMap": false,
"strict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"stripInternal": true,
"target": "ES2019",
"types": ["node", "jest"]
}
}
+4 -1
View File
@@ -35,7 +35,7 @@
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/web-scripts": "^6.0.0",
"@spotify/eslint-config": "^7.0.1",
"@sucrase/webpack-loader": "^2.0.0",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
@@ -44,6 +44,7 @@
"css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"eslint": "^7.1.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-monorepo": "^0.2.1",
"fork-ts-checker-webpack-plugin": "^4.0.5",
@@ -74,9 +75,11 @@
"tar": "^6.0.1",
"ts-jest": "^26.0.0",
"ts-loader": "^7.0.4",
"typescript": "^3.9.3",
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3",
"yaml": "^1.10.0",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
+2
View File
@@ -16,10 +16,12 @@
import { buildBundle } from '../../lib/bundler';
import { Command } from 'commander';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
await buildBundle({
entry: 'src/index',
statsJsonEnabled: cmd.stats,
appConfig: await loadConfig(),
});
};
+2
View File
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+8 -2
View File
@@ -16,12 +16,18 @@
import { Command } from 'commander';
import { run } from '../lib/run';
import { paths } from '../lib/paths';
export default async (cmd: Command) => {
const args = ['lint', '--max-warnings=0', '--format=codeframe'];
const args = [
'--ext=js,jsx,ts,tsx',
'--max-warnings=0',
'--format=codeframe',
paths.targetDir,
];
if (cmd.fix) {
args.push('--fix');
}
await run('web-scripts', args);
await run('eslint', args);
};
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 type { AppConfig } from './types';
export { loadConfig } from './loaders';
@@ -0,0 +1,41 @@
/*
* 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 { AppConfig } from './types';
import fs from 'fs-extra';
import yaml from 'yaml';
import { paths } from '../paths';
type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};
export async function loadConfig(
options: LoadConfigOptions = {},
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options;
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
}
}
+17
View File
@@ -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 type AppConfig = any;
+6
View File
@@ -51,6 +51,12 @@ export function createConfig(
);
}
plugins.push(
new webpack.EnvironmentPlugin({
APP_CONFIG: options.appConfig,
}),
);
return {
mode: isDev ? 'development' : 'production',
profile: false,
+4
View File
@@ -15,16 +15,20 @@
*/
import { BundlingPathsOptions } from './paths';
import { AppConfig } from '../app-config';
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
appConfig: AppConfig[];
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
appConfig: AppConfig[];
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
appConfig: AppConfig[];
};
@@ -0,0 +1,5 @@
app:
title: Scaffolded Backstage App
organization:
name: Acme Corporation
+4 -3
View File
@@ -31,9 +31,7 @@
"@backstage/theme": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "^5.2.0",
@@ -46,6 +44,9 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,38 @@
/*
* 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 { createApiRef } from '../ApiRef';
export type Config = {
getConfig(key: string): Config;
getConfigArray(key: string): Config[];
getNumber(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getStringArray(key: string): string[] | undefined;
};
// Using interface to make the ConfigApi name show up in docs
export interface ConfigApi extends Config {}
export const configApiRef = createApiRef<ConfigApi>({
id: 'core.config',
description: 'Used to access runtime configuration',
});
@@ -24,6 +24,7 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
@@ -0,0 +1,226 @@
/*
* 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 { ConfigReader } from './ConfigReader';
const DATA = {
zero: 0,
one: 1,
true: true,
false: false,
null: null,
string: 'string',
emptyString: '',
strings: ['string1', 'string2'],
badStrings: ['string1', ''],
worseStrings: ['string1', 3] as string[],
worstStrings: ['string1', 'string2', {}] as string[],
nested: {
one: 1,
string: 'string',
strings: ['string1', 'string2'],
},
nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[],
};
function expectValidValues(config: ConfigReader) {
expect(config.getNumber('zero')).toBe(0);
expect(config.getNumber('one')).toBe(1);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
expect(config.getConfig('nested').getNumber('one')).toBe(1);
expect(config.getConfig('nested').getString('string')).toBe('string');
expect(config.getConfig('nested').getStringArray('strings')).toEqual([
'string1',
'string2',
]);
const [config1, config2, config3] = config.getConfigArray('nestlings');
expect(config1.getBoolean('boolean')).toBe(true);
expect(config2.getString('string')).toBe('string');
expect(config3.getNumber('number')).toBe(42);
}
function expectInvalidValues(config: ConfigReader) {
expect(() => config.getNumber('string')).toThrow(
'Invalid type in config for key string, got string, wanted number',
);
expect(() => config.getString('one')).toThrow(
'Invalid type in config for key one, got number, wanted string',
);
expect(() => config.getNumber('true')).toThrow(
'Invalid type in config for key true, got boolean, wanted number',
);
expect(() => config.getStringArray('null')).toThrow(
'Invalid type in config for key null, got null, wanted string-array',
);
expect(() => config.getString('emptyString')).toThrow(
'Invalid type in config for key emptyString, got empty-string, wanted string',
);
expect(() => config.getStringArray('badStrings')).toThrow(
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
);
expect(() => config.getStringArray('worseStrings')).toThrow(
'Invalid type in config for key worseStrings[1], got number, wanted string',
);
expect(() => config.getStringArray('worstStrings')).toThrow(
'Invalid type in config for key worstStrings[2], got object, wanted string',
);
expect(() => config.getConfig('one')).toThrow(
'Invalid type in config for key one, got number, wanted object',
);
expect(() => config.getConfigArray('one')).toThrow(
'Invalid type in config for key one, got number, wanted object-array',
);
}
describe('ConfigReader', () => {
it('should read empty config with valid keys', () => {
const config = new ConfigReader({});
expect(config.getString('x')).toBeUndefined();
expect(config.getString('x_x')).toBeUndefined();
expect(config.getString('x-X')).toBeUndefined();
expect(config.getString('x0')).toBeUndefined();
expect(config.getString('X-x2')).toBeUndefined();
expect(config.getString('x0_x0')).toBeUndefined();
expect(config.getString('x_x-x_x')).toBeUndefined();
});
it('should throw on invalid keys', () => {
const config = new ConfigReader({});
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
expect(() => config.getString('(')).toThrow(/^Invalid config key/);
expect(() => config.getString('z-_')).toThrow(/^Invalid config key/);
expect(() => config.getString('-')).toThrow(/^Invalid config key/);
expect(() => config.getString('.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a..a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a...')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a._')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/);
});
it('should read valid values', () => {
const config = new ConfigReader(DATA);
expectValidValues(config);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
expectInvalidValues(config);
});
});
describe('ConfigReader with fallback', () => {
it('should behave as if without fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expect(config.getString('x')).toBeUndefined();
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
});
it('should read values from itself', () => {
const config = new ConfigReader(DATA, new ConfigReader({}));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from a fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from multiple levels of fallbacks', () => {
const config = new ConfigReader(
{},
new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))),
);
expectValidValues(config);
expectInvalidValues(config);
});
it('should read merged objects', () => {
const a = {
merged: {
x: 'x',
z: 'z1',
arr: ['a', 'b'],
config: { d: 'd' },
configs: [{ a: 'a' }],
},
};
const b = {
merged: {
y: 'y',
z: 'z2',
arr: ['c'],
config: { e: 'e' },
configs: [{ b: 'b' }],
},
};
const config = new ConfigReader(a, new ConfigReader(b));
expect(config.getString('merged.x')).toBe('x');
expect(config.getString('merged.y')).toBe('y');
expect(config.getString('merged.z')).toBe('z1');
expect(config.getConfig('merged').getString('x')).toBe('x');
expect(config.getConfig('merged').getString('y')).toBe('y');
expect(config.getConfig('merged').getString('z')).toBe('z1');
expect(config.getString('merged.config.d')).toBe('d');
expect(config.getString('merged.config.e')).toBe('e');
expect(config.getConfig('merged').getString('config.d')).toBe('d');
expect(config.getConfig('merged').getString('config.e')).toBe('e');
expect(config.getConfig('merged').getConfig('config').getString('d')).toBe(
'd',
);
expect(config.getConfig('merged').getConfig('config').getString('e')).toBe(
'e',
);
// Arrays are not merged
expect(config.getStringArray('merged.arr')).toEqual(['a', 'b']);
expect(config.getConfig('merged').getStringArray('arr')).toEqual([
'a',
'b',
]);
// Config arrays aren't merged either
expect(config.getConfigArray('merged.configs').length).toBe(1);
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(
config.getConfigArray('merged.configs')[0].getString('b'),
).toBeUndefined();
// Config arrays aren't merged either
expect(config.getConfig('merged').getConfigArray('configs').length).toBe(1);
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('a'),
).toBe('a');
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('b'),
).toBeUndefined();
});
});
@@ -0,0 +1,181 @@
/*
* 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 { ConfigApi, Config } from '../../definitions/ConfigApi';
import { AppConfig } from '../../../app';
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
type JsonObject = { [key in string]: JsonValue };
type JsonArray = JsonValue[];
type JsonValue = JsonObject | JsonArray | number | string | boolean | null;
function isObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function typeOf(value: JsonValue | undefined): string {
if (value === null) {
return 'null';
} else if (Array.isArray(value)) {
return 'array';
}
const type = typeof value;
if (type === 'number' && isNaN(value as number)) {
return 'nan';
}
return type;
}
function typeErrorMessage(key: string, got: string, wanted: string) {
return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`;
}
function validateString(
key: string,
value: JsonValue | undefined,
): value is string {
if (typeof value === 'string' && value.length > 0) {
return true;
}
if (value === '') {
throw new TypeError(typeErrorMessage(key, 'empty-string', 'string'));
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'string'));
}
return false;
}
export class ConfigReader implements ConfigApi {
static nullReader = new ConfigReader({});
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
}
// Merge together all configs info a single config with recursive fallback
// readers, giving the first config object in the array the highest priority.
return configs.reduceRight((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined);
}
constructor(
private readonly data: JsonObject,
private readonly fallback?: ConfigApi,
) {}
getConfig(key: string): Config {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getConfig(key);
if (isObject(value)) {
return new ConfigReader(value, fallbackConfig);
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
}
return fallbackConfig ?? ConfigReader.nullReader;
}
getConfigArray(key: string): Config[] {
const values = this.readValue(key);
if (Array.isArray(values)) {
return values.map((value, index) => {
if (isObject(value)) {
return new ConfigReader(value);
}
throw new TypeError(
typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'),
);
});
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'object-array'),
);
}
return this.fallback?.getConfigArray(key) ?? [];
}
getNumber(key: string): number | undefined {
const value = this.readValue(key);
if (typeof value === 'number' && !isNaN(value)) {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'number'));
}
return this.fallback?.getNumber(key);
}
getBoolean(key: string): boolean | undefined {
const value = this.readValue(key);
if (typeof value === 'boolean') {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean'));
}
return this.fallback?.getBoolean(key);
}
getString(key: string): string | undefined {
const value = this.readValue(key);
if (validateString(key, value)) {
return value;
}
return this.fallback?.getString(key);
}
getStringArray(key: string): string[] | undefined {
const values = this.readValue(key);
if (Array.isArray(values)) {
for (const [index, value] of values.entries()) {
const iKey = `${key}[${index}]`;
if (!validateString(iKey, value)) {
throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string'));
}
}
return values as string[];
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'string-array'),
);
}
return this.fallback?.getStringArray(key);
}
private readValue(key: string): JsonValue | undefined {
const parts = key.split('.');
let value: JsonValue | undefined = this.data;
for (const part of parts) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid config key '${key}'`);
}
if (isObject(value)) {
value = value[part];
} else {
value = undefined;
}
}
return value;
}
}
@@ -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 { ConfigReader } from './ConfigReader';
@@ -0,0 +1,29 @@
/*
* 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 GithubAuth from './GithubAuth';
describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token' });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
});
@@ -0,0 +1,111 @@
/*
* 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 GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type GithubAuthResponse = {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
const DEFAULT_PROVIDER = {
id: 'github',
title: 'Github',
icon: GithubIcon,
};
class GithubAuth implements OAuthApi {
static create({
apiOrigin,
basePath,
environment = 'dev',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
accessToken: res.accessToken,
scopes: GithubAuth.normalizeScope(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['user']),
sessionScopes: session => session.scopes,
});
return new GithubAuth(sessionManager);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
const normalizedScopes = GithubAuth.normalizeScope(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
if (session) {
return session.accessToken;
}
return '';
}
async logout() {
await this.sessionManager.removeSession();
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -0,0 +1,18 @@
/*
* 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 * from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -0,0 +1,21 @@
/*
* 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 type GithubSession = {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
@@ -15,3 +15,4 @@
*/
export * from './google';
export * from './github';
@@ -22,5 +22,6 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
+35 -12
View File
@@ -17,7 +17,7 @@
import React, { ComponentType, FC } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents } from './types';
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
@@ -31,8 +31,11 @@ import {
AppTheme,
AppThemeSelector,
appThemeApiRef,
configApiRef,
ConfigReader,
} from '../apis';
import { ApiAggregator } from '../apis/ApiAggregator';
import { useAsync } from 'react-use';
type FullAppOptions = {
apis: ApiHolder;
@@ -40,6 +43,7 @@ type FullAppOptions = {
plugins: BackstagePlugin[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
};
export class PrivateAppImpl implements BackstageApp {
@@ -48,6 +52,7 @@ export class PrivateAppImpl implements BackstageApp {
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
constructor(options: FullAppOptions) {
this.apis = options.apis;
@@ -55,6 +60,7 @@ export class PrivateAppImpl implements BackstageApp {
this.plugins = options.plugins;
this.components = options.components;
this.themes = options.themes;
this.configLoader = options.configLoader;
}
getApis(): ApiHolder {
@@ -141,18 +147,35 @@ export class PrivateAppImpl implements BackstageApp {
}
getProvider(): ComponentType<{}> {
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
]);
const apis = new ApiAggregator(this.apis, appApis);
const Provider: FC<{}> = ({ children }) => {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(this.configLoader);
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
const Provider: FC<{}> = ({ children }) => (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
let childNode = children;
if (hasConfig && config.loading) {
const { Progress } = this.components;
childNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = this.components;
childNode = <BootErrorPage step="load-config" error={config.error} />;
}
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
]);
const apis = new ApiAggregator(this.apis, appApis);
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{childNode}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
@@ -49,10 +49,6 @@ function resolveTheme(
}
const useShouldPreferDarkTheme = () => {
if (!window.matchMedia) {
return false;
}
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
@@ -74,12 +70,16 @@ const useShouldPreferDarkTheme = () => {
export const AppThemeProvider: FC<{}> = ({ children }) => {
const appThemeApi = useApi(appThemeApiRef);
const shouldPreferDark = useShouldPreferDarkTheme();
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
// Browser feature detection won't change over time, so ignore lint rule
const shouldPreferDark = Boolean(window.matchMedia)
? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks
: false;
const appTheme = resolveTheme(
themeId,
shouldPreferDark,
+31
View File
@@ -20,10 +20,30 @@ import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme } from '../apis/definitions';
export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
};
/**
* TBD
*/
export type AppConfig = any;
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
export type AppOptions = {
/**
* A holder of all APIs available in the app.
@@ -68,6 +88,17 @@ export type AppOptions = {
* ```
*/
themes?: AppTheme[];
/**
* A function that loads in App configuration that will be accessible via
* the ConfigApi.
*
* Defaults to an empty config.
*
* TODO(Rugvip): Omitting this should instead default to loading in configuration
* that was packaged by the backstage-cli and default docker container boot script.
*/
configLoader?: AppConfigLoader;
};
export type BackstageApp = {
@@ -15,4 +15,5 @@
*/
export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
export { StaticAuthSessionManager } from './StaticAuthSessionManager';
export * from './types';
+1
View File
@@ -16,3 +16,4 @@
export * from './types';
export { createRouteRef } from './RouteRef';
export type { MutableRouteRef } from './RouteRef';
+7 -6
View File
@@ -33,13 +33,8 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/react": "^16.9",
"@types/react-sparklines": "^1.7.0",
"@types/zen-observable": "^0.8.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
@@ -61,6 +56,12 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,74 @@
/*
* 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 { defaultConfigLoader } from './createApp';
describe('defaultConfigLoader', () => {
afterEach(() => {
delete process.env.APP_CONFIG;
});
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
]);
});
it('fails to load invalid missing config', async () => {
await expect(defaultConfigLoader()).rejects.toThrow(
'No static configuration provided',
);
});
it('fails to load invalid static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: { my: 'invalid-config' } as any,
});
await expect(defaultConfigLoader()).rejects.toThrow(
'Static configuration has invalid format',
);
});
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
});
+66 -2
View File
@@ -14,18 +14,60 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import privateExports, {
AppOptions,
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
AppConfig,
} from '@backstage/core-api';
import { BrowserRouter as Router } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
const { PrivateAppImpl } = privateExports;
/**
* The default config loader, which expects that config is available at compile-time
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
* returned by the config loader.
*
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
* which can be rewritten at runtime to contain an additional JSON config object.
* If runtime config is present, it will be placed first in the config array, overriding
* other config values.
*/
export const defaultConfigLoader: AppConfigLoader = async (
// This string may be replaced at runtime to provide additional config.
// It should be replaced by a JSON-serialized config object.
// It's a param so we can test it, but at runtime this will always fall back to default.
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
) => {
const appConfig = process.env.APP_CONFIG;
if (!appConfig) {
throw new Error('No static configuration provided');
}
if (!Array.isArray(appConfig)) {
throw new Error('Static configuration has invalid format');
}
const configs = (appConfig.slice() as unknown) as AppConfig[];
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
return configs;
};
// createApp is defined in core, and not core-api, since we need access
// to the components inside core to provide defaults.
// The actual implementation of the app class still lives in core-api,
@@ -38,12 +80,26 @@ export function createApp(options?: AppOptions) {
const DefaultNotFoundPage = () => (
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
);
const DefaultBootErrorPage: FC<BootErrorPageProps> = ({ step, error }) => {
let message = '';
if (step === 'load-config') {
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
}
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
return (
<Router>
<ErrorPage status="501" statusMessage={message} />
</Router>
);
};
const apis = options?.apis ?? ApiRegistry.from([]);
const icons = { ...defaultSystemIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
NotFoundErrorPage: DefaultNotFoundPage,
BootErrorPage: DefaultBootErrorPage,
Progress: Progress,
...options?.components,
};
const themes = options?.themes ?? [
@@ -60,8 +116,16 @@ export function createApp(options?: AppOptions) {
theme: darkTheme,
},
];
const configLoader = options?.configLoader ?? defaultConfigLoader;
const app = new PrivateAppImpl({ apis, icons, plugins, components, themes });
const app = new PrivateAppImpl({
apis,
icons,
plugins,
components,
themes,
configLoader,
});
app.verify();
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
@@ -33,16 +33,14 @@ const minProps = {
describe('<CodeSnippet />', () => {
it('renders text without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<CodeSnippet {...minProps} />));
expect(getByText(/"Hello"/)).toBeInTheDocument();
expect(getByText(/"World"/)).toBeInTheDocument();
});
it('renders without line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
wrapInTestApp(<CodeSnippet {...minProps} />),
);
expect(queryByText('1')).not.toBeInTheDocument();
expect(queryByText('2')).not.toBeInTheDocument();
@@ -51,7 +49,7 @@ describe('<CodeSnippet />', () => {
it('renders with line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} showLineNumbers />),
wrapInTestApp(<CodeSnippet {...minProps} showLineNumbers />),
);
expect(queryByText(/1/)).toBeInTheDocument();
expect(queryByText(/2/)).toBeInTheDocument();
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CopyTextButton from './CopyTextButton';
import {
ApiRegistry,
@@ -57,7 +57,7 @@ const apiRegistry = ApiRegistry.from([
describe('<CopyTextButton />', () => {
it('renders without exploding', () => {
const { getByDisplayValue } = render(
wrapInThemedTestApp(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
@@ -69,7 +69,7 @@ describe('<CopyTextButton />', () => {
it('displays tooltip on click', async () => {
document.execCommand = jest.fn();
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
@@ -16,7 +16,7 @@
import React from 'react';
// import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
import { renderWithEffects, wrapInThemedTestApp } from '@backstage/test-utils';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
// import { createSetting } from 'shared/apis/settings';
import DismissableBanner from './DismissableBanner';
@@ -30,7 +30,7 @@ describe('<DismissableBanner />', () => {
*/
const rendered = await renderWithEffects(
wrapInThemedTestApp(
wrapInTestApp(
<DismissableBanner
variant="info"
// setting={mockSetting}
@@ -31,6 +31,7 @@ const useStyles = makeStyles((theme: Theme) => ({
marginTop: -theme.spacing(3),
display: 'flex',
flexFlow: 'row nowrap',
zIndex: 'unset',
},
icon: {
fontSize: 20,
@@ -141,9 +141,9 @@ export const FeatureCalloutCircular: FC<Props> = ({
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update);
};
}, []);
}, [update]);
useLayoutEffect(update, [wrapperRef.current]);
useLayoutEffect(update, [wrapperRef.current, update]);
if (!show) {
return <>{children}</>;
@@ -51,27 +51,30 @@ function addRootElement(rootElem: Element): void {
export function usePortal(id: string): HTMLElement {
const rootElemRef = useRef<HTMLElement | null>(null);
useEffect(function setupElement() {
// Look for existing target dom element to append to
const existingParent = document.querySelector(`#${id}`);
// Parent is either a new root or the existing dom element
const parentElem = existingParent || createRootElement(id);
useEffect(
function setupElement() {
// Look for existing target dom element to append to
const existingParent = document.querySelector(`#${id}`);
// Parent is either a new root or the existing dom element
const parentElem = existingParent || createRootElement(id);
// If there is no existing DOM element, add a new one.
if (!existingParent) {
addRootElement(parentElem);
}
// Add the detached element to the parent
parentElem.appendChild(rootElemRef.current!);
return function removeElement() {
rootElemRef.current!.remove();
if (parentElem.childNodes.length === -1) {
parentElem.remove();
// If there is no existing DOM element, add a new one.
if (!existingParent) {
addRootElement(parentElem);
}
};
}, []);
// Add the detached element to the parent
parentElem.appendChild(rootElemRef.current!);
return function removeElement() {
rootElemRef.current!.remove();
if (parentElem.childNodes.length === -1) {
parentElem.remove();
}
};
},
[id],
);
/**
* It's important we evaluate this lazily:
@@ -45,7 +45,7 @@ function useCalloutHasBeenSeen(
const markSeen = useCallback(() => {
setState(featureId, true);
}, [featureId]);
}, [setState, featureId]);
return { seen: states[featureId] === true, markSeen };
}
@@ -16,7 +16,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInThemedTestApp } from '@backstage/test-utils';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
@@ -34,7 +34,7 @@ describe('<HorizontalScrollGrid />', () => {
it('renders without exploding', () => {
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HorizontalScrollGrid>
<Grid item>item1</Grid>
<Grid item>item2</Grid>
@@ -69,7 +69,7 @@ describe('<HorizontalScrollGrid />', () => {
};
const rendered = await renderWithEffects(
wrapInThemedTestApp(
wrapInTestApp(
<HorizontalScrollGrid style={{ maxWidth: 300 }}>
<Grid item style={{ minWidth: 200 }}>
item1
@@ -16,29 +16,27 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import { Lifecycle } from './Lifecycle';
describe('<Lifecycle />', () => {
it('renders Alpha with shorthand', async () => {
const { getByText } = render(
wrapInThemedTestApp(<Lifecycle alpha shorthand />),
);
const { getByText } = render(wrapInTestApp(<Lifecycle alpha shorthand />));
expect(getByText('α')).toBeInTheDocument();
});
it('renders Alpha without shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle alpha />));
const { getByText } = render(wrapInTestApp(<Lifecycle alpha />));
expect(getByText('Alpha')).toBeInTheDocument();
});
it('renders Beta with shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle shorthand />));
const { getByText } = render(wrapInTestApp(<Lifecycle shorthand />));
expect(getByText('β')).toBeInTheDocument();
});
it('renders Beta without shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle />));
const { getByText } = render(wrapInTestApp(<Lifecycle />));
expect(getByText('Beta')).toBeInTheDocument();
});
});
@@ -16,37 +16,33 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CircleProgress, { getProgressColor } from './CircleProgress';
describe('<CircleProgress />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<CircleProgress value={10} fractional={false} />),
wrapInTestApp(<CircleProgress value={10} fractional={false} />),
);
getByText('10%');
});
it('handles fractional prop', () => {
const { getByText } = render(
wrapInThemedTestApp(<CircleProgress value={0.1} fractional />),
wrapInTestApp(<CircleProgress value={0.1} fractional />),
);
getByText('10%');
});
it('handles max prop', () => {
const { getByText } = render(
wrapInThemedTestApp(
<CircleProgress value={1} max={10} fractional={false} />,
),
wrapInTestApp(<CircleProgress value={1} max={10} fractional={false} />),
);
getByText('1%');
});
it('handles unit prop', () => {
const { getByText } = render(
wrapInThemedTestApp(
<CircleProgress value={10} fractional={false} unit="m" />,
),
wrapInTestApp(<CircleProgress value={10} fractional={false} unit="m" />),
);
getByText('10m');
});
@@ -29,6 +29,7 @@ type Props = {
};
const HorizontalProgress: FC<Props> = ({ value }) => {
const theme = useTheme<BackstageTheme>();
if (isNaN(value)) {
return null;
}
@@ -36,7 +37,6 @@ const HorizontalProgress: FC<Props> = ({ value }) => {
if (percent > 100) {
percent = 100;
}
const theme = useTheme<BackstageTheme>();
const strokeColor = getProgressColor(theme.palette, percent, false, 100);
return (
<Tooltip title={`${percent}%`}>
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import ProgressCard from './ProgressCard';
@@ -24,32 +24,26 @@ const minProps = { title: 'Tingle upgrade', progress: 0.12 };
describe('<ProgressCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...minProps} />));
expect(getByText(/Tingle.*/)).toBeInTheDocument();
});
it('renders progress and title', () => {
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...minProps} />));
expect(getByText(/Tingle.*/)).toBeInTheDocument();
expect(getByText(/12%.*/)).toBeInTheDocument();
});
it('does not render deepLink', () => {
const { queryByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
wrapInTestApp(<ProgressCard {...minProps} />),
);
expect(queryByText('View more')).not.toBeInTheDocument();
});
it('handles invalid numbers', () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' };
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...badProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...badProps} />));
expect(getByText(/N\/A.*/)).toBeInTheDocument();
});
});
@@ -17,7 +17,7 @@
/* eslint-disable jest/no-disabled-tests */
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import TrendLine from '.';
@@ -25,7 +25,7 @@ describe('TrendLine', () => {
describe('when no data is present', () => {
it('renders null without throwing', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[]} title="sparkline" />),
);
expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument();
});
@@ -34,7 +34,7 @@ describe('TrendLine', () => {
describe('when one datapoint is present', () => {
it('renders as a straight line', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -43,7 +43,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes above the success threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.95]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.95]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -52,7 +52,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes within the the warning threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.65]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.65]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -61,7 +61,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes within the the error threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.4]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.4]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import WarningPanel from './WarningPanel';
@@ -24,15 +24,13 @@ const minProps = { title: 'Mock title', message: 'Some more info' };
describe('<WarningPanel />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<WarningPanel {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<WarningPanel {...minProps} />));
expect(getByText('Mock title')).toBeInTheDocument();
});
it('renders message and children', () => {
const { getByText } = render(
wrapInThemedTestApp(<WarningPanel {...minProps}>children</WarningPanel>),
wrapInTestApp(<WarningPanel {...minProps}>children</WarningPanel>),
);
expect(getByText('Some more info')).toBeInTheDocument();
expect(getByText('children')).toBeInTheDocument();
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ContentHeader } from './ContentHeader';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
jest.mock('react-helmet', () => {
return {
@@ -27,9 +27,7 @@ jest.mock('react-helmet', () => {
describe('<ContentHeader/>', () => {
it('should render with title', () => {
const rendered = render(
wrapInThemedTestApp(<ContentHeader title="Title" />),
);
const rendered = render(wrapInTestApp(<ContentHeader title="Title" />));
rendered.getByText('Title');
});
@@ -37,14 +35,14 @@ describe('<ContentHeader/>', () => {
const title = 'Custom title';
const titleComponent = () => <h1>{title}</h1>;
const rendered = render(
wrapInThemedTestApp(<ContentHeader titleComponent={titleComponent} />),
wrapInTestApp(<ContentHeader titleComponent={titleComponent} />),
);
rendered.getByText(title);
});
it('should render with description', () => {
const rendered = render(
wrapInThemedTestApp(<ContentHeader description="description" />),
wrapInTestApp(<ContentHeader description="description" />),
);
rendered.getByText('description');
});
@@ -17,14 +17,12 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ErrorPage } from './ErrorPage';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
describe('<ErrorPage/>', () => {
it('should render with status code, status message and go back link', () => {
const rendered = render(
wrapInThemedTestApp(
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />,
),
wrapInTestApp(<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />),
);
rendered.getByText(/page not found/i);
rendered.getByText(/404/i);
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import { Header } from './Header';
jest.mock('react-helmet', () => {
@@ -27,19 +27,19 @@ jest.mock('react-helmet', () => {
describe('<Header/>', () => {
it('should render with title', () => {
const rendered = render(wrapInThemedTestApp(<Header title="Title" />));
const rendered = render(wrapInTestApp(<Header title="Title" />));
rendered.getByText('Title');
});
it('should set document title', () => {
const rendered = render(wrapInThemedTestApp(<Header title="Title1" />));
const rendered = render(wrapInTestApp(<Header title="Title1" />));
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title1 | Backstage');
});
it('should override document title', () => {
const rendered = render(
wrapInThemedTestApp(<Header title="Title1" pageTitleOverride="Title2" />),
wrapInTestApp(<Header title="Title1" pageTitleOverride="Title2" />),
);
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title2 | Backstage');
@@ -47,14 +47,14 @@ describe('<Header/>', () => {
it('should have subtitle', () => {
const rendered = render(
wrapInThemedTestApp(<Header title="Title" subtitle="Subtitle" />),
wrapInTestApp(<Header title="Title" subtitle="Subtitle" />),
);
rendered.getByText('Subtitle');
});
it('should have type rendered', () => {
const rendered = render(
wrapInThemedTestApp(<Header title="Title" type="tool" />),
wrapInTestApp(<Header title="Title" type="tool" />),
);
rendered.getByText('tool');
});
@@ -16,18 +16,18 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInThemedTestApp, Keyboard } from '@backstage/test-utils';
import { wrapInTestApp, Keyboard } from '@backstage/test-utils';
import { HeaderActionMenu } from './HeaderActionMenu';
describe('<ComponentContextMenu />', () => {
it('renders without any items and without exploding', () => {
render(wrapInThemedTestApp(<HeaderActionMenu actionItems={[]} />));
render(wrapInTestApp(<HeaderActionMenu actionItems={[]} />));
});
it('can open the menu and click menu items', () => {
const onClickFunction = jest.fn();
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', onClick: onClickFunction }]}
/>,
@@ -49,7 +49,7 @@ describe('<ComponentContextMenu />', () => {
it('Disabled', async () => {
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', disabled: true }]}
/>,
@@ -66,7 +66,7 @@ describe('<ComponentContextMenu />', () => {
it('Test wrapper, and secondary label', () => {
const onClickFunction = jest.fn();
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HeaderActionMenu
actionItems={[
{
@@ -92,7 +92,7 @@ describe('<ComponentContextMenu />', () => {
it('should close when hitting escape', async () => {
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HeaderActionMenu actionItems={[{ label: 'Some label' }]} />,
),
);
@@ -16,39 +16,37 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import { HeaderLabel } from './HeaderLabel';
describe('<HeaderLabel />', () => {
it('should have a label', () => {
const rendered = render(wrapInThemedTestApp(<HeaderLabel label="Label" />));
const rendered = render(wrapInTestApp(<HeaderLabel label="Label" />));
expect(rendered.getByText('Label')).toBeInTheDocument();
});
it('should say unknown', () => {
const rendered = render(wrapInThemedTestApp(<HeaderLabel label="Label" />));
const rendered = render(wrapInTestApp(<HeaderLabel label="Label" />));
expect(rendered.getByText('<Unknown>')).toBeInTheDocument();
});
it('should say unknown when passing null as value prop', () => {
const rendered = render(
wrapInThemedTestApp(<HeaderLabel label="Label" value={null} />),
wrapInTestApp(<HeaderLabel label="Label" value={null} />),
);
expect(rendered.getByText('<Unknown>')).toBeInTheDocument();
});
it('should have value', () => {
const rendered = render(
wrapInThemedTestApp(<HeaderLabel label="Label" value="Value" />),
wrapInTestApp(<HeaderLabel label="Label" value="Value" />),
);
expect(rendered.getByText('Value')).toBeInTheDocument();
});
it('should have a link', () => {
const rendered = render(
wrapInThemedTestApp(
<HeaderLabel label="Label" value="Value" url="/test" />,
),
wrapInTestApp(<HeaderLabel label="Label" value="Value" url="/test" />),
);
const anchor = rendered.container.querySelector('a') as HTMLAnchorElement;
expect(rendered.getByText('Value')).toBeInTheDocument();
@@ -1,178 +0,0 @@
/*
* 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, { FC, useState } from 'react';
import GitHubIcon from '@material-ui/icons/GitHub';
import { Page } from '../Page';
import { Header } from '../Header';
import { Content } from '../Content';
import { ContentHeader } from '../ContentHeader';
import { InfoCard } from '../InfoCard/InfoCard';
import {
Grid,
Typography,
Button,
TextField,
List,
ListItem,
Link,
} from '@material-ui/core';
enum AuthType {
GitHub,
}
export const LoginPage: FC<{}> = () => {
const [githubUsername, setGithubUsername] = useState(String);
const [githubPersonalAuthToken, setGithubPersonalAuthToken] = useState(
String,
);
const [loginDetails, setLoginDetails] = useState(Object);
const saveGithubInfo = (info: {}) => {
localStorage.setItem('githubLoginDetails', JSON.stringify(info));
setLoginDetails(info);
};
const deleteGithubInfo = () => {
localStorage.removeItem('githubLoginDetails');
setLoginDetails(undefined);
};
const handleTokenRegistration = (event: any) => {
switch (event.target.name) {
case 'github-username-tf':
setGithubUsername(event.target.value);
break;
case 'github-auth-tf':
setGithubPersonalAuthToken(event.target.value);
break;
default:
break;
}
};
const fetchGitHubToken = (username: String, token: String) => {
fetch('https://api.github.com/user', {
headers: new Headers({
Authorization: `Basic ${btoa(`${username}:${token}`)}`,
'Content-Type': 'application/x-www-form-urlencoded',
}),
})
.then(response => {
if (response.status === 200) return response.json();
throw Error(`${response.status} ${response.statusText}`);
})
.then(data => {
const info = {
username: username,
token: token,
name: data.name || data.login,
};
saveGithubInfo(info);
})
.catch(() => {});
};
const validateUsernameAndToken = (username: String, token: String) => {
if (username === undefined || username === null || username === '')
return false;
if (token === undefined || token === null || token === '') return false;
return true;
};
const authenticate = (type: AuthType) => {
switch (type) {
case AuthType.GitHub:
{
const username = githubUsername;
const token = githubPersonalAuthToken;
if (validateUsernameAndToken(username, token))
fetchGitHubToken(username, token);
}
break;
default:
break;
}
};
const LoginIndicator = () => {
const ls = localStorage.getItem('githubLoginDetails');
if (ls !== null) {
const obj = ls || loginDetails ? JSON.parse(ls) : loginDetails;
return (
<Typography variant="h6" component="h2">
{`Welcome, ${obj.name}!`}
<br />
<Link onClick={deleteGithubInfo}>Logout</Link>
</Typography>
);
}
return (
<Typography variant="h6" component="h2">
Welcome, guest!
</Typography>
);
};
return (
<Page>
<Header title="Login">
<LoginIndicator />
</Header>
<Content>
<ContentHeader title="Choose a method to authenticate" />
<Grid container>
<Grid item>
<InfoCard>
<Typography variant="h6">
<GitHubIcon /> GitHub
</Typography>
<List>
<ListItem>
<TextField
name="github-username-tf"
label="Username"
onChange={handleTokenRegistration}
/>
</ListItem>
<ListItem>
<TextField
name="github-auth-tf"
label="Token"
onChange={handleTokenRegistration}
/>
</ListItem>
<ListItem>
<Button
data-testid="github-auth-button"
variant="outlined"
color="primary"
onClick={() => authenticate(AuthType.GitHub)}
>
Authenticate
</Button>
</ListItem>
</List>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
-1
View File
@@ -21,7 +21,6 @@ export * from './Header';
export * from './HeaderLabel';
export * from './HomepageTimer';
export * from './InfoCard';
export * from './LoginPage';
export * from './Page';
export * from './Sidebar';
export * from './TabbedCard';
+5 -2
View File
@@ -37,14 +37,17 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
},
"files": [
"dist/**/*.{js,d.ts}"
]
@@ -22,6 +22,8 @@ import {
createApiFactory,
ErrorAlerter,
AlertApiForwarder,
oauthRequestApiRef,
OAuthRequestManager,
} from '@backstage/core';
// TODO(rugvip): We should likely figure out how to reuse all of these between apps
@@ -41,3 +43,9 @@ export const errorApiFactory = createApiFactory({
factory: ({ alertApi }) =>
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
});
export const oauthRequestApiFactory = createApiFactory({
implements: oauthRequestApiRef,
deps: {},
factory: () => new OAuthRequestManager(),
});
+15 -1
View File
@@ -15,7 +15,7 @@
*/
import { hot } from 'react-hot-loader/root';
import React, { FC, ComponentType } from 'react';
import React, { FC, ComponentType, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import BookmarkIcon from '@material-ui/icons/Bookmark';
@@ -30,6 +30,7 @@ import {
ApiTestRegistry,
ApiHolder,
AlertDisplay,
OAuthRequestDialog,
} from '@backstage/core';
import * as defaultApiFactories from './apiFactories';
@@ -43,6 +44,7 @@ type BackstagePlugin = ReturnType<typeof createPlugin>;
class DevAppBuilder {
private readonly plugins = new Array<BackstagePlugin>();
private readonly factories = new Array<ApiFactory<any, any, any>>();
private readonly rootChildren = new Array<ReactNode>();
/**
* Register one or more plugins to render in the dev app
@@ -62,6 +64,16 @@ class DevAppBuilder {
return this;
}
/**
* Adds a React node to place just inside the App Provider.
*
* Useful for adding more global components like the AlertDisplay.
*/
addRootChild(node: ReactNode): DevAppBuilder {
this.rootChildren.push(node);
return this;
}
/**
* Build a DevApp component using the resources registered so far
*/
@@ -79,6 +91,8 @@ class DevAppBuilder {
return (
<AppProvider>
<AlertDisplay />
<OAuthRequestDialog />
{this.rootChildren}
<BrowserRouter>
<SidebarPage>
{sidebar}
+5 -2
View File
@@ -30,11 +30,14 @@
"dependencies": {
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
},
"files": [
"dist/**/*.{js,d.ts}"
]

Some files were not shown because too many files have changed in this diff Show More