diff --git a/.github/workflows/cli-win.yml b/.github/workflows/cli-win.yml
new file mode 100644
index 0000000000..52ec1b8b27
--- /dev/null
+++ b/.github/workflows/cli-win.yml
@@ -0,0 +1,65 @@
+name: CLI Test Windows
+
+# Building on windows is really slow, so this workflow is separate from cli.yml and only builds on changes
+# to the cli itself. They're more likely to introduce issues on windows, compared to changes to core and yarn.lock.
+on:
+ pull_request:
+ paths:
+ - '.github/workflows/cli-win.yml'
+ - 'packages/cli/**'
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ os: [windows-latest]
+ node-version: [12.x]
+
+ env:
+ CI: true
+ NODE_OPTIONS: --max-old-space-size=4096
+
+ name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v2
+ - name: find location of global yarn cache
+ id: yarn-cache
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+ - name: cache global yarn cache
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.yarn-cache.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+ - name: use node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: yarn install
+ run: yarn install --frozen-lockfile
+ - run: yarn tsc
+ - run: yarn build
+ - name: verify app and plugin creation
+ working-directory: ${{ runner.temp }}
+ run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
+ env:
+ BACKSTAGE_E2E_CLI_TEST: true
+ - name: lint newly created app and plugin
+ run: yarn lint:all
+ working-directory: ${{ runner.temp }}/test-app
+ env:
+ BACKSTAGE_E2E_CLI_TEST: true
+ - name: test newly created app and plugin
+ run: yarn test:all
+ working-directory: ${{ runner.temp }}/test-app
+ env:
+ BACKSTAGE_E2E_CLI_TEST: true
+ - name: e2e test newly created app
+ run: yarn test:e2e:ci
+ working-directory: ${{ runner.temp }}/test-app/packages/app
+ env:
+ PORT: 3001
+ BACKSTAGE_E2E_CLI_TEST: true
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 07ed78743c..62f91e3259 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -6,7 +6,7 @@ on:
- '.github/workflows/cli.yml'
- 'packages/cli/**'
- 'packages/core/**'
- - 'scripts/**'
+ - 'packages/core-api/**'
- 'yarn.lock'
jobs:
@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
- os: [ubuntu-latest, windows-latest]
+ os: [ubuntu-latest]
node-version: [12.x]
env:
@@ -29,7 +29,7 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -43,15 +43,8 @@ jobs:
run: yarn install --frozen-lockfile
- run: yarn tsc
- run: yarn build
- - name: verify app and plugin creation on Windows
+ - name: verify app and plugin creation
working-directory: ${{ runner.temp }}
- if: runner.os == 'Windows'
- run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
- env:
- BACKSTAGE_E2E_CLI_TEST: true
- - name: verify app and plugin creation on Linux
- working-directory: ${{ runner.temp }}
- if: runner.os == 'Linux'
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index d916141588..c3fa6e4e23 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -25,19 +25,19 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
@@ -71,6 +71,9 @@ jobs:
if: ${{ steps.yarn-lock.outcome == 'failure' }}
run: yarn lerna -- run build
+ - name: verify type dependencies
+ run: yarn lint:type-deps
+
- name: test changed packages
if: ${{ steps.yarn-lock.outcome == 'success' }}
run: yarn lerna -- run test --since origin/master -- --coverage
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index a41f25c262..52f2760469 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -24,19 +24,19 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
@@ -60,6 +60,9 @@ jobs:
- name: build
run: yarn build
+ - name: verify type dependencies
+ run: yarn lint:type-deps
+
- name: test
run: yarn lerna -- run test -- --coverage
diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml
index d6eef29744..33ffba5ccd 100644
--- a/.github/workflows/storybook-deploy.yml
+++ b/.github/workflows/storybook-deploy.yml
@@ -27,14 +27,14 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
- uses: actions/cache@v1
+ uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
diff --git a/README.md b/README.md
index 2e22818f4b..c1ca77c2cc 100644
--- a/README.md
+++ b/README.md
@@ -82,12 +82,11 @@ Take a look at the [Getting Started](docs/getting-started/README.md) guide to le
- [Getting Started](docs/getting-started/README.md)
- [Create a Backstage App](docs/create-an-app.md)
-- [Architecture](docs/architecture-terminology.md)
+- [Architecture](docs/architecture-terminology.md) ([Decisions](docs/architecture-decisions))
- [API references](docs/reference/README.md)
- [Designing for Backstage](docs/design.md)
- [Storybook - UI components](http://storybook.backstage.io)
- [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md)
-- Using Backstage components (TODO)
## Contributing
diff --git a/app-config.yaml b/app-config.yaml
new file mode 100644
index 0000000000..6ff336c727
--- /dev/null
+++ b/app-config.yaml
@@ -0,0 +1,9 @@
+app:
+ title: Backstage Example App
+ baseUrl: http://localhost:3000
+
+backend:
+ baseUrl: http://localhost:7000
+
+organization:
+ name: Spotify
diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md
new file mode 100644
index 0000000000..1014019f8a
--- /dev/null
+++ b/docs/architecture-decisions/adr005-catalog-core-entities.md
@@ -0,0 +1,77 @@
+# ADR005: Catalog Core Entities
+
+| Created | Status |
+| ---------- | ------ |
+| 2020-05-29 | Open |
+
+## Context
+
+We want to standardize on a few core entities that we are tracking in the Backstage catalog. This allows us to build specific plugins around them.
+
+## Decision
+
+Backstage should eventually support the following core entities:
+
+* **Components** are individual pieces of software
+* **APIs** are the boundaries between different components
+* **Resources** are physical or virtual infrastructure needed to operate a component
+
+![Catalog Core Entities][catalog-core-entities]
+
+For now, we'll start by only implementing support for the Component entity in the Backstage catalog. This can later be extended to APIs, Resources and other potentially useful entities.
+
+### Component
+A component is a piece of software, for example a mobile application feature, web site, backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. It can implement APIs for other components to consume. In turn it might depend on APIs implemented by other components, or resources that are attached to it at runtime.
+
+Component entities are typically defined in YAML descriptor files next to the code of the component, and could look like this (actual schema will evolve):
+```yaml
+apiVersion: backstage.io/v1beta1
+kind: Component
+metadata:
+ name: my-component-name
+spec:
+ type: service
+```
+
+### API
+APIs form an abstraction that allows large software ecosystems to scale. Thus, APIs are a first class citizen in the Backstage model and the primary way to discover existing functionality in the ecosystem.
+
+APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (eg in Protobuf, GraphQL or similar), a data schema (eg in Avro, TFRecord or similar), or as code interfaces (eg framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top.
+
+APIs are typically indexed from existing definitions in source control and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve):
+```yaml
+apiVersion: backstage.io/v1beta1
+kind: API
+metadata:
+ name: my-component-api
+spec:
+ type: grpc
+ definition: >
+ service HelloService {
+ rpc SayHello (HelloRequest) returns (HelloResponse);
+ }
+ message HelloRequest {
+ string greeting = 1;
+ }
+ message HelloResponse {
+ string reply = 1;
+ }
+```
+
+### Resource
+Resources are the infrastructure your software needs to operate at runtime like Bigtable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and APIs will allow us to visualize and create tooling around them in Backstage.
+
+Resources are typically indexed from declarative definitions (eg Terraform, GCP Config Connector, AWS Cloud Formation) and/or inventories from cloud providers (eg GCP Asset Inventory) and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve):
+```yaml
+apiVersion: backstage.io/v1beta1
+kind: Resource
+metadata:
+ name: my-component-db
+spec:
+ type: gcp-spanner
+ url: spanner.googleapis.com/projects/prj/instances/my-component-db/databases/my-db
+```
+
+## Consequences
+
+We will continue fleshing out support for the Component entity in the Backstage catalog.
diff --git a/docs/architecture-decisions/catalog-core-entities.png b/docs/architecture-decisions/catalog-core-entities.png
new file mode 100644
index 0000000000..b0c7cb4575
Binary files /dev/null and b/docs/architecture-decisions/catalog-core-entities.png differ
diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md
index 4dd2f7c9c7..26b432570f 100644
--- a/docs/getting-started/development-environment.md
+++ b/docs/getting-started/development-environment.md
@@ -1,16 +1,28 @@
# Development Environment
+This section describes how to get set up for doing development on the Backstage repository.
+
+## Cloning the Repository
+
+After you have cloned the Backstage repository, you should run the following commands
+once to set things up for development:
+
+```bash
+$ yarn install # fetch dependency packages - may take a while
+
+$ yarn tsc # does a first run of type generation and checks
+```
+
## Serving the Example App
-Open a terminal window and start the web app using the following commands from the project root:
+Open a terminal window and start the web app by using the following command from the project root.
+Make sure you have run the above mentioned commands first.
```bash
-$ yarn install # may take a while
-
$ yarn start
```
-The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
+This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
By default, backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`.
@@ -44,6 +56,7 @@ yarn build # Build published versions of packages, depends on tsc
yarn lint # lint packages that have changed since later commit on origin/master
yarn lint:all # lint all packages
+yarn lint:type-deps # verify that @types/* dependencies are placed correctly in packages
yarn test # test packages that have changed since later commit on origin/master
yarn test:all # test all packages
diff --git a/package.json b/package.json
index de924894c7..437ce36c4b 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
+ "lint:type-deps": "node scripts/check-type-dependencies.js",
"docker-build": "yarn bundle && docker build . -t spotify/backstage",
"create-plugin": "backstage-cli create-plugin",
"remove-plugin": "backstage-cli remove-plugin",
diff --git a/packages/app/package.json b/packages/app/package.json
index a366dcce1a..ed2f547ad7 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -71,6 +71,13 @@
"pathRewrite": {
"^/circleci/api/": "/"
}
+ },
+ "/catalog/api": {
+ "target": "http://localhost:3003",
+ "changeOrigin": true,
+ "pathRewrite": {
+ "^/catalog/api/": "/"
+ }
}
}
}
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 56faf4402b..b4d01e067b 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -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<{}> = () => (
-
diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts
index 8d76c75d91..6a23de1edd 100644
--- a/packages/app/src/apis.ts
+++ b/packages/app/src/apis.ts
@@ -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;
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 9aedafecbb..51fe04e51d 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -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*"
+ ]
}
}
diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts
index c2e349f640..7cf9610dc2 100644
--- a/packages/backend/src/plugins/auth.ts
+++ b/packages/backend/src/plugins/auth.ts
@@ -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 });
}
diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts
index 687fd9157a..906e8c2d11 100644
--- a/packages/backend/src/plugins/catalog.ts
+++ b/packages/backend/src/plugins/catalog.ts
@@ -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,
+ });
}
diff --git a/packages/backend/src/plugins/identity.ts b/packages/backend/src/plugins/identity.ts
index 26276afd01..63a326965c 100644
--- a/packages/backend/src/plugins/identity.ts
+++ b/packages/backend/src/plugins/identity.ts
@@ -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 });
}
diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts
index 311c9197aa..08e700bc74 100644
--- a/packages/backend/src/plugins/scaffolder.ts
+++ b/packages/backend/src/plugins/scaffolder.ts
@@ -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();
diff --git a/packages/backend/src/plugins/sentry.ts b/packages/backend/src/plugins/sentry.ts
index 34506ee3de..89ee153faf 100644
--- a/packages/backend/src/plugins/sentry.ts
+++ b/packages/backend/src/plugins/sentry.ts
@@ -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);
}
diff --git a/packages/catalog-model/.eslintrc.js b/packages/catalog-model/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/packages/catalog-model/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/packages/catalog-model/README.md b/packages/catalog-model/README.md
new file mode 100644
index 0000000000..6dab6e7cae
--- /dev/null
+++ b/packages/catalog-model/README.md
@@ -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)
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
new file mode 100644
index 0000000000..ffd8fdba22
--- /dev/null
+++ b/packages/catalog-model/package.json
@@ -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}"
+ ]
+}
diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts
new file mode 100644
index 0000000000..ca189c042e
--- /dev/null
+++ b/packages/catalog-model/src/EntityPolicies.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 {
+ 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 {
+ 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 {
+ return this.policy.enforce(entity);
+ }
+}
diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts
new file mode 100644
index 0000000000..80c5765ee1
--- /dev/null
+++ b/packages/catalog-model/src/entity/Entity.ts
@@ -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;
+
+ /**
+ * Key/value pairs of non-identifying auxiliary information attached to the
+ * entity.
+ */
+ annotations?: Record;
+};
diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts
new file mode 100644
index 0000000000..9e96021336
--- /dev/null
+++ b/packages/catalog-model/src/entity/index.ts
@@ -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';
diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts
new file mode 100644
index 0000000000..68658e5296
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts
@@ -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' },
+ }),
+ );
+ });
+});
diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
new file mode 100644
index 0000000000..e5aba745c7
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
@@ -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 {
+ if (entity.metadata.namespace) {
+ return entity;
+ }
+
+ return lodash.merge({ metadata: { namespace: this.namespace } }, entity);
+ }
+}
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
new file mode 100644
index 0000000000..14b44108e5
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
@@ -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);
+ });
+});
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
new file mode 100644
index 0000000000..4ccd8ec711
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts
new file mode 100644
index 0000000000..50496682e8
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts
@@ -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);
+ });
+});
diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
new file mode 100644
index 0000000000..9d1851bc02
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
@@ -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 {
+ for (const field of Object.keys(entity)) {
+ if (!this.knownFields.includes(field)) {
+ throw new Error(`Unknown field ${field}`);
+ }
+ }
+ return entity;
+ }
+}
diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts
new file mode 100644
index 0000000000..8db33955a7
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts
@@ -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,
+ );
+ });
+});
diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts
new file mode 100644
index 0000000000..57029ffa24
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
new file mode 100644
index 0000000000..c53fbf3e46
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
@@ -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/);
+ });
+});
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
new file mode 100644
index 0000000000..d367022ff1
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
@@ -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>().notRequired(),
+ annotations: yup.object>().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;
+
+ constructor(schema: yup.Schema = DEFAULT_ENTITY_SCHEMA) {
+ this.schema = schema;
+ }
+
+ async enforce(entity: Entity): Promise {
+ try {
+ return await this.schema.validate(entity, { strict: true });
+ } catch (e) {
+ throw new Error(`Malformed envelope, ${e}`);
+ }
+ }
+}
diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts
new file mode 100644
index 0000000000..d64053f7cb
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/index.ts
@@ -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';
diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts
new file mode 100644
index 0000000000..f149b8c9b4
--- /dev/null
+++ b/packages/catalog-model/src/index.ts
@@ -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';
diff --git a/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts b/packages/catalog-model/src/kinds/ComponentV1beta1.ts
similarity index 62%
rename from plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts
rename to packages/catalog-model/src/kinds/ComponentV1beta1.ts
index 974b34aa8e..b041bf7967 100644
--- a/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts
+++ b/packages/catalog-model/src/kinds/ComponentV1beta1.ts
@@ -15,19 +15,28 @@
*/
import * as yup from 'yup';
-import { DescriptorEnvelope, KindParser, ParserError } from '../types';
+import type { Entity, EntityMeta } from '../entity/Entity';
+import type { EntityPolicy } from '../types';
-export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
+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 ComponentDescriptorV1beta1Parser implements KindParser {
+export class ComponentV1beta1Policy implements EntityPolicy {
private schema: yup.Schema;
constructor() {
- this.schema = yup.object>({
+ this.schema = yup.object>({
metadata: yup
.object({
name: yup.string().required(),
@@ -41,23 +50,14 @@ export class ComponentDescriptorV1beta1Parser implements KindParser {
});
}
- async tryParse(
- envelope: DescriptorEnvelope,
- ): Promise {
+ async enforce(envelope: Entity): Promise {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
- return undefined;
+ throw new Error('Unsupported apiVersion / kind');
}
- try {
- return await this.schema.validate(envelope, { strict: true });
- } catch (e) {
- throw new ParserError(
- `Malformed component, ${e}`,
- envelope.metadata?.name,
- );
- }
+ return await this.schema.validate(envelope, { strict: true });
}
}
diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts
new file mode 100644
index 0000000000..ed79fed61d
--- /dev/null
+++ b/packages/catalog-model/src/kinds/index.ts
@@ -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';
diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts
new file mode 100644
index 0000000000..60465bff9b
--- /dev/null
+++ b/packages/catalog-model/src/location/index.ts
@@ -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';
diff --git a/plugins/circleci/src/proxy.ts b/packages/catalog-model/src/location/types.ts
similarity index 74%
rename from plugins/circleci/src/proxy.ts
rename to packages/catalog-model/src/location/types.ts
index 8a5ae460ab..50e6e82a54 100644
--- a/plugins/circleci/src/proxy.ts
+++ b/packages/catalog-model/src/location/types.ts
@@ -13,13 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export const proxySettings = {
- '/circleci/api': {
- target: 'https://circleci.com/api/v1.1',
- changeOrigin: true,
- logLevel: 'debug',
- pathRewrite: {
- '^/circleci/api/': '/',
- },
- },
+
+export type LocationSpec = {
+ type: string;
+ target: string;
};
+
+export type Location = {
+ id: string;
+} & LocationSpec;
diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts
new file mode 100644
index 0000000000..5fad47bdd0
--- /dev/null
+++ b/packages/catalog-model/src/location/validation.ts
@@ -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({
+ type: yup.string().required(),
+ target: yup.string().required(),
+ })
+ .noUnknown();
+
+export const locationSchema = yup
+ .object({
+ id: yup.string().required(),
+ type: yup.string().required(),
+ target: yup.string().required(),
+ })
+ .noUnknown();
diff --git a/packages/core/src/layout/LoginPage/index.ts b/packages/catalog-model/src/setupTests.ts
similarity index 93%
rename from packages/core/src/layout/LoginPage/index.ts
rename to packages/catalog-model/src/setupTests.ts
index caa94bd6d7..ba33cf996b 100644
--- a/packages/core/src/layout/LoginPage/index.ts
+++ b/packages/catalog-model/src/setupTests.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { LoginPage } from './LoginPage';
+export {};
diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts
new file mode 100644
index 0000000000..29ca8bdfa3
--- /dev/null
+++ b/packages/catalog-model/src/types.ts
@@ -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;
+};
diff --git a/plugins/catalog-backend/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/CommonValidatorFunctions.test.ts
rename to packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
diff --git a/plugins/catalog-backend/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/CommonValidatorFunctions.ts
rename to packages/catalog-model/src/validation/CommonValidatorFunctions.ts
diff --git a/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.test.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.test.ts
rename to packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts
diff --git a/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.ts
rename to packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts
diff --git a/packages/catalog-model/src/validation/index.ts b/packages/catalog-model/src/validation/index.ts
new file mode 100644
index 0000000000..d679a5323c
--- /dev/null
+++ b/packages/catalog-model/src/validation/index.ts
@@ -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';
diff --git a/plugins/catalog-backend/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/makeValidator.ts
rename to packages/catalog-model/src/validation/makeValidator.ts
diff --git a/plugins/catalog-backend/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts
similarity index 100%
rename from plugins/catalog-backend/src/validation/types.ts
rename to packages/catalog-model/src/validation/types.ts
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index 619d3c72e0..4f45e232b2 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -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()'
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 0c6bfbe8e7..922867ea5a 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -39,7 +39,7 @@ module.exports = {
version: 'detect',
},
},
- ignorePatterns: ['**/dist/**', '**/build/**'],
+ ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json
index 652e990387..f6d622ee16 100644
--- a/packages/cli/config/tsconfig.json
+++ b/packages/cli/config/tsconfig.json
@@ -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"]
}
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a15765c731..784df5de6b 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -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"
},
diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts
index c654baa439..fad3e6db13 100644
--- a/packages/cli/src/commands/app/build.ts
+++ b/packages/cli/src/commands/app/build.ts
@@ -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(),
});
};
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index 416f8f0151..19dfd9a7be 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -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();
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index 07819bacfb..41e69c51be 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -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);
};
diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts
index 8abbd92440..174d1fe4af 100644
--- a/packages/cli/src/commands/plugin/serve.ts
+++ b/packages/cli/src/commands/plugin/serve.ts
@@ -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();
diff --git a/packages/cli/src/lib/app-config/index.ts b/packages/cli/src/lib/app-config/index.ts
new file mode 100644
index 0000000000..e2c80f89e1
--- /dev/null
+++ b/packages/cli/src/lib/app-config/index.ts
@@ -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';
diff --git a/packages/cli/src/lib/app-config/loaders.ts b/packages/cli/src/lib/app-config/loaders.ts
new file mode 100644
index 0000000000..6e6a56e6c5
--- /dev/null
+++ b/packages/cli/src/lib/app-config/loaders.ts
@@ -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 {
+ // 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}`);
+ }
+}
diff --git a/packages/cli/src/lib/app-config/types.ts b/packages/cli/src/lib/app-config/types.ts
new file mode 100644
index 0000000000..d15cbe3787
--- /dev/null
+++ b/packages/cli/src/lib/app-config/types.ts
@@ -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;
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index a35c22e3bd..deb02a38c6 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -51,6 +51,12 @@ export function createConfig(
);
}
+ plugins.push(
+ new webpack.EnvironmentPlugin({
+ APP_CONFIG: options.appConfig,
+ }),
+ );
+
return {
mode: isDev ? 'development' : 'production',
profile: false,
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
index 4182226d69..1a9f49701c 100644
--- a/packages/cli/src/lib/bundler/types.ts
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -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[];
};
diff --git a/packages/cli/templates/default-app/app-config.yaml b/packages/cli/templates/default-app/app-config.yaml
new file mode 100644
index 0000000000..b4c53905de
--- /dev/null
+++ b/packages/cli/templates/default-app/app-config.yaml
@@ -0,0 +1,5 @@
+app:
+ title: Scaffolded Backstage App
+
+organization:
+ name: Acme Corporation
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
index 73e7a18d33..a8c2b939ac 100644
--- a/packages/core-api/package.json
+++ b/packages/core-api/package.json
@@ -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": [
diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts
new file mode 100644
index 0000000000..20676df899
--- /dev/null
+++ b/packages/core-api/src/apis/definitions/ConfigApi.ts
@@ -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({
+ id: 'core.config',
+ description: 'Used to access runtime configuration',
+});
diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts
index 475dba9189..41fbc839f9 100644
--- a/packages/core-api/src/apis/definitions/index.ts
+++ b/packages/core-api/src/apis/definitions/index.ts
@@ -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';
diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts
new file mode 100644
index 0000000000..68c1fd5353
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts
@@ -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();
+ });
+});
diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts
new file mode 100644
index 0000000000..f6a8bce17b
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts
@@ -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;
+ }
+}
diff --git a/packages/core-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-api/src/apis/implementations/ConfigApi/index.ts
new file mode 100644
index 0000000000..8839cb948e
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/ConfigApi/index.ts
@@ -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';
diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts
new file mode 100644
index 0000000000..7c8e6ce9f0
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts
@@ -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);
+ });
+});
diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts
new file mode 100644
index 0000000000..d75bceef97
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts
@@ -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) {}
+
+ 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 {
+ if (!scope) {
+ return new Set();
+ }
+
+ const scopeList = Array.isArray(scope)
+ ? scope
+ : scope.split(/[\s|,]/).filter(Boolean);
+
+ return new Set(scopeList);
+ }
+}
+export default GithubAuth;
diff --git a/plugins/catalog-backend/src/validation/index.ts b/packages/core-api/src/apis/implementations/auth/github/index.ts
similarity index 83%
rename from plugins/catalog-backend/src/validation/index.ts
rename to packages/core-api/src/apis/implementations/auth/github/index.ts
index be607e43ec..9e1722f4a4 100644
--- a/plugins/catalog-backend/src/validation/index.ts
+++ b/packages/core-api/src/apis/implementations/auth/github/index.ts
@@ -14,7 +14,5 @@
* limitations under the License.
*/
-export * from './CommonValidatorFunctions';
-export * from './KubernetesValidatorFunctions';
-export * from './makeValidator';
export * from './types';
+export { default as GithubAuth } from './GithubAuth';
diff --git a/plugins/catalog/src/data/with-mock-store.tsx b/packages/core-api/src/apis/implementations/auth/github/types.ts
similarity index 62%
rename from plugins/catalog/src/data/with-mock-store.tsx
rename to packages/core-api/src/apis/implementations/auth/github/types.ts
index 2e5425d03e..282017b80d 100644
--- a/plugins/catalog/src/data/with-mock-store.tsx
+++ b/packages/core-api/src/apis/implementations/auth/github/types.ts
@@ -13,14 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import * as React from 'react';
-import { ComponentFactory } from './component';
-import { MockComponentFactory } from './mock-factory';
-const componentFactory: ComponentFactory = MockComponentFactory;
-
-export const withMockStore = (Component: React.ElementType) => {
- return (props: any) => (
-
- );
+export type GithubSession = {
+ accessToken: string;
+ scopes: Set;
+ expiresAt: Date;
};
diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts
index 5fa6644b2a..f13368b5c4 100644
--- a/packages/core-api/src/apis/implementations/auth/index.ts
+++ b/packages/core-api/src/apis/implementations/auth/index.ts
@@ -15,3 +15,4 @@
*/
export * from './google';
+export * from './github';
diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts
index bb77cf5bd3..b5cc250ae4 100644
--- a/packages/core-api/src/apis/implementations/index.ts
+++ b/packages/core-api/src/apis/implementations/index.ts
@@ -22,5 +22,6 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
+export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 844c38ecce..282e0aa474 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -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 }) => (
-
-
- {children}
-
-
- );
+ let childNode = children;
+
+ if (hasConfig && config.loading) {
+ const { Progress } = this.components;
+ childNode = ;
+ } else if (config.error) {
+ const { BootErrorPage } = this.components;
+ childNode = ;
+ }
+
+ const appApis = ApiRegistry.from([
+ [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
+ [configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
+ ]);
+ const apis = new ApiAggregator(this.apis, appApis);
+
+ return (
+
+
+ {childNode}
+
+
+ );
+ };
return Provider;
}
diff --git a/packages/core-api/src/app/AppThemeProvider.tsx b/packages/core-api/src/app/AppThemeProvider.tsx
index 775d8293ba..6bbcaea93a 100644
--- a/packages/core-api/src/app/AppThemeProvider.tsx
+++ b/packages/core-api/src/app/AppThemeProvider.tsx
@@ -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,
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index ea3a812557..953a10cbb2 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -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;
+ 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;
+
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 = {
diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts
index 426c514646..16a8d3c378 100644
--- a/packages/core-api/src/lib/AuthSessionManager/index.ts
+++ b/packages/core-api/src/lib/AuthSessionManager/index.ts
@@ -15,4 +15,5 @@
*/
export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
+export { StaticAuthSessionManager } from './StaticAuthSessionManager';
export * from './types';
diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts
index 98e4f46d98..67d4c82167 100644
--- a/packages/core-api/src/routing/index.ts
+++ b/packages/core-api/src/routing/index.ts
@@ -16,3 +16,4 @@
export * from './types';
export { createRouteRef } from './RouteRef';
+export type { MutableRouteRef } from './RouteRef';
diff --git a/packages/core/package.json b/packages/core/package.json
index a89ffc0b92..452633a4e5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -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": [
diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx
new file mode 100644
index 0000000000..30553d84a7
--- /dev/null
+++ b/packages/core/src/api-wrappers/createApp.test.tsx
@@ -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',
+ );
+ });
+});
diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx
index 03adc41ec6..c53a4765b7 100644
--- a/packages/core/src/api-wrappers/createApp.tsx
+++ b/packages/core/src/api-wrappers/createApp.tsx
@@ -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 = () => (
);
+ const DefaultBootErrorPage: FC = ({ 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 (
+
+
+
+ );
+ };
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();
diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx
index 0d0c34a3e6..c019b316fd 100644
--- a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx
+++ b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx
@@ -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('', () => {
it('renders text without exploding', () => {
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText(/"Hello"/)).toBeInTheDocument();
expect(getByText(/"World"/)).toBeInTheDocument();
});
it('renders without line numbers', () => {
const { queryByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
expect(queryByText('1')).not.toBeInTheDocument();
expect(queryByText('2')).not.toBeInTheDocument();
@@ -51,7 +49,7 @@ describe('', () => {
it('renders with line numbers', () => {
const { queryByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
expect(queryByText(/1/)).toBeInTheDocument();
expect(queryByText(/2/)).toBeInTheDocument();
diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx
index e0f7271014..dd83cd318d 100644
--- a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx
+++ b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx
@@ -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('', () => {
it('renders without exploding', () => {
const { getByDisplayValue } = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
,
@@ -69,7 +69,7 @@ describe('', () => {
it('displays tooltip on click', async () => {
document.execCommand = jest.fn();
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
,
diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.test.js b/packages/core/src/components/DismissableBanner/DismissableBanner.test.js
index 485b6226d2..8981acf8af 100644
--- a/packages/core/src/components/DismissableBanner/DismissableBanner.test.js
+++ b/packages/core/src/components/DismissableBanner/DismissableBanner.test.js
@@ -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('', () => {
*/
const rendered = await renderWithEffects(
- wrapInThemedTestApp(
+ wrapInTestApp(
({
marginTop: -theme.spacing(3),
display: 'flex',
flexFlow: 'row nowrap',
+ zIndex: 'unset',
},
icon: {
fontSize: 20,
diff --git a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx
index a3b40d5b88..38e422dbcc 100644
--- a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx
+++ b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx
@@ -141,9 +141,9 @@ export const FeatureCalloutCircular: FC = ({
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update);
};
- }, []);
+ }, [update]);
- useLayoutEffect(update, [wrapperRef.current]);
+ useLayoutEffect(update, [wrapperRef.current, update]);
if (!show) {
return <>{children}>;
diff --git a/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts b/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts
index 6031a25f1c..d5fd2c23c9 100644
--- a/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts
+++ b/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts
@@ -51,27 +51,30 @@ function addRootElement(rootElem: Element): void {
export function usePortal(id: string): HTMLElement {
const rootElemRef = useRef(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:
diff --git a/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts b/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts
index 047473a7e2..0bbcf3b8ec 100644
--- a/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts
+++ b/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts
@@ -45,7 +45,7 @@ function useCalloutHasBeenSeen(
const markSeen = useCallback(() => {
setState(featureId, true);
- }, [featureId]);
+ }, [setState, featureId]);
return { seen: states[featureId] === true, markSeen };
}
diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
index d7f1e2387a..25da497c4c 100644
--- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
+++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
@@ -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('', () => {
it('renders without exploding', () => {
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
item1
item2
@@ -69,7 +69,7 @@ describe('', () => {
};
const rendered = await renderWithEffects(
- wrapInThemedTestApp(
+ wrapInTestApp(
item1
diff --git a/packages/core/src/components/Lifecycle/Lifecycle.test.jsx b/packages/core/src/components/Lifecycle/Lifecycle.test.jsx
index db0d2736ed..ac3823d85c 100644
--- a/packages/core/src/components/Lifecycle/Lifecycle.test.jsx
+++ b/packages/core/src/components/Lifecycle/Lifecycle.test.jsx
@@ -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('', () => {
it('renders Alpha with shorthand', async () => {
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText('α')).toBeInTheDocument();
});
it('renders Alpha without shorthand', async () => {
- const { getByText } = render(wrapInThemedTestApp());
+ const { getByText } = render(wrapInTestApp());
expect(getByText('Alpha')).toBeInTheDocument();
});
it('renders Beta with shorthand', async () => {
- const { getByText } = render(wrapInThemedTestApp());
+ const { getByText } = render(wrapInTestApp());
expect(getByText('β')).toBeInTheDocument();
});
it('renders Beta without shorthand', async () => {
- const { getByText } = render(wrapInThemedTestApp());
+ const { getByText } = render(wrapInTestApp());
expect(getByText('Beta')).toBeInTheDocument();
});
});
diff --git a/packages/core/src/components/ProgressBars/CircleProgress.test.jsx b/packages/core/src/components/ProgressBars/CircleProgress.test.jsx
index 4975e00cb8..b42559b7b7 100644
--- a/packages/core/src/components/ProgressBars/CircleProgress.test.jsx
+++ b/packages/core/src/components/ProgressBars/CircleProgress.test.jsx
@@ -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('', () => {
it('renders without exploding', () => {
const { getByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
getByText('10%');
});
it('handles fractional prop', () => {
const { getByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
getByText('10%');
});
it('handles max prop', () => {
const { getByText } = render(
- wrapInThemedTestApp(
- ,
- ),
+ wrapInTestApp(),
);
getByText('1%');
});
it('handles unit prop', () => {
const { getByText } = render(
- wrapInThemedTestApp(
- ,
- ),
+ wrapInTestApp(),
);
getByText('10m');
});
diff --git a/packages/core/src/components/ProgressBars/HorizontalProgress.tsx b/packages/core/src/components/ProgressBars/HorizontalProgress.tsx
index 7575c5a9b2..d7f813f55f 100644
--- a/packages/core/src/components/ProgressBars/HorizontalProgress.tsx
+++ b/packages/core/src/components/ProgressBars/HorizontalProgress.tsx
@@ -29,6 +29,7 @@ type Props = {
};
const HorizontalProgress: FC = ({ value }) => {
+ const theme = useTheme();
if (isNaN(value)) {
return null;
}
@@ -36,7 +37,6 @@ const HorizontalProgress: FC = ({ value }) => {
if (percent > 100) {
percent = 100;
}
- const theme = useTheme();
const strokeColor = getProgressColor(theme.palette, percent, false, 100);
return (
diff --git a/packages/core/src/components/ProgressBars/ProgressCard.test.jsx b/packages/core/src/components/ProgressBars/ProgressCard.test.jsx
index 7357cab812..3e93e3f302 100644
--- a/packages/core/src/components/ProgressBars/ProgressCard.test.jsx
+++ b/packages/core/src/components/ProgressBars/ProgressCard.test.jsx
@@ -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('', () => {
it('renders without exploding', () => {
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText(/Tingle.*/)).toBeInTheDocument();
});
it('renders progress and title', () => {
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText(/Tingle.*/)).toBeInTheDocument();
expect(getByText(/12%.*/)).toBeInTheDocument();
});
it('does not render deepLink', () => {
const { queryByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
expect(queryByText('View more')).not.toBeInTheDocument();
});
it('handles invalid numbers', () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' };
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText(/N\/A.*/)).toBeInTheDocument();
});
});
diff --git a/packages/core/src/components/TrendLine/TrendLine.test.tsx b/packages/core/src/components/TrendLine/TrendLine.test.tsx
index 985e5d2b31..84e8413f01 100644
--- a/packages/core/src/components/TrendLine/TrendLine.test.tsx
+++ b/packages/core/src/components/TrendLine/TrendLine.test.tsx
@@ -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(),
+ wrapInTestApp(),
);
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(),
+ wrapInTestApp(),
);
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(),
+ wrapInTestApp(),
);
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(),
+ wrapInTestApp(),
);
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(),
+ wrapInTestApp(),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
index 4094c65a1f..c4d836cfe1 100644
--- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
+++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
@@ -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('', () => {
it('renders without exploding', () => {
- const { getByText } = render(
- wrapInThemedTestApp(),
- );
+ const { getByText } = render(wrapInTestApp());
expect(getByText('Mock title')).toBeInTheDocument();
});
it('renders message and children', () => {
const { getByText } = render(
- wrapInThemedTestApp(children),
+ wrapInTestApp(children),
);
expect(getByText('Some more info')).toBeInTheDocument();
expect(getByText('children')).toBeInTheDocument();
diff --git a/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx b/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx
index 2f40511f35..5db676dcc5 100644
--- a/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx
+++ b/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx
@@ -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('', () => {
it('should render with title', () => {
- const rendered = render(
- wrapInThemedTestApp(),
- );
+ const rendered = render(wrapInTestApp());
rendered.getByText('Title');
});
@@ -37,14 +35,14 @@ describe('', () => {
const title = 'Custom title';
const titleComponent = () => {title}
;
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
rendered.getByText(title);
});
it('should render with description', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
rendered.getByText('description');
});
diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx
index d383c3fbf0..c47ff8d49e 100644
--- a/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx
+++ b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx
@@ -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('', () => {
it('should render with status code, status message and go back link', () => {
const rendered = render(
- wrapInThemedTestApp(
- ,
- ),
+ wrapInTestApp(),
);
rendered.getByText(/page not found/i);
rendered.getByText(/404/i);
diff --git a/packages/core/src/layout/Header/Header.test.tsx b/packages/core/src/layout/Header/Header.test.tsx
index c1f9be6cf6..5d28c6633f 100644
--- a/packages/core/src/layout/Header/Header.test.tsx
+++ b/packages/core/src/layout/Header/Header.test.tsx
@@ -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('', () => {
it('should render with title', () => {
- const rendered = render(wrapInThemedTestApp());
+ const rendered = render(wrapInTestApp());
rendered.getByText('Title');
});
it('should set document title', () => {
- const rendered = render(wrapInThemedTestApp());
+ const rendered = render(wrapInTestApp());
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title1 | Backstage');
});
it('should override document title', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title2 | Backstage');
@@ -47,14 +47,14 @@ describe('', () => {
it('should have subtitle', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
rendered.getByText('Subtitle');
});
it('should have type rendered', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
rendered.getByText('tool');
});
diff --git a/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx b/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx
index a1b5740f88..00fa4d27ac 100644
--- a/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx
+++ b/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx
@@ -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('', () => {
it('renders without any items and without exploding', () => {
- render(wrapInThemedTestApp());
+ render(wrapInTestApp());
});
it('can open the menu and click menu items', () => {
const onClickFunction = jest.fn();
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
,
@@ -49,7 +49,7 @@ describe('', () => {
it('Disabled', async () => {
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
,
@@ -66,7 +66,7 @@ describe('', () => {
it('Test wrapper, and secondary label', () => {
const onClickFunction = jest.fn();
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
', () => {
it('should close when hitting escape', async () => {
const rendered = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
,
),
);
diff --git a/packages/core/src/layout/HeaderLabel/HeaderLabel.test.tsx b/packages/core/src/layout/HeaderLabel/HeaderLabel.test.tsx
index 11a22f9b5d..fdc6ef8c6e 100644
--- a/packages/core/src/layout/HeaderLabel/HeaderLabel.test.tsx
+++ b/packages/core/src/layout/HeaderLabel/HeaderLabel.test.tsx
@@ -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('', () => {
it('should have a label', () => {
- const rendered = render(wrapInThemedTestApp());
+ const rendered = render(wrapInTestApp());
expect(rendered.getByText('Label')).toBeInTheDocument();
});
it('should say unknown', () => {
- const rendered = render(wrapInThemedTestApp());
+ const rendered = render(wrapInTestApp());
expect(rendered.getByText('')).toBeInTheDocument();
});
it('should say unknown when passing null as value prop', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
expect(rendered.getByText('')).toBeInTheDocument();
});
it('should have value', () => {
const rendered = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
expect(rendered.getByText('Value')).toBeInTheDocument();
});
it('should have a link', () => {
const rendered = render(
- wrapInThemedTestApp(
- ,
- ),
+ wrapInTestApp(),
);
const anchor = rendered.container.querySelector('a') as HTMLAnchorElement;
expect(rendered.getByText('Value')).toBeInTheDocument();
diff --git a/packages/core/src/layout/LoginPage/LoginPage.tsx b/packages/core/src/layout/LoginPage/LoginPage.tsx
deleted file mode 100644
index 7bf14dba01..0000000000
--- a/packages/core/src/layout/LoginPage/LoginPage.tsx
+++ /dev/null
@@ -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 (
-
- {`Welcome, ${obj.name}!`}
-
- Logout
-
- );
- }
- return (
-
- Welcome, guest!
-
- );
- };
-
- return (
-
-
-
-
-
-
-
-
- GitHub
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts
index c9dbae9ca2..e8341e1124 100644
--- a/packages/core/src/layout/index.ts
+++ b/packages/core/src/layout/index.ts
@@ -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';
diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json
index 8c4b960ebb..fc77298371 100644
--- a/packages/dev-utils/package.json
+++ b/packages/dev-utils/package.json
@@ -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}"
]
diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts
index 162375cf1c..967918d87a 100644
--- a/packages/dev-utils/src/devApp/apiFactories.ts
+++ b/packages/dev-utils/src/devApp/apiFactories.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(),
+});
diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx
index 72c9581a84..b174a0447f 100644
--- a/packages/dev-utils/src/devApp/render.tsx
+++ b/packages/dev-utils/src/devApp/render.tsx
@@ -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;
class DevAppBuilder {
private readonly plugins = new Array();
private readonly factories = new Array>();
+ private readonly rootChildren = new Array();
/**
* 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 (
+
+ {this.rootChildren}
{sidebar}
diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json
index ad4bbc95c9..f629877a65 100644
--- a/packages/test-utils-core/package.json
+++ b/packages/test-utils-core/package.json
@@ -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}"
]
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index fd893f8c8c..1f7afc9bbb 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -29,19 +29,23 @@
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
+ "@backstage/core-api": "^0.1.1-alpha.6",
"@backstage/test-utils-core": "^0.1.1-alpha.6",
"@backstage/theme": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@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-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}"
]
diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx
index e2f1b6b7cb..e46b6a95ce 100644
--- a/packages/test-utils/src/testUtils/appWrappers.test.tsx
+++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx
@@ -27,7 +27,7 @@ describe('wrapInTestApp', () => {
Route 1
Route 2
>,
- ['/route2'],
+ { routeEntries: ['/route2'] },
),
);
expect(rendered.getByText('Route 2')).toBeInTheDocument();
diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx
index 06d8781669..2450604976 100644
--- a/packages/test-utils/src/testUtils/appWrappers.tsx
+++ b/packages/test-utils/src/testUtils/appWrappers.tsx
@@ -14,16 +14,60 @@
* limitations under the License.
*/
-import React, { ComponentType, ReactNode, FunctionComponent } from 'react';
-import { ThemeProvider } from '@material-ui/core';
+import React, { ComponentType, ReactNode, FunctionComponent, FC } from 'react';
import { MemoryRouter } from 'react-router';
import { Route } from 'react-router-dom';
import { lightTheme } from '@backstage/theme';
+import privateExports, {
+ defaultSystemIcons,
+ ApiTestRegistry,
+ BootErrorPageProps,
+} from '@backstage/core-api';
+const { PrivateAppImpl } = privateExports;
+
+const NotFoundErrorPage = () => {
+ throw new Error('Reached NotFound Page');
+};
+const BootErrorPage: FC = ({ step, error }) => {
+ throw new Error(`Reached BootError Page at step ${step} with error ${error}`);
+};
+const Progress = () => ;
+
+/**
+ * Options to customize the behavior of the test app wrapper.
+ */
+type TestAppOptions = {
+ /**
+ * Initial route entries to pass along as `initialEntries` to the router.
+ */
+ routeEntries?: string[];
+};
export function wrapInTestApp(
Component: ComponentType | ReactNode,
- initialRouterEntries: string[] = ['/'],
+ options: TestAppOptions = {},
) {
+ const { routeEntries = ['/'] } = options;
+
+ const app = new PrivateAppImpl({
+ apis: new ApiTestRegistry(),
+ components: {
+ NotFoundErrorPage,
+ BootErrorPage,
+ Progress,
+ },
+ icons: defaultSystemIcons,
+ plugins: [],
+ themes: [
+ {
+ id: 'light',
+ theme: lightTheme,
+ title: 'Test App Theme',
+ variant: 'light',
+ },
+ ],
+ });
+
let Wrapper: ComponentType;
if (Component instanceof Function) {
Wrapper = Component;
@@ -31,21 +75,13 @@ export function wrapInTestApp(
Wrapper = (() => Component) as FunctionComponent;
}
+ const AppProvider = app.getProvider();
+
return (
-
-
-
+
+
+
+
+
);
}
-
-export function wrapInThemedTestApp(
- component: ReactNode,
- initialRouterEntries: string[] = ['/'],
-) {
- const themed = {component};
- return wrapInTestApp(themed, initialRouterEntries);
-}
-
-export const wrapInTheme = (component: ReactNode, theme = lightTheme) => (
- {component}
-);
diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md
index 46bfef8b3c..ae17a556c9 100644
--- a/plugins/auth-backend/README.md
+++ b/plugins/auth-backend/README.md
@@ -19,6 +19,14 @@ read -r AUTH_GOOGLE_CLIENT_SECRET
export AUTH_GOOGLE_CLIENT_SECRET
run `yarn start` in packages/backend folder
+### SAML
+
+To try out SAML, you can use the mock identity provider:
+
+```bash
+./scripts/start-saml-idp.sh
+```
+
## Links
- (The Backstage homepage)[https://backstage.io]
diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json
index fe82e544e7..3e902304db 100644
--- a/plugins/auth-backend/package.json
+++ b/plugins/auth-backend/package.json
@@ -16,28 +16,30 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
+ "@types/cookie-parser": "^1.4.2",
+ "@types/passport": "^1.0.3",
+ "@types/passport-github2": "^1.2.4",
+ "@types/passport-google-oauth20": "^2.0.3",
+ "body-parser": "^1.19.0",
"compression": "^1.7.4",
+ "cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"morgan": "^1.10.0",
- "winston": "^3.2.1",
- "yn": "^4.0.0",
"passport": "^0.4.1",
+ "passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
- "passport-oauth2-refresh": "^2.0.0",
- "passport-oauth2": "^1.5.0",
- "cookie-parser": "^1.4.5",
- "@types/passport-oauth2-refresh": "^1.1.1",
- "@types/passport": "^1.0.3",
- "@types/passport-google-oauth20": "^2.0.3",
- "@types/cookie-parser": "^1.4.2",
- "@types/passport-oauth2": "^1.4.9"
+ "passport-saml": "^1.3.3",
+ "winston": "^3.2.1",
+ "yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
+ "@types/body-parser": "^1.19.0",
+ "@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
diff --git a/plugins/auth-backend/scripts/.gitignore b/plugins/auth-backend/scripts/.gitignore
new file mode 100644
index 0000000000..cfaad76118
--- /dev/null
+++ b/plugins/auth-backend/scripts/.gitignore
@@ -0,0 +1 @@
+*.pem
diff --git a/plugins/auth-backend/scripts/start-saml-idp.sh b/plugins/auth-backend/scripts/start-saml-idp.sh
new file mode 100755
index 0000000000..33217f7978
--- /dev/null
+++ b/plugins/auth-backend/scripts/start-saml-idp.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+
+cd "$DIR"
+
+if [[ ! -f idp-public-cert.pem ]]; then
+ echo "Generating new SAML Certificates"
+ openssl req \
+ -x509 \
+ -newkey rsa:1024 \
+ -days 3650 \
+ -nodes \
+ -subj '/CN=localhost' \
+ -keyout "idp-private-key.pem" \
+ -out "idp-public-cert.pem"
+fi
+
+echo "Downloading and starting SAML-IdP"
+export NPM_CONFIG_REGISTRY=https://registry.npmjs.org
+exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001
diff --git a/plugins/auth-backend/src/providers/OAuthProvider.ts b/plugins/auth-backend/src/providers/OAuthProvider.ts
new file mode 100644
index 0000000000..a2be783155
--- /dev/null
+++ b/plugins/auth-backend/src/providers/OAuthProvider.ts
@@ -0,0 +1,244 @@
+/*
+ * 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 express, { CookieOptions } from 'express';
+import crypto from 'crypto';
+import {
+ AuthResponse,
+ AuthProviderRouteHandlers,
+ OAuthProviderHandlers,
+} from './types';
+import { InputError } from '@backstage/backend-common';
+
+export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
+export const TEN_MINUTES_MS = 600 * 1000;
+
+export const verifyNonce = (req: express.Request, provider: string) => {
+ const cookieNonce = req.cookies[`${provider}-nonce`];
+ const stateNonce = req.query.state;
+
+ if (!cookieNonce || !stateNonce) {
+ throw new Error('Missing nonce');
+ }
+
+ if (cookieNonce !== stateNonce) {
+ throw new Error('Invalid nonce');
+ }
+};
+
+export const setNonceCookie = (res: express.Response, provider: string) => {
+ const nonce = crypto.randomBytes(16).toString('base64');
+
+ const options: CookieOptions = {
+ maxAge: TEN_MINUTES_MS,
+ secure: false,
+ sameSite: 'none',
+ domain: 'localhost',
+ path: `/auth/${provider}/handler`,
+ httpOnly: true,
+ };
+
+ res.cookie(`${provider}-nonce`, nonce, options);
+
+ return nonce;
+};
+
+export const setRefreshTokenCookie = (
+ res: express.Response,
+ provider: string,
+ refreshToken: string,
+) => {
+ const options: CookieOptions = {
+ maxAge: THOUSAND_DAYS_MS,
+ secure: false,
+ sameSite: 'none',
+ domain: 'localhost',
+ path: `/auth/${provider}`,
+ httpOnly: true,
+ };
+
+ res.cookie(`${provider}-refresh-token`, refreshToken, options);
+};
+
+export const removeRefreshTokenCookie = (
+ res: express.Response,
+ provider: string,
+) => {
+ const options: CookieOptions = {
+ maxAge: 0,
+ secure: false,
+ sameSite: 'none',
+ domain: 'localhost',
+ path: `/auth/${provider}`,
+ httpOnly: true,
+ };
+
+ res.cookie(`${provider}-refresh-token`, '', options);
+};
+
+export const postMessageResponse = (
+ res: express.Response,
+ data: AuthResponse,
+) => {
+ const jsonData = JSON.stringify(data);
+ const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
+
+ res.setHeader('Content-Type', 'text/html');
+ res.setHeader('X-Frame-Options', 'sameorigin');
+
+ // TODO: Make target app origin configurable globally
+ res.end(`
+
+
+
+
+
+ `);
+};
+
+export const ensuresXRequestedWith = (req: express.Request) => {
+ const requiredHeader = req.header('X-Requested-With');
+
+ if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
+ return false;
+ }
+ return true;
+};
+
+export class OAuthProvider implements AuthProviderRouteHandlers {
+ private readonly provider: string;
+ private readonly providerHandlers: OAuthProviderHandlers;
+ private readonly disableRefresh: boolean;
+ constructor(
+ providerHandlers: OAuthProviderHandlers,
+ provider: string,
+ disableRefresh?: boolean,
+ ) {
+ this.provider = provider;
+ this.providerHandlers = providerHandlers;
+ this.disableRefresh = disableRefresh ?? false;
+ }
+
+ async start(req: express.Request, res: express.Response): Promise {
+ // retrieve scopes from request
+ const scope = req.query.scope?.toString() ?? '';
+
+ if (!scope) {
+ throw new InputError('missing scope parameter');
+ }
+
+ // set a nonce cookie before redirecting to oauth provider
+ const nonce = setNonceCookie(res, this.provider);
+
+ const options = {
+ scope,
+ accessType: 'offline',
+ prompt: 'consent',
+ state: nonce,
+ };
+ const { url, status } = await this.providerHandlers.start(req, options);
+
+ res.statusCode = status || 302;
+ res.setHeader('Location', url);
+ res.setHeader('Content-Length', '0');
+ res.end();
+ }
+
+ async frameHandler(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ try {
+ // verify nonce cookie and state cookie on callback
+ verifyNonce(req, this.provider);
+
+ const { user, info } = await this.providerHandlers.handler(req);
+
+ if (!this.disableRefresh) {
+ // throw error if missing refresh token
+ const { refreshToken } = info;
+ if (!refreshToken) {
+ throw new Error('Missing refresh token');
+ }
+
+ // set new refresh token
+ setRefreshTokenCookie(res, this.provider, refreshToken);
+ }
+
+ // post message back to popup if successful
+ return postMessageResponse(res, {
+ type: 'auth-result',
+ payload: user,
+ });
+ } catch (error) {
+ // post error message back to popup if failure
+ return postMessageResponse(res, {
+ type: 'auth-result',
+ error: {
+ name: error.name,
+ message: error.message,
+ },
+ });
+ }
+ }
+
+ async logout(req: express.Request, res: express.Response): Promise {
+ if (!ensuresXRequestedWith(req)) {
+ return res.status(401).send('Invalid X-Requested-With header');
+ }
+
+ if (!this.disableRefresh) {
+ // remove refresh token cookie before logout
+ removeRefreshTokenCookie(res, this.provider);
+ }
+ return res.send('logout!');
+ }
+
+ async refresh(req: express.Request, res: express.Response): Promise {
+ if (!ensuresXRequestedWith(req)) {
+ return res.status(401).send('Invalid X-Requested-With header');
+ }
+
+ if (!this.providerHandlers.refresh || this.disableRefresh) {
+ return res.send(
+ `Refresh token not supported for provider: ${this.provider}`,
+ );
+ }
+
+ try {
+ const refreshToken = req.cookies[`${this.provider}-refresh-token`];
+
+ // throw error if refresh token is missing in the request
+ if (!refreshToken) {
+ throw new Error('Missing session cookie');
+ }
+
+ const scope = req.query.scope?.toString() ?? '';
+
+ // get new access_token
+ const refreshInfo = await this.providerHandlers.refresh(
+ refreshToken,
+ scope,
+ );
+ return res.send(refreshInfo);
+ } catch (error) {
+ return res.status(401).send(`${error.message}`);
+ }
+ }
+}
diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts
new file mode 100644
index 0000000000..e977ee877d
--- /dev/null
+++ b/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts
@@ -0,0 +1,214 @@
+/*
+ * 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 express from 'express';
+import passport from 'passport';
+import {
+ executeRedirectStrategy,
+ executeFrameHandlerStrategy,
+ executeRefreshTokenStrategy,
+} from './PassportStrategyHelper';
+
+const mockRequest = ({} as unknown) as express.Request;
+
+describe('PassportStrategyHelper', () => {
+ class MyCustomRedirectStrategy extends passport.Strategy {
+ authenticate() {
+ this.redirect('a', 302);
+ }
+ }
+
+ describe('executeRedirectStrategy', () => {
+ it('should call authenticate and resolve with RedirectInfo', async () => {
+ const mockStrategy = new MyCustomRedirectStrategy();
+ const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
+ const redirectStrategyPromise = executeRedirectStrategy(
+ mockRequest,
+ mockStrategy,
+ {},
+ );
+ expect(spyAuthenticate).toBeCalledTimes(1);
+ await expect(redirectStrategyPromise).resolves.toStrictEqual(
+ expect.objectContaining({ url: 'a', status: 302 }),
+ );
+ });
+ });
+
+ describe('executeFrameHandlerStrategy', () => {
+ class MyCustomAuthSuccessStrategy extends passport.Strategy {
+ authenticate() {
+ this.success(
+ { accessToken: 'ACCESS_TOKEN' },
+ { refreshToken: 'REFRESH_TOKEN' },
+ );
+ }
+ }
+ class MyCustomAuthErrorStrategy extends passport.Strategy {
+ authenticate() {
+ this.error(new Error('MyCustomAuth error'));
+ }
+ }
+ class MyCustomAuthRedirectStrategy extends passport.Strategy {
+ authenticate() {
+ this.redirect('URL', 302);
+ }
+ }
+ class MyCustomAuthFailStrategy extends passport.Strategy {
+ authenticate() {
+ this.fail('challenge', 302);
+ }
+ }
+
+ it('should resolve with user and info on success', async () => {
+ const mockStrategy = new MyCustomAuthSuccessStrategy();
+ const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
+ const frameHandlerStrategyPromise = executeFrameHandlerStrategy(
+ mockRequest,
+ mockStrategy,
+ );
+ expect(spyAuthenticate).toBeCalledTimes(1);
+ await expect(frameHandlerStrategyPromise).resolves.toStrictEqual(
+ expect.objectContaining({
+ user: { accessToken: 'ACCESS_TOKEN' },
+ info: { refreshToken: 'REFRESH_TOKEN' },
+ }),
+ );
+ });
+
+ it('should reject on error', async () => {
+ const mockStrategy = new MyCustomAuthErrorStrategy();
+ const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
+ const frameHandlerStrategyPromise = executeFrameHandlerStrategy(
+ mockRequest,
+ mockStrategy,
+ );
+ expect(spyAuthenticate).toBeCalledTimes(1);
+ await expect(frameHandlerStrategyPromise).rejects.toThrow(
+ 'Authentication failed, Error: MyCustomAuth error',
+ );
+ });
+
+ it('should reject on redirect', async () => {
+ const mockStrategy = new MyCustomAuthRedirectStrategy();
+ const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
+ const frameHandlerStrategyPromise = executeFrameHandlerStrategy(
+ mockRequest,
+ mockStrategy,
+ );
+ expect(spyAuthenticate).toBeCalledTimes(1);
+ await expect(frameHandlerStrategyPromise).rejects.toThrow(
+ 'Unexpected redirect',
+ );
+ });
+
+ it('should reject on fail', async () => {
+ const mockStrategy = new MyCustomAuthFailStrategy();
+ const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
+ const frameHandlerStrategyPromise = executeFrameHandlerStrategy(
+ mockRequest,
+ mockStrategy,
+ );
+ expect(spyAuthenticate).toBeCalledTimes(1);
+ await expect(frameHandlerStrategyPromise).rejects.toThrow();
+ });
+ });
+
+ describe('executeRefreshTokenStrategy', () => {
+ it('should resolve with a new access token, scope and expiry', async () => {
+ class MyCustomOAuth2Success {
+ getOAuthAccessToken(
+ _refreshToken: string,
+ _options: any,
+ callback: Function,
+ ) {
+ callback(null, 'ACCESS_TOKEN', 'REFRESH_TOKEN', {
+ scope: 'a',
+ expires_in: 10,
+ });
+ }
+ }
+ class MyCustomRefreshTokenSuccess extends passport.Strategy {
+ // @ts-ignore
+ private _oauth2 = new MyCustomOAuth2Success();
+ }
+
+ const mockStrategy = new MyCustomRefreshTokenSuccess();
+ const refreshTokenPromise = executeRefreshTokenStrategy(
+ mockStrategy,
+ 'REFRESH_TOKEN',
+ 'a',
+ );
+ await expect(refreshTokenPromise).resolves.toStrictEqual(
+ expect.objectContaining({
+ accessToken: 'ACCESS_TOKEN',
+ params: expect.objectContaining({ scope: 'a', expires_in: 10 }),
+ }),
+ );
+ });
+
+ it('should reject with an error if refresh failed', async () => {
+ class MyCustomOAuth2Error {
+ getOAuthAccessToken(
+ _refreshToken: string,
+ _options: any,
+ callback: Function,
+ ) {
+ callback(new Error('Unknown error'));
+ }
+ }
+ class MyCustomRefreshTokenSuccess extends passport.Strategy {
+ // @ts-ignore
+ private _oauth2 = new MyCustomOAuth2Error();
+ }
+
+ const mockStrategy = new MyCustomRefreshTokenSuccess();
+ const refreshTokenPromise = executeRefreshTokenStrategy(
+ mockStrategy,
+ 'REFRESH_TOKEN',
+ 'a',
+ );
+ await expect(refreshTokenPromise).rejects.toThrow(
+ 'Failed to refresh access token Error: Unknown error',
+ );
+ });
+
+ it('should reject with an error if access token missing in refresh callback', async () => {
+ class MyCustomOAuth2AccessTokenMissing {
+ getOAuthAccessToken(
+ _refreshToken: string,
+ _options: any,
+ callback: Function,
+ ) {
+ callback(null, '');
+ }
+ }
+ class MyCustomRefreshTokenSuccess extends passport.Strategy {
+ // @ts-ignore
+ private _oauth2 = new MyCustomOAuth2AccessTokenMissing();
+ }
+
+ const mockStrategy = new MyCustomRefreshTokenSuccess();
+ const refreshTokenPromise = executeRefreshTokenStrategy(
+ mockStrategy,
+ 'REFRESH_TOKEN',
+ 'a',
+ );
+ await expect(refreshTokenPromise).rejects.toThrow(
+ 'Failed to refresh access token, no access token received',
+ );
+ });
+ });
+});
diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts b/plugins/auth-backend/src/providers/PassportStrategyHelper.ts
new file mode 100644
index 0000000000..5c02930c2c
--- /dev/null
+++ b/plugins/auth-backend/src/providers/PassportStrategyHelper.ts
@@ -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 express from 'express';
+import passport from 'passport';
+import { RedirectInfo, RefreshTokenResponse } from './types';
+
+export const executeRedirectStrategy = async (
+ req: express.Request,
+ providerStrategy: passport.Strategy,
+ options: any,
+): Promise => {
+ return new Promise(resolve => {
+ const strategy = Object.create(providerStrategy);
+ strategy.redirect = (url: string, status?: number) => {
+ resolve({ url, status: status ?? undefined });
+ };
+
+ strategy.authenticate(req, { ...options });
+ });
+};
+
+export const executeFrameHandlerStrategy = async (
+ req: express.Request,
+ providerStrategy: passport.Strategy,
+) => {
+ return new Promise<{ user: any; info: any }>((resolve, reject) => {
+ const strategy = Object.create(providerStrategy);
+ strategy.success = (user: any, info: any) => {
+ resolve({ user, info });
+ };
+ strategy.fail = (
+ info: { type: 'success' | 'error'; message?: string },
+ // _status: number,
+ ) => {
+ reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
+ };
+ strategy.error = (error: Error) => {
+ reject(new Error(`Authentication failed, ${error}`));
+ };
+ strategy.redirect = () => {
+ reject(new Error('Unexpected redirect'));
+ };
+
+ strategy.authenticate(req, {});
+ });
+};
+
+export const executeRefreshTokenStrategy = async (
+ providerstrategy: passport.Strategy,
+ refreshToken: string,
+ scope: string,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ const anyStrategy = providerstrategy as any;
+ const OAuth2 = anyStrategy._oauth2.constructor;
+ const oauth2 = new OAuth2(
+ anyStrategy._oauth2._clientId,
+ anyStrategy._oauth2._clientSecret,
+ anyStrategy._oauth2._baseSite,
+ anyStrategy._oauth2._authorizeUrl,
+ anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
+ anyStrategy._oauth2._customHeaders,
+ );
+
+ oauth2.getOAuthAccessToken(
+ refreshToken,
+ {
+ scope,
+ grant_type: 'refresh_token',
+ },
+ (
+ err: Error | null,
+ accessToken: string,
+ _refreshToken: string,
+ params: any,
+ ) => {
+ if (err) {
+ reject(new Error(`Failed to refresh access token ${err}`));
+ }
+ if (!accessToken) {
+ reject(
+ new Error(
+ `Failed to refresh access token, no access token received`,
+ ),
+ );
+ }
+ resolve({
+ accessToken,
+ params,
+ });
+ },
+ );
+ });
+};
diff --git a/plugins/auth-backend/src/providers/config.ts b/plugins/auth-backend/src/providers/config.ts
index 45098afb89..5ec73b7827 100644
--- a/plugins/auth-backend/src/providers/config.ts
+++ b/plugins/auth-backend/src/providers/config.ts
@@ -23,4 +23,21 @@ export const providers = [
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
},
},
+ {
+ provider: 'github',
+ options: {
+ clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
+ clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
+ callbackURL: 'http://localhost:7000/auth/github/handler/frame',
+ },
+ disableRefresh: true,
+ },
+ {
+ provider: 'saml',
+ options: {
+ path: '/auth/saml/handler/frame',
+ entryPoint: 'http://localhost:7001/',
+ issuer: 'passport-saml',
+ },
+ },
];
diff --git a/plugins/auth-backend/src/providers/factories.test.ts b/plugins/auth-backend/src/providers/factories.test.ts
deleted file mode 100644
index 1647f62682..0000000000
--- a/plugins/auth-backend/src/providers/factories.test.ts
+++ /dev/null
@@ -1,51 +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 express from 'express';
-import passport from 'passport';
-import { AuthProvider, AuthProviderRouteHandlers } from './types';
-import { ProviderFactories } from './factories';
-
-class MyAuthProvider implements AuthProvider, AuthProviderRouteHandlers {
- strategy(): passport.Strategy {
- return new passport.Strategy();
- }
- async start(_: express.Request, res: express.Response): Promise {
- res.send('start');
- }
- async frameHandler(_: express.Request, res: express.Response): Promise {
- res.send('frameHandler');
- }
- async logout(_: express.Request, res: express.Response): Promise {
- res.send('logout');
- }
-}
-
-describe('getProviderFactory', () => {
- it('makes a provider for MyAuthProvider', () => {
- jest
- .spyOn(ProviderFactories, 'getProviderFactory')
- .mockReturnValueOnce(MyAuthProvider);
- const provider = ProviderFactories.getProviderFactory('a');
- expect(provider).toBeDefined();
- });
-
- it('throws an error when provider implementation does not exist', () => {
- expect(() => {
- ProviderFactories.getProviderFactory('b');
- }).toThrow('Provider Implementation missing for : b auth provider');
- });
-});
diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts
index 077d45076e..f7560174ef 100644
--- a/plugins/auth-backend/src/providers/factories.ts
+++ b/plugins/auth-backend/src/providers/factories.ts
@@ -14,21 +14,37 @@
* limitations under the License.
*/
-import { AuthProviderFactories, AuthProviderFactory } from './types';
-import { GoogleAuthProvider } from './google/provider';
+import Router from 'express-promise-router';
+import { createGithubProvider } from './github';
+import { createGoogleProvider } from './google';
+import { createSamlProvider } from './saml';
+import { AuthProviderFactory, AuthProviderConfig } from './types';
-export class ProviderFactories {
- private static readonly providerFactories: AuthProviderFactories = {
- google: GoogleAuthProvider,
- };
+const factories: { [providerId: string]: AuthProviderFactory } = {
+ google: createGoogleProvider,
+ github: createGithubProvider,
+ saml: createSamlProvider,
+};
- public static getProviderFactory(providerId: string): AuthProviderFactory {
- const ProviderImpl = ProviderFactories.providerFactories[providerId];
- if (!ProviderImpl) {
- throw Error(
- `Provider Implementation missing for : ${providerId} auth provider`,
- );
- }
- return ProviderImpl;
+export function createAuthProvider(providerId: string, config: any) {
+ const factory = factories[providerId];
+ if (!factory) {
+ throw Error(`No auth provider available for '${providerId}'`);
}
+ return factory(config);
}
+
+export const createAuthProviderRouter = (config: AuthProviderConfig) => {
+ const providerId = config.provider;
+ const provider = createAuthProvider(providerId, config);
+
+ const router = Router();
+ router.get('/start', provider.start.bind(provider));
+ router.get('/handler/frame', provider.frameHandler.bind(provider));
+ router.post('/handler/frame', provider.frameHandler.bind(provider));
+ router.get('/logout', provider.logout.bind(provider));
+ if (provider.refresh) {
+ router.get('/refresh', provider.refresh.bind(provider));
+ }
+ return router;
+};
diff --git a/plugins/auth-backend/src/providers/github/index.ts b/plugins/auth-backend/src/providers/github/index.ts
new file mode 100644
index 0000000000..60ad6998b7
--- /dev/null
+++ b/plugins/auth-backend/src/providers/github/index.ts
@@ -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 { createGithubProvider } from './provider';
diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts
new file mode 100644
index 0000000000..09622d9303
--- /dev/null
+++ b/plugins/auth-backend/src/providers/github/provider.ts
@@ -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 express from 'express';
+import { Strategy as GithubStrategy } from 'passport-github2';
+import {
+ executeFrameHandlerStrategy,
+ executeRedirectStrategy,
+} from '../PassportStrategyHelper';
+import {
+ OAuthProviderHandlers,
+ AuthProviderConfig,
+ RedirectInfo,
+ AuthInfoBase,
+ AuthInfoPrivate,
+} from '../types';
+import { OAuthProvider } from '../OAuthProvider';
+
+export class GithubAuthProvider implements OAuthProviderHandlers {
+ private readonly providerConfig: AuthProviderConfig;
+ private readonly _strategy: GithubStrategy;
+
+ constructor(providerConfig: AuthProviderConfig) {
+ this.providerConfig = providerConfig;
+ this._strategy = new GithubStrategy(
+ { ...this.providerConfig.options },
+ (accessToken: any, _: any, params: any, profile: any, done: any) => {
+ done(undefined, {
+ profile,
+ accessToken,
+ scope: params.scope,
+ expiresInSeconds: params.expires_in,
+ });
+ },
+ );
+ }
+
+ async start(req: express.Request, options: any): Promise {
+ return await executeRedirectStrategy(req, this._strategy, options);
+ }
+
+ async handler(
+ req: express.Request,
+ ): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
+ return await executeFrameHandlerStrategy(req, this._strategy);
+ }
+}
+
+export function createGithubProvider(config: AuthProviderConfig) {
+ const provider = new GithubAuthProvider(config);
+ const oauthProvider = new OAuthProvider(provider, config.provider, true);
+ return oauthProvider;
+}
diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts
new file mode 100644
index 0000000000..b2cd85e6de
--- /dev/null
+++ b/plugins/auth-backend/src/providers/google/index.ts
@@ -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 { createGoogleProvider } from './provider';
diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts
deleted file mode 100644
index 327bf0260b..0000000000
--- a/plugins/auth-backend/src/providers/google/provider.test.ts
+++ /dev/null
@@ -1,522 +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 {
- GoogleAuthProvider,
- THOUSAND_DAYS_MS,
- TEN_MINUTES_MS,
-} from './provider';
-import passport from 'passport';
-import express from 'express';
-import * as utils from './../utils';
-import refresh from 'passport-oauth2-refresh';
-
-const googleAuthProviderConfig = {
- provider: 'google',
- options: {
- clientID: 'a',
- clientSecret: 'b',
- callbackURL: 'c',
- },
-};
-
-const googleAuthProviderConfigInvalidOptions = {
- provider: 'google',
- options: {},
-};
-
-describe('GoogleAuthProvider', () => {
- afterEach(() => {
- jest.clearAllMocks();
- });
- describe('create a new provider', () => {
- it('should succeed with valid config', () => {
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
- expect(googleAuthProvider).toBeDefined();
- expect(googleAuthProvider.start).toBeDefined();
- expect(googleAuthProvider.logout).toBeDefined();
- expect(googleAuthProvider.frameHandler).toBeDefined();
- expect(googleAuthProvider.strategy).toBeDefined();
- });
- });
-
- describe('start authentication handler', () => {
- const mockResponse = ({
- send: jest.fn().mockReturnThis(),
- status: jest.fn().mockReturnThis(),
- cookie: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
- const mockNext: express.NextFunction = jest.fn();
-
- it('should initiate authenticate request with provided scopes', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- query: {
- scope: 'a,b',
- },
- } as unknown) as express.Request;
-
- const spyPassport = jest
- .spyOn(passport, 'authenticate')
- .mockImplementation(() => jest.fn());
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
- googleAuthProvider.start(mockRequest, mockResponse, mockNext);
- expect(spyPassport).toBeCalledTimes(1);
- expect(spyPassport).toBeCalledWith('google', {
- scope: 'a,b',
- accessType: 'offline',
- prompt: 'consent',
- state: expect.any(String),
- });
- });
-
- it('should set a nonce cookie', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- query: {
- scope: 'a,b',
- },
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
- googleAuthProvider.start(mockRequest, mockResponse, mockNext);
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'google-nonce',
- expect.any(String),
- expect.objectContaining({
- maxAge: TEN_MINUTES_MS,
- path: `/auth/${googleAuthProviderConfig.provider}/handler`,
- }),
- );
- });
-
- it('should throw error if no scopes provided', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- query: {},
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
- expect(() => {
- googleAuthProvider.start(mockRequest, mockResponse, mockNext);
- }).toThrowError('missing scope parameter');
- });
- });
-
- describe('logout handler', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- } as unknown) as express.Request;
-
- it('should perform logout and respond with 200', () => {
- const mockResponse: any = ({
- send: jest.fn(),
- cookie: jest.fn(),
- } as unknown) as express.Response;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- const spyResponse = jest
- .spyOn(mockResponse, 'send')
- .mockImplementation(() => jest.fn());
-
- googleAuthProvider.logout(mockRequest, mockResponse);
- expect(spyResponse).toBeCalledTimes(1);
- expect(spyResponse).toBeCalledWith('logout!');
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'google-refresh-token',
- '',
- expect.objectContaining({ maxAge: 0 }),
- );
- });
- });
-
- describe('redirect frame handler', () => {
- const mockResponse: any = ({
- status: jest.fn().mockReturnThis(),
- send: jest.fn().mockReturnThis(),
- cookie: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
- const mockNext: express.NextFunction = jest.fn();
-
- it('should call authenticate and post a response', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-nonce': 'NONCE' },
- query: {
- state: 'NONCE',
- },
- } as unknown) as express.Request;
-
- const spyPostMessage = jest
- .spyOn(utils, 'postMessageResponse')
- .mockImplementation(() => jest.fn());
-
- const spyPassport = jest
- .spyOn(passport, 'authenticate')
- .mockImplementation((_x, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(null, { refreshToken: 'REFRESH_TOKEN' });
- return jest.fn();
- });
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(spyPassport).toBeCalledTimes(1);
- expect(spyPostMessage).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'google-refresh-token',
- 'REFRESH_TOKEN',
- expect.objectContaining({
- path: '/auth/google',
- sameSite: 'none',
- httpOnly: true,
- maxAge: THOUSAND_DAYS_MS,
- }),
- );
- });
-
- it('should respond with a error message if no refresh token returned', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-nonce': 'NONCE' },
- query: {
- state: 'NONCE',
- },
- } as unknown) as express.Request;
-
- const spyPassport = jest
- .spyOn(passport, 'authenticate')
- .mockImplementation((_x, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(null, {});
- return jest.fn();
- });
-
- const spyPostMessage = jest
- .spyOn(utils, 'postMessageResponse')
- .mockImplementation(() => jest.fn());
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(spyPassport).toBeCalledTimes(1);
- expect(spyPostMessage).toBeCalledTimes(1);
- expect(spyPostMessage).toBeCalledWith(mockResponse, {
- type: 'auth-result',
- error: new Error('Missing refresh token'),
- });
- });
-
- it('should respond with a error message if auth failed', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-nonce': 'NONCE' },
- query: {
- state: 'NONCE',
- },
- } as unknown) as express.Request;
-
- const spyPassport = jest
- .spyOn(passport, 'authenticate')
- .mockImplementation((_x, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(new Error('TokenError'), null);
- return jest.fn();
- });
-
- const spyPostMessage = jest
- .spyOn(utils, 'postMessageResponse')
- .mockImplementation(() => jest.fn());
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(spyPassport).toBeCalledTimes(1);
- expect(spyPostMessage).toBeCalledTimes(1);
- expect(spyPostMessage).toBeCalledWith(mockResponse, {
- type: 'auth-result',
- error: new Error('Google auth failed, Error: TokenError'),
- });
- });
-
- it('should respond with a error message if cookie nonce is missing', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: {},
- query: { state: 'NONCE' },
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith('Missing nonce');
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- });
-
- it('should respond with a error message if state nonce is missing', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-nonce': 'NONCE' },
- query: {},
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith('Missing nonce');
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- });
-
- it('should respond with a error message if nonce mismatch', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-nonce': 'NONCA' },
- query: { state: 'NONCEB' },
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith('Invalid nonce');
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- });
- });
-
- describe('strategy handler', () => {
- it('should return a valid passport strategy', () => {
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- expect(googleAuthProvider.strategy()).toBeInstanceOf(passport.Strategy);
- });
-
- it('should throw an error for invalid options', () => {
- expect(() => {
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfigInvalidOptions,
- );
- googleAuthProvider.strategy();
- }).toThrow();
- });
- });
-
- describe('refresh token handler', () => {
- const mockResponse = ({
- status: jest.fn().mockReturnThis(),
- send: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
-
- describe('no refresh token cookie', () => {
- it('should respond with a 401', () => {
- const mockRequest = ({
- cookies: jest.fn(),
- header: () => 'XMLHttpRequest',
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- googleAuthProvider.refresh(mockRequest, mockResponse);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith('Missing session cookie');
-
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- });
- });
-
- describe('refresh token cookie, no scope', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
- query: {},
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- it('should request for a new access token and fail if no access token returned', () => {
- const spyRefresh = jest
- .spyOn(refresh, 'requestNewAccessToken')
- .mockImplementation((_x, _y, _z, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(undefined, undefined, undefined, {});
- });
-
- googleAuthProvider.refresh(mockRequest, mockResponse);
- expect(spyRefresh).toBeCalledTimes(1);
- expect(spyRefresh).toBeCalledWith(
- 'google',
- 'REFRESH_TOKEN',
- {},
- expect.any(Function),
- );
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith(
- 'Failed to refresh access token',
- );
- });
-
- it('should request for a new access token and return 401 if any error', () => {
- const spyRefresh = jest
- .spyOn(refresh, 'requestNewAccessToken')
- .mockImplementation((_x, _y, _z, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb({ error: 'ERROR' }, undefined, undefined, {});
- });
-
- googleAuthProvider.refresh(mockRequest, mockResponse);
- expect(spyRefresh).toBeCalledTimes(1);
- expect(spyRefresh).toBeCalledWith(
- 'google',
- 'REFRESH_TOKEN',
- {},
- expect.any(Function),
- );
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith(
- 'Failed to refresh access token',
- );
- });
-
- it('should fetch and return a new access token', () => {
- const spyRefresh = jest
- .spyOn(refresh, 'requestNewAccessToken')
- .mockImplementation((_x, _y, _z, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(undefined, 'ACCESS_TOKEN', undefined, {
- expires_in: 'EXPIRES_IN',
- id_token: 'ID_TOKEN',
- });
- });
-
- googleAuthProvider.refresh(mockRequest, mockResponse);
- expect(spyRefresh).toBeCalledTimes(1);
- expect(spyRefresh).toBeCalledWith(
- 'google',
- 'REFRESH_TOKEN',
- {},
- expect.any(Function),
- );
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith({
- accessToken: 'ACCESS_TOKEN',
- idToken: 'ID_TOKEN',
- expiresInSeconds: 'EXPIRES_IN',
- scope: undefined,
- });
- });
- });
-
- describe('refresh token cookie and scope', () => {
- const mockRequest = ({
- header: () => 'XMLHttpRequest',
- cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
- query: {
- scope: 'a,b',
- },
- } as unknown) as express.Request;
-
- const googleAuthProvider = new GoogleAuthProvider(
- googleAuthProviderConfig,
- );
-
- it('should fetch and return a new access token with scopes', () => {
- const spyRefresh = jest
- .spyOn(refresh, 'requestNewAccessToken')
- .mockImplementation((_x, _y, _z, callbackFunc) => {
- const cb = callbackFunc as Function;
- cb(undefined, 'ACCESS_TOKEN', undefined, {
- expires_in: 'EXPIRES_IN',
- id_token: 'ID_TOKEN',
- scope: 'a,b',
- });
- });
-
- googleAuthProvider.refresh(mockRequest, mockResponse);
- expect(spyRefresh).toBeCalledTimes(1);
- expect(spyRefresh).toBeCalledWith(
- 'google',
- 'REFRESH_TOKEN',
- { scope: 'a,b' },
- expect.any(Function),
- );
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith({
- accessToken: 'ACCESS_TOKEN',
- idToken: 'ID_TOKEN',
- expiresInSeconds: 'EXPIRES_IN',
- scope: 'a,b',
- });
- });
-
- it('ensures x-requested-with header', () => {
- const mockHeaderRequest = ({
- header: () => 'TEST',
- } as unknown) as express.Request;
-
- googleAuthProvider.refresh(mockHeaderRequest, mockResponse);
- expect(mockResponse.send).toBeCalledTimes(1);
- expect(mockResponse.send).toBeCalledWith(
- 'Invalid X-Requested-With header',
- );
- expect(mockResponse.status).toBeCalledTimes(1);
- expect(mockResponse.status).toBeCalledWith(401);
- });
- });
- });
-});
diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts
index cb080e2fd3..e3969b14c8 100644
--- a/plugins/auth-backend/src/providers/google/provider.ts
+++ b/plugins/auth-backend/src/providers/google/provider.ts
@@ -14,168 +14,30 @@
* limitations under the License.
*/
-import passport from 'passport';
-import express, { CookieOptions } from 'express';
-import crypto from 'crypto';
+import express from 'express';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
-import refresh from 'passport-oauth2-refresh';
import {
- AuthProvider,
- AuthProviderRouteHandlers,
+ executeFrameHandlerStrategy,
+ executeRedirectStrategy,
+ executeRefreshTokenStrategy,
+} from '../PassportStrategyHelper';
+import {
+ OAuthProviderHandlers,
+ AuthInfoBase,
+ AuthInfoPrivate,
+ RedirectInfo,
AuthProviderConfig,
-} from './../types';
-import { postMessageResponse, ensuresXRequestedWith } from './../utils';
-import { InputError } from '@backstage/backend-common';
+} from '../types';
+import { OAuthProvider } from '../OAuthProvider';
-export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
-export const TEN_MINUTES_MS = 600 * 1000;
-export class GoogleAuthProvider
- implements AuthProvider, AuthProviderRouteHandlers {
+export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
+ private readonly _strategy: GoogleStrategy;
+
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
- }
-
- start(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ) {
- const nonce = crypto.randomBytes(16).toString('base64');
-
- const options: CookieOptions = {
- maxAge: TEN_MINUTES_MS,
- secure: false,
- sameSite: 'none',
- domain: 'localhost',
- path: `/auth/${this.providerConfig.provider}/handler`,
- httpOnly: true,
- };
-
- res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options);
-
- const scope = req.query.scope?.toString() ?? '';
- if (!scope) {
- throw new InputError('missing scope parameter');
- }
- return passport.authenticate('google', {
- scope,
- accessType: 'offline',
- prompt: 'consent',
- state: nonce,
- })(req, res, next);
- }
-
- frameHandler(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ) {
- const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`];
- const stateNonce = req.query.state;
-
- if (!cookieNonce || !stateNonce) {
- return res.status(401).send('Missing nonce');
- }
-
- if (cookieNonce !== stateNonce) {
- return res.status(401).send('Invalid nonce');
- }
-
- return passport.authenticate('google', (err, user) => {
- if (err) {
- return postMessageResponse(res, {
- type: 'auth-result',
- error: new Error(`Google auth failed, ${err}`),
- });
- }
-
- const { refreshToken } = user;
-
- if (!refreshToken) {
- return postMessageResponse(res, {
- type: 'auth-result',
- error: new Error('Missing refresh token'),
- });
- }
-
- delete user.refreshToken;
-
- const options: CookieOptions = {
- maxAge: THOUSAND_DAYS_MS,
- secure: false,
- sameSite: 'none',
- domain: 'localhost',
- path: `/auth/${this.providerConfig.provider}`,
- httpOnly: true,
- };
-
- res.cookie(
- `${this.providerConfig.provider}-refresh-token`,
- refreshToken,
- options,
- );
- return postMessageResponse(res, {
- type: 'auth-result',
- payload: user,
- });
- })(req, res, next);
- }
-
- async logout(req: express.Request, res: express.Response) {
- if (!ensuresXRequestedWith(req)) {
- return res.status(401).send('Invalid X-Requested-With header');
- }
-
- const options: CookieOptions = {
- maxAge: 0,
- secure: false,
- sameSite: 'none',
- domain: 'localhost',
- path: `/auth/${this.providerConfig.provider}`,
- httpOnly: true,
- };
-
- res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
- return res.send('logout!');
- }
-
- async refresh(req: express.Request, res: express.Response) {
- if (!ensuresXRequestedWith(req)) {
- return res.status(401).send('Invalid X-Requested-With header');
- }
-
- const refreshToken =
- req.cookies[`${this.providerConfig.provider}-refresh-token`];
-
- if (!refreshToken) {
- return res.status(401).send('Missing session cookie');
- }
-
- const scope = req.query.scope?.toString() ?? '';
- const refreshTokenRequestParams = scope ? { scope } : {};
-
- return refresh.requestNewAccessToken(
- this.providerConfig.provider,
- refreshToken,
- refreshTokenRequestParams,
- (err, accessToken, _refreshToken, params) => {
- if (err || !accessToken) {
- return res.status(401).send('Failed to refresh access token');
- }
- return res.send({
- accessToken,
- idToken: params.id_token,
- expiresInSeconds: params.expires_in,
- scope: params.scope,
- });
- },
- );
- }
-
- strategy(): passport.Strategy {
// TODO: throw error if env variables not set?
- return new GoogleStrategy(
+ this._strategy = new GoogleStrategy(
{ ...this.providerConfig.options },
(
accessToken: any,
@@ -184,15 +46,51 @@ export class GoogleAuthProvider
profile: any,
done: any,
) => {
- done(undefined, {
- profile,
- idToken: params.id_token,
- accessToken,
- refreshToken,
- scope: params.scope,
- expiresInSeconds: params.expires_in,
- });
+ done(
+ undefined,
+ {
+ profile,
+ idToken: params.id_token,
+ accessToken,
+ scope: params.scope,
+ expiresInSeconds: params.expires_in,
+ },
+ {
+ refreshToken,
+ },
+ );
},
);
}
+
+ async start(req: express.Request, options: any): Promise {
+ return await executeRedirectStrategy(req, this._strategy, options);
+ }
+
+ async handler(
+ req: express.Request,
+ ): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
+ return await executeFrameHandlerStrategy(req, this._strategy);
+ }
+
+ async refresh(refreshToken: string, scope: string): Promise {
+ const { accessToken, params } = await executeRefreshTokenStrategy(
+ this._strategy,
+ refreshToken,
+ scope,
+ );
+
+ return {
+ accessToken,
+ idToken: params.id_token,
+ expiresInSeconds: params.expires_in,
+ scope: params.scope,
+ };
+ }
+}
+
+export function createGoogleProvider(config: AuthProviderConfig) {
+ const provider = new GoogleAuthProvider(config);
+ const oauthProvider = new OAuthProvider(provider, config.provider);
+ return oauthProvider;
}
diff --git a/plugins/auth-backend/src/providers/index.test.ts b/plugins/auth-backend/src/providers/index.test.ts
deleted file mode 100644
index e42cd32edc..0000000000
--- a/plugins/auth-backend/src/providers/index.test.ts
+++ /dev/null
@@ -1,103 +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 passport from 'passport';
-import express from 'express';
-import { makeProvider, defaultRouter } from '.';
-import {
- AuthProvider,
- AuthProviderRouteHandlers,
- AuthProviderConfig,
-} from './types';
-import * as passportGoogleOAuth20 from 'passport-google-oauth20';
-import { ProviderFactories } from './factories';
-
-class MyAuthProvider implements AuthProvider, AuthProviderRouteHandlers {
- private readonly providerConfig: AuthProviderConfig;
- constructor(providerConfig: AuthProviderConfig) {
- this.providerConfig = providerConfig;
- }
-
- strategy(): passport.Strategy {
- return new passportGoogleOAuth20.Strategy(
- this.providerConfig.options,
- () => {},
- );
- }
- async start(_: express.Request, res: express.Response): Promise {
- res.send('start');
- }
- async frameHandler(_: express.Request, res: express.Response): Promise {
- res.send('frameHandler');
- }
- async logout(_: express.Request, res: express.Response): Promise {
- res.send('logout');
- }
-}
-
-class MyAuthProviderWithRefresh extends MyAuthProvider {
- async refresh(_: express.Request, res: express.Response): Promise {
- res.send('logout');
- }
-}
-
-const providerConfig = {
- provider: 'a',
- options: {
- clientID: 'somevalue',
- },
-};
-
-const providerConfigInvalid = {
- provider: 'b',
- options: {
- clientID: 'somevalue',
- },
-};
-
-describe('makeProvider', () => {
- it('makes a provider for Myauthprovider', () => {
- jest
- .spyOn(ProviderFactories, 'getProviderFactory')
- .mockReturnValueOnce(MyAuthProvider);
- const provider = makeProvider(providerConfig);
- expect(provider.providerId).toEqual('a');
- expect(provider.strategy).toBeDefined();
- expect(provider.providerRouter).toBeDefined();
- });
-
- it('throws an error when provider implementation does not exist', () => {
- expect(() => {
- makeProvider(providerConfigInvalid);
- }).toThrow('Provider Implementation missing for : b auth provider');
- });
-});
-
-describe('defaultRouter', () => {
- it('make router for auth provider without refresh', () => {
- expect(
- defaultRouter(new MyAuthProvider({ provider: 'a', options: {} })),
- ).toBeDefined();
- });
-
- it('make router for auth provider with refresh', () => {
- expect(
- defaultRouter(
- new MyAuthProviderWithRefresh({ provider: 'b', options: {} }),
- ),
- ).toBeDefined();
- });
-});
diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts
index 1b33391c27..d210bfd1bb 100644
--- a/plugins/auth-backend/src/providers/index.ts
+++ b/plugins/auth-backend/src/providers/index.ts
@@ -14,26 +14,4 @@
* limitations under the License.
*/
-import Router from 'express-promise-router';
-import { AuthProviderRouteHandlers, AuthProviderConfig } from './types';
-import { ProviderFactories } from './factories';
-
-export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
- const router = Router();
- router.get('/start', provider.start.bind(provider));
- router.get('/handler/frame', provider.frameHandler.bind(provider));
- router.get('/logout', provider.logout.bind(provider));
- if (provider.refresh) {
- router.get('/refresh', provider.refresh.bind(provider));
- }
- return router;
-};
-
-export const makeProvider = (config: AuthProviderConfig) => {
- const providerId = config.provider;
- const ProviderImpl = ProviderFactories.getProviderFactory(providerId);
- const providerInstance = new ProviderImpl(config);
- const strategy = providerInstance.strategy();
- const providerRouter = defaultRouter(providerInstance);
- return { providerId, strategy, providerRouter };
-};
+export { createAuthProviderRouter } from './factories';
diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts
new file mode 100644
index 0000000000..582deb1608
--- /dev/null
+++ b/plugins/auth-backend/src/providers/saml/index.ts
@@ -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 { createSamlProvider } from './provider';
diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts
new file mode 100644
index 0000000000..50bea3495e
--- /dev/null
+++ b/plugins/auth-backend/src/providers/saml/provider.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import { Strategy as SamlStrategy } from 'passport-saml';
+import {
+ executeFrameHandlerStrategy,
+ executeRedirectStrategy,
+} from '../PassportStrategyHelper';
+import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
+import { postMessageResponse } from '../OAuthProvider';
+
+export class SamlAuthProvider implements AuthProviderRouteHandlers {
+ private readonly strategy: SamlStrategy;
+
+ constructor(providerConfig: AuthProviderConfig) {
+ this.strategy = new SamlStrategy(
+ { ...providerConfig.options },
+ (profile: any, done: any) => {
+ // TODO: There's plenty more validation and profile handling to do here,
+ // this provider is currently only intended to validate the provider pattern
+ // for non-oauth auth flows.
+ // TODO: This flow doesn't issue an identity token that can be used to validate
+ // the identity of the user in other backends, which we need in some form.
+ done(undefined, {
+ email: profile.email,
+ firstName: profile.firstName,
+ lastName: profile.lastName,
+ displayName: profile.displayName,
+ });
+ },
+ );
+ }
+
+ async start(req: express.Request, res: express.Response): Promise {
+ const { url } = await executeRedirectStrategy(req, this.strategy, {});
+ res.redirect(url);
+ }
+
+ async frameHandler(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ try {
+ const { user } = await executeFrameHandlerStrategy(req, this.strategy);
+
+ return postMessageResponse(res, {
+ type: 'auth-result',
+ payload: user,
+ });
+ } catch (error) {
+ return postMessageResponse(res, {
+ type: 'auth-result',
+ error: {
+ name: error.name,
+ message: error.message,
+ },
+ });
+ }
+ }
+
+ async logout(_req: express.Request, res: express.Response): Promise {
+ res.send('noop');
+ }
+}
+
+export function createSamlProvider(config: AuthProviderConfig) {
+ return new SamlAuthProvider(config);
+}
diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts
index 36941c6850..661435e74b 100644
--- a/plugins/auth-backend/src/providers/types.ts
+++ b/plugins/auth-backend/src/providers/types.ts
@@ -20,43 +20,26 @@ import passport from 'passport';
export type AuthProviderConfig = {
provider: string;
options: any;
+ disableRefresh?: boolean;
};
-export interface AuthProvider {
- strategy(): passport.Strategy;
- router?(): express.Router;
+export interface OAuthProviderHandlers {
+ start(req: express.Request, options: any): Promise;
+ handler(req: express.Request): Promise;
+ refresh?(refreshToken: string, scope: string): Promise;
+ logout?(): Promise;
}
export interface AuthProviderRouteHandlers {
- start(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ): Promise;
- frameHandler(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ): Promise;
- refresh?(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ): Promise;
- logout(
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ): Promise;
+ start(req: express.Request, res: express.Response): Promise;
+ frameHandler(req: express.Request, res: express.Response): Promise;
+ refresh?(req: express.Request, res: express.Response): Promise;
+ logout(req: express.Request, res: express.Response): Promise;
}
-export type AuthProviderFactories = {
- [key: string]: AuthProviderFactory;
-};
-
-export type AuthProviderFactory = {
- new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
-};
+export type AuthProviderFactory = (
+ config: AuthProviderConfig,
+) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
accessToken: string;
@@ -69,7 +52,7 @@ export type AuthInfoWithProfile = AuthInfoBase & {
profile: passport.Profile;
};
-export type AuthInfoPrivate = AuthInfoWithProfile & {
+export type AuthInfoPrivate = {
refreshToken: string;
};
@@ -82,3 +65,13 @@ export type AuthResponse =
type: 'auth-result';
error: Error;
};
+
+export type RedirectInfo = {
+ url: string;
+ status?: number;
+};
+
+export type RefreshTokenResponse = {
+ accessToken: string;
+ params: any;
+};
diff --git a/plugins/auth-backend/src/providers/utils.ts b/plugins/auth-backend/src/providers/utils.ts
deleted file mode 100644
index 83229e55d4..0000000000
--- a/plugins/auth-backend/src/providers/utils.ts
+++ /dev/null
@@ -1,50 +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 express from 'express';
-import { AuthResponse } from './types';
-
-export const postMessageResponse = (
- res: express.Response,
- data: AuthResponse,
-) => {
- const jsonData = JSON.stringify(data);
- const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
-
- res.setHeader('Content-Type', 'text/html');
- res.setHeader('X-Frame-Options', 'sameorigin');
-
- // TODO: Make target app origin configurable globally
- res.end(`
-
-
-
-
-
- `);
-};
-
-export const ensuresXRequestedWith = (req: express.Request) => {
- const requiredHeader = req.header('X-Requested-With');
-
- if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
- return false;
- }
- return true;
-};
diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts
index a2e3b3e1db..9de5fb80a5 100644
--- a/plugins/auth-backend/src/service/router.ts
+++ b/plugins/auth-backend/src/service/router.ts
@@ -16,13 +16,11 @@
import express from 'express';
import Router from 'express-promise-router';
-import passport from 'passport';
import cookieParser from 'cookie-parser';
-import refresh from 'passport-oauth2-refresh';
-import OAuth2Strategy from 'passport-oauth2';
+import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { providers } from './../providers/config';
-import { makeProvider } from '../providers';
+import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
logger: Logger;
@@ -33,38 +31,17 @@ export async function createRouter(
): Promise {
const router = Router();
const logger = options.logger.child({ plugin: 'auth' });
- const providerRouters: { [key: string]: express.Router } = {};
+
+ router.use(cookieParser());
+ router.use(bodyParser.urlencoded({ extended: false }));
+ router.use(bodyParser.json());
// configure all the providers
for (const providerConfig of providers) {
- const { providerId, strategy, providerRouter } = makeProvider(
- providerConfig,
- );
- logger.info(`Configuring provider: ${providerId}`);
- passport.use(strategy);
- if (strategy instanceof OAuth2Strategy) {
- refresh.use(strategy);
- }
- providerRouters[providerId] = providerRouter;
- }
-
- passport.serializeUser((user, done) => {
- done(null, user);
- });
-
- passport.deserializeUser((user, done) => {
- done(null, user);
- });
-
- router.use(passport.initialize());
- router.use(passport.session());
- router.use(cookieParser());
-
- for (const providerId in providerRouters) {
- if (providerRouters.hasOwnProperty(providerId)) {
- const providerRouter = providerRouters[providerId];
- router.use(`/${providerId}`, providerRouter);
- }
+ const { provider } = providerConfig;
+ const providerRouter = createAuthProviderRouter(providerConfig);
+ logger.info(`Configuring provider, ${provider}`);
+ router.use(`/${provider}`, providerRouter);
}
return router;
diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json
index f32ffffb6c..fca79aef1b 100644
--- a/plugins/catalog-backend/package.json
+++ b/plugins/catalog-backend/package.json
@@ -6,7 +6,7 @@
"license": "Apache-2.0",
"private": true,
"scripts": {
- "start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"",
+ "start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm dist/run.js\\\"",
"build": "tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
@@ -16,10 +16,10 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
- "@types/node-fetch": "^2.5.7",
- "@types/supertest": "^2.0.8",
+ "@backstage/catalog-model": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
+ "esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
@@ -38,6 +38,8 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/lodash": "^4.14.151",
+ "@types/node-fetch": "^2.5.7",
+ "@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts
new file mode 100644
index 0000000000..eb9cb2439c
--- /dev/null
+++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 '@backstage/catalog-model';
+import type { Database } from '../database';
+import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
+
+describe('DatabaseEntitiesCatalog', () => {
+ let db: jest.Mocked;
+
+ beforeAll(() => {
+ db = {
+ transaction: jest.fn(),
+ addEntity: jest.fn(),
+ updateEntity: jest.fn(),
+ entities: jest.fn(),
+ entity: jest.fn(),
+ removeEntity: jest.fn(),
+ addLocation: jest.fn(),
+ removeLocation: jest.fn(),
+ location: jest.fn(),
+ locations: jest.fn(),
+ locationHistory: jest.fn(),
+ addLocationUpdateLogEvent: jest.fn(),
+ };
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ db.transaction.mockImplementation(async f => f('tx'));
+ });
+
+ describe('addOrUpdateEntity', () => {
+ it('adds when no given uid and no matching by name', async () => {
+ const entity: Entity = {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c',
+ namespace: 'd',
+ },
+ };
+
+ db.entities.mockResolvedValue([]);
+ db.addEntity.mockResolvedValue({ entity });
+
+ const catalog = new DatabaseEntitiesCatalog(db);
+ const result = await catalog.addOrUpdateEntity(entity);
+
+ expect(db.entities).toHaveBeenCalledTimes(1);
+ expect(db.addEntity).toHaveBeenCalledTimes(1);
+ expect(result).toBe(entity);
+ });
+
+ it('updates when given uid', async () => {
+ const entity: Entity = {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ uid: 'uuuu',
+ name: 'c',
+ namespace: 'd',
+ },
+ };
+
+ db.entities.mockResolvedValue([]);
+ db.updateEntity.mockResolvedValue({ entity });
+
+ const catalog = new DatabaseEntitiesCatalog(db);
+ const result = await catalog.addOrUpdateEntity(entity);
+
+ expect(db.entities).toHaveBeenCalledTimes(0);
+ expect(db.updateEntity).toHaveBeenCalledTimes(1);
+ expect(result).toBe(entity);
+ });
+
+ it('update when no given uid and matching by name', async () => {
+ const added: Entity = {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c',
+ namespace: 'd',
+ },
+ };
+ const existing: Entity = {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c',
+ namespace: 'd',
+ },
+ };
+
+ db.entities.mockResolvedValue([{ entity: existing }]);
+ db.updateEntity.mockResolvedValue({ entity: added });
+
+ const catalog = new DatabaseEntitiesCatalog(db);
+ const result = await catalog.addOrUpdateEntity(added);
+
+ expect(db.entities).toHaveBeenCalledTimes(1);
+ expect(db.updateEntity).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(existing);
+ });
+ });
+});
diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
index 9d0d8fcaf7..1ec1ebf6e5 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
@@ -14,21 +14,21 @@
* limitations under the License.
*/
-import { Database } from '../database';
-import { DescriptorEnvelope } from '../ingestion/types';
-import { EntitiesCatalog, EntityFilters } from './types';
+import type { Entity } from '@backstage/catalog-model';
+import type { Database, DbEntityResponse, EntityFilters } from '../database';
+import type { EntitiesCatalog } from './types';
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
constructor(private readonly database: Database) {}
- async entities(filters?: EntityFilters): Promise {
+ async entities(filters?: EntityFilters): Promise {
const items = await this.database.transaction(tx =>
this.database.entities(tx, filters),
);
return items.map(i => i.entity);
}
- async entityByUid(uid: string): Promise {
+ async entityByUid(uid: string): Promise {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
);
@@ -37,23 +37,68 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
}
async entityByName(
+ kind: string,
+ namespace: string | undefined,
+ name: string,
+ ): Promise {
+ return await this.database.transaction(tx =>
+ this.entityByNameInternal(tx, kind, name, namespace),
+ );
+ }
+
+ async addOrUpdateEntity(
+ entity: Entity,
+ locationId?: string,
+ ): Promise {
+ return await this.database.transaction(async tx => {
+ let response: DbEntityResponse;
+
+ if (entity.metadata.uid) {
+ response = await this.database.updateEntity(tx, { locationId, entity });
+ } else {
+ const existing = await this.entityByNameInternal(
+ tx,
+ entity.kind,
+ entity.metadata.name,
+ entity.metadata.namespace,
+ );
+ if (existing) {
+ response = await this.database.updateEntity(tx, {
+ locationId,
+ entity,
+ });
+ } else {
+ response = await this.database.addEntity(tx, { locationId, entity });
+ }
+ }
+
+ return response.entity;
+ });
+ }
+
+ async removeEntityByUid(uid: string): Promise {
+ return await this.database.transaction(async tx => {
+ await this.database.removeEntity(tx, uid);
+ });
+ }
+
+ private async entityByNameInternal(
+ tx: unknown,
kind: string,
name: string,
namespace: string | undefined,
- ): Promise {
- const matches = await this.database.transaction(tx =>
- this.database.entities(tx, [
- { key: 'kind', values: [kind] },
- { key: 'name', values: [name] },
- {
- key: 'namespace',
- values:
- !namespace || namespace === 'default'
- ? [null, 'default']
- : [namespace],
- },
- ]),
- );
+ ): Promise {
+ const matches = await this.database.entities(tx, [
+ { key: 'kind', values: [kind] },
+ { key: 'name', values: [name] },
+ {
+ key: 'namespace',
+ values:
+ !namespace || namespace === 'default'
+ ? [null, 'default']
+ : [namespace],
+ },
+ ]);
return matches.length ? matches[0].entity : undefined;
}
diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
index 0181b7fc28..907bdea2d5 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
@@ -13,70 +13,45 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
-import knex from 'knex';
-import path from 'path';
-import { Database } from '../database';
-import { ReaderOutput } from '../ingestion/types';
import { getVoidLogger } from '@backstage/backend-common';
+import Knex from 'knex';
+import path from 'path';
+import { CommonDatabase } from '../database';
+import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
describe('DatabaseLocationsCatalog', () => {
- const database = knex({
- client: 'sqlite3',
- connection: ':memory:',
- useNullAsDefault: true,
- });
- database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
- resource.run('PRAGMA foreign_keys = ON', () => {});
- });
- let db: Database;
let catalog: DatabaseLocationsCatalog;
- const mockLocationReader = {
- read: async (type: string, target: string): Promise => {
- if (type !== 'valid_type') {
- throw new Error(`Unknown location type ${type}`);
- }
- if (target === 'valid_target') {
- return Promise.resolve([{ type: 'data', data: {} }]);
- }
- throw new Error(
- `Can't read location at ${target} with error: Something is broken`,
- );
- },
- };
-
beforeEach(async () => {
- await database.migrate.latest({
+ const knex = Knex({
+ client: 'sqlite3',
+ connection: ':memory:',
+ useNullAsDefault: true,
+ });
+ knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
+ resource.run('PRAGMA foreign_keys = ON', () => {});
+ });
+ await knex.migrate.latest({
directory: path.resolve(__dirname, '../database/migrations'),
loadExtensions: ['.ts'],
});
- db = new Database(database, getVoidLogger());
- catalog = new DatabaseLocationsCatalog(db, mockLocationReader);
+ const db = new CommonDatabase(knex, getVoidLogger());
+ catalog = new DatabaseLocationsCatalog(db);
});
- it('resolves to location with id', async () => {
- return expect(
- catalog.addLocation({ type: 'valid_type', target: 'valid_target' }),
- ).resolves.toEqual({
- id: expect.anything(),
+ it('can add a location', async () => {
+ const location = {
+ id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'valid_type',
target: 'valid_target',
- });
- });
- it('rejects for invalid type', async () => {
- const type = 'invalid_type';
- return expect(
- catalog.addLocation({ type, target: 'valid_target' }),
- ).rejects.toThrow(/Unknown location type/);
- });
- it('rejects for unreadable target ', async () => {
- const target = 'invalid_target';
- return expect(
- catalog.addLocation({ type: 'valid_type', target }),
- ).rejects.toThrow(
- `Can't read location at ${target} with error: Something is broken`,
- );
+ };
+ await expect(catalog.addLocation(location)).resolves.toEqual(location);
+ await expect(
+ catalog.location('dd12620d-0436-422f-93bd-929aa0788123'),
+ ).resolves.toEqual(expect.objectContaining({ data: location }));
+ await expect(catalog.locations()).resolves.toEqual([
+ expect.objectContaining({ data: location }),
+ ]);
});
});
diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts
index 6e3c9ae306..a1e76679f7 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts
@@ -14,26 +14,18 @@
* limitations under the License.
*/
-import { Database } from '../database';
-import { AddLocation, Location, LocationsCatalog } from './types';
-import { LocationReader } from '../ingestion';
+import { Location } from '@backstage/catalog-model';
+import type { Database } from '../database';
+import {
+ DatabaseLocationUpdateLogEvent,
+ DatabaseLocationUpdateLogStatus,
+} from '../database/types';
+import { LocationResponse, LocationsCatalog } from './types';
export class DatabaseLocationsCatalog implements LocationsCatalog {
- constructor(
- private readonly database: Database,
- private readonly reader: LocationReader,
- ) {}
-
- async addLocation(location: AddLocation): Promise {
- const outputs = await this.reader.read(location.type, location.target);
- outputs.forEach(output => {
- if (output.type === 'error') {
- throw new Error(
- `Can't read location at ${location.target}, ${output.error}`,
- );
- }
- });
+ constructor(private readonly database: Database) {}
+ async addLocation(location: Location): Promise {
const added = await this.database.addLocation(location);
return added;
}
@@ -42,13 +34,60 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
await this.database.removeLocation(id);
}
- async locations(): Promise {
+ async locations(): Promise {
const items = await this.database.locations();
- return items;
+ return items.map(({ message, status, timestamp, ...data }) => ({
+ currentStatus: {
+ message,
+ status,
+ timestamp,
+ },
+ data,
+ }));
}
- async location(id: string): Promise {
- const item = await this.database.location(id);
- return item;
+ async locationHistory(id: string): Promise {
+ return this.database.locationHistory(id);
+ }
+
+ async location(id: string): Promise {
+ const {
+ message,
+ status,
+ timestamp,
+ ...data
+ } = await this.database.location(id);
+ return {
+ currentStatus: {
+ message,
+ status,
+ timestamp,
+ },
+ data,
+ };
+ }
+
+ async logUpdateSuccess(
+ locationId: string,
+ entityName?: string,
+ ): Promise {
+ await this.database.addLocationUpdateLogEvent(
+ locationId,
+ DatabaseLocationUpdateLogStatus.SUCCESS,
+ entityName,
+ );
+ }
+
+ async logUpdateFailure(
+ locationId: string,
+ error?: Error,
+ entityName?: string,
+ ): Promise {
+ await this.database.addLocationUpdateLogEvent(
+ locationId,
+ DatabaseLocationUpdateLogStatus.FAIL,
+ entityName,
+ error?.message,
+ );
}
}
diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts
index 371cdf1f76..22bbd2e1a3 100644
--- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts
+++ b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts
@@ -14,44 +14,45 @@
* limitations under the License.
*/
-import { NotFoundError } from '@backstage/backend-common';
+import type { Entity } from '@backstage/catalog-model';
import lodash from 'lodash';
-import { DescriptorEnvelope } from '../ingestion';
-import { EntitiesCatalog } from './types';
+import type { EntitiesCatalog } from './types';
export class StaticEntitiesCatalog implements EntitiesCatalog {
- private _entities: DescriptorEnvelope[];
+ private _entities: Entity[];
- constructor(entities: DescriptorEnvelope[]) {
+ constructor(entities: Entity[]) {
this._entities = entities;
}
- async entities(): Promise {
+ async entities(): Promise {
return lodash.cloneDeep(this._entities);
}
- async entityByUid(uid: string): Promise {
- const item = this._entities.find(e => uid === e.metadata?.uid);
- if (!item) {
- throw new NotFoundError('Entity cannot be found');
- }
- return lodash.cloneDeep(item);
+ async entityByUid(uid: string): Promise {
+ const item = this._entities.find(e => uid === e.metadata.uid);
+ return item ? lodash.cloneDeep(item) : undefined;
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
- ): Promise {
+ ): Promise {
const item = this._entities.find(
e =>
kind === e.kind &&
- name === e.metadata?.name &&
- namespace === e.metadata?.namespace,
+ name === e.metadata.name &&
+ namespace === e.metadata.namespace,
);
- if (!item) {
- throw new NotFoundError('Entity cannot be found');
- }
- return lodash.cloneDeep(item);
+ return item ? lodash.cloneDeep(item) : undefined;
+ }
+
+ async addOrUpdateEntity(): Promise {
+ throw new Error('Not supported');
+ }
+
+ async removeEntityByUid(): Promise {
+ throw new Error('Not supported');
}
}
diff --git a/plugins/catalog-backend/src/catalog/index.ts b/plugins/catalog-backend/src/catalog/index.ts
index 58ae531944..308078b1fc 100644
--- a/plugins/catalog-backend/src/catalog/index.ts
+++ b/plugins/catalog-backend/src/catalog/index.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-export * from './DatabaseEntitiesCatalog';
-export * from './DatabaseLocationsCatalog';
-export * from './StaticEntitiesCatalog';
-export * from './types';
+export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
+export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
+export { StaticEntitiesCatalog } from './StaticEntitiesCatalog';
+export type { EntitiesCatalog, LocationsCatalog } from './types';
diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts
index d5627ea86a..b0d3dde0fb 100644
--- a/plugins/catalog-backend/src/catalog/types.ts
+++ b/plugins/catalog-backend/src/catalog/types.ts
@@ -14,54 +14,58 @@
* limitations under the License.
*/
-import * as yup from 'yup';
-import { DescriptorEnvelope } from '../ingestion';
+import { Entity, Location } from '@backstage/catalog-model';
+import type { EntityFilters } from '../database';
//
// Entities
//
-export type EntityFilter = {
- key: string;
- values: (string | null)[];
-};
-export type EntityFilters = EntityFilter[];
-
export type EntitiesCatalog = {
- entities(filters?: EntityFilters): Promise;
- entityByUid(uid: string): Promise;
+ entities(filters?: EntityFilters): Promise;
+ entityByUid(uid: string): Promise;
entityByName(
kind: string,
namespace: string | undefined,
name: string,
- ): Promise;
+ ): Promise;
+ addOrUpdateEntity(entity: Entity, locationId?: string): Promise;
+ removeEntityByUid(uid: string): Promise;
};
//
// Locations
//
-export type Location = {
+export type LocationUpdateStatus = {
+ timestamp: string | null;
+ status: string | null;
+ message: string | null;
+};
+export type LocationUpdateLogEvent = {
id: string;
- type: string;
- target: string;
+ status: 'fail' | 'success';
+ location_id: string;
+ entity_name: string;
+ created_at?: string;
+ message?: string;
};
-export type AddLocation = {
- type: string;
- target: string;
+export type LocationResponse = {
+ data: Location;
+ currentStatus: LocationUpdateStatus;
};
-export const addLocationSchema: yup.Schema = yup
- .object({
- type: yup.string().required(),
- target: yup.string().required(),
- })
- .noUnknown();
-
export type LocationsCatalog = {
- addLocation(location: AddLocation): Promise;
+ addLocation(location: Location): Promise;
removeLocation(id: string): Promise;
- locations(): Promise;
- location(id: string): Promise;
+ locations(): Promise;
+ location(id: string): Promise;
+ locationHistory(id: string): Promise;
+ logUpdateSuccess(locationId: string, entityName?: string): Promise;
+ logUpdateFailure(
+ locationId: string,
+ error?: Error,
+ entityName?: string,
+ ): Promise;
};
diff --git a/plugins/catalog-backend/src/database/Database.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
similarity index 59%
rename from plugins/catalog-backend/src/database/Database.test.ts
rename to plugins/catalog-backend/src/database/CommonDatabase.test.ts
index a82385aae4..f1723e3733 100644
--- a/plugins/catalog-backend/src/database/Database.test.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
@@ -19,31 +19,31 @@ import {
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
+import type { Entity, Location } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
-import { DescriptorEnvelope } from '../ingestion';
-import { Database } from './Database';
-import {
- AddDatabaseLocation,
+import { CommonDatabase } from './CommonDatabase';
+import { DatabaseLocationUpdateLogStatus } from './types';
+import type {
DbEntityRequest,
DbEntityResponse,
- DbLocationsRow,
+ DbLocationsRowWithStatus,
} from './types';
-describe('Database', () => {
- let database: Knex;
+describe('CommonDatabase', () => {
+ let knex: Knex;
let entityRequest: DbEntityRequest;
let entityResponse: DbEntityResponse;
beforeEach(async () => {
- database = Knex({
+ knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
- await database.raw('PRAGMA foreign_keys = ON');
- await database.migrate.latest({
+ await knex.raw('PRAGMA foreign_keys = ON');
+ await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.ts'],
});
@@ -74,7 +74,9 @@ describe('Database', () => {
name: 'c',
namespace: 'd',
labels: { e: 'f' },
- annotations: { g: 'h' },
+ annotations: {
+ g: 'h',
+ },
},
spec: { i: 'j' },
},
@@ -82,13 +84,20 @@ describe('Database', () => {
});
it('manages locations', async () => {
- const db = new Database(database, getVoidLogger());
- const input: AddDatabaseLocation = { type: 'a', target: 'b' };
- const output: DbLocationsRow = {
- id: expect.anything(),
+ const db = new CommonDatabase(knex, getVoidLogger());
+ const input: Location = {
+ id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
target: 'b',
};
+ const output: DbLocationsRowWithStatus = {
+ id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ type: 'a',
+ target: 'b',
+ message: null,
+ status: null,
+ timestamp: null,
+ };
await db.addLocation(input);
@@ -105,34 +114,18 @@ describe('Database', () => {
);
});
- it('instead of adding second location with the same target, returns existing one', async () => {
- // Prepare
- const catalog = new Database(database, getVoidLogger());
- const input: AddDatabaseLocation = { type: 'a', target: 'b' };
- const output1: DbLocationsRow = await catalog.addLocation(input);
-
- // Try to insert the same location
- const output2: DbLocationsRow = await catalog.addLocation(input);
- const locations = await catalog.locations();
-
- // Output is the same
- expect(output2).toEqual(output1);
- // Locations contain only one record
- expect(locations).toEqual([output1]);
- });
-
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
expect(added).toStrictEqual(entityResponse);
- expect(added.entity.metadata!.generation).toBe(1);
+ expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
@@ -140,19 +133,64 @@ describe('Database', () => {
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
- const catalog = new Database(database, getVoidLogger());
- entityRequest.entity.metadata!.namespace = 'namespace1';
+ const catalog = new CommonDatabase(knex, getVoidLogger());
+ entityRequest.entity.metadata.namespace = 'namespace1';
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
- entityRequest.entity.metadata!.namespace = 'namespace2';
+ entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
});
});
+ describe('locationHistory', () => {
+ it('outputs the history correctly', async () => {
+ const catalog = new CommonDatabase(knex, getVoidLogger());
+ const location: Location = {
+ id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ type: 'a',
+ target: 'b',
+ };
+ await catalog.addLocation(location);
+
+ await catalog.addLocationUpdateLogEvent(
+ 'dd12620d-0436-422f-93bd-929aa0788123',
+ DatabaseLocationUpdateLogStatus.SUCCESS,
+ );
+ await catalog.addLocationUpdateLogEvent(
+ 'dd12620d-0436-422f-93bd-929aa0788123',
+ DatabaseLocationUpdateLogStatus.FAIL,
+ undefined,
+ 'Something went wrong',
+ );
+
+ const result = await catalog.locationHistory(
+ 'dd12620d-0436-422f-93bd-929aa0788123',
+ );
+ expect(result).toEqual([
+ {
+ created_at: expect.anything(),
+ entity_name: null,
+ id: expect.anything(),
+ location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ message: null,
+ status: DatabaseLocationUpdateLogStatus.SUCCESS,
+ },
+ {
+ created_at: expect.anything(),
+ entity_name: null,
+ id: expect.anything(),
+ location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ message: 'Something went wrong',
+ status: DatabaseLocationUpdateLogStatus.FAIL,
+ },
+ ]);
+ });
+ });
+
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
@@ -161,40 +199,38 @@ describe('Database', () => {
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
- expect(updated.entity.metadata!.etag).not.toEqual(
- added.entity.metadata!.etag,
+ expect(updated.entity.metadata.etag).not.toEqual(
+ added.entity.metadata.etag,
);
- expect(updated.entity.metadata!.generation).toEqual(
- added.entity.metadata!.generation,
+ expect(updated.entity.metadata.generation).toEqual(
+ added.entity.metadata.generation,
);
- expect(updated.entity.metadata!.name).toEqual(
- added.entity.metadata!.name,
- );
- expect(updated.entity.metadata!.namespace).toEqual(
- added.entity.metadata!.namespace,
+ expect(updated.entity.metadata.name).toEqual(added.entity.metadata.name);
+ expect(updated.entity.metadata.namespace).toEqual(
+ added.entity.metadata.namespace,
);
});
it('can update name if uid matches', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
- added.entity.metadata!.name! = 'new!';
+ added.entity.metadata.name! = 'new!';
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
);
- expect(updated.entity.metadata!.name).toEqual('new!');
+ expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
added.entity.apiVersion = 'something.new';
- delete added.entity.metadata!.uid;
- delete added.entity.metadata!.generation;
+ delete added.entity.metadata.uid;
+ delete added.entity.metadata.generation;
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
);
@@ -202,14 +238,14 @@ describe('Database', () => {
});
it('rejects if kind, name, but not namespace match', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
added.entity.apiVersion = 'something.new';
- delete added.entity.metadata!.uid;
- delete added.entity.metadata!.generation;
- added.entity.metadata!.namespace = 'something.wrong';
+ delete added.entity.metadata.uid;
+ delete added.entity.metadata.generation;
+ added.entity.metadata.namespace = 'something.wrong';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
@@ -218,11 +254,11 @@ describe('Database', () => {
});
it('fails to update an entity if etag does not match', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
- added.entity.metadata!.etag = 'garbage';
+ added.entity.metadata.etag = 'garbage';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
@@ -231,11 +267,11 @@ describe('Database', () => {
});
it('fails to update an entity if generation does not match', async () => {
- const catalog = new Database(database, getVoidLogger());
+ const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
- added.entity.metadata!.generation! += 100;
+ added.entity.metadata.generation! += 100;
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
@@ -246,11 +282,16 @@ describe('Database', () => {
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
- const catalog = new Database(database, getVoidLogger());
- const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
- const e2: DescriptorEnvelope = {
+ const catalog = new CommonDatabase(knex, getVoidLogger());
+ const e1: Entity = {
apiVersion: 'a',
- kind: 'b',
+ kind: 'k1',
+ metadata: { name: 'n' },
+ };
+ const e2: Entity = {
+ apiVersion: 'c',
+ kind: 'k2',
+ metadata: { name: 'n' },
spec: { c: null },
};
await catalog.transaction(async tx => {
@@ -263,24 +304,32 @@ describe('Database', () => {
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
- { locationId: undefined, entity: expect.objectContaining(e1) },
- { locationId: undefined, entity: expect.objectContaining(e2) },
+ {
+ locationId: undefined,
+ entity: expect.objectContaining({ kind: 'k1' }),
+ },
+ {
+ locationId: undefined,
+ entity: expect.objectContaining({ kind: 'k2' }),
+ },
]),
);
});
it('can get all specific entities for matching filters (naive case)', async () => {
- const catalog = new Database(database, getVoidLogger());
- const entities: DescriptorEnvelope[] = [
- { apiVersion: 'a', kind: 'b' },
+ const catalog = new CommonDatabase(knex, getVoidLogger());
+ const entities: Entity[] = [
+ { apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
apiVersion: 'a',
- kind: 'b',
+ kind: 'k2',
+ metadata: { name: 'n' },
spec: { c: 'some' },
},
{
apiVersion: 'a',
- kind: 'b',
+ kind: 'k3',
+ metadata: { name: 'n' },
spec: { c: null },
},
];
@@ -294,27 +343,32 @@ describe('Database', () => {
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
- { key: 'kind', values: ['b'] },
+ { key: 'kind', values: ['k2'] },
{ key: 'spec.c', values: ['some'] },
]),
),
).resolves.toEqual([
- { locationId: undefined, entity: expect.objectContaining(entities[1]) },
+ {
+ locationId: undefined,
+ entity: expect.objectContaining({ kind: 'k2' }),
+ },
]);
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
- const catalog = new Database(database, getVoidLogger());
- const entities: DescriptorEnvelope[] = [
- { apiVersion: 'a', kind: 'b' },
+ const catalog = new CommonDatabase(knex, getVoidLogger());
+ const entities: Entity[] = [
+ { apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
apiVersion: 'a',
- kind: 'b',
+ kind: 'k2',
+ metadata: { name: 'n' },
spec: { c: 'some' },
},
{
apiVersion: 'a',
- kind: 'b',
+ kind: 'k3',
+ metadata: { name: 'n' },
spec: { c: null },
},
];
@@ -327,7 +381,7 @@ describe('Database', () => {
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
- { key: 'kind', values: ['b'] },
+ { key: 'apiVersion', values: ['a'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
);
@@ -337,15 +391,15 @@ describe('Database', () => {
expect.arrayContaining([
{
locationId: undefined,
- entity: expect.objectContaining(entities[0]),
+ entity: expect.objectContaining({ kind: 'k1' }),
},
{
locationId: undefined,
- entity: expect.objectContaining(entities[1]),
+ entity: expect.objectContaining({ kind: 'k2' }),
},
{
locationId: undefined,
- entity: expect.objectContaining(entities[2]),
+ entity: expect.objectContaining({ kind: 'k3' }),
},
]),
);
diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts
similarity index 73%
rename from plugins/catalog-backend/src/database/Database.ts
rename to plugins/catalog-backend/src/database/CommonDatabase.ts
index 614f7d09bf..dd965c89ef 100644
--- a/plugins/catalog-backend/src/database/Database.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.ts
@@ -19,15 +19,14 @@ import {
InputError,
NotFoundError,
} from '@backstage/backend-common';
+import type { Entity, EntityMeta, Location } from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
-import { Logger } from 'winston';
-import { EntityFilters } from '../catalog';
-import { DescriptorEnvelope, EntityMeta } from '../ingestion';
+import type { Logger } from 'winston';
import { buildEntitySearch } from './search';
-import {
- AddDatabaseLocation,
+import type {
+ Database,
DatabaseLocationUpdateLogEvent,
DatabaseLocationUpdateLogStatus,
DbEntitiesRow,
@@ -35,6 +34,8 @@ import {
DbEntityRequest,
DbEntityResponse,
DbLocationsRow,
+ DbLocationsRowWithStatus,
+ EntityFilters,
} from './types';
function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
@@ -46,17 +47,11 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
return output;
}
-function serializeMetadata(metadata: EntityMeta | undefined): string | null {
- if (!metadata) {
- return null;
- }
-
+function serializeMetadata(metadata: EntityMeta): string {
return JSON.stringify(getStrippedMetadata(metadata));
}
-function serializeSpec(
- spec: DescriptorEnvelope['spec'],
-): DbEntitiesRow['spec'] {
+function serializeSpec(spec: Entity['spec']): DbEntitiesRow['spec'] {
if (!spec) {
return null;
}
@@ -66,38 +61,34 @@ function serializeSpec(
function toEntityRow(
locationId: string | undefined,
- entity: DescriptorEnvelope,
+ entity: Entity,
): DbEntitiesRow {
return {
- id: entity.metadata!.uid!,
+ id: entity.metadata.uid!,
location_id: locationId || null,
- etag: entity.metadata!.etag!,
- generation: entity.metadata!.generation!,
+ etag: entity.metadata.etag!,
+ generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
- name: entity.metadata!.name || null,
- namespace: entity.metadata!.namespace || null,
+ name: entity.metadata.name || null,
+ namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
};
}
function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
- const entity: DescriptorEnvelope = {
+ const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
+ ...(JSON.parse(row.metadata) as Entity['metadata']),
uid: row.id,
etag: row.etag,
generation: Number(row.generation), // cast because of sqlite
},
};
- if (row.metadata) {
- const metadata = JSON.parse(row.metadata) as DescriptorEnvelope['metadata'];
- entity.metadata = { ...entity.metadata, ...metadata };
- }
-
if (row.spec) {
const spec = JSON.parse(row.spec);
entity.spec = spec;
@@ -130,27 +121,13 @@ function generateEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
}
-/**
- * An abstraction on top of the underlying database, wrapping the basic CRUD
- * needs.
- */
-export class Database {
+export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
private readonly logger: Logger,
) {}
- /**
- * Runs a transaction.
- *
- * The callback is expected to make calls back into this class. When it
- * completes, the transaction is closed.
- *
- * @param fn The callback that implements the transaction
- */
- async transaction(
- fn: (tx: Knex.Transaction) => Promise,
- ): Promise {
+ async transaction(fn: (tx: unknown) => Promise): Promise {
try {
return await this.database.transaction(fn);
} catch (e) {
@@ -167,22 +144,17 @@ export class Database {
}
}
- /**
- * Adds a new entity to the catalog.
- *
- * @param tx An ongoing transaction
- * @param request The entity being added
- * @returns The added entity, with uid, etag and generation set
- */
async addEntity(
- tx: Knex.Transaction,
+ txOpaque: unknown,
request: DbEntityRequest,
): Promise {
- if (request.entity.metadata?.uid !== undefined) {
+ const tx = txOpaque as Knex.Transaction;
+
+ if (request.entity.metadata.uid !== undefined) {
throw new InputError('May not specify uid for new entities');
- } else if (request.entity.metadata?.etag !== undefined) {
+ } else if (request.entity.metadata.etag !== undefined) {
throw new InputError('May not specify etag for new entities');
- } else if (request.entity.metadata?.generation !== undefined) {
+ } else if (request.entity.metadata.generation !== undefined) {
throw new InputError('May not specify generation for new entities');
}
@@ -192,6 +164,12 @@ export class Database {
uid: generateUid(),
etag: generateEtag(),
generation: 1,
+ annotations: {
+ ...(newEntity.metadata?.annotations ?? {}),
+ ...(request.locationId
+ ? { 'backstage.io/managed-by-location': request.locationId }
+ : {}),
+ },
};
const newRow = toEntityRow(request.locationId, newEntity);
@@ -201,25 +179,12 @@ export class Database {
return { locationId: request.locationId, entity: newEntity };
}
- /**
- * Updates an existing entity in the catalog.
- *
- * The given entity must contain enough information to identify an already
- * stored entity in the catalog - either by uid, or by kind + namespace +
- * name. If no matching entity is found, the operation fails.
- *
- * If etag or generation are given, they are taken into account. Attempts to
- * update a matching entity, but where the etag and/or generation are not
- * equal to the passed values, will fail.
- *
- * @param tx An ongoing transaction
- * @param request The entity being updated
- * @returns The updated entity
- */
async updateEntity(
- tx: Knex.Transaction,
+ txOpaque: unknown,
request: DbEntityRequest,
): Promise {
+ const tx = txOpaque as Knex.Transaction;
+
const { kind } = request.entity;
const {
uid,
@@ -289,9 +254,9 @@ export class Database {
if (oldRow.metadata) {
const oldMetadata = JSON.parse(oldRow.metadata) as EntityMeta;
if (oldMetadata.annotations) {
- newEntity.metadata!.annotations = {
+ newEntity.metadata.annotations = {
...oldMetadata.annotations,
- ...newEntity.metadata!.annotations,
+ ...newEntity.metadata.annotations,
};
}
}
@@ -313,9 +278,11 @@ export class Database {
}
async entities(
- tx: Knex.Transaction,
+ txOpaque: unknown,
filters?: EntityFilters,
): Promise {
+ const tx = txOpaque as Knex.Transaction;
+
let builder = tx('entities');
for (const [index, filter] of (filters ?? []).entries()) {
builder = builder
@@ -340,11 +307,13 @@ export class Database {
}
async entity(
- tx: Knex.Transaction,
+ txOpaque: unknown,
kind: string,
name: string,
namespace?: string,
): Promise {
+ const tx = txOpaque as Knex.Transaction;
+
const rows = await tx('entities')
.where({ kind, name, namespace: namespace || null })
.select();
@@ -356,25 +325,25 @@ export class Database {
return toEntityResponse(rows[0]);
}
- async addLocation(location: AddDatabaseLocation): Promise {
+ async removeEntity(txOpaque: unknown, uid: string): Promise {
+ const tx = txOpaque as Knex.Transaction;
+
+ const result = await tx('entities').where({ id: uid }).del();
+
+ if (!result) {
+ throw new NotFoundError(`Found no entity with ID ${uid}`);
+ }
+ }
+
+ async addLocation(location: Location): Promise {
return await this.database.transaction(async tx => {
- const existingLocation = await tx('locations')
- .where({ target: location.target })
- .select();
-
- if (existingLocation?.[0]) {
- return existingLocation[0];
- }
-
- const id = uuidv4();
- const { type, target } = location;
- await tx('locations').insert({
- id,
- type,
- target,
- });
-
- return (await tx('locations').where({ id }).select())![0];
+ const row: DbLocationsRow = {
+ id: location.id,
+ type: location.type,
+ target: location.target,
+ };
+ await tx('locations').insert(row);
+ return row;
});
}
@@ -388,18 +357,52 @@ export class Database {
}
}
- async location(id: string): Promise {
- const items = await this.database('locations')
- .where({ id })
- .select();
+ async location(id: string): Promise {
+ const items = await this.database('locations')
+ .where('locations.id', id)
+ .leftOuterJoin(
+ 'location_update_log_latest',
+ 'locations.id',
+ 'location_update_log_latest.location_id',
+ )
+ .select('locations.*', {
+ status: 'location_update_log_latest.status',
+ timestamp: 'location_update_log_latest.created_at',
+ message: 'location_update_log_latest.message',
+ });
+
if (!items.length) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
return items[0];
}
- async locations(): Promise {
- return this.database('locations').select();
+ async locations(): Promise {
+ const locations = await this.database('locations')
+ .leftOuterJoin(
+ 'location_update_log_latest',
+ 'locations.id',
+ 'location_update_log_latest.location_id',
+ )
+ .select('locations.*', {
+ status: 'location_update_log_latest.status',
+ timestamp: 'location_update_log_latest.created_at',
+ message: 'location_update_log_latest.message',
+ });
+
+ return locations;
+ }
+
+ async locationHistory(id: string): Promise {
+ const result = await this.database(
+ 'location_update_log',
+ )
+ .where('location_id', id)
+ .orderBy('created_at', 'desc')
+ .limit(10)
+ .select();
+
+ return result;
}
async addLocationUpdateLogEvent(
@@ -412,7 +415,7 @@ export class Database {
'location_update_log',
).insert({
id: uuidv4(),
- status: status,
+ status,
location_id: locationId,
entity_name: entityName,
message,
@@ -422,7 +425,7 @@ export class Database {
private async updateEntitiesSearch(
tx: Knex.Transaction,
entityId: string,
- data: DescriptorEnvelope,
+ data: Entity,
): Promise {
try {
const entries = buildEntitySearch(entityId, data);
diff --git a/plugins/catalog-backend/src/database/DatabaseManager.test.ts b/plugins/catalog-backend/src/database/DatabaseManager.test.ts
deleted file mode 100644
index ca50f518cb..0000000000
--- a/plugins/catalog-backend/src/database/DatabaseManager.test.ts
+++ /dev/null
@@ -1,238 +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 { getVoidLogger } from '@backstage/backend-common';
-import Knex from 'knex';
-import {
- ComponentDescriptor,
- DescriptorParser,
- LocationReader,
- ParserError,
-} from '../ingestion';
-import { Database } from './Database';
-import { DatabaseManager } from './DatabaseManager';
-import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types';
-
-describe('DatabaseManager', () => {
- describe('refreshLocations', () => {
- it('works with no locations added', async () => {
- const db = ({
- locations: jest.fn().mockResolvedValue([]),
- } as unknown) as Database;
- const reader: LocationReader = {
- read: jest.fn(),
- };
- const parser: DescriptorParser = {
- parse: jest.fn(),
- };
-
- await expect(
- DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
- ).resolves.toBeUndefined();
- expect(reader.read).not.toHaveBeenCalled();
- expect(parser.parse).not.toHaveBeenCalled();
- });
-
- it('can update a single location', async () => {
- const location: DbLocationsRow = {
- id: '123',
- type: 'some',
- target: 'thing',
- };
- const desc: ComponentDescriptor = {
- apiVersion: 'backstage.io/v1beta1',
- kind: 'Component',
- metadata: { name: 'c1' },
- spec: { type: 'service' },
- };
- const tx = (undefined as unknown) as Knex.Transaction;
-
- const db = ({
- transaction: jest.fn(f => f(tx)),
- entity: jest.fn(() => Promise.resolve(undefined)),
- addEntity: jest.fn(),
- locations: jest.fn(() => Promise.resolve([location])),
- addLocationUpdateLogEvent: jest.fn(),
- } as Partial) as Database;
-
- const reader: LocationReader = {
- read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
- };
- const parser: DescriptorParser = {
- parse: jest.fn(() => Promise.resolve(desc)),
- };
-
- await expect(
- DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
- ).resolves.toBeUndefined();
- expect(reader.read).toHaveBeenCalledTimes(1);
- expect(reader.read).toHaveBeenNthCalledWith(1, 'some', 'thing');
- expect(db.addEntity).toHaveBeenCalledTimes(1);
- expect(db.addEntity).toHaveBeenNthCalledWith(1, undefined, {
- locationId: '123',
- entity: expect.objectContaining({
- metadata: expect.objectContaining({ name: 'c1' }),
- }),
- });
- });
-
- it('logs successful updates', async () => {
- const tx = (undefined as unknown) as Knex.Transaction;
-
- const db = ({
- transaction: jest.fn(f => f(tx)),
- addEntity: jest.fn(),
- entity: jest.fn(() => Promise.resolve(undefined)),
- locations: jest.fn(() =>
- Promise.resolve([
- {
- id: '123',
- type: 'some',
- target: 'thing',
- } as DbLocationsRow,
- ]),
- ),
- addLocationUpdateLogEvent: jest.fn(),
- } as unknown) as Database;
-
- const desc: ComponentDescriptor = {
- apiVersion: 'backstage.io/v1beta1',
- kind: 'Component',
- metadata: { name: 'c1' },
- spec: { type: 'service' },
- };
- const reader: LocationReader = {
- read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
- };
- const parser: DescriptorParser = {
- parse: jest.fn(() => Promise.resolve(desc)),
- };
-
- await expect(
- DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
- ).resolves.toBeUndefined();
-
- expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
- 1,
- '123',
- DatabaseLocationUpdateLogStatus.SUCCESS,
- 'c1',
- );
-
- expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
- 2,
- '123',
- DatabaseLocationUpdateLogStatus.SUCCESS,
- undefined,
- );
- });
-
- it('logs unsuccessful updates when parser fails', async () => {
- const tx = (undefined as unknown) as Knex.Transaction;
-
- const db = ({
- transaction: jest.fn(f => f(tx)),
- addEntity: jest.fn(),
- locations: jest.fn(() =>
- Promise.resolve([
- {
- id: '123',
- type: 'some',
- target: 'thing',
- } as DbLocationsRow,
- ]),
- ),
- addLocationUpdateLogEvent: jest.fn(),
- } as unknown) as Database;
-
- const desc: ComponentDescriptor = {
- apiVersion: 'backstage.io/v1beta1',
- kind: 'Component',
- metadata: { name: 'c1' },
- spec: { type: 'service' },
- };
- const reader: LocationReader = {
- read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
- };
- const parser: DescriptorParser = {
- parse: jest.fn(() =>
- Promise.reject(new ParserError('parser error message', 'c1')),
- ),
- };
-
- await expect(
- DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
- ).resolves.toBeUndefined();
-
- expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
- 1,
- '123',
- DatabaseLocationUpdateLogStatus.FAIL,
- 'c1',
- 'parser error message',
- );
-
- expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
- 2,
- '123',
- DatabaseLocationUpdateLogStatus.SUCCESS,
- undefined,
- );
- });
-
- it('logs unsuccessful updates when reader fails', async () => {
- const tx = (undefined as unknown) as Knex.Transaction;
-
- const db = ({
- transaction: jest.fn(f => f(tx)),
- addEntity: jest.fn(),
- locations: jest.fn(() =>
- Promise.resolve([
- {
- id: '123',
- type: 'some',
- target: 'thing',
- } as DbLocationsRow,
- ]),
- ),
- addLocationUpdateLogEvent: jest.fn(),
- } as unknown) as Database;
-
- const reader: LocationReader = {
- read: jest.fn(() =>
- Promise.reject([{ type: 'error', error: new Error('test message') }]),
- ),
- };
- const parser: DescriptorParser = {
- parse: jest.fn(() =>
- Promise.reject(new ParserError('parser error message', 'c1')),
- ),
- };
-
- await expect(
- DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
- ).resolves.toBeUndefined();
-
- expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
- 1,
- '123',
- DatabaseLocationUpdateLogStatus.FAIL,
- undefined,
- undefined,
- );
- });
- });
-});
diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts
index 7f646971db..bc7c7b8dae 100644
--- a/plugins/catalog-backend/src/database/DatabaseManager.ts
+++ b/plugins/catalog-backend/src/database/DatabaseManager.ts
@@ -15,173 +15,20 @@
*/
import Knex from 'knex';
-import lodash from 'lodash';
import path from 'path';
import { Logger } from 'winston';
-import {
- DescriptorEnvelope,
- DescriptorParser,
- LocationReader,
- ParserError,
-} from '../ingestion';
-import { Database } from './Database';
-import { DatabaseLocationUpdateLogStatus, DbEntityRequest } from './types';
+import { CommonDatabase } from './CommonDatabase';
+import type { Database } from './types';
export class DatabaseManager {
public static async createDatabase(
- database: Knex,
+ knex: Knex,
logger: Logger,
): Promise {
- await database.migrate.latest({
+ await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.js'],
});
- return new Database(database, logger);
- }
-
- private static async logUpdateSuccess(
- database: Database,
- locationId: string,
- entityName?: string,
- ) {
- return database.addLocationUpdateLogEvent(
- locationId,
- DatabaseLocationUpdateLogStatus.SUCCESS,
- entityName,
- );
- }
-
- private static async logUpdateFailure(
- database: Database,
- locationId: string,
- error?: Error,
- entityName?: string,
- ) {
- return database.addLocationUpdateLogEvent(
- locationId,
- DatabaseLocationUpdateLogStatus.FAIL,
- entityName,
- error?.message,
- );
- }
-
- public static async refreshLocations(
- database: Database,
- reader: LocationReader,
- parser: DescriptorParser,
- logger: Logger,
- ): Promise {
- const locations = await database.locations();
- for (const location of locations) {
- try {
- logger.debug(
- `Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`,
- );
-
- const readerOutput = await reader.read(location.type, location.target);
-
- for (const readerItem of readerOutput) {
- if (readerItem.type === 'error') {
- logger.info(readerItem.error);
- continue;
- }
-
- try {
- const entity = await parser.parse(readerItem.data);
- await DatabaseManager.refreshSingleEntity(
- database,
- location.id,
- entity,
- logger,
- );
- await DatabaseManager.logUpdateSuccess(
- database,
- location.id,
- entity.metadata!.name,
- );
- } catch (error) {
- let entityName;
- if (error instanceof ParserError) {
- entityName = error.entityName;
- }
- await DatabaseManager.logUpdateFailure(
- database,
- location.id,
- error,
- entityName,
- );
- }
- }
- await DatabaseManager.logUpdateSuccess(
- database,
- location.id,
- undefined,
- );
- } catch (error) {
- logger.debug(
- `Failed to refresh location id="${location.id}", ${error}`,
- );
- await DatabaseManager.logUpdateFailure(database, location.id, error);
- }
- }
- }
-
- private static async refreshSingleEntity(
- database: Database,
- locationId: string,
- entity: DescriptorEnvelope,
- logger: Logger,
- ): Promise {
- const { kind } = entity;
- const { name, namespace } = entity.metadata || {};
- if (!name) {
- throw new Error('Entities without names are not yet supported');
- }
-
- const request: DbEntityRequest = {
- locationId: locationId,
- entity: entity,
- };
-
- logger.debug(
- `Read entity kind="${kind}" name="${name}" namespace="${namespace}"`,
- );
-
- await database.transaction(async tx => {
- const previous = await database.entity(tx, kind, name, namespace);
- if (!previous) {
- logger.debug(`No such entity found, adding`);
- await database.addEntity(tx, request);
- } else if (
- !DatabaseManager.entitiesAreEqual(previous.entity, request.entity)
- ) {
- logger.debug(`Different from existing entity, updating`);
- await database.updateEntity(tx, request);
- } else {
- logger.debug(`Equal to existing entity, skipping update`);
- }
- });
- }
-
- private static entitiesAreEqual(
- first: DescriptorEnvelope,
- second: DescriptorEnvelope,
- ) {
- const firstClone = lodash.cloneDeep(first);
- const secondClone = lodash.cloneDeep(second);
-
- // Remove generated fields
- if (firstClone.metadata) {
- delete firstClone.metadata.uid;
- delete firstClone.metadata.etag;
- delete firstClone.metadata.generation;
- }
- if (secondClone.metadata) {
- delete secondClone.metadata.uid;
- delete secondClone.metadata.etag;
- delete secondClone.metadata.generation;
- }
-
- return lodash.isEqual(firstClone, secondClone);
+ return new CommonDatabase(knex, logger);
}
}
diff --git a/plugins/catalog-backend/src/database/index.ts b/plugins/catalog-backend/src/database/index.ts
index 616808fb67..565a41cfb2 100644
--- a/plugins/catalog-backend/src/database/index.ts
+++ b/plugins/catalog-backend/src/database/index.ts
@@ -14,6 +14,12 @@
* limitations under the License.
*/
-export * from './Database';
-export * from './DatabaseManager';
-export * from './types';
+export { CommonDatabase } from './CommonDatabase';
+export { DatabaseManager } from './DatabaseManager';
+export type {
+ Database,
+ DbEntityRequest,
+ DbEntityResponse,
+ EntityFilter,
+ EntityFilters,
+} from './types';
diff --git a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts b/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts
index f029871a70..5f136670f4 100644
--- a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts
+++ b/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts
@@ -26,7 +26,11 @@ export async function up(knex: Knex): Promise {
table.comment(
'Registered locations that shall be contiuously scanned for catalog item updates',
);
- table.uuid('id').primary().comment('Auto-generated ID of the location');
+ table
+ .uuid('id')
+ .primary()
+ .notNullable()
+ .comment('Auto-generated ID of the location');
table.string('type').notNullable().comment('The type of location');
table
.string('target')
@@ -76,7 +80,7 @@ export async function up(knex: Knex): Promise {
.comment('The metadata.namespace field of the entity');
table
.string('metadata')
- .nullable()
+ .notNullable()
.comment('The entire metadata JSON blob of the entity');
table
.string('spec')
diff --git a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts b/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts
index b2e1dc0d32..6700f5748e 100644
--- a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts
+++ b/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts
@@ -19,7 +19,10 @@ export async function up(knex: Knex): Promise {
return knex.schema.createTable('location_update_log', table => {
table.uuid('id').primary();
table.enum('status', ['success', 'fail']).notNullable();
- table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable();
+ table
+ .dateTime('created_at')
+ .defaultTo(knex.fn.now())
+ .notNullable();
table.string('message');
table
.uuid('location_id')
diff --git a/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts b/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts
new file mode 100644
index 0000000000..6da9eeed9b
--- /dev/null
+++ b/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 Knex from 'knex';
+
+export async function up(knex: Knex): Promise {
+ // Need to first order by date of creation
+ const query = knex
+ .select()
+ .from('location_update_log')
+ .orderBy('location_update_log.created_at', 'desc');
+
+ // And only then to do the grouping to get the latest per location
+ const groupedQuery = knex(query).groupBy('location_id').select();
+
+ await knex.schema.raw(
+ `CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`,
+ );
+}
+
+export async function down(knex: Knex): Promise {
+ return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
+}
diff --git a/plugins/catalog-backend/src/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts
index 26b20d1846..38a2d40e74 100644
--- a/plugins/catalog-backend/src/database/search.test.ts
+++ b/plugins/catalog-backend/src/database/search.test.ts
@@ -14,9 +14,9 @@
* limitations under the License.
*/
-import { DescriptorEnvelope } from '../ingestion';
+import type { Entity } from '@backstage/catalog-model';
import { buildEntitySearch, visitEntityPart } from './search';
-import { DbEntitiesSearchRow } from './types';
+import type { DbEntitiesSearchRow } from './types';
describe('search', () => {
describe('visitEntityPart', () => {
@@ -99,24 +99,25 @@ describe('search', () => {
describe('buildEntitySearch', () => {
it('adds special keys even if missing', () => {
- const input: DescriptorEnvelope = {
+ const input: Entity = {
apiVersion: 'a',
kind: 'b',
+ metadata: { name: 'n' },
};
expect(buildEntitySearch('eid', input)).toEqual([
- { entity_id: 'eid', key: 'metadata.name', value: null },
+ { entity_id: 'eid', key: 'metadata.name', value: 'n' },
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
{ entity_id: 'eid', key: 'metadata.uid', value: null },
{ entity_id: 'eid', key: 'apiVersion', value: 'a' },
{ entity_id: 'eid', key: 'kind', value: 'b' },
- { entity_id: 'eid', key: 'name', value: null },
+ { entity_id: 'eid', key: 'name', value: 'n' },
{ entity_id: 'eid', key: 'namespace', value: null },
{ entity_id: 'eid', key: 'uid', value: null },
]);
});
it('adds prefix-stripped versions', () => {
- const input: DescriptorEnvelope = {
+ const input: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts
index f54cfde7e6..c14acb6661 100644
--- a/plugins/catalog-backend/src/database/search.ts
+++ b/plugins/catalog-backend/src/database/search.ts
@@ -14,8 +14,8 @@
* limitations under the License.
*/
-import { DescriptorEnvelope } from '../ingestion';
-import { DbEntitiesSearchRow } from './types';
+import type { Entity } from '@backstage/catalog-model';
+import type { DbEntitiesSearchRow } from './types';
// Search entries that start with these prefixes, also get a shorthand without
// that prefix
@@ -119,7 +119,7 @@ export function visitEntityPart(
*/
export function buildEntitySearch(
entityId: string,
- entity: DescriptorEnvelope,
+ entity: Entity,
): DbEntitiesSearchRow[] {
// Start with some special keys that are always present because you want to
// be able to easily search for null specifically
@@ -127,17 +127,17 @@ export function buildEntitySearch(
{
entity_id: entityId,
key: 'metadata.name',
- value: toValue(entity.metadata?.name),
+ value: toValue(entity.metadata.name),
},
{
entity_id: entityId,
key: 'metadata.namespace',
- value: toValue(entity.metadata?.namespace),
+ value: toValue(entity.metadata.namespace),
},
{
entity_id: entityId,
key: 'metadata.uid',
- value: toValue(entity.metadata?.uid),
+ value: toValue(entity.metadata.uid),
},
];
diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts
index 06e234545f..17da373ae9 100644
--- a/plugins/catalog-backend/src/database/types.ts
+++ b/plugins/catalog-backend/src/database/types.ts
@@ -14,8 +14,7 @@
* limitations under the License.
*/
-import * as yup from 'yup';
-import { DescriptorEnvelope } from '../ingestion';
+import type { Entity, Location } from '@backstage/catalog-model';
export type DbEntitiesRow = {
id: string;
@@ -26,18 +25,18 @@ export type DbEntitiesRow = {
namespace: string | null;
etag: string;
generation: number;
- metadata: string | null;
+ metadata: string;
spec: string | null;
};
export type DbEntityRequest = {
locationId?: string;
- entity: DescriptorEnvelope;
+ entity: Entity;
};
export type DbEntityResponse = {
locationId?: string;
- entity: DescriptorEnvelope;
+ entity: Entity;
};
export type DbEntitiesSearchRow = {
@@ -52,18 +51,12 @@ export type DbLocationsRow = {
target: string;
};
-export type AddDatabaseLocation = {
- type: string;
- target: string;
+export type DbLocationsRowWithStatus = DbLocationsRow & {
+ status: string | null;
+ timestamp: string | null;
+ message: string | null;
};
-export const addDatabaseLocationSchema: yup.Schema = yup
- .object({
- type: yup.string().required(),
- target: yup.string().required(),
- })
- .noUnknown();
-
export enum DatabaseLocationUpdateLogStatus {
FAIL = 'fail',
SUCCESS = 'success',
@@ -77,3 +70,82 @@ export type DatabaseLocationUpdateLogEvent = {
created_at?: string;
message?: string;
};
+
+export type EntityFilter = {
+ key: string;
+ values: (string | null)[];
+};
+export type EntityFilters = EntityFilter[];
+
+/**
+ * An abstraction on top of the underlying database, wrapping the basic CRUD
+ * needs.
+ */
+export type Database = {
+ /**
+ * Runs a transaction.
+ *
+ * The callback is expected to make calls back into this class. When it
+ * completes, the transaction is closed.
+ *
+ * @param fn The callback that implements the transaction
+ */
+ transaction(fn: (tx: unknown) => Promise): Promise;
+
+ /**
+ * Adds a new entity to the catalog.
+ *
+ * @param tx An ongoing transaction
+ * @param request The entity being added
+ * @returns The added entity, with uid, etag and generation set
+ */
+ addEntity(tx: unknown, request: DbEntityRequest): Promise;
+
+ /**
+ * Updates an existing entity in the catalog.
+ *
+ * The given entity must contain enough information to identify an already
+ * stored entity in the catalog - either by uid, or by kind + namespace +
+ * name. If no matching entity is found, the operation fails.
+ *
+ * If etag or generation are given, they are taken into account. Attempts to
+ * update a matching entity, but where the etag and/or generation are not
+ * equal to the passed values, will fail.
+ *
+ * @param tx An ongoing transaction
+ * @param request The entity being updated
+ * @returns The updated entity
+ */
+ updateEntity(
+ tx: unknown,
+ request: DbEntityRequest,
+ ): Promise;
+
+ entities(tx: unknown, filters?: EntityFilters): Promise;
+
+ entity(
+ tx: unknown,
+ kind: string,
+ name: string,
+ namespace?: string,
+ ): Promise;
+
+ removeEntity(tx: unknown, uid: string): Promise;
+
+ addLocation(location: Location): Promise;
+
+ removeLocation(id: string): Promise;
+
+ location(id: string): Promise;
+
+ locations(): Promise;
+
+ locationHistory(id: string): Promise;
+
+ addLocationUpdateLogEvent(
+ locationId: string,
+ status: DatabaseLocationUpdateLogStatus,
+ entityName?: string,
+ message?: string,
+ ): Promise;
+};
diff --git a/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts b/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts
deleted file mode 100644
index 86283df666..0000000000
--- a/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts
+++ /dev/null
@@ -1,53 +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 { makeValidator } from '../validation';
-import { ComponentDescriptorV1beta1Parser } from './descriptors/ComponentDescriptorV1beta1Parser';
-import { DescriptorEnvelopeParser } from './descriptors/DescriptorEnvelopeParser';
-import {
- DescriptorEnvelope,
- DescriptorParser,
- KindParser,
- ParserError,
-} from './types';
-
-export class DescriptorParsers implements DescriptorParser {
- static create(): DescriptorParser {
- const validators = makeValidator();
- return new DescriptorParsers(new DescriptorEnvelopeParser(validators), [
- new ComponentDescriptorV1beta1Parser(),
- ]);
- }
-
- constructor(
- private readonly envelopeParser: DescriptorEnvelopeParser,
- private readonly kindParsers: KindParser[],
- ) {}
-
- async parse(descriptor: object): Promise {
- const envelope = await this.envelopeParser.parse(descriptor);
- for (const parser of this.kindParsers) {
- const parsed = await parser.tryParse(envelope);
- if (parsed) {
- return parsed;
- }
- }
- throw new ParserError(
- `Unsupported object ${envelope.apiVersion}, ${envelope.kind}`,
- envelope.metadata?.name,
- );
- }
-}
diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts
new file mode 100644
index 0000000000..bd8dbedb27
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts
@@ -0,0 +1,292 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { getVoidLogger } from '@backstage/backend-common';
+import { Entity, Location } from '@backstage/catalog-model';
+import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { LocationUpdateStatus } from '../catalog/types';
+import { DatabaseLocationUpdateLogStatus } from '../database/types';
+import { HigherOrderOperations } from './HigherOrderOperations';
+import { IngestionModel } from './types';
+
+describe('HigherOrderOperations', () => {
+ let entitiesCatalog: jest.Mocked;
+ let locationsCatalog: jest.Mocked;
+ let ingestionModel: jest.Mocked;
+ let higherOrderOperation: HigherOrderOperations;
+
+ beforeAll(() => {
+ entitiesCatalog = {
+ entities: jest.fn(),
+ entityByUid: jest.fn(),
+ entityByName: jest.fn(),
+ addOrUpdateEntity: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ };
+ locationsCatalog = {
+ addLocation: jest.fn(),
+ removeLocation: jest.fn(),
+ locations: jest.fn(),
+ location: jest.fn(),
+ locationHistory: jest.fn(),
+ logUpdateSuccess: jest.fn(),
+ logUpdateFailure: jest.fn(),
+ };
+ ingestionModel = {
+ readLocation: jest.fn(),
+ };
+ higherOrderOperation = new HigherOrderOperations(
+ entitiesCatalog,
+ locationsCatalog,
+ ingestionModel,
+ getVoidLogger(),
+ );
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('addLocation', () => {
+ it('just inserts the location when there are no entities to read', async () => {
+ const spec = {
+ type: 'a',
+ target: 'b',
+ };
+ locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x));
+ locationsCatalog.locations.mockResolvedValue([]);
+ ingestionModel.readLocation.mockResolvedValue([]);
+
+ const result = await higherOrderOperation.addLocation(spec);
+
+ expect(result.location).toEqual(
+ expect.objectContaining({
+ id: expect.anything(),
+ ...spec,
+ }),
+ );
+ expect(result.entities).toEqual([]);
+ expect(locationsCatalog.locations).toBeCalledTimes(1);
+ expect(ingestionModel.readLocation).toBeCalledTimes(1);
+ expect(ingestionModel.readLocation).toBeCalledWith('a', 'b');
+ expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
+ expect(locationsCatalog.addLocation).toBeCalledTimes(1);
+ expect(locationsCatalog.addLocation).toBeCalledWith(
+ expect.objectContaining({
+ id: expect.anything(),
+ ...spec,
+ }),
+ );
+ });
+
+ it('reuses the location if a match already existed', async () => {
+ const spec = {
+ type: 'a',
+ target: 'b',
+ };
+ const location = {
+ id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ ...spec,
+ };
+
+ locationsCatalog.locations.mockResolvedValue([
+ {
+ currentStatus: { timestamp: '', status: '', message: '' },
+ data: location,
+ },
+ ]);
+ ingestionModel.readLocation.mockResolvedValue([]);
+
+ const result = await higherOrderOperation.addLocation(spec);
+
+ expect(result.location).toEqual(location);
+ expect(result.entities).toEqual([]);
+ expect(locationsCatalog.locations).toBeCalledTimes(1);
+ expect(ingestionModel.readLocation).toBeCalledTimes(1);
+ expect(ingestionModel.readLocation).toBeCalledWith('a', 'b');
+ expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
+ expect(locationsCatalog.addLocation).not.toBeCalled();
+ });
+
+ it('rejects the whole operation if any entity could not be read', async () => {
+ const spec = {
+ type: 'a',
+ target: 'b',
+ };
+ const entity: Entity = {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: { name: 'n' },
+ };
+
+ locationsCatalog.locations.mockResolvedValue([]);
+ ingestionModel.readLocation.mockResolvedValue([
+ { type: 'data', data: entity },
+ { type: 'error', error: new Error('abcd') },
+ ]);
+
+ await expect(higherOrderOperation.addLocation(spec)).rejects.toThrow(
+ /abcd/,
+ );
+ expect(locationsCatalog.locations).toBeCalledTimes(1);
+ expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
+ expect(locationsCatalog.addLocation).not.toBeCalled();
+ });
+ });
+
+ describe('refreshLocations', () => {
+ it('works with no locations added', async () => {
+ locationsCatalog.locations.mockResolvedValue([]);
+
+ await expect(
+ higherOrderOperation.refreshAllLocations(),
+ ).resolves.toBeUndefined();
+
+ expect(locationsCatalog.locations).toHaveBeenCalledTimes(1);
+ expect(ingestionModel.readLocation).not.toHaveBeenCalled();
+ expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
+ });
+
+ it('can update a single location where a matching entity did not exist', async () => {
+ const locationStatus: LocationUpdateStatus = {
+ message: '',
+ status: DatabaseLocationUpdateLogStatus.SUCCESS,
+ timestamp: new Date(314159265).toISOString(),
+ };
+ const location: Location = {
+ id: '123',
+ type: 'some',
+ target: 'thing',
+ };
+ const desc: Entity = {
+ apiVersion: 'backstage.io/v1beta1',
+ kind: 'Component',
+ metadata: { name: 'c1' },
+ spec: { type: 'service' },
+ };
+
+ locationsCatalog.locations.mockResolvedValue([
+ { currentStatus: locationStatus, data: location },
+ ]);
+ ingestionModel.readLocation.mockResolvedValue([
+ { type: 'data', data: desc },
+ ]);
+ entitiesCatalog.entityByName.mockResolvedValue(undefined);
+ entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
+
+ await expect(
+ higherOrderOperation.refreshAllLocations(),
+ ).resolves.toBeUndefined();
+
+ expect(locationsCatalog.locations).toHaveBeenCalledTimes(1);
+ expect(ingestionModel.readLocation).toHaveBeenCalledTimes(1);
+ expect(ingestionModel.readLocation).toHaveBeenNthCalledWith(
+ 1,
+ 'some',
+ 'thing',
+ );
+ expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(
+ 1,
+ 'Component',
+ undefined,
+ 'c1',
+ );
+ expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ metadata: expect.objectContaining({ name: 'c1' }),
+ }),
+ '123',
+ );
+ });
+
+ it('logs successful updates', async () => {
+ const locationStatus: LocationUpdateStatus = {
+ message: '',
+ status: DatabaseLocationUpdateLogStatus.SUCCESS,
+ timestamp: new Date(314159265).toISOString(),
+ };
+ const location: Location = {
+ id: '123',
+ type: 'some',
+ target: 'thing',
+ };
+ const desc: Entity = {
+ apiVersion: 'backstage.io/v1beta1',
+ kind: 'Component',
+ metadata: { name: 'c1' },
+ spec: { type: 'service' },
+ };
+
+ locationsCatalog.locations.mockResolvedValue([
+ { currentStatus: locationStatus, data: location },
+ ]);
+ ingestionModel.readLocation.mockResolvedValue([
+ { type: 'data', data: desc },
+ ]);
+ entitiesCatalog.entityByName.mockResolvedValue(undefined);
+ entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
+
+ await expect(
+ higherOrderOperation.refreshAllLocations(),
+ ).resolves.toBeUndefined();
+
+ expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledTimes(2);
+ expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith(
+ '123',
+ undefined,
+ );
+ expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith(
+ '123',
+ 'c1',
+ );
+ });
+
+ it('logs unsuccessful updates when reader fails', async () => {
+ const locationStatus: LocationUpdateStatus = {
+ message: '',
+ status: DatabaseLocationUpdateLogStatus.SUCCESS,
+ timestamp: new Date(314159265).toISOString(),
+ };
+ const location: Location = {
+ id: '123',
+ type: 'some',
+ target: 'thing',
+ };
+
+ locationsCatalog.locations.mockResolvedValue([
+ { currentStatus: locationStatus, data: location },
+ ]);
+ ingestionModel.readLocation.mockRejectedValue(
+ new Error('reader error message'),
+ );
+
+ await expect(
+ higherOrderOperation.refreshAllLocations(),
+ ).resolves.toBeUndefined();
+
+ expect(ingestionModel.readLocation).toHaveBeenCalledTimes(1);
+ expect(locationsCatalog.logUpdateFailure).toHaveBeenCalledTimes(1);
+ expect(locationsCatalog.logUpdateSuccess).not.toHaveBeenCalled();
+ expect(locationsCatalog.logUpdateFailure).toHaveBeenCalledWith(
+ '123',
+ expect.objectContaining({ message: 'reader error message' }),
+ );
+ });
+ });
+});
diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts
new file mode 100644
index 0000000000..e9051d1f78
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts
@@ -0,0 +1,269 @@
+/*
+ * 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 { InputError } from '@backstage/backend-common';
+import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
+import lodash from 'lodash';
+import { v4 as uuidv4 } from 'uuid';
+import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { IngestionModel } from '../ingestion';
+import { AddLocationResult, HigherOrderOperation } from './types';
+import { Logger } from 'winston';
+
+const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
+
+/**
+ * Placeholder for operations that span several catalogs and/or stretches out
+ * in time.
+ *
+ * TODO(freben): Find a better home for these, possibly refactoring to use the
+ * database more directly.
+ */
+export class HigherOrderOperations implements HigherOrderOperation {
+ private readonly entitiesCatalog: EntitiesCatalog;
+ private readonly locationsCatalog: LocationsCatalog;
+ private readonly ingestionModel: IngestionModel;
+ private readonly logger: Logger;
+
+ constructor(
+ entitiesCatalog: EntitiesCatalog,
+ locationsCatalog: LocationsCatalog,
+ ingestionModel: IngestionModel,
+ logger: Logger,
+ ) {
+ this.entitiesCatalog = entitiesCatalog;
+ this.locationsCatalog = locationsCatalog;
+ this.ingestionModel = ingestionModel;
+ this.logger = logger;
+ }
+
+ /**
+ * Adds a single location to the catalog.
+ *
+ * The location is inspected and fetched, and all of the resulting data is
+ * validated. If everything goes well, the location and entities are stored
+ * in the catalog.
+ *
+ * If the location already existed, the old location is returned instead and
+ * the catalog is left unchanged.
+ *
+ * @param spec The location to add
+ */
+ async addLocation(spec: LocationSpec): Promise {
+ // Attempt to find a previous location matching the spec
+ const previousLocations = await this.locationsCatalog.locations();
+ const previousLocation = previousLocations.find(
+ l => spec.type === l.data.type && spec.target === l.data.target,
+ );
+ const location: Location = previousLocation
+ ? previousLocation.data
+ : {
+ id: uuidv4(),
+ type: spec.type,
+ target: spec.target,
+ };
+
+ // Read the location fully, bailing on any errors
+ const readerOutput = await this.ingestionModel.readLocation(
+ location.type,
+ location.target,
+ );
+ const inputEntities: Entity[] = [];
+ for (const entry of readerOutput) {
+ if (entry.type === 'error') {
+ throw new InputError(
+ `Failed to read location ${location.type} ${location.target}, ${entry.error}`,
+ );
+ } else {
+ // Append the location reference annotation
+ entry.data.metadata.annotations = {
+ ...entry.data.metadata.annotations,
+ [LOCATION_ANNOTATION]: location.id,
+ };
+ inputEntities.push(entry.data);
+ }
+ }
+
+ // TODO(freben): At this point, we could detect orphaned entities, by way
+ // of having a LOCATION_ANNOTATION pointing to the location but not being
+ // in the entities list. But we aren't sure what to do about those yet.
+
+ // Write
+ if (!previousLocation) {
+ await this.locationsCatalog.addLocation(location);
+ }
+ const outputEntities: Entity[] = [];
+ for (const entity of inputEntities) {
+ const out = await this.entitiesCatalog.addOrUpdateEntity(
+ entity,
+ location.id,
+ );
+ outputEntities.push(out);
+ }
+
+ return { location, entities: outputEntities };
+ }
+
+ /**
+ * Goes through all registered locations, and performs a refresh of each one.
+ *
+ * Entities are read from their respective sources, are parsed and validated
+ * according to the entity policy, and get inserted or updated in the catalog.
+ * Entities that have disappeared from their location are left orphaned,
+ * without changes.
+ */
+ async refreshAllLocations(): Promise {
+ const startTimestamp = new Date().valueOf();
+ this.logger.info('Beginning locations refresh');
+
+ const locations = await this.locationsCatalog.locations();
+ this.logger.info(`Visiting ${locations.length} locations`);
+
+ for (const { data: location } of locations) {
+ this.logger.debug(
+ `Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`,
+ );
+ try {
+ await this.refreshSingleLocation(location);
+ await this.locationsCatalog.logUpdateSuccess(location.id, undefined);
+ } catch (e) {
+ this.logger.debug(
+ `Failed to refresh location id="${location.id}" type="${location.type}" target="${location.target}", ${e}`,
+ );
+ await this.locationsCatalog.logUpdateFailure(location.id, e);
+ }
+ }
+
+ const endTimestamp = new Date().valueOf();
+ const duration = ((endTimestamp - startTimestamp) / 1000).toFixed(1);
+ this.logger.debug(`Completed locations refresh in ${duration} seconds`);
+ }
+
+ // Performs a full refresh of a single location
+ private async refreshSingleLocation(location: Location) {
+ const readerOutput = await this.ingestionModel.readLocation(
+ location.type,
+ location.target,
+ );
+
+ for (const readerItem of readerOutput) {
+ if (readerItem.type === 'error') {
+ this.logger.debug(
+ `Failed item in location id="${location.id}" type="${location.type}" target="${location.target}", ${readerItem.error}`,
+ );
+ continue;
+ }
+
+ const entity = readerItem.data;
+ this.logger.debug(
+ `Read entity kind="${entity.kind}" name="${
+ entity.metadata.name
+ }" namespace="${entity.metadata.namespace || ''}"`,
+ );
+
+ try {
+ const previous = await this.entitiesCatalog.entityByName(
+ entity.kind,
+ entity.metadata.namespace,
+ entity.metadata.name,
+ );
+
+ if (!previous) {
+ this.logger.debug(`No such entity found, adding`);
+ await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
+ } else if (!this.entitiesAreEqual(previous, entity)) {
+ this.logger.debug(`Different from existing entity, updating`);
+ await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
+ } else {
+ this.logger.debug(`Equal to existing entity, skipping update`);
+ }
+
+ await this.locationsCatalog.logUpdateSuccess(
+ location.id,
+ entity.metadata.name,
+ );
+ } catch (error) {
+ this.logger.debug(
+ `Failed refresh of entity kind="${entity.kind}" name="${
+ entity.metadata.name
+ }" namespace="${entity.metadata.namespace || ''}", ${error}`,
+ );
+
+ await this.locationsCatalog.logUpdateFailure(
+ location.id,
+ error,
+ entity.metadata.name,
+ );
+ }
+ }
+ }
+
+ // Compares entities, ignoring generated and irrelevant data
+ private entitiesAreEqual(previous: Entity, next: Entity): boolean {
+ if (
+ previous.apiVersion !== next.apiVersion ||
+ previous.kind !== next.kind ||
+ !lodash.isEqual(previous.spec, next.spec) // Accept that {} !== undefined
+ ) {
+ return false;
+ }
+
+ // Since the next annotations get merged into the previous, extract only
+ // the overlapping keys and check if their values match.
+ if (next.metadata.annotations) {
+ if (!previous.metadata.annotations) {
+ return false;
+ }
+ if (
+ !lodash.isEqual(
+ next.metadata.annotations,
+ lodash.pick(
+ previous.metadata.annotations,
+ Object.keys(next.metadata.annotations),
+ ),
+ )
+ ) {
+ return false;
+ }
+ }
+
+ const e1 = lodash.cloneDeep(previous);
+ const e2 = lodash.cloneDeep(next);
+
+ if (!e1.metadata.labels) {
+ e1.metadata.labels = {};
+ }
+ if (!e2.metadata.labels) {
+ e2.metadata.labels = {};
+ }
+
+ // Remove generated fields
+ delete e1.metadata.uid;
+ delete e1.metadata.etag;
+ delete e1.metadata.generation;
+ delete e2.metadata.uid;
+ delete e2.metadata.etag;
+ delete e2.metadata.generation;
+
+ // Remove already compared things
+ delete e1.metadata.annotations;
+ delete e1.spec;
+ delete e2.metadata.annotations;
+ delete e2.spec;
+
+ return lodash.isEqual(e1, e2);
+ }
+}
diff --git a/plugins/catalog-backend/src/ingestion/IngestionModels.ts b/plugins/catalog-backend/src/ingestion/IngestionModels.ts
new file mode 100644
index 0000000000..8febdecd18
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/IngestionModels.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { EntityPolicies, EntityPolicy } from '@backstage/catalog-model';
+import { DescriptorParsers } from './descriptor';
+import { DescriptorParser, ReaderOutput } from './descriptor/parsers/types';
+import { LocationReader, LocationReaders } from './source';
+import { IngestionModel } from './types';
+
+export class IngestionModels implements IngestionModel {
+ private readonly reader: LocationReader;
+ private readonly parser: DescriptorParser;
+ private readonly entityPolicy: EntityPolicy;
+
+ static default(): IngestionModel {
+ return new IngestionModels(
+ new LocationReaders(),
+ new DescriptorParsers(),
+ new EntityPolicies(),
+ );
+ }
+
+ constructor(
+ reader: LocationReader,
+ parser: DescriptorParser,
+ entityPolicy: EntityPolicy,
+ ) {
+ this.reader = reader;
+ this.parser = parser;
+ this.entityPolicy = entityPolicy;
+ }
+
+ async readLocation(type: string, target: string): Promise {
+ const buffer = await this.reader.tryRead(type, target);
+ if (!buffer) {
+ throw new Error(`No reader could handle location ${type} ${target}`);
+ }
+
+ const items = await this.parser.tryParse(buffer);
+ if (!items) {
+ throw new Error(`No parser could handle location ${type} ${target}`);
+ }
+
+ const result: ReaderOutput[] = [];
+ for (const item of items) {
+ if (item.type === 'error') {
+ result.push(item);
+ } else {
+ try {
+ const output = await this.entityPolicy.enforce(item.data);
+ result.push({ type: 'data', data: output });
+ } catch (e) {
+ result.push({ type: 'error', error: e });
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts
deleted file mode 100644
index aa88d299e4..0000000000
--- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts
+++ /dev/null
@@ -1,39 +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 { FileLocationSource } from './sources/FileLocationSource';
-import { GitHubLocationSource } from './sources/GitHubLocationSource';
-import { LocationReader, LocationSource, ReaderOutput } from './types';
-
-export class LocationReaders implements LocationReader {
- static create(): LocationReader {
- return new LocationReaders({
- file: new FileLocationSource(),
- github: new GitHubLocationSource(),
- });
- }
-
- constructor(private readonly sources: Record) {}
-
- async read(type: string, target: string): Promise {
- const source = this.sources[type];
- if (!source) {
- throw new Error(`Unknown location type ${type}`);
- }
-
- return source.read(target);
- }
-}
diff --git a/plugins/catalog-backend/src/ingestion/descriptor/DescriptorParsers.ts b/plugins/catalog-backend/src/ingestion/descriptor/DescriptorParsers.ts
new file mode 100644
index 0000000000..ed05855109
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/descriptor/DescriptorParsers.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { DescriptorParser, ReaderOutput } from './parsers/types';
+import { YamlDescriptorParser } from './parsers/YamlDescriptorParser';
+
+/**
+ * Parses raw descriptor data (e.g. from a file or stream) into entities.
+ */
+export class DescriptorParsers implements DescriptorParser {
+ private readonly parsers: DescriptorParser[];
+
+ static defaultParsers(): DescriptorParser[] {
+ return [new YamlDescriptorParser()];
+ }
+
+ constructor(
+ parsers: DescriptorParser[] = DescriptorParsers.defaultParsers(),
+ ) {
+ this.parsers = parsers;
+ }
+
+ async tryParse(data: Buffer): Promise {
+ for (const parser of this.parsers) {
+ const result = await parser.tryParse(data);
+ if (result) {
+ return result;
+ }
+ }
+ throw new Error(`Unsupported descriptor format`);
+ }
+}
diff --git a/plugins/catalog-backend/src/ingestion/descriptor/index.ts b/plugins/catalog-backend/src/ingestion/descriptor/index.ts
new file mode 100644
index 0000000000..1529c78afc
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/descriptor/index.ts
@@ -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 { DescriptorParsers } from './DescriptorParsers';
+export { YamlDescriptorParser } from './parsers/YamlDescriptorParser';
diff --git a/plugins/catalog-backend/src/ingestion/descriptor/parsers/YamlDescriptorParser.ts b/plugins/catalog-backend/src/ingestion/descriptor/parsers/YamlDescriptorParser.ts
new file mode 100644
index 0000000000..3f2e9e3c5c
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/descriptor/parsers/YamlDescriptorParser.ts
@@ -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 { Entity } from '@backstage/catalog-model';
+import yaml from 'yaml';
+import { DescriptorParser, ReaderOutput } from './types';
+
+/**
+ * Parses descriptors on YAML format
+ */
+export class YamlDescriptorParser implements DescriptorParser {
+ async tryParse(data: Buffer): Promise {
+ // TODO(freben): Should perhaps first do format detection, so the parse
+ // failure can be emitted as a proper error instead of just as if we
+ // weren't handling the format at all.
+ let documents;
+ try {
+ documents = yaml.parseAllDocuments(data.toString('utf8'));
+ } catch (e) {
+ return undefined;
+ }
+
+ const result: ReaderOutput[] = [];
+
+ for (const document of documents) {
+ if (document.contents) {
+ if (document.errors?.length) {
+ result.push({
+ type: 'error',
+ error: new Error(`Malformed YAML document, ${document.errors[0]}`),
+ });
+ } else {
+ const json = document.toJSON();
+ if (typeof json !== 'object' || Array.isArray(json)) {
+ result.push({
+ type: 'error',
+ error: new Error(`Malformed descriptor, expected object at root`),
+ });
+ } else {
+ result.push({
+ type: 'data',
+ data: json as Entity,
+ });
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/plugins/catalog-backend/src/ingestion/descriptor/parsers/types.ts b/plugins/catalog-backend/src/ingestion/descriptor/parsers/types.ts
new file mode 100644
index 0000000000..baef5dc3df
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/descriptor/parsers/types.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 } from '@backstage/catalog-model';
+
+export type ReaderOutput =
+ | { type: 'error'; error: Error }
+ | { type: 'data'; data: Entity };
+
+/**
+ * Parses raw descriptor data (e.g. from a file) into entities.
+ */
+export type DescriptorParser = {
+ /**
+ * Try to parse some raw data into an entity.
+ *
+ * Note that this is only the low level operation of parsing the raw file
+ * format, e.g. reading JSON or YAML or similar and emitting as structured
+ * but unvalidated data. The actual validation is performed by EntityPolicy
+ * and KindParser.
+ *
+ * @param data Raw descriptor data
+ * @returns A list of raw unvalidated entities / errors, or undefined if the
+ * given data is not meant to be handled by this parser
+ * @throws An Error if the format was handled and found to not be properly
+ * formed
+ */
+ tryParse(data: Buffer): Promise;
+};
diff --git a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts
deleted file mode 100644
index 7c96fb7cf5..0000000000
--- a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts
+++ /dev/null
@@ -1,172 +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 yaml from 'yaml';
-import { makeValidator } from '../../validation';
-import { DescriptorEnvelopeParser } from './DescriptorEnvelopeParser';
-
-describe('DescriptorEnvelopeParser', () => {
- let data: any;
- let parser: DescriptorEnvelopeParser;
-
- 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
- `);
- parser = new DescriptorEnvelopeParser(makeValidator());
- });
-
- it('works for the happy path', async () => {
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects missing apiVersion', async () => {
- delete data.apiVersion;
- await expect(parser.parse(data)).rejects.toThrow(/apiVersion/);
- });
-
- it('rejects wrong root type', async () => {
- await expect(parser.parse(7)).rejects.toThrow(/object/);
- });
-
- it('rejects bad apiVersion', async () => {
- data.apiVersion = 'a#b';
- await expect(parser.parse(data)).rejects.toThrow(/apiVersion/);
- });
-
- it('rejects missing kind', async () => {
- delete data.kind;
- await expect(parser.parse(data)).rejects.toThrow(/kind/);
- });
-
- it('rejects bad kind', async () => {
- data.kind = 'a#b';
- await expect(parser.parse(data)).rejects.toThrow(/kind/);
- });
-
- it('accepts missing metadata', async () => {
- delete data.medatata;
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects non-object metadata', async () => {
- data.metadata = 7;
- await expect(parser.parse(data)).rejects.toThrow(/metadata/);
- });
-
- it('accepts missing uid', async () => {
- delete data.metadata.uid;
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects bad uid', async () => {
- data.metadata.uid = 7;
- await expect(parser.parse(data)).rejects.toThrow(/uid/);
- });
-
- it('accepts missing etag', async () => {
- delete data.metadata.etag;
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects bad etag', async () => {
- data.metadata.etag = 7;
- await expect(parser.parse(data)).rejects.toThrow(/etag/);
- });
-
- it('accepts missing generation', async () => {
- delete data.metadata.generation;
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects bad generation', async () => {
- data.metadata.generation = 'a';
- await expect(parser.parse(data)).rejects.toThrow(/generation/);
- });
-
- it('accepts missing spec', async () => {
- delete data.spec;
- await expect(parser.parse(data)).resolves.toBe(data);
- });
-
- it('rejects non-object spec', async () => {
- data.spec = 7;
- await expect(parser.parse(data)).rejects.toThrow(/spec/);
- });
-
- it('rejects bad name', async () => {
- data.metadata.name = 7;
- await expect(parser.parse(data)).rejects.toThrow(/name/);
- });
-
- it('rejects bad namespace', async () => {
- data.metadata.namespace = 7;
- await expect(parser.parse(data)).rejects.toThrow(/namespace/);
- });
-
- it('rejects bad label key', async () => {
- data.metadata.labels['a#b'] = 'value';
- await expect(parser.parse(data)).rejects.toThrow(/label.*key/i);
- });
-
- it('rejects bad label value', async () => {
- data.metadata.labels.a = 'a#b';
- await expect(parser.parse(data)).rejects.toThrow(/label.*value/i);
- });
-
- it('rejects bad annotation key', async () => {
- data.metadata.annotations['a#b'] = 'value';
- await expect(parser.parse(data)).rejects.toThrow(/annotation.*key/i);
- });
-
- it('rejects bad annotation value', async () => {
- data.metadata.annotations.a = [];
- await expect(parser.parse(data)).rejects.toThrow(/annotation.*value/i);
- });
-
- it('rejects unknown root keys', async () => {
- data.spec2 = {};
- await expect(parser.parse(data)).rejects.toThrow(/spec2/i);
- });
-
- it('rejects reserved keys in the spec root', async () => {
- data.spec.apiVersion = 'a/b';
- await expect(parser.parse(data)).rejects.toThrow(/spec.*apiVersion/i);
- });
-
- it('rejects reserved keys in labels', async () => {
- data.metadata.labels.apiVersion = 'a';
- await expect(parser.parse(data)).rejects.toThrow(/label.*apiVersion/i);
- });
-
- it('rejects reserved keys in annotations', async () => {
- data.metadata.annotations.apiVersion = 'a';
- await expect(parser.parse(data)).rejects.toThrow(/annotation.*apiVersion/i);
- });
-});
diff --git a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts
deleted file mode 100644
index 1f21e2df9e..0000000000
--- a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts
+++ /dev/null
@@ -1,206 +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 * as yup from 'yup';
-import { Validators } from '../../validation';
-import { DescriptorEnvelope } from '../types';
-
-/**
- * Parses some raw structured data as a descriptor envelope
- */
-export class DescriptorEnvelopeParser {
- private schema: yup.Schema;
-
- constructor(validators: Validators) {
- const apiVersionSchema = yup
- .string()
- .required()
- .test(
- 'apiVersion',
- 'The apiVersion is not formatted according to schema',
- validators.isValidApiVersion,
- );
-
- const kindSchema = yup
- .string()
- .required()
- .test(
- 'kind',
- 'The kind is not formatted according to schema',
- validators.isValidKind,
- );
-
- const uidSchema = yup
- .string()
- .notRequired()
- .test(
- 'metadata.uid',
- 'The uid is not formatted according to schema',
- value => value === undefined || value.length > 0,
- );
-
- const etagSchema = yup
- .string()
- .notRequired()
- .test(
- 'metadata.etag',
- 'The etag value is not according to schema',
- value => value === undefined || value.length > 0,
- );
-
- const generationSchema = yup
- .number()
- .notRequired()
- .test(
- 'metadata.generation',
- 'The generation value is not according to schema',
- value => value === undefined || value > 0,
- );
-
- const nameSchema = yup
- .string()
- .notRequired()
- .test(
- 'metadata.name',
- 'The name is not formatted according to schema',
- value => value === undefined || validators.isValidEntityName(value),
- );
-
- const namespaceSchema = yup
- .string()
- .notRequired()
- .test(
- 'metadata.namespace',
- 'The namespace is malformed',
- value => value === undefined || validators.isValidNamespace(value),
- );
-
- const labelsSchema = yup
- .object>()
- .notRequired()
- .test({
- name: 'metadata.labels.keys',
- message: 'Label keys not formatted according to schema',
- test(value: object) {
- return (
- value === undefined ||
- Object.keys(value).every(validators.isValidLabelKey)
- );
- },
- })
- .test({
- name: 'metadata.labels.values',
- message: 'Label values not formatted according to schema',
- test(value: object) {
- return (
- value === undefined ||
- Object.values(value).every(validators.isValidLabelValue)
- );
- },
- });
-
- const annotationsSchema = yup
- .object>()
- .notRequired()
- .test({
- name: 'metadata.annotations.keys',
- message: 'Annotation keys not formatted according to schema',
- test(value: object) {
- return (
- value === undefined ||
- Object.keys(value).every(validators.isValidAnnotationKey)
- );
- },
- })
- .test({
- name: 'metadata.annotations.values',
- message: 'Annotation values not formatted according to schema',
- test(value: object) {
- return (
- value === undefined ||
- Object.values(value).every(validators.isValidAnnotationValue)
- );
- },
- });
-
- const metadataSchema = yup
- .object({
- uid: uidSchema,
- etag: etagSchema,
- generation: generationSchema,
- name: nameSchema,
- namespace: namespaceSchema,
- labels: labelsSchema,
- annotations: annotationsSchema,
- })
- .notRequired();
-
- const specSchema = yup.object({}).notRequired();
-
- this.schema = yup
- .object({
- apiVersion: apiVersionSchema,
- kind: kindSchema,
- metadata: metadataSchema,
- spec: specSchema,
- })
- .noUnknown();
- }
-
- async parse(data: any): Promise {
- let result: DescriptorEnvelope;
- try {
- result = await this.schema.validate(data, { strict: true });
- } catch (e) {
- throw new Error(`Malformed envelope, ${e}`);
- }
-
- // These are keys with specific semantic meaning in a document, that we do
- // not want to appear in the root of the spec, or as labels or as
- // annotations, because they will lead to confusion.
- const reservedKeys = [
- 'apiVersion',
- 'kind',
- 'uid',
- 'etag',
- 'generation',
- 'name',
- 'namespace',
- 'labels',
- 'annotations',
- 'spec',
- ];
- for (const key of reservedKeys) {
- if (result.spec?.hasOwnProperty(key)) {
- throw new Error(
- `The spec may not contain the key ${key}, because it has reserved meaning`,
- );
- }
- if (result.metadata?.labels?.hasOwnProperty(key)) {
- throw new Error(
- `A label may not have the key ${key}, because it has reserved meaning`,
- );
- }
- if (result.metadata?.annotations?.hasOwnProperty(key)) {
- throw new Error(
- `An annotation may not have the key ${key}, because it has reserved meaning`,
- );
- }
- }
-
- return result;
- }
-}
diff --git a/plugins/catalog-backend/src/ingestion/index.ts b/plugins/catalog-backend/src/ingestion/index.ts
index ca6f2dd2be..6c530c7234 100644
--- a/plugins/catalog-backend/src/ingestion/index.ts
+++ b/plugins/catalog-backend/src/ingestion/index.ts
@@ -14,6 +14,8 @@
* limitations under the License.
*/
-export * from './DescriptorParsers';
-export * from './LocationReaders';
-export * from './types';
+export * from './descriptor';
+export { HigherOrderOperations } from './HigherOrderOperations';
+export { IngestionModels } from './IngestionModels';
+export * from './source';
+export type { HigherOrderOperation, IngestionModel } from './types';
diff --git a/plugins/catalog-backend/src/ingestion/source/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/source/LocationReaders.ts
new file mode 100644
index 0000000000..a670f309ad
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/source/LocationReaders.ts
@@ -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 { FileLocationReader } from './readers/FileLocationReader';
+import { GitHubLocationReader } from './readers/GitHubLocationReader';
+import { LocationReader } from './readers/types';
+
+export class LocationReaders implements LocationReader {
+ private readonly readers: LocationReader[];
+
+ static defaultReaders(): LocationReader[] {
+ return [new FileLocationReader(), new GitHubLocationReader()];
+ }
+
+ constructor(readers: LocationReader[] = LocationReaders.defaultReaders()) {
+ this.readers = readers;
+ }
+
+ async tryRead(type: string, target: string): Promise {
+ for (const reader of this.readers) {
+ const result = await reader.tryRead(type, target);
+ if (result) {
+ return result;
+ }
+ }
+ throw new Error(`Could not read unknown location "${type}", "${target}"`);
+ }
+}
diff --git a/plugins/catalog-backend/src/ingestion/source/index.ts b/plugins/catalog-backend/src/ingestion/source/index.ts
new file mode 100644
index 0000000000..db7aa2f0bd
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/source/index.ts
@@ -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 { LocationReaders } from './LocationReaders';
+export { FileLocationReader } from './readers/FileLocationReader';
+export { GitHubLocationReader } from './readers/GitHubLocationReader';
+export type { LocationReader } from './readers/types';
diff --git a/plugins/catalog-backend/src/ingestion/sources/FileLocationSource.ts b/plugins/catalog-backend/src/ingestion/source/readers/FileLocationReader.ts
similarity index 63%
rename from plugins/catalog-backend/src/ingestion/sources/FileLocationSource.ts
rename to plugins/catalog-backend/src/ingestion/source/readers/FileLocationReader.ts
index 9d2794ec4f..0c64aebf14 100644
--- a/plugins/catalog-backend/src/ingestion/sources/FileLocationSource.ts
+++ b/plugins/catalog-backend/src/ingestion/source/readers/FileLocationReader.ts
@@ -15,22 +15,21 @@
*/
import fs from 'fs-extra';
-import { LocationSource, ReaderOutput } from '../types';
-import { readDescriptorYaml } from './util';
+import { LocationReader } from './types';
+
+/**
+ * Reads a file from the local file system.
+ */
+export class FileLocationReader implements LocationReader {
+ async tryRead(type: string, target: string): Promise {
+ if (type !== 'file') {
+ return undefined;
+ }
-export class FileLocationSource implements LocationSource {
- async read(target: string): Promise {
- let rawYaml;
try {
- rawYaml = await fs.readFile(target, 'utf8');
+ return await fs.readFile(target);
} catch (e) {
throw new Error(`Unable to read "${target}", ${e}`);
}
-
- try {
- return readDescriptorYaml(rawYaml);
- } catch (e) {
- throw new Error(`Malformed descriptor at "${target}", ${e}`);
- }
}
}
diff --git a/plugins/catalog-backend/src/ingestion/sources/__tests__/GitHubLocationSource.test.ts b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts
similarity index 64%
rename from plugins/catalog-backend/src/ingestion/sources/__tests__/GitHubLocationSource.test.ts
rename to plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts
index 083c2f7e86..1f0f6ed539 100644
--- a/plugins/catalog-backend/src/ingestion/sources/__tests__/GitHubLocationSource.test.ts
+++ b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts
@@ -16,43 +16,26 @@
jest.mock('node-fetch');
-import fs from 'fs-extra';
import fetch from 'node-fetch';
-import path from 'path';
-import { GitHubLocationSource } from '../GitHubLocationSource';
+import { GitHubLocationReader } from './GitHubLocationReader';
const { Response } = jest.requireActual('node-fetch');
-const FIXTURES_DIR = path.resolve(
- __dirname,
- '..',
- '..',
- '..',
- '..',
- 'fixtures',
-);
-const fixtures = fs.readdirSync(FIXTURES_DIR).reduce((acc, filename) => {
- acc[filename] = fs.readFileSync(path.resolve(FIXTURES_DIR, filename), 'utf8');
- return acc;
-}, {} as Record);
-
-describe('Unit: GitHubLocationSource', () => {
+describe('Unit: GitHubLocationReader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches the file and parses it correctly', async () => {
- (fetch as any).mockReturnValueOnce(
- Promise.resolve(new Response(fixtures['one_component.yaml'])),
- );
- const reader = new GitHubLocationSource();
+ (fetch as any).mockResolvedValueOnce(new Response('hello'));
- const result = await reader.read(
+ const reader = new GitHubLocationReader();
+ const buffer = await reader.tryRead(
+ 'github',
'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/one_component.yaml',
);
- expect(result[0].type).toBe('data');
- expect((result[0] as any).data.metadata.name).toBe('component3');
+ expect(buffer?.toString('utf8')).toBe('hello');
});
it('changes the url to point to https://raw.githubusercontent.com', async () => {
@@ -61,12 +44,12 @@ describe('Unit: GitHubLocationSource', () => {
const folderPath = `master/plugins/catalog-backend/fixtures`;
const componentFilename = `one_component.yaml`;
const rawGitHubUrl = `https://raw.githubusercontent.com`;
- const reader = new GitHubLocationSource();
- (fetch as any).mockReturnValueOnce(
- Promise.resolve(new Response(fixtures[componentFilename])),
- );
- await reader.read(
+ const reader = new GitHubLocationReader();
+ (fetch as any).mockResolvedValueOnce(new Response('hello'));
+
+ await reader.tryRead(
+ 'github',
`${gitHubUrl}/${project}/blob/${folderPath}/${componentFilename}`,
);
@@ -76,7 +59,7 @@ describe('Unit: GitHubLocationSource', () => {
});
describe('rejects wrong urls', () => {
- const reader = new GitHubLocationSource();
+ const reader = new GitHubLocationReader();
it.each([
['http://example.com/one_component.yaml'],
@@ -87,7 +70,7 @@ describe('Unit: GitHubLocationSource', () => {
])(
'%p',
async (url: string) =>
- await expect(reader.read(url)).rejects.toThrow(/url/),
+ await expect(reader.tryRead('github', url)).rejects.toThrow(/url/),
);
});
});
@@ -100,11 +83,10 @@ describe('Integration: GitHubLocationSource', () => {
it('fetches the fixture from backstage repo', async () => {
const PERMANENT_LINK =
'https://github.com/spotify/backstage/blob/ee84a874f8e37f87940cbe515a86c07a2db29541/plugins/catalog-backend/fixtures/one_component.yaml';
- const reader = new GitHubLocationSource();
- const result = await reader.read(PERMANENT_LINK);
+ const reader = new GitHubLocationReader();
+ const result = await reader.tryRead('github', PERMANENT_LINK);
- expect(result[0].type).toBe('data');
- expect((result[0] as any).data.metadata.name).toBe('component3');
+ expect(result?.toString('utf8')).toContain('component3');
});
});
diff --git a/plugins/catalog-backend/src/ingestion/sources/GitHubLocationSource.ts b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.ts
similarity index 70%
rename from plugins/catalog-backend/src/ingestion/sources/GitHubLocationSource.ts
rename to plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.ts
index 69ba4911a6..bd330e28b4 100644
--- a/plugins/catalog-backend/src/ingestion/sources/GitHubLocationSource.ts
+++ b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.ts
@@ -16,17 +16,31 @@
import fetch from 'node-fetch';
import { URL } from 'url';
-import { LocationSource, ReaderOutput } from '../types';
-import { readDescriptorYaml } from './util';
+import { LocationReader } from './types';
-// Pointing to raw.githubusercontent.com for now
-// to be changed in the future, after auth and tokens are done
-export class GitHubLocationSource implements LocationSource {
- async read(target: string): Promise {
- let url: URL;
+/**
+ * Reads a file whose target is a GitHub URL.
+ *
+ * Uses raw.githubusercontent.com for now, but this will probably change in the
+ * future when token auth is implemented.
+ */
+export class GitHubLocationReader implements LocationReader {
+ async tryRead(type: string, target: string): Promise {
+ if (type !== 'github') {
+ return undefined;
+ }
+ const url = this.buildRawUrl(target);
try {
- url = new URL(target);
+ return await fetch(url.toString()).then(x => x.buffer());
+ } catch (e) {
+ throw new Error(`Unable to read "${target}", ${e}`);
+ }
+ }
+
+ private buildRawUrl(target: string): URL {
+ try {
+ const url = new URL(target);
const [
empty,
@@ -51,23 +65,10 @@ export class GitHubLocationSource implements LocationSource {
url.pathname = [empty, userOrOrg, repoName, ...restOfPath].join('/');
url.hostname = 'raw.githubusercontent.com';
url.protocol = 'https';
+
+ return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
-
- let rawYaml;
- try {
- rawYaml = await fetch(url.toString()).then(x => {
- return x.text();
- });
- } catch (e) {
- throw new Error(`Unable to read "${target}", ${e}`);
- }
-
- try {
- return readDescriptorYaml(rawYaml);
- } catch (e) {
- throw new Error(`Malformed descriptor at "${target}", ${e}`);
- }
}
}
diff --git a/plugins/catalog-backend/src/ingestion/source/readers/types.ts b/plugins/catalog-backend/src/ingestion/source/readers/types.ts
new file mode 100644
index 0000000000..37c7b46885
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/source/readers/types.ts
@@ -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.
+ */
+
+export type LocationReader = {
+ /**
+ * Reads the contents of a single location.
+ *
+ * @param type The type of location to read
+ * @param target The location target (type-specific)
+ * @returns The target contents, as a raw Buffer, or undefined if this type
+ * was not meant to be consumed by this reader
+ * @throws An error if the type was meant for this reader, but could not be
+ * read
+ */
+ tryRead(type: string, target: string): Promise;
+};
diff --git a/plugins/catalog-backend/src/ingestion/sources/util.ts b/plugins/catalog-backend/src/ingestion/sources/util.ts
deleted file mode 100644
index cccbeb92b3..0000000000
--- a/plugins/catalog-backend/src/ingestion/sources/util.ts
+++ /dev/null
@@ -1,55 +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 yaml from 'yaml';
-import { ReaderOutput } from '../types';
-
-export function readDescriptorYaml(data: string): ReaderOutput[] {
- let documents;
- try {
- documents = yaml.parseAllDocuments(data);
- } catch (e) {
- throw new Error(`Could not parse YAML data, ${e}`);
- }
-
- const result: ReaderOutput[] = [];
-
- for (const document of documents) {
- if (document.contents) {
- if (document.errors?.length) {
- result.push({
- type: 'error',
- error: new Error(`Malformed YAML document, ${document.errors[0]}`),
- });
- } else {
- const json = document.toJSON();
- if (typeof json !== 'object' || Array.isArray(json)) {
- result.push({
- type: 'error',
- error: new Error(`Malformed descriptor, expected object at root`),
- });
- } else {
- result.push({
- type: 'data',
- data: json,
- });
- }
- }
- }
- }
-
- return result;
-}
diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts
index e5083a65f1..8fb56367a6 100644
--- a/plugins/catalog-backend/src/ingestion/types.ts
+++ b/plugins/catalog-backend/src/ingestion/types.ts
@@ -14,172 +14,19 @@
* limitations under the License.
*/
-import { ComponentDescriptorV1beta1 } from './descriptors/ComponentDescriptorV1beta1Parser';
+import type { Entity, Location, LocationSpec } from '@backstage/catalog-model';
+import type { ReaderOutput } from './descriptor/parsers/types';
-export type ComponentDescriptor = ComponentDescriptorV1beta1;
-
-/**
- * Metadata fields common to all versions/kinds of entity.
- *
- * @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
- */
-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, for any given kind.
- */
- name?: string;
-
- /**
- * The namespace that the entity belongs to.
- */
- namespace?: string;
-
- /**
- * Key/value pairs of identifying information attached to the entity.
- */
- labels?: Record;
-
- /**
- * Key/value pairs of non-identifying auxiliary information attached to the
- * entity.
- */
- annotations?: Record;
+export type AddLocationResult = {
+ location: Location;
+ entities: Entity[];
};
-/**
- * The format envelope that's common to all versions/kinds.
- *
- * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
- */
-export type DescriptorEnvelope = {
- /**
- * 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;
-
- /**
- * Optional metadata related to the entity.
- */
- metadata?: EntityMeta;
-
- /**
- * The specification data describing the entity itself.
- */
- spec?: object;
+export type IngestionModel = {
+ readLocation(type: string, target: string): Promise;
};
-/**
- * Parses and validates descriptors.
- *
- * The output must be validated and well formed.
- */
-export type DescriptorParser = {
- /**
- * Parses and validates a single raw descriptor.
- *
- * @param descriptor A raw descriptor object
- * @returns A structure describing the parsed and validated descriptor
- * @throws An Error if the descriptor was malformed
- */
- parse(descriptor: object): Promise;
-};
-
-/**
- * Parses and validates a single envelope into its materialized kind.
- *
- * These parsers may assume that the envelope is already validated and well
- * formed.
- */
-export type KindParser = {
- /**
- * Try to parse an envelope into a materialized kind.
- *
- * @param envelope A valid descriptor envelope
- * @returns A materialized type, or undefined if the given version/kind is
- * not meant to be handled by this parser
- * @throws An Error if the type was handled and found to not be properly
- * formatted
- */
- tryParse(
- envelope: DescriptorEnvelope,
- ): Promise;
-};
-
-export class ParserError extends Error {
- constructor(message?: string, private _entityName?: string | undefined) {
- super(message);
- }
- get entityName() {
- return this._entityName;
- }
-}
-
-export type ReaderOutput =
- | { type: 'error'; error: Error }
- | { type: 'data'; data: object };
-
-export type LocationReader = {
- /**
- * Reads the contents of a single location.
- *
- * @param type The type of location to read
- * @param target The location target (type-specific)
- * @returns The parsed contents, as an array of unverified descriptors or
- * errors where the individual documents could not be parsed.
- * @throws An error if the location as a whole could not be read
- */
- read(type: string, target: string): Promise;
-};
-
-export type LocationSource = {
- /**
- * Reads the contents of a single location.
- *
- * @param target The location target to read
- * @returns The parsed contents, as an array of unverified descriptors
- * @throws An error if the location target could not be read
- */
- read(target: string): Promise;
+export type HigherOrderOperation = {
+ addLocation(spec: LocationSpec): Promise;
+ refreshAllLocations(): Promise;
};
diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts
index 8c29362015..29b3f7ce04 100644
--- a/plugins/catalog-backend/src/service/router.test.ts
+++ b/plugins/catalog-backend/src/service/router.test.ts
@@ -14,40 +14,63 @@
* limitations under the License.
*/
-import { getVoidLogger } from '@backstage/backend-common';
+import { getVoidLogger, NotFoundError } from '@backstage/backend-common';
+import type { Entity, LocationSpec } from '@backstage/catalog-model';
import express from 'express';
import request from 'supertest';
-import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
-import { DescriptorEnvelope } from '../ingestion';
+import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { LocationResponse } from '../catalog/types';
+import { HigherOrderOperation } from '../ingestion/types';
import { createRouter } from './router';
-class MockEntitiesCatalog implements EntitiesCatalog {
- entities = jest.fn();
- entityByUid = jest.fn();
- entityByName = jest.fn();
-}
-
-class MockLocationsCatalog implements LocationsCatalog {
- addLocation = jest.fn();
- removeLocation = jest.fn();
- locations = jest.fn();
- location = jest.fn();
-}
-
describe('createRouter', () => {
- describe('entities', () => {
+ let entitiesCatalog: jest.Mocked;
+ let locationsCatalog: jest.Mocked;
+ let higherOrderOperation: jest.Mocked;
+ let app: express.Express;
+
+ beforeAll(async () => {
+ entitiesCatalog = {
+ entities: jest.fn(),
+ entityByUid: jest.fn(),
+ entityByName: jest.fn(),
+ addOrUpdateEntity: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ };
+ locationsCatalog = {
+ addLocation: jest.fn(),
+ removeLocation: jest.fn(),
+ locations: jest.fn(),
+ location: jest.fn(),
+ locationHistory: jest.fn(),
+ logUpdateSuccess: jest.fn(),
+ logUpdateFailure: jest.fn(),
+ };
+ higherOrderOperation = {
+ addLocation: jest.fn(),
+ refreshAllLocations: jest.fn(),
+ };
+ const router = await createRouter({
+ entitiesCatalog,
+ locationsCatalog,
+ higherOrderOperation,
+ logger: getVoidLogger(),
+ });
+ app = express().use(router);
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('GET /entities', () => {
it('happy path: lists entities', async () => {
- const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
+ const entities: Entity[] = [
+ { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
+ ];
- const catalog = new MockEntitiesCatalog();
- catalog.entities.mockResolvedValueOnce(entities);
+ entitiesCatalog.entities.mockResolvedValueOnce(entities);
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
const response = await request(app).get('/entities');
expect(response.status).toEqual(200);
@@ -55,18 +78,11 @@ describe('createRouter', () => {
});
it('parses single and multiple request parameters and passes them down', async () => {
- const catalog = new MockEntitiesCatalog();
-
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
expect(response.status).toEqual(200);
- expect(catalog.entities).toHaveBeenCalledWith([
+ expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entities).toHaveBeenCalledWith([
{ key: 'a', values: ['1', null, '3'] },
{ key: 'b', values: ['4'] },
{ key: 'c', values: [null] },
@@ -74,50 +90,83 @@ describe('createRouter', () => {
});
});
- describe('entityByUid', () => {
+ describe('GET /entities/by-uid/:uid', () => {
it('can fetch entity by uid', async () => {
- const entity: DescriptorEnvelope = {
+ const entity: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
},
};
- const catalog = new MockEntitiesCatalog();
- catalog.entityByUid.mockResolvedValue(entity);
+ entitiesCatalog.entityByUid.mockResolvedValue(entity);
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
+ expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
- const catalog = new MockEntitiesCatalog();
- catalog.entityByUid.mockResolvedValue(undefined);
+ entitiesCatalog.entityByUid.mockResolvedValue(undefined);
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
+ expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/uid/);
});
});
- describe('entityByName', () => {
+ describe('GET /entities/by-name/:kind/:namespace/:name', () => {
it('can fetch entity by name', async () => {
- const entity: DescriptorEnvelope = {
+ const entity: Entity = {
+ apiVersion: 'a',
+ kind: 'k',
+ metadata: {
+ name: 'n',
+ namespace: 'ns',
+ },
+ };
+ entitiesCatalog.entityByName.mockResolvedValue(entity);
+
+ const response = await request(app).get('/entities/by-name/k/ns/n');
+
+ expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n');
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual(expect.objectContaining(entity));
+ });
+
+ it('responds with a 404 for missing entities', async () => {
+ entitiesCatalog.entityByName.mockResolvedValue(undefined);
+
+ const response = await request(app).get('/entities/by-name/b/d/c');
+
+ expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c');
+ expect(response.status).toEqual(404);
+ expect(response.text).toMatch(/name/);
+ });
+ });
+
+ describe('POST /entities', () => {
+ it('requires a body', async () => {
+ const response = await request(app)
+ .post('/entities')
+ .set('Content-Type', 'application/json')
+ .send();
+
+ expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
+ expect(response.status).toEqual(400);
+ expect(response.text).toMatch(/body/);
+ });
+
+ it('passes the body down', async () => {
+ const entity: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
@@ -125,74 +174,99 @@ describe('createRouter', () => {
namespace: 'd',
},
};
- const catalog = new MockEntitiesCatalog();
- catalog.entityByName.mockResolvedValue(entity);
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
+ entitiesCatalog.addOrUpdateEntity.mockResolvedValue(entity);
- const app = express().use(router);
- const response = await request(app).get('/entities/by-name/b/d/c');
+ const response = await request(app)
+ .post('/entities')
+ .send(entity)
+ .set('Content-Type', 'application/json');
+ expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
+ 1,
+ entity,
+ );
expect(response.status).toEqual(200);
- expect(response.body).toEqual(expect.objectContaining(entity));
- });
-
- it('responds with a 404 for missing entities', async () => {
- const catalog = new MockEntitiesCatalog();
- catalog.entityByName.mockResolvedValue(undefined);
-
- const router = await createRouter({
- entitiesCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
- const response = await request(app).get('/entities/by-name//b/d/c');
-
- expect(response.status).toEqual(404);
- expect(response.text).toMatch(/name/);
+ expect(response.body).toEqual(entity);
});
});
- describe('locations', () => {
+ describe('DELETE /entities/by-uid/:uid', () => {
+ it('can remove', async () => {
+ entitiesCatalog.removeEntityByUid.mockResolvedValue(undefined);
+
+ const response = await request(app).delete('/entities/by-uid/apa');
+
+ expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
+ expect(response.status).toEqual(204);
+ });
+
+ it('responds with a 404 for missing entities', async () => {
+ entitiesCatalog.removeEntityByUid.mockRejectedValue(
+ new NotFoundError('nope'),
+ );
+
+ const response = await request(app).delete('/entities/by-uid/apa');
+
+ expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
+ expect(response.status).toEqual(404);
+ });
+ });
+
+ describe('GET /locations', () => {
it('happy path: lists locations', async () => {
- const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
+ const locations: LocationResponse[] = [
+ {
+ currentStatus: { timestamp: '', status: '', message: '' },
+ data: { id: 'a', type: 'b', target: 'c' },
+ },
+ ];
+ locationsCatalog.locations.mockResolvedValueOnce(locations);
- const catalog = new MockLocationsCatalog();
- catalog.locations.mockResolvedValueOnce(locations);
-
- const router = await createRouter({
- locationsCatalog: catalog,
- logger: getVoidLogger(),
- });
-
- const app = express().use(router);
const response = await request(app).get('/locations');
expect(response.status).toEqual(200);
expect(response.body).toEqual(locations);
});
+ });
+ describe('POST /locations', () => {
it('rejects malformed locations', async () => {
- const location = ({
- id: 'a',
+ const spec = ({
typez: 'b',
target: 'c',
- } as unknown) as Location;
+ } as unknown) as LocationSpec;
- const catalog = new MockLocationsCatalog();
- const router = await createRouter({
- locationsCatalog: catalog,
- logger: getVoidLogger(),
+ const response = await request(app).post('/locations').send(spec);
+
+ expect(higherOrderOperation.addLocation).not.toHaveBeenCalled();
+ expect(response.status).toEqual(400);
+ });
+
+ it('passes the body down', async () => {
+ const spec: LocationSpec = {
+ type: 'b',
+ target: 'c',
+ };
+
+ higherOrderOperation.addLocation.mockResolvedValue({
+ location: { id: 'a', ...spec },
+ entities: [],
});
- const app = express().use(router);
- const response = await request(app).post('/locations').send(location);
+ const response = await request(app).post('/locations').send(spec);
- expect(response.status).toEqual(400);
+ expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1);
+ expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec);
+ expect(response.status).toEqual(201);
+ expect(response.body).toEqual(
+ expect.objectContaining({
+ location: { id: 'a', ...spec },
+ }),
+ );
});
});
});
diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts
index c3318a7377..a3da182416 100644
--- a/plugins/catalog-backend/src/service/router.ts
+++ b/plugins/catalog-backend/src/service/router.ts
@@ -15,27 +15,27 @@
*/
import { errorHandler, InputError } from '@backstage/backend-common';
+import { locationSpecSchema } from '@backstage/catalog-model';
+import type { Entity } from '@backstage/catalog-model';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
-import {
- addLocationSchema,
- EntitiesCatalog,
- EntityFilters,
- LocationsCatalog,
-} from '../catalog';
-import { validateRequestBody } from './util';
+import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { EntityFilters } from '../database';
+import { HigherOrderOperation } from '../ingestion/types';
+import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
+ higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise {
- const { entitiesCatalog, locationsCatalog } = options;
+ const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
const router = Router();
router.use(express.json());
@@ -47,6 +47,11 @@ export async function createRouter(
const entities = await entitiesCatalog.entities(filters);
res.status(200).send(entities);
})
+ .post('/entities', async (req, res) => {
+ const body = await requireRequestBody(req);
+ const result = await entitiesCatalog.addOrUpdateEntity(body as Entity);
+ res.status(200).send(result);
+ })
.get('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
const entity = await entitiesCatalog.entityByUid(uid);
@@ -55,12 +60,17 @@ export async function createRouter(
}
res.status(200).send(entity);
})
+ .delete('/entities/by-uid/:uid', async (req, res) => {
+ const { uid } = req.params;
+ await entitiesCatalog.removeEntityByUid(uid);
+ res.status(204).send();
+ })
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
const { kind, namespace, name } = req.params;
const entity = await entitiesCatalog.entityByName(
kind,
- name,
namespace,
+ name,
);
if (!entity) {
res
@@ -73,17 +83,25 @@ export async function createRouter(
});
}
+ if (higherOrderOperation) {
+ router.post('/locations', async (req, res) => {
+ const input = await validateRequestBody(req, locationSpecSchema);
+ const output = await higherOrderOperation.addLocation(input);
+ res.status(201).send(output);
+ });
+ }
+
if (locationsCatalog) {
router
- .post('/locations', async (req, res) => {
- const input = await validateRequestBody(req, addLocationSchema);
- const output = await locationsCatalog.addLocation(input);
- res.status(201).send(output);
- })
.get('/locations', async (_req, res) => {
const output = await locationsCatalog.locations();
res.status(200).send(output);
})
+ .get('/locations/:id/history', async (req, res) => {
+ const { id } = req.params;
+ const output = await locationsCatalog.locationHistory(id);
+ res.status(200).send(output);
+ })
.get('/locations/:id', async (req, res) => {
const { id } = req.params;
const output = await locationsCatalog.location(id);
diff --git a/plugins/catalog-backend/src/service/standaloneApplication.ts b/plugins/catalog-backend/src/service/standaloneApplication.ts
index 126806c751..bdf296d06b 100644
--- a/plugins/catalog-backend/src/service/standaloneApplication.ts
+++ b/plugins/catalog-backend/src/service/standaloneApplication.ts
@@ -25,19 +25,27 @@ import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { HigherOrderOperation } from '../ingestion';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
entitiesCatalog: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
+ higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise {
- const { enableCors, entitiesCatalog, locationsCatalog, logger } = options;
+ const {
+ enableCors,
+ entitiesCatalog,
+ locationsCatalog,
+ higherOrderOperation,
+ logger,
+ } = options;
const app = express();
app.use(helmet());
@@ -49,7 +57,12 @@ export async function createStandaloneApplication(
app.use(requestLoggingHandler());
app.use(
'/',
- await createRouter({ entitiesCatalog, locationsCatalog, logger }),
+ await createRouter({
+ entitiesCatalog,
+ locationsCatalog,
+ higherOrderOperation,
+ logger,
+ }),
);
app.use(notFoundHandler());
app.use(errorHandler());
diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts
index 4c37154c48..39692030e3 100644
--- a/plugins/catalog-backend/src/service/util.ts
+++ b/plugins/catalog-backend/src/service/util.ts
@@ -16,12 +16,10 @@
import { InputError } from '@backstage/backend-common';
import { Request } from 'express';
+import lodash from 'lodash';
import yup from 'yup';
-export async function validateRequestBody(
- req: Request,
- schema: yup.Schema,
-): Promise {
+export async function requireRequestBody(req: Request): Promise {
const contentType = req.header('content-type');
if (!contentType) {
throw new InputError('Content-Type missing');
@@ -32,13 +30,27 @@ export async function validateRequestBody(
const body = req.body;
if (!body) {
throw new InputError('Missing request body');
+ } else if (!lodash.isPlainObject(body)) {
+ throw new InputError('Expected body to be a JSON object');
+ } else if (Object.keys(body).length === 0) {
+ // Because of how express.json() translates the empty body to {}
+ throw new InputError('Empty request body');
}
+ return body;
+}
+
+export async function validateRequestBody(
+ req: Request,
+ schema: yup.Schema,
+): Promise {
+ const body = await requireRequestBody(req);
+
try {
await schema.validate(body, { strict: true });
} catch (e) {
throw new InputError(`Malformed request: ${e}`);
}
- return body as T;
+ return (body as unknown) as T;
}
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index cd8548bdde..0cdc5d2618 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -19,6 +19,7 @@
"dependencies": {
"@backstage/core": "^0.1.1-alpha.6",
"@backstage/theme": "^0.1.1-alpha.6",
+ "@backstage/catalog-model": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts
new file mode 100644
index 0000000000..42ef1c4da5
--- /dev/null
+++ b/plugins/catalog/src/api/CatalogClient.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { CatalogApi } from './types';
+import { DescriptorEnvelope } from '../types';
+
+export class CatalogClient implements CatalogApi {
+ private apiOrigin: string;
+ private basePath: string;
+ constructor({
+ apiOrigin,
+ basePath,
+ }: {
+ apiOrigin: string;
+ basePath: string;
+ }) {
+ this.apiOrigin = apiOrigin;
+ this.basePath = basePath;
+ }
+ async getEntities(): Promise {
+ const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`);
+ return await response.json();
+ }
+ async getEntityByName(name: string): Promise {
+ const response = await fetch(
+ `${this.apiOrigin}${this.basePath}/entities/by-name/Component/default/${name}`,
+ );
+ const entity = await response.json();
+ if (entity) return entity;
+ throw new Error(`'Entity not found: ${name}`);
+ }
+}
diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts
new file mode 100644
index 0000000000..eb2cdca562
--- /dev/null
+++ b/plugins/catalog/src/api/types.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+
+export const catalogApiRef = createApiRef({
+ id: 'plugin.catalog.service',
+ description:
+ 'Used by the Catalog plugin to make requests to accompanying backend',
+});
+
+export interface CatalogApi {
+ getEntities(): Promise;
+ getEntityByName(name: string): Promise;
+}
diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx
index 762a881302..ec44f53777 100644
--- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx
+++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx
@@ -16,7 +16,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
-import { wrapInThemedTestApp } from '@backstage/test-utils';
+import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
describe('Catalog Filter', () => {
@@ -26,7 +26,7 @@ describe('Catalog Filter', () => {
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
for (const group of mockGroups) {
@@ -52,7 +52,7 @@ describe('Catalog Filter', () => {
];
const { findByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
const [group] = mockGroups;
@@ -81,7 +81,7 @@ describe('Catalog Filter', () => {
];
const { findByText } = render(
- wrapInThemedTestApp(),
+ wrapInTestApp(),
);
const [group] = mockGroups;
@@ -112,7 +112,7 @@ describe('Catalog Filter', () => {
const onSelectedChangeHandler = jest.fn();
const { findByText } = render(
- wrapInThemedTestApp(
+ wrapInTestApp(
Promise.resolve([{ name: 'test' }])),
- getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
- removeComponentByName: jest.fn(() => Promise.resolve(true)),
-};
+const errorApi = { post: () => {} };
+const catalogApi = { getEntities: () => Promise.resolve([{ kind: '' }]) };
describe('CatalogPage', () => {
// this test right now causes some red lines in the log output when running tests
@@ -32,8 +30,15 @@ describe('CatalogPage', () => {
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const rendered = render(
- wrapInThemedTestApp(
- ,
+ wrapInTestApp(
+
+
+ ,
),
);
expect(
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
index 610ecc3bb8..8a2b448d97 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
@@ -24,9 +24,9 @@ import {
SupportButton,
Page,
pageTheme,
+ useApi,
} from '@backstage/core';
import { useAsync } from 'react-use';
-import { ComponentFactory } from '../../data/component';
import CatalogTable from '../CatalogTable/CatalogTable';
import {
CatalogFilter,
@@ -44,12 +44,12 @@ const useStyles = makeStyles(theme => ({
},
}));
-type CatalogPageProps = {
- componentFactory: ComponentFactory;
-};
+import { catalogApiRef } from '../..';
+import { envelopeToComponent } from '../../data/utils';
-const CatalogPage: FC = ({ componentFactory }) => {
- const { value, error, loading } = useAsync(componentFactory.getAllComponents);
+const CatalogPage: FC<{}> = () => {
+ const catalogApi = useApi(catalogApiRef);
+ const { value, error, loading } = useAsync(() => catalogApi.getEntities());
const [selectedFilter, setSelectedFilter] = React.useState(
defaultFilter,
);
@@ -58,8 +58,8 @@ const CatalogPage: FC