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:
@@ -71,6 +71,13 @@
|
||||
"pathRewrite": {
|
||||
"^/circleci/api/": "/"
|
||||
}
|
||||
},
|
||||
"/catalog/api": {
|
||||
"target": "http://localhost:3003",
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/catalog/api/": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LoginPage } from './LoginPage';
|
||||
export {};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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()'
|
||||
|
||||
@@ -39,7 +39,7 @@ module.exports = {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['**/dist/**', '**/build/**'],
|
||||
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
|
||||
rules: {
|
||||
'import/no-duplicates': 'warn',
|
||||
'import/no-extraneous-dependencies': [
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -51,6 +51,12 @@ export function createConfig(
|
||||
);
|
||||
}
|
||||
|
||||
plugins.push(
|
||||
new webpack.EnvironmentPlugin({
|
||||
APP_CONFIG: options.appConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: isDev ? 'development' : 'production',
|
||||
profile: false,
|
||||
|
||||
@@ -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
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export * from './types';
|
||||
export { createRouteRef } from './RouteRef';
|
||||
export type { MutableRouteRef } from './RouteRef';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user