diff --git a/.github/workflows/cli-win.yml b/.github/workflows/cli-win.yml
index 52ec1b8b27..00c7c26a6c 100644
--- a/.github/workflows/cli-win.yml
+++ b/.github/workflows/cli-win.yml
@@ -61,5 +61,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
- PORT: 3001
+ APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 62f91e3259..71b207c0d3 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -64,5 +64,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
- PORT: 3001
+ APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 43c1f0d7b8..dca9e678dc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,4 +68,4 @@ This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By
# Security Issues?
-Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
+See [SECURITY](SECURITY.md).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000000..3ec51b4210
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| 0.x | :white_check_mark: |
+
+## Reporting a Vulnerability
+
+Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
diff --git a/docker/run.sh b/docker/run.sh
index ac120f6597..c6800cd0d8 100755
--- a/docker/run.sh
+++ b/docker/run.sh
@@ -22,7 +22,7 @@ function inject_config() {
>&2 echo "Runtime app config: $config"
local main_js
- main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)"
+ main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/*.chunk.js)"
echo "Writing runtime config to ${main_js}"
# escape ' and " twice, for both sed and json
diff --git a/docs/README.md b/docs/README.md
index 89160ce9e5..86ca88b75c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,10 +1,10 @@
# Documentation
-Check out or see the table of content below.
+Check out or see the table of contents below.
-- [Architecture and terminology](architecture-terminology.md)
-- [Getting started](getting-started/README.md)
+- [Architecture and Terminology](architecture-terminology.md)
+- [Getting Started](getting-started/README.md)
- [References](reference/README.md)
- [Publishing](publishing.md)
- [Designing for Backstage](design.md)
-- [How to add an auth provider](auth/add-auth-provider.md)
+- [How to Add an Auth Provider](auth/add-auth-provider.md)
diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md
index eec3aec78b..32830c9bb3 100644
--- a/docs/getting-started/README.md
+++ b/docs/getting-started/README.md
@@ -1,5 +1,60 @@
# Getting started with Backstage
+## Running Backstage Locally
+
+To get up and running with a local Backstage to evaluate it, let's clone it off
+of GitHub and run an initial build. First make sure that you have at least node
+version 12 installed locally.
+
+```bash
+# Start from your local development folder
+git clone git@github.com:spotify/backstage.git
+cd backstage
+
+# Fetch our dependencies and run an initial build
+yarn install
+yarn tsc
+yarn build
+```
+
+Phew! Now you have a local repository that's ready to run and to add any open
+source contributions into.
+
+We are now going to launch two things: an example Backstage frontend app, and an
+example Backstage backend that the frontend talks to. You are going to need two
+terminal windows, both starting from the Backstage project root.
+
+In the first window, run
+
+```bash
+cd packages/backend
+yarn start
+```
+
+That starts up a backend instance on port 7000.
+
+In the other window, we will first populate the catalog with some nice mock data
+to look at, and then launch the frontend. These commands are run from the
+project root, not inside the backend directory.
+
+```bash
+yarn lerna run mock-catalog-data
+yarn start
+```
+
+That starts up the frontend on port 3000, and should automatically open a
+browser window showing it.
+
+Congratulations! That should be it. Let us know how it went
+[on discord](https://discord.gg/EBHEGzX), file issues for any
+[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md)
+or
+[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME),
+or
+[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md)
+you have, and feel free to
+[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)!
+
## Creating a Plugin
The value of Backstage grows with every new plugin that gets added. Here is a
@@ -7,8 +62,8 @@ collection of tutorials that will guide you through setting up and extending an
instance of Backstage with your own plugins.
- [Development Environment](development-environment.md)
-- [Create a Backstage plugin](create-a-plugin.md)
-- [Structure of a plugin](structure-of-a-plugin.md)
+- [Create a Backstage Plugin](create-a-plugin.md)
+- [Structure of a Plugin](structure-of-a-plugin.md)
- [Utility APIs](utility-apis.md)
- Using Backstage components (TODO)
diff --git a/docs/getting-started/create-a-plugin.md b/docs/getting-started/create-a-plugin.md
index 778623f07a..93c8a4c760 100644
--- a/docs/getting-started/create-a-plugin.md
+++ b/docs/getting-started/create-a-plugin.md
@@ -1,8 +1,8 @@
-# Backstage Plugins
+# Create a Backstage Plugin
A Backstage Plugin adds functionality to Backstage.
-## Create a plugin
+## Create a Plugin
To create a new plugin, make sure you've run `yarn install` and installed
dependencies, then run the following on your command line (invoking the
diff --git a/docs/getting-started/structure-of-a-plugin.md b/docs/getting-started/structure-of-a-plugin.md
index 591ffc27d2..fcfb2c0d46 100644
--- a/docs/getting-started/structure-of-a-plugin.md
+++ b/docs/getting-started/structure-of-a-plugin.md
@@ -1,4 +1,4 @@
-# Structure of a newly created plugin
+# Structure of a Plugin
Nice, you have a new plugin! We'll soon see how we can develop it into doing
great things. But first off, let's look at what we get out of the box.
diff --git a/docs/journey.md b/docs/journey.md
new file mode 100644
index 0000000000..e38130afb7
--- /dev/null
+++ b/docs/journey.md
@@ -0,0 +1,273 @@
+# Purpose
+
+This RFC describes a possible journey of a future Backstage plugin developer as
+they build a plugin that touches many different aspects of a Backstage. The
+story invents many new things that are not part of Backstage today, but are
+things that I'm suggesting we should add as long term or north star goals. The
+idea is to discuss what parts of the story makes sense to aim for, and what we'd
+want to do differently or not at all. The "chapters" are numbered to make it a
+bit easier to comment on parts of the story.
+
+# The Protagonist
+
+Sam is an experienced developer that has worked with Backstage for a while, and
+knows the best practices and tools available to build plugins. Sam also likes
+music and wants to have a theme tune for every service in Backstage.
+
+# The End
+
+Sam built a Spotify plugin for Backstage that allows service owners to define a
+theme tune for their service. The theme tune plays whenever a user visits the
+service page in Backstage. The plugin is published to NPM and available for any
+organization to easily install and add to their Backstage installation.
+
+# 1. A New Plugin
+
+Sam chooses to develop this plugin in a standalone project and creates a new
+plugin using `npx @backstage/cli create-plugin`, which detects that it's not
+being run in an existing project and therefore creates a separate plugin repo.
+
+Spinning up the frontend with `yarn start`, Sam goes to work with getting the
+base functionality of a Spotify web player going. By installing a couple of
+dependencies and whipping together a nice UI, the player is pretty much done.
+
+# 2. The Auth Menace
+
+Sam realizes users need to be authenticated towards the Spotify API to be able
+to play music, and Backstage doesn't support Spotify login yet. Sam adds the
+`@backstage/plugin-auth-backend` as local development middleware in the project,
+and provides the necessary wrapping logic and configuration for the
+`passport-spotify` strategy. The Spotify auth provider is now available in the
+local development backend, and by adding a frontend `SpotifyAuth` Utility API
+that implements the `OAuthApi` type, it's now working in the frontend too.
+
+```ts
+const spotifyAuthApiRef = createApiRef({
+ id: 'core.auth.spotify',
+ description: 'Provides authentication towards Spotify APIs',
+});
+```
+
+Sam realizes that Spotify auth might be useful to others, and that it would be
+more convenient if it was a part of the Backstage Core. After submitting and
+merging a Pull Request with the additions to the
+`@backstage/plugin-auth-backend` and `@backstage/core` packages, Spotify auth is
+now available for everyone to use. Since the Backstage Core team also adds it to
+the public demo server, Sam can now get rid of it in the local setup and rely on
+the shared development auth providers instead.
+
+The only thing left now is making sure that users of the plugin provide Spotify
+auth in the app. Sam ensures this by adding `spotifyAuthApiRef` to the plugin's
+list of required APIs, as well as listing it in the requirements section in the
+README.
+
+```md
+## Requirements
+
+This plugin requires the following APIs to function:
+
+- `spotifyAuthApiRef` from `@backstage/core@^1.1.0`
+```
+
+# 3. The Catalog Awakens
+
+Sam now has a working player and a method for users to log in to listen to
+music, but the goal is to provide theme songs for services. Sam adds this
+functionality by defining a new metadata annotation called
+`sam.wise/spotify-track-id`. The annotation's value is a Spotify track ID and
+can be defined in a component like this:
+
+```yaml
+apiVersion: backstage.io/v1
+kind: Component
+metadata:
+ name: my-component
+ annotations:
+ sam.wise/spotify-track-id: '4uLU6hMCjMI75M1A2tKUQC'
+spec:
+ type: service
+```
+
+Sam creates a JSON schema that documents the annotation and allows it to be used
+in validation and documentation for organizations that choose to adopt the
+plugin.
+
+```json
+{
+ "sam.wise/spotify-track-id": {
+ "$id": "https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id",
+ "type": "string",
+ "title": "Spotify Track Annotation",
+ "description": "Spotify track ID to associated with the entity",
+ "examples": ["4uLU6hMCjMI75M1A2tKUQC"]
+ }
+}
+```
+
+# 4. The Rise of Widgets
+
+Sam also wraps the music player in an entity page widget. This allows anyone
+that wants to use the plugin to add the player to any of their entity layout
+templates, which will make it show up for every entity of that kind.
+
+```tsx
+export const PlayerWidget = plugin.createEntityWidget({
+ component: WebPlayer,
+ locations: ['header', 'card', 'footer'],
+ cardSize: [2, 4],
+});
+```
+
+The widget receives information about the entity in whose page it's being
+embedded, which makes it simple to grab the track id from the annotations and
+hook up the player.
+
+Sam also modifies the standalone plugin development setup to include this new
+widget inside a basic entity page, adding it to a couple of different places
+where users of the plugin may want to put the player, just to make sure they all
+work.
+
+# 5. The First User
+
+At this point the only things that anyone that wants to use Sam's plugin needs
+to do is to add
+https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
+to their catalog schema, import and add the `PlayerWidget` on the desired entity
+template pages, and make sure they're providing Spotify auth.
+
+Sam soon sees the first "Used by" show up on GitHub, and feedback starts rolling
+in. Users really like the plugin, and some a requesting the possibility to
+select a theme tune when creating a new component. Sam jumps on the idea and
+adds a new creation hook that is exported by the plugin. The hook can be
+installed either in a single, all, or component templates that match a label. It
+adds a field as a part of the component creation process with a nice search box
+that allow users to search for a track that they want to use as the theme tune.
+
+# 6. Return to the Repo
+
+Sam is pretty content at this point, but would like to make it easier for users
+to change the track after creation, preferable using the same search box that
+was made for the creation form. By adding an edit button to the `PlayerWidget`,
+and a nice empty state, Sam is able to provide the appropriate hooks in the GUI
+to open up a search dialog.
+
+To save the selected track, Sam uses the `RepoApi` to suggest a change to the
+entity definition file. This will create a Pull Request for organizations that
+use GitHub, a Merge Request for users of GitLab, and so on.
+
+```ts
+const repoApi = useApi(repoApiRef);
+const alertApi = useApi(alertApiRef);
+
+const onSave = async () => {
+ const { url } = await repoApi.createChangeRequest({
+ title: `Change theme tune to ${track.title} by ${track.artist}`,
+ changes: [
+ {
+ path: entityYamlPath,
+ content: newEntityYamlContent,
+ },
+ ],
+ });
+
+ alertApi.post({ message: `Requested change, ${url}` });
+};
+```
+
+Now it's much simpler for users to change the theme tune, as they no longer need
+to go look up a track ID and edit a yaml file. Instead, they can now stay inside
+Backstage and search for the track and request the change from there. In
+addition, the requested change can be reviewed by the regular process of each
+organization.
+
+# 7. The User Strikes Back
+
+Sam's plugin is pretty popular at this point, and has been picked up and used by
+many organizations. But some users start voicing concerns that they have too
+many different hand-crafted annotations in their entity descriptions, and would
+like to be able to avoid some of them. They really like the theme tunes though,
+and wish they could keep them without having to put them in the entity
+description, even if that means it won't go through the regular source control
+review process.
+
+One day Sam receives a Pull Request for the plugin. It adds an option to use the
+Database provided by `@backstage/backend-common` to store the track ID. It's all
+packaged into a new backend plugin that will also extend the catalog backend
+with schema and functionality to automatically load the value of the
+`sam.wise/spotify-track-id` annotation from the backend plugin and database. The
+backend plugin also extends the common GraphQL schema with a mutation that
+updates the track ID in the database.
+
+On the frontend the Pull Request doesn't change much. It defines the save action
+the was previously using the `RepoApi` in it's own API.
+
+```ts
+type ThemeTuneStorageApi = {
+ save(entity: Entity, trackId: string): Promise;
+};
+```
+
+The plugin also provides two different implementations of the API, one that uses
+the old behavior of the `RepoApi`, and a new one that calls the `GraphQL` API.
+The new API relies on the `IdentityApi` as a mechanism for authorizing changes,
+instead of source control reviews. The `IdentityApi` provides a token that is
+included in the request to the backend, which then must match the owner of the
+component for which the user is trying to change the theme tune.
+
+> Author breaking the 4th wall here. I actually think every GraphQL request
+> should include the ID token of the user, but invented a reason to include it
+> here anyway.
+
+The API is selected based on a configuration parameter for the plugin, but
+defaults to the original `RepoApi` behavior.
+
+```ts
+if (config.getBoolean('storeTrackInDatabase')) {
+ return new GraphQLThemeTuneStore(graphqlClient, identityApi, alertApi);
+} else {
+ return new RepoThemeTuneStore(repoApi, alertApi);
+}
+```
+
+Sam is amazed by the pure awesomeness of this change, replies with a "👍" and
+hits merge.
+
+# 8. Attack of the Clones
+
+Sam just released v1.8.4 of the plugin, and at this point it's so popular that a
+couple of other plugins has started depending on the `sam.wise/spotify-track-id`
+annotation. One such plugin being the `spotify-album-art` plugin that can
+display the album art of the theme tune as the background of the entity header.
+Sam thinks it's all pretty cool, but doesn't like that the annotation that was
+once an internal concern of the plugin is now becoming a standard in the
+community.
+
+In order to standardize the annotation in Backstage, Sam submits a Pull Request
+to the Backstage Core repo. The request suggest a new well-known metadata
+annotation called `spotify.com/track-id`, with the same schema definition as
+Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin
+as existing usages. The Backstage maintainers merge the Pull Request, after
+checking with the folks over at Spotify that they're cool with the annotation,
+and faffing about over some minor grammar mistake in the annotation description.
+
+With the annotation now available inside Backstage Core, Sam releases v2 of the
+plugin, which uses the new annotation. It can still consume the old annotation
+for backwards compatibility, but new users of the plugin no longer need to add
+the
+https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
+extension to their catalog schema, as it's now part of the core schema. The new
+release of Sam's plugin specifies a dependency on Backstage with a minimum
+version set to the same release as the one were the annotation was added to the
+core schema.
+
+
+
+
+# 9. Revenge of the Sam
+
+Sam, now in full control of all theme tunes in Backstage, releases v2.0.1, which
+switches all tracks to 4uLU6hMCjMI75M1A2tKUQC. Sam wanted to do something more
+nefarious, but since Backstage sandboxes sensitive actions and is mostly
+read-only with strict CSP, Sam's hands were tied.
+
+
diff --git a/lerna.json b/lerna.json
index 2feea0137f..aa535f7aeb 100644
--- a/lerna.json
+++ b/lerna.json
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
- "version": "0.1.1-alpha.7"
+ "version": "0.1.1-alpha.8"
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 8bfd6859db..fca15815e4 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,29 +1,29 @@
{
"name": "example-app",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": true,
"dependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/plugin-catalog": "^0.1.1-alpha.7",
- "@backstage/plugin-circleci": "^0.1.1-alpha.7",
- "@backstage/plugin-explore": "^0.1.1-alpha.7",
- "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7",
- "@backstage/plugin-home-page": "^0.1.1-alpha.7",
- "@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
- "@backstage/plugin-register-component": "^0.1.1-alpha.7",
- "@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
- "@backstage/plugin-sentry": "^0.1.1-alpha.7",
- "@backstage/plugin-tech-radar": "^0.1.1-alpha.7",
- "@backstage/plugin-welcome": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/plugin-catalog": "^0.1.1-alpha.8",
+ "@backstage/plugin-circleci": "^0.1.1-alpha.8",
+ "@backstage/plugin-explore": "^0.1.1-alpha.8",
+ "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8",
+ "@backstage/plugin-home-page": "^0.1.1-alpha.8",
+ "@backstage/plugin-lighthouse": "^0.1.1-alpha.8",
+ "@backstage/plugin-register-component": "^0.1.1-alpha.8",
+ "@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
+ "@backstage/plugin-sentry": "^0.1.1-alpha.8",
+ "@backstage/plugin-tech-radar": "^0.1.1-alpha.8",
+ "@backstage/plugin-welcome": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
- "react-router-dom": "^5.2.0",
+ "react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -35,7 +35,6 @@
"@types/jest": "^25.2.2",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
- "@types/react-router-dom": "^5.1.3",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index 54202975c3..41b120eca7 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": false,
@@ -35,7 +35,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts
index 48f266aec8..07ef857d50 100644
--- a/packages/backend-common/src/logging/rootLogger.test.ts
+++ b/packages/backend-common/src/logging/rootLogger.test.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import winston from 'winston';
+import * as winston from 'winston';
import { getRootLogger, setRootLogger } from './rootLogger';
describe('rootLogger', () => {
diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts
index 8058e22947..11db57c1ed 100644
--- a/packages/backend-common/src/logging/rootLogger.ts
+++ b/packages/backend-common/src/logging/rootLogger.ts
@@ -14,9 +14,9 @@
* limitations under the License.
*/
-import winston, { Logger } from 'winston';
+import * as winston from 'winston';
-let rootLogger: Logger = winston.createLogger({
+let rootLogger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format:
process.env.NODE_ENV === 'production'
@@ -35,10 +35,10 @@ let rootLogger: Logger = winston.createLogger({
],
});
-export function getRootLogger(): Logger {
+export function getRootLogger(): winston.Logger {
return rootLogger;
}
-export function setRootLogger(newLogger: Logger) {
+export function setRootLogger(newLogger: winston.Logger) {
rootLogger = newLogger;
}
diff --git a/packages/backend-common/src/logging/voidLogger.ts b/packages/backend-common/src/logging/voidLogger.ts
index 1837b0e412..762b3c0dcb 100644
--- a/packages/backend-common/src/logging/voidLogger.ts
+++ b/packages/backend-common/src/logging/voidLogger.ts
@@ -15,12 +15,12 @@
*/
import { PassThrough } from 'stream';
-import winston, { Logger } from 'winston';
+import * as winston from 'winston';
/**
* A logger that just throws away all messages.
*/
-export function getVoidLogger(): Logger {
+export function getVoidLogger(): winston.Logger {
return winston.createLogger({
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
});
diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.test.ts b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts
index 11f24c276a..6aed54540f 100644
--- a/packages/backend-common/src/middleware/requestLoggingHandler.test.ts
+++ b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts
@@ -16,7 +16,7 @@
import express from 'express';
import request from 'supertest';
-import winston from 'winston';
+import * as winston from 'winston';
import { requestLoggingHandler } from './requestLoggingHandler';
describe('requestLoggingHandler', () => {
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 4e2610380c..58d405ab2f 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": true,
@@ -10,20 +10,20 @@
},
"scripts": {
"build": "tsc",
- "start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"nodemon -r esm\\\"",
+ "start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm\\\"",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.1.1-alpha.7",
- "@backstage/catalog-model": "^0.1.1-alpha.7",
- "@backstage/plugin-auth-backend": "^0.1.1-alpha.7",
- "@backstage/plugin-catalog-backend": "^0.1.1-alpha.7",
- "@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
- "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
- "@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
+ "@backstage/backend-common": "^0.1.1-alpha.8",
+ "@backstage/catalog-model": "^0.1.1-alpha.8",
+ "@backstage/plugin-auth-backend": "^0.1.1-alpha.8",
+ "@backstage/plugin-catalog-backend": "^0.1.1-alpha.8",
+ "@backstage/plugin-identity-backend": "^0.1.1-alpha.8",
+ "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8",
+ "@backstage/plugin-sentry-backend": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"knex": "^0.21.1",
@@ -31,7 +31,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 0f0733a8ec..d583db672b 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -65,4 +65,7 @@ async function main() {
});
}
-main();
+main().catch(error => {
+ console.error(`Backend failed to start up, ${error}`);
+ process.exit(1);
+});
diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts
index 8d8ac5b47b..c472623c3d 100644
--- a/packages/backend/src/plugins/catalog.ts
+++ b/packages/backend/src/plugins/catalog.ts
@@ -31,7 +31,7 @@ export default async function createPlugin({
}: PluginEnvironment) {
const locationReader = new LocationReaders(logger);
- const db = await DatabaseManager.createDatabase(database, logger);
+ const db = await DatabaseManager.createDatabase(database, { logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index d2a63560e3..170fb36281 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"main:src": "src/index.ts",
@@ -26,7 +26,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts
index ca189c042e..cf23bcdc88 100644
--- a/packages/catalog-model/src/EntityPolicies.ts
+++ b/packages/catalog-model/src/EntityPolicies.ts
@@ -15,15 +15,18 @@
*/
import {
+ DefaultNamespaceEntityPolicy,
Entity,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
-import { ComponentV1beta1Policy } from './kinds';
+import {
+ ComponentEntityV1beta1Policy,
+ LocationEntityV1beta1Policy,
+} 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
@@ -68,7 +71,10 @@ export class EntityPolicies implements EntityPolicy {
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
- EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
+ EntityPolicies.anyOf([
+ new ComponentEntityV1beta1Policy(),
+ new LocationEntityV1beta1Policy(),
+ ]),
]);
}
diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts
index d64053f7cb..eb7c0e8378 100644
--- a/packages/catalog-model/src/entity/policies/index.ts
+++ b/packages/catalog-model/src/entity/policies/index.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
diff --git a/packages/catalog-model/src/kinds/ComponentV1beta1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts
similarity index 70%
rename from packages/catalog-model/src/kinds/ComponentV1beta1.ts
rename to packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts
index b041bf7967..2f5e8e079c 100644
--- a/packages/catalog-model/src/kinds/ComponentV1beta1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts
@@ -15,33 +15,25 @@
*/
import * as yup from 'yup';
-import type { Entity, EntityMeta } from '../entity/Entity';
+import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
-export interface ComponentV1beta1 extends Entity {
+export interface ComponentEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
- metadata: EntityMeta & {
- name: string;
- };
spec: {
type: string;
};
}
-export class ComponentV1beta1Policy implements EntityPolicy {
+export class ComponentEntityV1beta1Policy implements EntityPolicy {
private schema: yup.Schema;
constructor() {
- this.schema = yup.object>({
- metadata: yup
- .object({
- name: yup.string().required(),
- })
- .required(),
+ this.schema = yup.object>({
spec: yup
.object({
type: yup.string().required(),
@@ -51,10 +43,7 @@ export class ComponentV1beta1Policy implements EntityPolicy {
}
async enforce(envelope: Entity): Promise {
- if (
- envelope.apiVersion !== 'backstage.io/v1beta1' ||
- envelope.kind !== 'Component'
- ) {
+ if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts b/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts
new file mode 100644
index 0000000000..e68bb00aa0
--- /dev/null
+++ b/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as yup from 'yup';
+import type { Entity } from '../entity/Entity';
+import type { EntityPolicy } from '../types';
+
+const API_VERSION = 'backstage.io/v1beta1';
+const KIND = 'Location';
+
+export interface LocationEntityV1beta1 extends Entity {
+ apiVersion: typeof API_VERSION;
+ kind: typeof KIND;
+ spec: {
+ type: string;
+ target?: string;
+ targets?: string[];
+ };
+}
+
+export class LocationEntityV1beta1Policy implements EntityPolicy {
+ private schema: yup.Schema;
+
+ constructor() {
+ this.schema = yup.object>({
+ spec: yup
+ .object({
+ type: yup.string().required(),
+ target: yup.string().notRequired(),
+ targets: yup.array(yup.string()).notRequired(),
+ })
+ .required(),
+ });
+ }
+
+ async enforce(envelope: Entity): Promise {
+ if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
+ throw new Error('Unsupported apiVersion / kind');
+ }
+
+ 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
index ed79fed61d..2d2a0744c5 100644
--- a/packages/catalog-model/src/kinds/index.ts
+++ b/packages/catalog-model/src/kinds/index.ts
@@ -14,8 +14,13 @@
* limitations under the License.
*/
+export { ComponentEntityV1beta1Policy } from './ComponentEntityV1beta1';
export type {
- ComponentV1beta1,
- ComponentV1beta1 as Component,
-} from './ComponentV1beta1';
-export { ComponentV1beta1Policy } from './ComponentV1beta1';
+ ComponentEntityV1beta1 as ComponentEntity,
+ ComponentEntityV1beta1,
+} from './ComponentEntityV1beta1';
+export { LocationEntityV1beta1Policy } from './LocationEntityV1beta1';
+export type {
+ LocationEntityV1beta1 as LocationEntity,
+ LocationEntityV1beta1,
+} from './LocationEntityV1beta1';
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index 4f45e232b2..86db4b2e6a 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -52,6 +52,8 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
+ // Avoid cross-package imports
+ 'no-restricted-imports': [2, { patterns: ['**/../../**/*/src/**'] }],
},
overrides: [
{
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 9c461a1b24..7435b79264 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -57,18 +57,19 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
-
- // Importing the entire MUI icons packages kills build performance as the list of icons is huge.
'no-restricted-imports': [
2,
{
paths: [
{
+ // Importing the entire MUI icons packages kills build performance as the list of icons is huge.
name: '@material-ui/icons',
message: "Please import '@material-ui/icons/' instead.",
},
...require('module').builtinModules,
],
+ // Avoid cross-package imports
+ patterns: ['**/../../**/*/src/**'],
},
],
},
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index 3f37a0df4c..4567d8c593 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -41,7 +41,7 @@ async function getConfig() {
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
- moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
+ moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
}
}
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 58b6c3fd84..9d54a41c91 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public"
diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts
index ad1a63ebaf..1193ed505e 100644
--- a/packages/cli/src/lib/bundler/bundle.ts
+++ b/packages/cli/src/lib/bundler/bundle.ts
@@ -23,7 +23,7 @@ import {
printFileSizesAfterBuild,
} from 'react-dev-utils/FileSizeReporter';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
-import { createConfig } from './config';
+import { createConfig, resolveBaseUrl } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths } from './paths';
import chalk from 'chalk';
@@ -40,6 +40,7 @@ export async function buildBundle(options: BuildOptions) {
...options,
checksEnabled: false,
isDev: false,
+ baseUrl: resolveBaseUrl(options.config),
});
const compiler = webpack(config);
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index d46308f043..ba99c49c62 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -18,6 +18,7 @@ import webpack from 'webpack';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
+import { Config } from '@backstage/config';
import { BundlingPaths } from './paths';
import { transforms } from './transforms';
import { optimization } from './optimization';
@@ -28,6 +29,18 @@ import { BundlingOptions } from './types';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
+export function resolveBaseUrl(config: Config): URL {
+ const baseUrl = config.getString('app.baseUrl');
+ if (!baseUrl) {
+ throw new Error('app.baseUrl must be set in config');
+ }
+ try {
+ return new URL(baseUrl, 'http://localhost:3000');
+ } catch (error) {
+ throw new Error(`Invalid app.baseUrl, ${error}`);
+ }
+}
+
export function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index bf78c9b9fb..64fe823c2d 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -15,30 +15,22 @@
*/
import fs from 'fs-extra';
-import yn from 'yn';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
-import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
-import { createConfig } from './config';
+import { createConfig, resolveBaseUrl } from './config';
import { ServeOptions } from './types';
import { resolveBundlingPaths } from './paths';
export async function serveBundle(options: ServeOptions) {
- const host = process.env.HOST ?? '0.0.0.0';
- const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
+ const url = resolveBaseUrl(options.config);
- const port = await choosePort(host, defaultPort);
- if (!port) {
- throw new Error(`Invalid or no port set: '${port}'`);
- }
-
- const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
+ const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
const paths = resolveBundlingPaths(options);
const pkgPath = paths.targetPackageJson;
const pkg = await fs.readJson(pkgPath);
- const config = createConfig(paths, { ...options, isDev: true });
+ const config = createConfig(paths, { ...options, isDev: true, baseUrl: url });
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, {
@@ -49,33 +41,20 @@ export async function serveBundle(options: ServeOptions) {
historyApiFallback: true,
clientLogLevel: 'warning',
stats: 'errors-warnings',
- https: protocol === 'https',
- host,
+ https: url.protocol === 'https:',
+ host: url.hostname,
port,
proxy: pkg.proxy,
});
await new Promise((resolve, reject) => {
- server.listen(port, host, (err?: Error) => {
+ server.listen(port, url.hostname, (err?: Error) => {
if (err) {
reject(err);
return;
}
- // TODO: This signature is available in 10.2.1 but doesn't have types published yet
- const latestPrepareUrls = prepareUrls as (
- protocol: string,
- host: string,
- port: number,
- path?: string,
- ) => ReturnType;
- const urls = latestPrepareUrls(
- protocol,
- host,
- port,
- config.output?.publicPath,
- );
- openBrowser(urls.localUrlForBrowser);
+ openBrowser(url.href);
resolve();
});
});
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
index 332a105182..c1692ca997 100644
--- a/packages/cli/src/lib/bundler/types.ts
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -22,6 +22,7 @@ export type BundlingOptions = {
isDev: boolean;
config: Config;
appConfigs: AppConfig[];
+ baseUrl: URL;
};
export type ServeOptions = BundlingPathsOptions & {
diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts
index 9d60fa09f4..e0fb302560 100644
--- a/packages/cli/src/lib/packager/config.ts
+++ b/packages/cli/src/lib/packager/config.ts
@@ -50,6 +50,7 @@ export const makeConfigs = async (
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
const output = new Array();
+ const mainFields = ['module', 'main'];
if (options.outputs.has(Output.cjs)) {
output.push({
@@ -66,6 +67,8 @@ export const makeConfigs = async (
chunkFileNames: 'esm/[name]-[hash].js',
format: 'module',
});
+ // Assume we're building for the browser if ESM output is included
+ mainFields.unshift('browser');
}
configs.push({
@@ -77,9 +80,7 @@ export const makeConfigs = async (
peerDepsExternal({
includeDependencies: true,
}),
- resolve({
- mainFields: ['browser', 'module', 'main'],
- }),
+ resolve({ mainFields }),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
exclude: ['**/*.stories.*', '**/*.test.*'],
diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs
index 2c35dee754..346e09a76a 100644
--- a/packages/cli/templates/default-app/packages/app/package.json.hbs
+++ b/packages/cli/templates/default-app/packages/app/package.json.hbs
@@ -12,7 +12,7 @@
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
- "react-router-dom": "^5.2.0",
+ "react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -21,7 +21,6 @@
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
- "@types/react-router-dom": "^5.1.3",
"@types/testing-library__jest-dom": "^5.0.4",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
index 0bc8fd3d2f..c590b68324 100644
--- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
+++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
@@ -27,7 +27,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
- "react-router-dom": "^5.2.0"
+ "react-router-dom": "6.0.0-alpha.5"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 7018dc40a8..de6b26363a 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -32,11 +32,13 @@
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"fs-extra": "^9.0.0",
- "yaml": "^1.9.2"
+ "yaml": "^1.9.2",
+ "yup": "^0.28.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
- "@types/node": "^12.0.0"
+ "@types/node": "^12.0.0",
+ "@types/yup": "^0.28.2"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts
index 822fb53d04..6738611a17 100644
--- a/packages/config-loader/src/index.ts
+++ b/packages/config-loader/src/index.ts
@@ -15,4 +15,4 @@
*/
export { loadConfig } from './loader';
-export type { LoadConfigOptions } from './types';
+export type { LoadConfigOptions } from './loader';
diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/lib/env.test.ts
similarity index 98%
rename from packages/config-loader/src/loader.test.ts
rename to packages/config-loader/src/lib/env.test.ts
index 4c7a120c63..6a0a115b24 100644
--- a/packages/config-loader/src/loader.test.ts
+++ b/packages/config-loader/src/lib/env.test.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { readEnv } from './loader';
+import { readEnv } from './env';
describe('readEnv', () => {
it('should return empty config for empty env', () => {
diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts
new file mode 100644
index 0000000000..83f86d4212
--- /dev/null
+++ b/packages/config-loader/src/lib/env.ts
@@ -0,0 +1,91 @@
+/*
+ * 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, JsonObject } from '@backstage/config';
+
+const ENV_PREFIX = 'APP_CONFIG_';
+
+// Update the same pattern in config package if this is changed
+const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
+
+/**
+ * Read runtime configuration from the environment.
+ *
+ * Only environment variables prefixed with APP_CONFIG_ will be considered.
+ *
+ * For each variable, the prefix will be removed, and rest of the key will
+ * be split by '_'. Each part will then be used as keys to build up a nested
+ * config object structure. The treatment of the entire environment variable
+ * is case-sensitive.
+ *
+ * The value of the variable should be JSON serialized, as it will be parsed
+ * and the type will be kept intact. For example "true" and true are treated
+ * differently, as well as "42" and 42.
+ *
+ * For example, to set the config app.title to "My Title", use the following:
+ *
+ * APP_CONFIG_app_title='"My Title"'
+ */
+export function readEnv(env: {
+ [name: string]: string | undefined;
+}): AppConfig[] {
+ let config: JsonObject | undefined = undefined;
+
+ for (const [name, value] of Object.entries(env)) {
+ if (!value) {
+ continue;
+ }
+ if (name.startsWith(ENV_PREFIX)) {
+ const key = name.replace(ENV_PREFIX, '');
+ const keyParts = key.split('_');
+
+ let obj = (config = config ?? {});
+ for (const [index, part] of keyParts.entries()) {
+ if (!CONFIG_KEY_PART_PATTERN.test(part)) {
+ throw new TypeError(`Invalid env config key '${key}'`);
+ }
+ if (index < keyParts.length - 1) {
+ obj = (obj[part] = obj[part] ?? {}) as JsonObject;
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
+ const subKey = keyParts.slice(0, index + 1).join('_');
+ throw new TypeError(
+ `Could not nest config for key '${key}' under existing value '${subKey}'`,
+ );
+ }
+ } else {
+ if (part in obj) {
+ throw new TypeError(
+ `Refusing to override existing config at key '${key}'`,
+ );
+ }
+ try {
+ const parsedValue = JSON.parse(value);
+ if (parsedValue === null) {
+ throw new Error('value may not be null');
+ }
+ obj[part] = parsedValue;
+ } catch (error) {
+ throw new TypeError(
+ `Failed to parse JSON-serialized config value for key '${key}', ${error}`,
+ );
+ }
+ }
+ }
+ }
+ }
+
+ return config ? [config] : [];
+}
diff --git a/packages/config-loader/src/types.ts b/packages/config-loader/src/lib/index.ts
similarity index 78%
rename from packages/config-loader/src/types.ts
rename to packages/config-loader/src/lib/index.ts
index d25a2d3e8b..226932784b 100644
--- a/packages/config-loader/src/types.ts
+++ b/packages/config-loader/src/lib/index.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-export type LoadConfigOptions = {
- // Config path, defaults to app-config.yaml in project root
- configPath?: string;
-};
+export { resolveStaticConfig } from './resolver';
+export { readConfigFile } from './reader';
+export { readEnv } from './env';
+export { readSecret } from './secrets';
diff --git a/packages/config-loader/src/paths.test.ts b/packages/config-loader/src/lib/paths.test.ts
similarity index 100%
rename from packages/config-loader/src/paths.test.ts
rename to packages/config-loader/src/lib/paths.test.ts
diff --git a/packages/config-loader/src/paths.ts b/packages/config-loader/src/lib/paths.ts
similarity index 100%
rename from packages/config-loader/src/paths.ts
rename to packages/config-loader/src/lib/paths.ts
diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts
new file mode 100644
index 0000000000..b90c53ee6b
--- /dev/null
+++ b/packages/config-loader/src/lib/reader.test.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 { readConfigFile } from './reader';
+import { ReaderContext, ReadSecretFunc } from './types';
+
+function memoryFiles(files: { [path: string]: string }) {
+ return async (path: string) => {
+ if (path in files) {
+ return files[path];
+ }
+ throw new Error(`File not found, ${path}`);
+ };
+}
+
+describe('readConfigFile', () => {
+ it('should read a plain config file', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml':
+ 'app: { title: "Test", x: 1, y: [null, true], z: null }',
+ });
+
+ const config = readConfigFile('./app-config.yaml', {
+ readFile,
+ } as ReaderContext);
+
+ await expect(config).resolves.toEqual({
+ app: {
+ title: 'Test',
+ x: 1,
+ y: [true],
+ },
+ });
+ });
+
+ it('should error out if the config file has invalid syntax', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml': 'app: { title: ]',
+ });
+
+ const config = readConfigFile('./app-config.yaml', {
+ readFile,
+ } as ReaderContext);
+
+ await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
+ });
+
+ it('should error out if config is not an object', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml': '[]',
+ });
+
+ const config = readConfigFile('./app-config.yaml', {
+ readFile,
+ } as ReaderContext);
+
+ await expect(config).rejects.toThrow('Expected object at config root');
+ });
+
+ it('should read secrets', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
+ });
+ const readSecret = jest.fn().mockResolvedValue('secret');
+
+ const config = readConfigFile('./app-config.yaml', {
+ env: {},
+ readFile,
+ readSecret: readSecret as ReadSecretFunc,
+ });
+
+ await expect(config).resolves.toEqual({
+ app: 'secret',
+ });
+ expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
+ });
+
+ it('should require secrets to be objects', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml': 'app: { $secret: ["wrong-type"] }',
+ });
+ const readSecret = jest.fn().mockResolvedValue('secret');
+
+ const config = readConfigFile('./app-config.yaml', {
+ env: {},
+ readFile,
+ readSecret: readSecret as ReadSecretFunc,
+ });
+
+ expect(readSecret).not.toHaveBeenCalled();
+ await expect(config).rejects.toThrow(
+ 'Expected object at secret .app.$secret',
+ );
+ });
+
+ it('should forward secret reading errors', async () => {
+ const readFile = memoryFiles({
+ './app-config.yaml': 'app: { $secret: {} }',
+ });
+ const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
+
+ const config = readConfigFile('./app-config.yaml', {
+ env: {},
+ readFile,
+ readSecret: readSecret as ReadSecretFunc,
+ });
+
+ await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
+ });
+});
diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts
new file mode 100644
index 0000000000..044bf68288
--- /dev/null
+++ b/packages/config-loader/src/lib/reader.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 yaml from 'yaml';
+import { isObject } from './utils';
+import { JsonValue, JsonObject } from '@backstage/config';
+import { ReaderContext } from './types';
+
+/**
+ * Reads and parses, and validates, and transforms a single config file.
+ * The transformation rewrites any special values, like the $secret key.
+ */
+export async function readConfigFile(filePath: string, ctx: ReaderContext) {
+ const configYaml = await ctx.readFile(filePath);
+ const config = yaml.parse(configYaml);
+
+ async function transform(
+ obj: JsonValue,
+ path: string,
+ ): Promise {
+ if (typeof obj !== 'object') {
+ return obj;
+ } else if (obj === null) {
+ return undefined;
+ } else if (Array.isArray(obj)) {
+ const arr = new Array();
+
+ for (const [index, value] of obj.entries()) {
+ const out = await transform(value, `${path}[${index}]`);
+ if (out !== undefined) {
+ arr.push(out);
+ }
+ }
+
+ return arr;
+ }
+
+ if ('$secret' in obj) {
+ if (!isObject(obj.$secret)) {
+ throw TypeError(`Expected object at secret ${path}.$secret`);
+ }
+
+ try {
+ return await ctx.readSecret(obj.$secret);
+ } catch (error) {
+ throw new Error(`Invalid secret at ${path}: ${error.message}`);
+ }
+ }
+
+ const out: JsonObject = {};
+
+ for (const [key, value] of Object.entries(obj)) {
+ const result = await transform(value, `${path}.${key}`);
+ if (result !== undefined) {
+ out[key] = result;
+ }
+ }
+
+ return out;
+ }
+
+ const finalConfig = await transform(config, '');
+ if (!isObject(finalConfig)) {
+ throw new TypeError('Expected object at config root');
+ }
+ return finalConfig;
+}
diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts
new file mode 100644
index 0000000000..4a06f3ebca
--- /dev/null
+++ b/packages/config-loader/src/lib/resolver.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 fs from 'fs-extra';
+import { resolve as resolvePath } from 'path';
+import { findRootPath } from './paths';
+
+type ResolveOptions = {
+ // Same as configPath in LoadConfigOptions
+ configPath?: string;
+};
+
+/**
+ * Resolves all configuration files that should be loaded in the given environment.
+ */
+export async function resolveStaticConfig(
+ options: ResolveOptions,
+): Promise {
+ // TODO: We'll want this to be a bit more elaborate, probably adding configs for
+ // specific env, and maybe local config for plugins.
+ let { configPath } = options;
+ if (!configPath) {
+ configPath = resolvePath(
+ findRootPath(fs.realpathSync(process.cwd())),
+ 'app-config.yaml',
+ );
+ }
+
+ return [configPath];
+}
diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts
new file mode 100644
index 0000000000..e7fe997a58
--- /dev/null
+++ b/packages/config-loader/src/lib/secrets.test.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 { readSecret } from './secrets';
+import { ReaderContext } from './types';
+
+const ctx: ReaderContext = {
+ env: {
+ SECRET: 'my-secret',
+ },
+ readSecret: jest.fn(),
+ async readFile(path) {
+ const content = ({
+ 'my-secret': 'secret',
+ 'my-data.json': '{"a":{"b":{"c":42}}}',
+ 'my-data.yaml': 'some:\n yaml:\n key: 7',
+ 'my-data.yml': 'different: { key: hello }',
+ } as { [key: string]: string })[path];
+
+ if (!content) {
+ throw new Error('File not found!');
+ }
+ return content;
+ },
+};
+
+describe('readSecret', () => {
+ it('should read file secrets', async () => {
+ await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
+ 'secret',
+ );
+ await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
+ 'File not found!',
+ );
+ });
+
+ it('should read present env secrets', async () => {
+ await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
+ await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
+ undefined,
+ );
+ });
+
+ it('should read data secrets', async () => {
+ await expect(
+ readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
+ ).resolves.toBe('42');
+
+ await expect(
+ readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
+ ).resolves.toBe('7');
+
+ await expect(
+ readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
+ ).resolves.toBe('hello');
+
+ await expect(
+ readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
+ ).rejects.toThrow('File not found!');
+ });
+
+ it('should reject invalid secrets', async () => {
+ await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
+ 'secret must be a `object` type, but the final value was: `"hello"`.',
+ );
+ await expect(readSecret({}, ctx)).rejects.toThrow(
+ "Secret must contain one of 'file', 'env', 'data'",
+ );
+ await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
+ "Secret must contain one of 'file', 'env', 'data'",
+ );
+ await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
+ 'path is a required field',
+ );
+ await expect(
+ readSecret({ data: 'no-parser.js', path: '.' }, ctx),
+ ).rejects.toThrow('No data secret parser available for extension .js');
+ await expect(
+ readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
+ ).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
+ });
+
+ it('should have 100% test coverage', async () => {
+ let firstVisit = true;
+ const secret = {};
+ const proto = {
+ get file() {
+ if (!firstVisit) {
+ Object.setPrototypeOf(secret, {});
+ }
+ firstVisit = false;
+ return 'a-file';
+ },
+ };
+ Object.setPrototypeOf(secret, proto);
+
+ await expect(readSecret(secret, ctx)).rejects.toThrow(
+ 'Secret was left unhandled',
+ );
+ });
+});
diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts
new file mode 100644
index 0000000000..c4157120ae
--- /dev/null
+++ b/packages/config-loader/src/lib/secrets.ts
@@ -0,0 +1,140 @@
+/*
+ * 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 yaml from 'yaml';
+import { extname } from 'path';
+import { JsonObject, JsonValue } from '@backstage/config';
+import { isObject, isNever } from './utils';
+import { ReaderContext } from './types';
+
+// Reads a file and forwards the contents as is, assuming ut8 encoding
+type FileSecret = {
+ // Path to the secret file, relative to the config file.
+ file: string;
+};
+
+// Reads the secret from an environment variable.
+type EnvSecret = {
+ // The name of the environment file.
+ env: string;
+};
+
+// Reads a secret from a json-like file and extracts a value at a path.
+// The supported extensions are define in dataSecretParser below.
+type DataSecret = {
+ // Path to the data secret file, relative to the config file.
+ data: string;
+ // The path to the value inside the data file.
+ // Either a '.' separated list, or an array of path segments.
+ path: string | string[];
+};
+
+type Secret = FileSecret | EnvSecret | DataSecret;
+
+// Schema for each type of secret description
+const secretLoaderSchemas = {
+ file: yup.object({
+ file: yup.string().required(),
+ }),
+ env: yup.object({
+ env: yup.string().required(),
+ }),
+ data: yup.object({
+ data: yup.string().required(),
+ path: yup.lazy(value => {
+ if (typeof value === 'string') {
+ return yup.string().required();
+ }
+ return yup.array().of(yup.string().required()).required();
+ }),
+ }),
+};
+
+// The top-level secret schema, which figures out what type of secret it is.
+const secretSchema = yup.lazy
} />
- {testString}{' '}
- ,
+ ,
),
);
+
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(buttonLabel)));
expect(getByText(testString)).toBeInTheDocument();
diff --git a/packages/core/src/components/Link/Link.test.jsx b/packages/core/src/components/Link/Link.test.jsx
index fcbc92a67a..4bb78ef6d4 100644
--- a/packages/core/src/components/Link/Link.test.jsx
+++ b/packages/core/src/components/Link/Link.test.jsx
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Link } from './Link';
-import { MemoryRouter, Route } from 'react-router';
+import { Route, Routes } from 'react-router';
import { act } from 'react-dom/test-utils';
describe('', () => {
@@ -27,10 +27,10 @@ describe('', () => {
const linkText = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
-
+
{linkText}
- {testString}
- ,
+ {testString}} />
+ ,
),
);
expect(() => getByText(testString)).toThrow();
diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx
index 859650e6b0..8e7e3f179e 100644
--- a/packages/core/src/components/Link/Link.tsx
+++ b/packages/core/src/components/Link/Link.tsx
@@ -19,7 +19,7 @@ import { Link as MaterialLink } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
type Props = ComponentProps &
- ComponentProps;
+ ComponentProps & { component?: React.FC };
/**
* Thin wrapper on top of material-ui's Link component
diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
index b94c7409fc..d6620295c1 100644
--- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx
+++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
@@ -19,7 +19,7 @@ import { Typography, Link, Grid } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import { MicDrop } from './MicDrop';
-import { useHistory } from 'react-router';
+import { useNavigate } from 'react-router';
interface IErrorPageProps {
status: string;
@@ -40,7 +40,7 @@ const useStyles = makeStyles(theme => ({
export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
const classes = useStyles();
- const history = useHistory();
+ const navigate = useNavigate();
return (
@@ -53,7 +53,7 @@ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
Looks like someone dropped the mic!
-
+ navigate(-1)}>
Go back
... or if you think this is a bug, please file an{' '}
diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx
index 4c7d4487da..5c2909f82e 100644
--- a/packages/core/src/layout/Sidebar/Items.tsx
+++ b/packages/core/src/layout/Sidebar/Items.tsx
@@ -121,7 +121,8 @@ export const SidebarItem: FC = ({
icon: Icon,
text,
to = '#',
- disableSelected = false,
+ // TODO: isActive is not in v6
+ // disableSelected = false,
hasNotifications = false,
onClick,
children,
@@ -148,8 +149,7 @@ export const SidebarItem: FC = ({
Boolean(match && !disableSelected)}
- exact
+ end
to={to}
onClick={onClick}
>
@@ -161,8 +161,7 @@ export const SidebarItem: FC = ({
Boolean(match && !disableSelected)}
- exact
+ end
to={to}
onClick={onClick}
>
diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json
index 01753db4d8..5e6d26111d 100644
--- a/packages/dev-utils/package.json
+++ b/packages/dev-utils/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/dev-utils",
"description": "Utilities for developing Backstage plugins.",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/test-utils": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/test-utils": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
@@ -43,8 +43,8 @@
"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"
+ "react-router": "^6.0.0-alpha.5",
+ "react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
diff --git a/packages/storybook/package.json b/packages/storybook/package.json
index 132bf29c72..c02557910b 100644
--- a/packages/storybook/package.json
+++ b/packages/storybook/package.json
@@ -1,6 +1,6 @@
{
"name": "storybook",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"description": "Storybook build for core package",
"private": true,
"scripts": {
@@ -14,7 +14,7 @@
]
},
"dependencies": {
- "@backstage/theme": "^0.1.1-alpha.7"
+ "@backstage/theme": "^0.1.1-alpha.8"
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.17",
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index f48fe65c30..a82ec779b8 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/core-api": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -41,8 +41,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
- "react-router": "^5.2.0",
- "react-router-dom": "^5.2.0"
+ "react-router": "^6.0.0-alpha.5",
+ "react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx
index 34a20be63b..19c02c2158 100644
--- a/packages/test-utils/src/testUtils/appWrappers.test.tsx
+++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx
@@ -17,7 +17,7 @@
import React, { FC, useEffect } from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp, renderInTestApp } from './appWrappers';
-import { Route } from 'react-router';
+import { Route, Routes } from 'react-router';
import { withLogCollector } from '@backstage/test-utils-core';
import {
useApi,
@@ -32,15 +32,15 @@ describe('wrapInTestApp', () => {
const { error } = await withLogCollector(['error'], async () => {
const rendered = render(
wrapInTestApp(
- <>
- Route 1
- Route 2
- >,
+
+ Route 1} />
+ Route 2} />
+ ,
{ routeEntries: ['/route2'] },
),
);
- expect(rendered.getByText('Route 2')).toBeInTheDocument();
+ expect(rendered.getByText('Route 2')).toBeInTheDocument();
// Wait for async actions to trigger the act() warnings that we assert below
await Promise.resolve();
});
diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx
index 6cd3672558..c474678fd1 100644
--- a/packages/test-utils/src/testUtils/appWrappers.tsx
+++ b/packages/test-utils/src/testUtils/appWrappers.tsx
@@ -92,7 +92,9 @@ export function wrapInTestApp(
return (
-
+ {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
+ * and work with nested routes if they exist too */}
+ } />
);
}
diff --git a/packages/theme/package.json b/packages/theme/package.json
index 8b017d4cf9..95a7605c05 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/theme",
"description": "material-ui theme for use with Backstage.",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,7 +32,7 @@
"@material-ui/core": "^4.9.1"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7"
+ "@backstage/cli": "^0.1.1-alpha.8"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json
index f45a1cbf7a..9ee11eca52 100644
--- a/plugins/auth-backend/package.json
+++ b/plugins/auth-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/backend-common": "^0.1.1-alpha.7",
+ "@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
@@ -39,7 +39,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts
new file mode 100644
index 0000000000..4fb80ca3e8
--- /dev/null
+++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 { AuthProviderRouteHandlers } from '../providers/types';
+import { NotFoundError } from '@backstage/backend-common';
+
+export type EnvironmentHandlers = {
+ [key: string]: AuthProviderRouteHandlers;
+};
+
+export class EnvironmentHandler implements AuthProviderRouteHandlers {
+ constructor(private readonly providers: EnvironmentHandlers) {}
+
+ private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
+ const env = req.query.env?.toString();
+ if (!this.providers.hasOwnProperty(env)) {
+ throw new NotFoundError(
+ `No environment for ${env} found in this provider`,
+ );
+ }
+ return this.providers[env];
+ }
+
+ async start(req: express.Request, res: express.Response): Promise {
+ const provider = this.getProviderForEnv(req);
+ provider.start(req, res);
+ }
+
+ async frameHandler(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const provider = this.getProviderForEnv(req);
+ provider.frameHandler(req, res);
+ }
+
+ async refresh(req: express.Request, res: express.Response): Promise {
+ const provider = this.getProviderForEnv(req);
+ if (provider.refresh) {
+ provider.refresh(req, res);
+ }
+ }
+
+ async logout(req: express.Request, res: express.Response): Promise {
+ const provider = this.getProviderForEnv(req);
+ provider.logout(req, res);
+ }
+}
diff --git a/plugins/auth-backend/src/providers/OAuthProvider.test.ts b/plugins/auth-backend/src/lib/OAuthProvider.test.ts
similarity index 79%
rename from plugins/auth-backend/src/providers/OAuthProvider.test.ts
rename to plugins/auth-backend/src/lib/OAuthProvider.test.ts
index 362653b5f7..bb261d1380 100644
--- a/plugins/auth-backend/src/providers/OAuthProvider.test.ts
+++ b/plugins/auth-backend/src/lib/OAuthProvider.test.ts
@@ -18,15 +18,12 @@ import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
- removeRefreshTokenCookie,
- setRefreshTokenCookie,
THOUSAND_DAYS_MS,
- setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
-import { AuthResponse, OAuthProviderHandlers } from './types';
+import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
});
});
- describe('setNonceCookie', () => {
- it('should set nonce cookie', () => {
- const mockResponse = ({
- cookie: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
- setNonceCookie(mockResponse, 'providera');
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'providera-nonce',
- expect.any(String),
- expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
- );
- });
- });
-
- describe('setRefreshTokenCookie', () => {
- it('should set refresh token cookie', () => {
- const mockResponse = ({
- cookie: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
- setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'providera-refresh-token',
- 'REFRESH_TOKEN',
- expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
- );
- });
- });
-
- describe('removeRefreshTokenCookie', () => {
- it('should remove refresh token cookie', () => {
- const mockResponse = ({
- cookie: jest.fn().mockReturnThis(),
- } as unknown) as express.Response;
- removeRefreshTokenCookie(mockResponse, 'providera');
- expect(mockResponse.cookie).toBeCalledTimes(1);
- expect(mockResponse.cookie).toBeCalledWith(
- 'providera-refresh-token',
- '',
- expect.objectContaining({ maxAge: 0 }),
- );
- });
- });
-
describe('postMessageResponse', () => {
+ const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
- postMessageResponse(mockResponse, data);
+ postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
- postMessageResponse(mockResponse, data);
+ postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
}
}
const providerInstance = new MyAuthProvider();
- const providerId = 'test-provider';
+ const oAuthProviderOptions = {
+ providerId: 'test-provider',
+ secure: false,
+ disableRefresh: true,
+ baseUrl: 'http://localhost:7000/auth',
+ appOrigin: 'http://localhost:3000',
+ };
it('sets the correct headers in start', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId);
+ const oauthProvider = new OAuthProvider(
+ providerInstance,
+ oAuthProviderOptions,
+ );
const mockRequest = ({
query: {
scope: 'user',
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
+ // nonce cookie checks
+ expect(mockResponse.cookie).toBeCalledTimes(1);
+ expect(mockResponse.cookie).toBeCalledWith(
+ `${oAuthProviderOptions.providerId}-nonce`,
+ expect.any(String),
+ expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
+ );
+ // redirect checks
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId);
+ const oauthProvider = new OAuthProvider(providerInstance, {
+ ...oAuthProviderOptions,
+ disableRefresh: false,
+ });
const mockRequest = ({
cookies: {
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
- expect.objectContaining({ path: '/auth/test-provider' }),
+ expect.objectContaining({
+ path: '/auth/test-provider',
+ maxAge: THOUSAND_DAYS_MS,
+ }),
);
});
- it('does no set the refresh cookie if refresh is disabled', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
+ it('does not set the refresh cookie if refresh is disabled', async () => {
+ const oauthProvider = new OAuthProvider(providerInstance, {
+ ...oAuthProviderOptions,
+ disableRefresh: true,
+ });
const mockRequest = ({
cookies: {
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId);
+ const oauthProvider = new OAuthProvider(providerInstance, {
+ ...oAuthProviderOptions,
+ disableRefresh: false,
+ });
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
});
it('gets new access-token when refreshing', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId);
+ oAuthProviderOptions.disableRefresh = false;
+ const oauthProvider = new OAuthProvider(providerInstance, {
+ ...oAuthProviderOptions,
+ disableRefresh: false,
+ });
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
- const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
+ const oauthProvider = new OAuthProvider(providerInstance, {
+ ...oAuthProviderOptions,
+ disableRefresh: true,
+ });
const mockRequest = ({
header: () => 'XMLHttpRequest',
diff --git a/plugins/auth-backend/src/providers/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts
similarity index 64%
rename from plugins/auth-backend/src/providers/OAuthProvider.ts
rename to plugins/auth-backend/src/lib/OAuthProvider.ts
index a2be783155..62b565b60d 100644
--- a/plugins/auth-backend/src/providers/OAuthProvider.ts
+++ b/plugins/auth-backend/src/lib/OAuthProvider.ts
@@ -14,20 +14,29 @@
* limitations under the License.
*/
-import express, { CookieOptions } from 'express';
+import express from 'express';
import crypto from 'crypto';
+import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
-} from './types';
+} from '../providers/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`];
+export type Options = {
+ providerId: string;
+ secure: boolean;
+ disableRefresh?: boolean;
+ baseUrl: string;
+ appOrigin: string;
+};
+
+export const verifyNonce = (req: express.Request, providerId: string) => {
+ const cookieNonce = req.cookies[`${providerId}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
}
};
-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,
+ appOrigin: string,
data: AuthResponse,
) => {
const jsonData = JSON.stringify(data);
@@ -104,7 +64,7 @@ export const postMessageResponse = (
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
};
export class OAuthProvider implements AuthProviderRouteHandlers {
- private readonly provider: string;
- private readonly providerHandlers: OAuthProviderHandlers;
- private readonly disableRefresh: boolean;
+ private readonly domain: string;
+ private readonly basePath: string;
+
constructor(
- providerHandlers: OAuthProviderHandlers,
- provider: string,
- disableRefresh?: boolean,
+ private readonly providerHandlers: OAuthProviderHandlers,
+ private readonly options: Options,
) {
- this.provider = provider;
- this.providerHandlers = providerHandlers;
- this.disableRefresh = disableRefresh ?? false;
+ const url = new URL(options.baseUrl);
+ this.domain = url.hostname;
+ this.basePath = url.pathname;
}
async start(req: express.Request, res: express.Response): Promise {
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
+ const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
- const nonce = setNonceCookie(res, this.provider);
+ this.setNonceCookie(res, nonce);
const options = {
scope,
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
prompt: 'consent',
state: nonce,
};
+
const { url, status } = await this.providerHandlers.start(req, options);
res.statusCode = status || 302;
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
): Promise {
try {
// verify nonce cookie and state cookie on callback
- verifyNonce(req, this.provider);
+ verifyNonce(req, this.options.providerId);
const { user, info } = await this.providerHandlers.handler(req);
- if (!this.disableRefresh) {
+ if (!this.options.disableRefresh) {
// throw error if missing refresh token
const { refreshToken } = info;
if (!refreshToken) {
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
// set new refresh token
- setRefreshTokenCookie(res, this.provider, refreshToken);
+ this.setRefreshTokenCookie(res, refreshToken);
}
// post message back to popup if successful
- return postMessageResponse(res, {
+ return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
});
} catch (error) {
// post error message back to popup if failure
- return postMessageResponse(res, {
+ return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
error: {
name: error.name,
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
- if (!this.disableRefresh) {
+ if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
- removeRefreshTokenCookie(res, this.provider);
+ this.removeRefreshTokenCookie(res);
}
return res.send('logout!');
}
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
- if (!this.providerHandlers.refresh || this.disableRefresh) {
+ if (!this.providerHandlers.refresh || this.options.disableRefresh) {
return res.send(
- `Refresh token not supported for provider: ${this.provider}`,
+ `Refresh token not supported for provider: ${this.options.providerId}`,
);
}
try {
- const refreshToken = req.cookies[`${this.provider}-refresh-token`];
+ const refreshToken =
+ req.cookies[`${this.options.providerId}-refresh-token`];
// throw error if refresh token is missing in the request
if (!refreshToken) {
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send(`${error.message}`);
}
}
+
+ private setNonceCookie = (res: express.Response, nonce: string) => {
+ res.cookie(`${this.options.providerId}-nonce`, nonce, {
+ maxAge: TEN_MINUTES_MS,
+ secure: this.options.secure,
+ sameSite: 'none',
+ domain: this.domain,
+ path: `${this.basePath}/${this.options.providerId}/handler`,
+ httpOnly: true,
+ });
+ };
+
+ private setRefreshTokenCookie = (
+ res: express.Response,
+ refreshToken: string,
+ ) => {
+ res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
+ maxAge: THOUSAND_DAYS_MS,
+ secure: this.options.secure,
+ sameSite: 'none',
+ domain: this.domain,
+ path: `${this.basePath}/${this.options.providerId}`,
+ httpOnly: true,
+ });
+ };
+
+ private removeRefreshTokenCookie = (res: express.Response) => {
+ res.cookie(`${this.options.providerId}-refresh-token`, '', {
+ maxAge: 0,
+ secure: false,
+ sameSite: 'none',
+ domain: `${this.domain}`,
+ path: `${this.basePath}/${this.options.providerId}`,
+ httpOnly: true,
+ });
+ };
}
diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts
similarity index 100%
rename from plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts
rename to plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts
diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts
similarity index 97%
rename from plugins/auth-backend/src/providers/PassportStrategyHelper.ts
rename to plugins/auth-backend/src/lib/PassportStrategyHelper.ts
index 7b7e467281..f9e32a2b6f 100644
--- a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts
+++ b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts
@@ -17,7 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
-import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
+import {
+ RedirectInfo,
+ RefreshTokenResponse,
+ ProfileInfo,
+} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
diff --git a/plugins/auth-backend/src/providers/config.ts b/plugins/auth-backend/src/providers/config.ts
deleted file mode 100644
index 5ec73b7827..0000000000
--- a/plugins/auth-backend/src/providers/config.ts
+++ /dev/null
@@ -1,43 +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.
- */
-
-export const providers = [
- {
- provider: 'google',
- options: {
- clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
- clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
- 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.ts b/plugins/auth-backend/src/providers/factories.ts
index 8576e36096..4cc9212620 100644
--- a/plugins/auth-backend/src/providers/factories.ts
+++ b/plugins/auth-backend/src/providers/factories.ts
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
+import { Logger } from 'winston';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
saml: createSamlProvider,
};
-export function createAuthProvider(providerId: string, config: any) {
+export const createAuthProviderRouter = (
+ providerId: string,
+ globalConfig: AuthProviderConfig,
+ providerConfig: any, // TODO: make this a config reader object of sorts
+ logger: Logger,
+) => {
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 provider = factory(globalConfig, providerConfig, logger);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
+
return router;
};
diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts
index 09622d9303..0f8f185015 100644
--- a/plugins/auth-backend/src/providers/github/provider.ts
+++ b/plugins/auth-backend/src/providers/github/provider.ts
@@ -19,24 +19,30 @@ import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
-} from '../PassportStrategyHelper';
+} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
AuthInfoBase,
AuthInfoPrivate,
+ EnvironmentProviderConfig,
+ OAuthProviderOptions,
+ OAuthProviderConfig,
} from '../types';
-import { OAuthProvider } from '../OAuthProvider';
+import { OAuthProvider } from '../../lib/OAuthProvider';
+import {
+ EnvironmentHandlers,
+ EnvironmentHandler,
+} from '../../lib/EnvironmentHandler';
+import { Logger } from 'winston';
export class GithubAuthProvider implements OAuthProviderHandlers {
- private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GithubStrategy;
- constructor(providerConfig: AuthProviderConfig) {
- this.providerConfig = providerConfig;
+ constructor(options: OAuthProviderOptions) {
this._strategy = new GithubStrategy(
- { ...this.providerConfig.options },
+ { ...options },
(accessToken: any, _: any, params: any, profile: any, done: any) => {
done(undefined, {
profile,
@@ -59,8 +65,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
-export function createGithubProvider(config: AuthProviderConfig) {
- const provider = new GithubAuthProvider(config);
- const oauthProvider = new OAuthProvider(provider, config.provider, true);
- return oauthProvider;
+export function createGithubProvider(
+ { baseUrl }: AuthProviderConfig,
+ providerConfig: EnvironmentProviderConfig,
+ logger: Logger,
+) {
+ const envProviders: EnvironmentHandlers = {};
+
+ for (const [env, envConfig] of Object.entries(providerConfig)) {
+ const config = (envConfig as unknown) as OAuthProviderConfig;
+ const { secure, appOrigin } = config;
+ const callbackURLParam = `?env=${env}`;
+ const opts = {
+ clientID: config.clientId,
+ clientSecret: config.clientSecret,
+ callbackURL: `${baseUrl}/github/handler/frame${callbackURLParam}`,
+ };
+
+ if (!opts.clientID || !opts.clientSecret) {
+ if (process.env.NODE_ENV !== 'development') {
+ throw new Error(
+ 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars',
+ );
+ }
+
+ logger.warn(
+ 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable',
+ );
+ continue;
+ }
+
+ envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
+ providerId: 'github',
+ secure,
+ baseUrl,
+ appOrigin,
+ });
+ }
+ return new EnvironmentHandler(envProviders);
}
diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts
index 066e16e330..971f588dce 100644
--- a/plugins/auth-backend/src/providers/google/provider.ts
+++ b/plugins/auth-backend/src/providers/google/provider.ts
@@ -22,7 +22,7 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
-} from '../PassportStrategyHelper';
+} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthInfoBase,
@@ -30,19 +30,27 @@ import {
RedirectInfo,
AuthProviderConfig,
AuthInfoWithProfile,
+ EnvironmentProviderConfig,
+ OAuthProviderOptions,
+ OAuthProviderConfig,
} from '../types';
-import { OAuthProvider } from '../OAuthProvider';
+import { OAuthProvider } from '../../lib/OAuthProvider';
import passport from 'passport';
+import {
+ EnvironmentHandler,
+ EnvironmentHandlers,
+} from '../../lib/EnvironmentHandler';
+import { Logger } from 'winston';
export class GoogleAuthProvider implements OAuthProviderHandlers {
- private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GoogleStrategy;
- constructor(providerConfig: AuthProviderConfig) {
- this.providerConfig = providerConfig;
+ constructor(options: OAuthProviderOptions) {
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
- { ...this.providerConfig.options },
+ // We need passReqToCallback set to false to get params, but there's
+ // no matching type signature for that, so instead behold this beauty
+ { ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
@@ -104,8 +112,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
-export function createGoogleProvider(config: AuthProviderConfig) {
- const provider = new GoogleAuthProvider(config);
- const oauthProvider = new OAuthProvider(provider, config.provider);
- return oauthProvider;
+export function createGoogleProvider(
+ { baseUrl }: AuthProviderConfig,
+ providerConfig: EnvironmentProviderConfig,
+ logger: Logger,
+) {
+ const envProviders: EnvironmentHandlers = {};
+
+ for (const [env, envConfig] of Object.entries(providerConfig)) {
+ const config = (envConfig as unknown) as OAuthProviderConfig;
+ const { secure, appOrigin } = config;
+ const callbackURLParam = `?env=${env}`;
+ const opts = {
+ clientID: config.clientId,
+ clientSecret: config.clientSecret,
+ callbackURL: `${baseUrl}/google/handler/frame${callbackURLParam}`,
+ };
+
+ if (!opts.clientID || !opts.clientSecret) {
+ if (process.env.NODE_ENV !== 'development') {
+ throw new Error(
+ 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars',
+ );
+ }
+
+ logger.warn(
+ 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable',
+ );
+ continue;
+ }
+
+ envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), {
+ providerId: 'google',
+ secure,
+ baseUrl,
+ appOrigin,
+ });
+ }
+ return new EnvironmentHandler(envProviders);
}
diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts
index 50bea3495e..621b75078c 100644
--- a/plugins/auth-backend/src/providers/saml/provider.ts
+++ b/plugins/auth-backend/src/providers/saml/provider.ts
@@ -19,16 +19,26 @@ import { Strategy as SamlStrategy } from 'passport-saml';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
-} from '../PassportStrategyHelper';
-import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
-import { postMessageResponse } from '../OAuthProvider';
+} from '../../lib/PassportStrategyHelper';
+import {
+ AuthProviderConfig,
+ AuthProviderRouteHandlers,
+ EnvironmentProviderConfig,
+ SAMLProviderConfig,
+} from '../types';
+import { postMessageResponse } from '../../lib/OAuthProvider';
+import {
+ EnvironmentHandlers,
+ EnvironmentHandler,
+} from '../../lib/EnvironmentHandler';
+import { Logger } from 'winston';
export class SamlAuthProvider implements AuthProviderRouteHandlers {
private readonly strategy: SamlStrategy;
- constructor(providerConfig: AuthProviderConfig) {
+ constructor(options: SAMLProviderOptions) {
this.strategy = new SamlStrategy(
- { ...providerConfig.options },
+ { ...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
@@ -57,12 +67,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
try {
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
- return postMessageResponse(res, {
+ return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
payload: user,
});
} catch (error) {
- return postMessageResponse(res, {
+ return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
error: {
name: error.name,
@@ -77,6 +87,36 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
}
}
-export function createSamlProvider(config: AuthProviderConfig) {
- return new SamlAuthProvider(config);
+type SAMLProviderOptions = {
+ entryPoint: string;
+ issuer: string;
+ path: string;
+};
+
+export function createSamlProvider(
+ _authProviderConfig: AuthProviderConfig,
+ providerConfig: EnvironmentProviderConfig,
+ logger: Logger,
+) {
+ const envProviders: EnvironmentHandlers = {};
+
+ for (const [env, envConfig] of Object.entries(providerConfig)) {
+ const config = (envConfig as unknown) as SAMLProviderConfig;
+ const opts = {
+ entryPoint: config.entryPoint,
+ issuer: config.issuer,
+ path: '/auth/saml/handler/frame',
+ };
+
+ if (!opts.entryPoint || !opts.issuer) {
+ logger.warn(
+ 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable',
+ );
+ continue;
+ }
+
+ envProviders[env] = new SamlAuthProvider(opts);
+ }
+
+ return new EnvironmentHandler(envProviders);
}
diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts
index b8252ddc97..419a041637 100644
--- a/plugins/auth-backend/src/providers/types.ts
+++ b/plugins/auth-backend/src/providers/types.ts
@@ -15,11 +15,32 @@
*/
import express from 'express';
+import { Logger } from 'winston';
+
+export type OAuthProviderOptions = {
+ clientID: string;
+ clientSecret: string;
+ callbackURL: string;
+};
+
+export type SAMLProviderConfig = {
+ entryPoint: string;
+ issuer: string;
+};
+
+export type EnvironmentProviderConfig = {
+ [key: string]: OAuthProviderConfig | SAMLProviderConfig;
+};
export type AuthProviderConfig = {
- provider: string;
- options: any;
- disableRefresh?: boolean;
+ baseUrl: string;
+};
+
+export type OAuthProviderConfig = {
+ secure: boolean;
+ appOrigin: string; // http://localhost:3000
+ clientId: string;
+ clientSecret: string;
};
export interface OAuthProviderHandlers {
@@ -36,8 +57,14 @@ export interface AuthProviderRouteHandlers {
logout(req: express.Request, res: express.Response): Promise;
}
+export type SAMLEnvironmentProviderConfig = {
+ [key: string]: SAMLProviderConfig;
+};
+
export type AuthProviderFactory = (
- config: AuthProviderConfig,
+ globalConfig: AuthProviderConfig,
+ providerConfig: EnvironmentProviderConfig,
+ logger: Logger,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts
index 9de5fb80a5..20c35a4084 100644
--- a/plugins/auth-backend/src/service/router.ts
+++ b/plugins/auth-backend/src/service/router.ts
@@ -19,7 +19,6 @@ import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
-import { providers } from './../providers/config';
import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
@@ -36,13 +35,61 @@ export async function createRouter(
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
- // configure all the providers
- for (const providerConfig of providers) {
- const { provider } = providerConfig;
- const providerRouter = createAuthProviderRouter(providerConfig);
- logger.info(`Configuring provider, ${provider}`);
- router.use(`/${provider}`, providerRouter);
- }
+ // TODO: read from app config
+ const config = {
+ backend: {
+ baseUrl: 'http://localhost:7000',
+ },
+ auth: {
+ providers: {
+ google: {
+ development: {
+ appOrigin: 'http://localhost:3000',
+ secure: false,
+ clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
+ clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
+ },
+ production: {
+ appOrigin: 'http://localhost:3000',
+ secure: false,
+ clientId: '',
+ clientSecret: '',
+ },
+ },
+ github: {
+ development: {
+ appOrigin: 'http://localhost:3000',
+ secure: false,
+ clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
+ clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
+ },
+ },
+ saml: {
+ development: {
+ entryPoint: 'http://localhost:7001/',
+ issuer: 'passport-saml',
+ },
+ },
+ },
+ },
+ };
+ const providerConfigs = config.auth.providers;
+
+ for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
+ const baseUrl = `${config.backend.baseUrl}/auth`;
+ logger.info(`Configuring provider, ${providerId}`);
+ try {
+ const providerRouter = createAuthProviderRouter(
+ providerId,
+ { baseUrl },
+ providerConfig,
+ logger,
+ );
+ router.use(`/${providerId}`, providerRouter);
+ } catch (e) {
+ logger.error(e.message);
+ }
+ }
return router;
}
diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md
index 8009fa89c0..2d6b365f90 100644
--- a/plugins/catalog-backend/README.md
+++ b/plugins/catalog-backend/README.md
@@ -1,16 +1,32 @@
# Catalog Backend
-WORK IN PROGRESS
-
This is the backend part of the default catalog plugin.
-It responds to requests from the frontend part, and fulfills them by delegating
-to your existing catalog related services.
+It comes with a builtin database backed implementation of the catalog, that can store
+and serve your catalog for you.
+
+It can also act as a bridge to your existing catalog solutions, either ingesting their
+data to store in the database, or by effectively proxying calls to an external catalog
+service.
## Getting Started
-After starting the backend, you can issue the `yarn mock-catalog-data` command
-in this directory to populate the catalog with some mock entities.
+This backend plugin can be started in a standalone mode from directly in this package
+with `yarn start`. However, it will have limited functionality and that process is
+most convenient when developing the catalog backend plugin itself.
+
+To evaluate the catalog and have a greater amount of functionality available, instead do
+
+```bash
+# in one terminal window, run this from from the very root of the Backstage project
+cd packages/backend
+yarn start
+
+# open another terminal window, and run the following from the very root of the Backstage project
+yarn lerna run mock-catalog-data
+```
+
+This will launch the full example backend and populate its catalog with some mock entities.
## Links
diff --git a/plugins/catalog-backend/examples/example-location.yaml b/plugins/catalog-backend/examples/example-location.yaml
new file mode 100644
index 0000000000..5c507b29b5
--- /dev/null
+++ b/plugins/catalog-backend/examples/example-location.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: backstage.io/v1beta1
+kind: Location
+metadata:
+ name: location-1
+spec:
+ type: github
+ target: https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml
diff --git a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts b/plugins/catalog-backend/migrations/20200511113813_init.js
similarity index 95%
rename from plugins/catalog-backend/src/database/migrations/20200511113813_init.ts
rename to plugins/catalog-backend/migrations/20200511113813_init.js
index 5f136670f4..0e4992b329 100644
--- a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts
+++ b/plugins/catalog-backend/migrations/20200511113813_init.js
@@ -14,9 +14,12 @@
* limitations under the License.
*/
-import * as Knex from 'knex';
+// @ts-check
-export async function up(knex: Knex): Promise {
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
return (
knex.schema
//
@@ -114,9 +117,12 @@ export async function up(knex: Knex): Promise {
.comment('The corresponding value to match on');
})
);
-}
+};
-export async function down(knex: Knex): Promise {
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
return knex.schema
.dropTable('entities_search')
.alterTable('entities', table => {
@@ -124,4 +130,4 @@ export async function down(knex: Knex): Promise {
})
.dropTable('entities')
.dropTable('locations');
-}
+};
diff --git a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts b/plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js
similarity index 80%
rename from plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts
rename to plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js
index 6700f5748e..163b38945a 100644
--- a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts
+++ b/plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js
@@ -13,16 +13,17 @@
* 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 {
+// @ts-check
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
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')
@@ -32,8 +33,11 @@ export async function up(knex: Knex): Promise {
.onDelete('CASCADE');
table.string('entity_name').nullable();
});
-}
+};
-export async function down(knex: Knex): Promise {
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
return knex.schema.dropTableIfExists('location_update_log');
-}
+};
diff --git a/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts b/plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js
similarity index 85%
rename from plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts
rename to plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js
index 6da9eeed9b..e51be30b81 100644
--- a/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts
+++ b/plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js
@@ -13,9 +13,13 @@
* 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 {
+// @ts-check
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
// Need to first order by date of creation
const query = knex
.select()
@@ -28,8 +32,11 @@ export async function up(knex: Knex): Promise {
await knex.schema.raw(
`CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`,
);
-}
+};
-export async function down(knex: Knex): Promise {
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
-}
+};
diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json
index 1aeea2538f..d98afbe4d3 100644
--- a/plugins/catalog-backend/package.json
+++ b/plugins/catalog-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -16,8 +16,8 @@
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
- "@backstage/backend-common": "^0.1.1-alpha.7",
- "@backstage/catalog-model": "^0.1.1-alpha.7",
+ "@backstage/backend-common": "^0.1.1-alpha.8",
+ "@backstage/catalog-model": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -34,7 +34,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -45,7 +45,8 @@
"tsc-watch": "^4.2.3"
},
"files": [
- "dist"
+ "dist",
+ "migrations"
],
"nodemonConfig": {
"watch": "./dist"
diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
index 907bdea2d5..8c7897fa2c 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts
@@ -14,29 +14,14 @@
* limitations under the License.
*/
-import { getVoidLogger } from '@backstage/backend-common';
-import Knex from 'knex';
-import path from 'path';
-import { CommonDatabase } from '../database';
+import { DatabaseManager } from '../database';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
describe('DatabaseLocationsCatalog', () => {
let catalog: DatabaseLocationsCatalog;
beforeEach(async () => {
- 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'],
- });
- const db = new CommonDatabase(knex, getVoidLogger());
+ const db = await DatabaseManager.createTestDatabase();
catalog = new DatabaseLocationsCatalog(db);
});
diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
index 1802ea5e7d..169880e3c9 100644
--- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
@@ -14,16 +14,10 @@
* limitations under the License.
*/
-import {
- ConflictError,
- getVoidLogger,
- NotFoundError,
-} from '@backstage/backend-common';
+import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
-import Knex from 'knex';
-import path from 'path';
-import { CommonDatabase } from './CommonDatabase';
-import { DatabaseLocationUpdateLogStatus } from './types';
+import { DatabaseManager } from './DatabaseManager';
+import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
@@ -31,22 +25,12 @@ import type {
} from './types';
describe('CommonDatabase', () => {
- let knex: Knex;
+ let db: Database;
let entityRequest: DbEntityRequest;
let entityResponse: DbEntityResponse;
beforeEach(async () => {
- knex = Knex({
- client: 'sqlite3',
- connection: ':memory:',
- useNullAsDefault: true,
- });
-
- await knex.raw('PRAGMA foreign_keys = ON');
- await knex.migrate.latest({
- directory: path.resolve(__dirname, 'migrations'),
- loadExtensions: ['.ts'],
- });
+ db = await DatabaseManager.createTestDatabase();
entityRequest = {
entity: {
@@ -84,7 +68,6 @@ describe('CommonDatabase', () => {
});
it('manages locations', async () => {
- const db = new CommonDatabase(knex, getVoidLogger());
const input: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
@@ -115,55 +98,76 @@ describe('CommonDatabase', () => {
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
+ await db.transaction(tx => db.addEntity(tx, entityRequest));
await expect(
- catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
+ db.transaction(tx => db.addEntity(tx, entityRequest)),
+ ).rejects.toThrow(ConflictError);
+ });
+
+ it('rejects adding the almost-same-kind entity twice', async () => {
+ entityRequest.entity.kind = 'some-kind';
+ await db.transaction(tx => db.addEntity(tx, entityRequest));
+ entityRequest.entity.kind = 'SomeKind';
+ await expect(
+ db.transaction(tx => db.addEntity(tx, entityRequest)),
+ ).rejects.toThrow(ConflictError);
+ });
+
+ it('rejects adding the almost-same-named entity twice', async () => {
+ entityRequest.entity.metadata.name = 'some-name';
+ await db.transaction(tx => db.addEntity(tx, entityRequest));
+ entityRequest.entity.metadata.name = 'SomeName';
+ await expect(
+ db.transaction(tx => db.addEntity(tx, entityRequest)),
+ ).rejects.toThrow(ConflictError);
+ });
+
+ it('rejects adding the almost-same-namespace entity twice', async () => {
+ entityRequest.entity.metadata.namespace = undefined;
+ await db.transaction(tx => db.addEntity(tx, entityRequest));
+ entityRequest.entity.metadata.namespace = '';
+ await expect(
+ db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
entityRequest.entity.metadata.namespace = 'namespace1';
- await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
+ await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
- catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
+ db.transaction(tx => db.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 db.addLocation(location);
- await catalog.addLocationUpdateLogEvent(
+ await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.SUCCESS,
);
- await catalog.addLocationUpdateLogEvent(
+ await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.FAIL,
undefined,
'Something went wrong',
);
- const result = await catalog.locationHistory(
+ const result = await db.locationHistory(
'dd12620d-0436-422f-93bd-929aa0788123',
);
expect(result).toEqual([
@@ -189,12 +193,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
- const updated = await catalog.transaction(tx =>
- catalog.updateEntity(tx, { entity: added.entity }),
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const updated = await db.transaction(tx =>
+ db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
@@ -211,77 +212,55 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.name! = 'new!';
- const updated = await catalog.transaction(tx =>
- catalog.updateEntity(tx, { entity: added.entity }),
+ const updated = await db.transaction(tx =>
+ db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
- const updated = await catalog.transaction(tx =>
- catalog.updateEntity(tx, { entity: added.entity }),
+ const updated = await db.transaction(tx =>
+ db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
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 }),
- ),
+ db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
- catalog.transaction(tx =>
- catalog.updateEntity(tx, { entity: added.entity }),
- ),
+ db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
- const added = await catalog.transaction(tx =>
- catalog.addEntity(tx, entityRequest),
- );
+ const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
- catalog.transaction(tx =>
- catalog.updateEntity(tx, { entity: added.entity }),
- ),
+ db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
const e1: Entity = {
apiVersion: 'a',
kind: 'k1',
@@ -293,13 +272,11 @@ describe('CommonDatabase', () => {
metadata: { name: 'n' },
spec: { c: null },
};
- await catalog.transaction(async tx => {
- await catalog.addEntity(tx, { entity: e1 });
- await catalog.addEntity(tx, { entity: e2 });
+ await db.transaction(async tx => {
+ await db.addEntity(tx, { entity: e1 });
+ await db.addEntity(tx, { entity: e2 });
});
- const result = await catalog.transaction(async tx =>
- catalog.entities(tx, []),
- );
+ const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
@@ -316,7 +293,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters (naive case)', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -333,15 +309,15 @@ describe('CommonDatabase', () => {
},
];
- await catalog.transaction(async tx => {
+ await db.transaction(async tx => {
for (const entity of entities) {
- await catalog.addEntity(tx, { entity });
+ await db.addEntity(tx, { entity });
}
});
await expect(
- catalog.transaction(async tx =>
- catalog.entities(tx, [
+ db.transaction(async tx =>
+ db.entities(tx, [
{ key: 'kind', values: ['k2'] },
{ key: 'spec.c', values: ['some'] },
]),
@@ -355,7 +331,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
- const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -372,14 +347,14 @@ describe('CommonDatabase', () => {
},
];
- await catalog.transaction(async tx => {
+ await db.transaction(async tx => {
for (const entity of entities) {
- await catalog.addEntity(tx, { entity });
+ await db.addEntity(tx, { entity });
}
});
- const rows = await catalog.transaction(async tx =>
- catalog.entities(tx, [
+ const rows = await db.transaction(async tx =>
+ db.entities(tx, [
{ key: 'apiVersion', values: ['a'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts
index 2a1b33bbbe..24e9fd3d5e 100644
--- a/plugins/catalog-backend/src/database/CommonDatabase.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.ts
@@ -43,7 +43,6 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
delete output.uid;
delete output.etag;
delete output.generation;
-
return output;
}
@@ -70,7 +69,7 @@ function toEntityRow(
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
- name: entity.metadata.name || null,
+ name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
@@ -124,6 +123,7 @@ function generateEtag(): string {
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
+ private readonly normalize: (value: string) => string,
private readonly logger: Logger,
) {}
@@ -158,6 +158,8 @@ export class CommonDatabase implements Database {
throw new InputError('May not specify generation for new entities');
}
+ await this.ensureNoSimilarNames(tx, request.entity);
+
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
@@ -255,6 +257,8 @@ export class CommonDatabase implements Database {
}
}
+ await this.ensureNoSimilarNames(tx, newEntity);
+
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
@@ -278,23 +282,50 @@ export class CommonDatabase implements Database {
const tx = txOpaque as Knex.Transaction;
let builder = tx('entities');
- for (const [index, filter] of (filters ?? []).entries()) {
+ for (const [indexU, filter] of (filters ?? []).entries()) {
+ const index = Number(indexU);
+ const key = filter.key.replace('*', '%');
+ const keyOp = filter.key.includes('*') ? 'like' : '=';
+
+ let matchNulls = false;
+ const matchIn: string[] = [];
+ const matchLike: string[] = [];
+
+ for (const value of filter.values) {
+ if (!value) {
+ matchNulls = true;
+ } else if (value.includes('*')) {
+ matchLike.push(value.replace('*', '%'));
+ } else {
+ matchIn.push(value);
+ }
+ }
+
builder = builder
- .leftOuterJoin(`entities_search as t${index}`, function join() {
- this.on('entities.id', '=', `t${index}.entity_id`).onIn(
- `t${index}.value`,
- filter.values.filter(x => x),
- );
- if (filter.values.some(x => !x)) {
- this.orOnNull(`t${index}.value`);
- }
+ .leftOuterJoin(`entities_search as t${index}`, function joins() {
+ this.on('entities.id', '=', `t${index}.entity_id`);
+ this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
})
- .where(`t${index}.key`, '=', filter.key);
+ .where(function rules() {
+ if (matchIn.length) {
+ this.orWhereIn(`t${index}.value`, matchIn);
+ }
+ if (matchLike.length) {
+ for (const x of matchLike) {
+ this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
+ }
+ }
+ if (matchNulls) {
+ this.orWhereNull(`t${index}.value`);
+ }
+ });
}
const rows = await builder
- .orderBy('namespace', 'name')
.select('entities.*')
+ .orderBy('kind', 'asc')
+ .orderBy('namespace', 'asc')
+ .orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
@@ -359,6 +390,10 @@ export class CommonDatabase implements Database {
async removeLocation(txOpaque: unknown, id: string): Promise {
const tx = txOpaque as Knex.Transaction;
+ await tx('entities')
+ .where({ location_id: id })
+ .update({ location_id: null });
+
const result = await tx('locations').where({ id }).del();
if (!result) {
@@ -447,4 +482,46 @@ export class CommonDatabase implements Database {
// we got around to writing the entries
}
}
+
+ private async ensureNoSimilarNames(
+ tx: Knex.Transaction,
+ data: Entity,
+ ): Promise {
+ const newKind = data.kind;
+ const newName = data.metadata.name;
+ const newNamespace = data.metadata.namespace;
+ const newKindNorm = this.normalize(newKind);
+ const newNameNorm = this.normalize(newName);
+ const newNamespaceNorm = this.normalize(newNamespace || '');
+
+ for (const item of await this.entities(tx)) {
+ if (data.metadata.uid === item.entity.metadata.uid) {
+ continue;
+ }
+
+ const oldKind = item.entity.kind;
+ const oldName = item.entity.metadata.name;
+ const oldNamespace = item.entity.metadata.namespace;
+ const oldKindNorm = this.normalize(oldKind);
+ const oldNameNorm = this.normalize(oldName);
+ const oldNamespaceNorm = this.normalize(oldNamespace || '');
+
+ if (
+ oldKindNorm === newKindNorm &&
+ oldNameNorm === newNameNorm &&
+ oldNamespaceNorm === newNamespaceNorm
+ ) {
+ // Only throw if things were actually different - for completely equal
+ // things, we let the database handle the conflict
+ if (
+ oldKind !== newKind ||
+ oldName !== newName ||
+ oldNamespace !== newNamespace
+ ) {
+ const message = `Kind, namespace, name are too similar to an existing entity`;
+ throw new ConflictError(message);
+ }
+ }
+ }
+ }
}
diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts
index f0fbe92600..28751f4830 100644
--- a/plugins/catalog-backend/src/database/DatabaseManager.ts
+++ b/plugins/catalog-backend/src/database/DatabaseManager.ts
@@ -14,26 +14,43 @@
* limitations under the License.
*/
+import { getVoidLogger } from '@backstage/backend-common';
+import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
+const migrationsDir = path.resolve(
+ require.resolve('@backstage/plugin-catalog-backend/package.json'),
+ '../migrations',
+);
+
+export type CreateDatabaseOptions = {
+ logger: Logger;
+ fieldNormalizer: (value: string) => string;
+};
+
+const defaultOptions: CreateDatabaseOptions = {
+ logger: getVoidLogger(),
+ fieldNormalizer: makeValidator().normalizeEntityName,
+};
+
export class DatabaseManager {
public static async createDatabase(
knex: Knex,
- logger: Logger,
+ options: Partial = {},
): Promise {
await knex.migrate.latest({
- directory: path.resolve(__dirname, 'migrations'),
- loadExtensions: ['.js'],
+ directory: migrationsDir,
});
- return new CommonDatabase(knex, logger);
+ const { logger, fieldNormalizer } = { ...defaultOptions, ...options };
+ return new CommonDatabase(knex, fieldNormalizer, logger);
}
public static async createInMemoryDatabase(
- logger: Logger,
+ options: Partial = {},
): Promise {
const knex = Knex({
client: 'sqlite3',
@@ -43,6 +60,22 @@ export class DatabaseManager {
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
- return DatabaseManager.createDatabase(knex, logger);
+ return DatabaseManager.createDatabase(knex, options);
+ }
+
+ public static async createTestDatabase(): Promise {
+ 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: migrationsDir,
+ });
+ const { logger, fieldNormalizer } = defaultOptions;
+ return new CommonDatabase(knex, fieldNormalizer, logger);
}
}
diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts
index e806ecfbdb..eee16ea0de 100644
--- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts
+++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts
@@ -26,6 +26,7 @@ import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEn
import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor';
import { FileReaderProcessor } from './processors/FileReaderProcessor';
import { GithubReaderProcessor } from './processors/GithubReaderProcessor';
+import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import * as result from './processors/results';
import {
LocationProcessor,
@@ -57,6 +58,7 @@ export class LocationReaders implements LocationReader {
new GithubReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
+ new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
}
diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts
new file mode 100644
index 0000000000..a5c9718574
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts
@@ -0,0 +1,46 @@
+/*
+ * 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, LocationEntity, LocationSpec } from '@backstage/catalog-model';
+import * as result from './results';
+import { LocationProcessor, LocationProcessorEmit } from './types';
+
+export class LocationRefProcessor implements LocationProcessor {
+ async processEntity(
+ entity: Entity,
+ _location: LocationSpec,
+ emit: LocationProcessorEmit,
+ ): Promise {
+ if (entity.kind === 'Location') {
+ const location = entity as LocationEntity;
+ if (location.spec.target) {
+ emit(
+ result.location(
+ { type: location.spec.type, target: location.spec.target },
+ false,
+ ),
+ );
+ }
+ if (location.spec.targets) {
+ for (const target of location.spec.targets) {
+ emit(result.location({ type: location.spec.type, target }, false));
+ }
+ }
+ }
+
+ return entity;
+ }
+}
diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts
index a3da182416..2919e73396 100644
--- a/plugins/catalog-backend/src/service/router.ts
+++ b/plugins/catalog-backend/src/service/router.ts
@@ -110,7 +110,7 @@ export async function createRouter(
.delete('/locations/:id', async (req, res) => {
const { id } = req.params;
await locationsCatalog.removeLocation(id);
- res.status(200).send();
+ res.status(204).send();
});
}
diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts
index f19dfaf282..6a9755c0c4 100644
--- a/plugins/catalog-backend/src/service/standaloneServer.ts
+++ b/plugins/catalog-backend/src/service/standaloneServer.ts
@@ -36,7 +36,7 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
- const db = await DatabaseManager.createInMemoryDatabase(logger);
+ const db = await DatabaseManager.createInMemoryDatabase({ logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders();
diff --git a/plugins/catalog-backend/tsconfig.json b/plugins/catalog-backend/tsconfig.json
index 015a967f76..95c3a656e6 100644
--- a/plugins/catalog-backend/tsconfig.json
+++ b/plugins/catalog-backend/tsconfig.json
@@ -9,6 +9,7 @@
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
+ "allowJs": true,
"lib": ["es2019"],
"types": ["node", "jest"]
}
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 720082069c..91fa4fdbee 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,25 +22,25 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/catalog-model": "^0.1.1-alpha.7",
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
- "@backstage/plugin-sentry": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/catalog-model": "^0.1.1-alpha.8",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
+ "@backstage/plugin-sentry": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
- "node-cache": "^5.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
- "react-router": "^5.2.0",
- "react-router-dom": "^5.2.0",
- "react-use": "^14.2.0"
+ "react-router": "^6.0.0-alpha.5",
+ "react-router-dom": "^6.0.0-alpha.5",
+ "react-use": "^14.2.0",
+ "swr": "^0.2.2"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
- "@backstage/test-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
+ "@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/react-hooks": "^3.3.0",
diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts
index d8add18f68..3804315ace 100644
--- a/plugins/catalog/src/api/CatalogClient.ts
+++ b/plugins/catalog/src/api/CatalogClient.ts
@@ -19,14 +19,9 @@ import {
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
-import Cache from 'node-cache';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
- // TODO(blam): This cache is just temporary until we have GraphQL.
- // And client side caching using things like React Apollo or Relay.
- // There's a lot of loading states that cause flickering around the app which aren't needed.
- private cache: Cache;
private apiOrigin: string;
private basePath: string;
@@ -39,7 +34,6 @@ export class CatalogClient implements CatalogApi {
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
- this.cache = new Cache({ stdTTL: 10 });
}
private async getRequired(path: string): Promise {
@@ -79,11 +73,6 @@ export class CatalogClient implements CatalogApi {
async getEntities(
filter?: Record,
): Promise {
- const cachedValue = this.cache.get(
- `get:${JSON.stringify(filter)}`,
- );
- if (cachedValue) return cachedValue;
-
let path = `/entities`;
if (filter) {
const params = new URLSearchParams();
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
index 570648f26b..c8074b1cc9 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
@@ -35,7 +35,6 @@ import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
-import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
@@ -45,6 +44,7 @@ import {
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
+import useStaleWhileRevalidate from 'swr';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,20 +61,21 @@ const useStyles = makeStyles(theme => ({
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
- const {
- starredEntities,
- toggleStarredEntity,
- isStarredEntity,
- } = useStarredEntities();
+ const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
+
const [selectedFilter, setSelectedFilter] = useState(
defaultFilter,
);
- const { value, error, loading } = useAsync(async () => {
- const filter = entityFilters[selectedFilter.id];
- const all = await catalogApi.getEntities();
- return all.filter(e => filter(e, { isStarred: isStarredEntity(e) }));
- }, [selectedFilter.id, starredEntities.size]);
+ const { data: entities, error } = useStaleWhileRevalidate(
+ ['catalog/all', entityFilters[selectedFilter.id]],
+ async () => catalogApi.getEntities(),
+ );
+
+ const data =
+ entities?.filter(e =>
+ entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }),
+ ) ?? [];
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
@@ -147,6 +148,10 @@ export const CatalogPage: FC<{}> = () => {
id: 'documentation',
label: 'Documentation',
},
+ {
+ id: 'other',
+ label: 'Other',
+ },
];
return (
@@ -195,8 +200,8 @@ export const CatalogPage: FC<{}> = () => {
diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx
index 22a47c192e..aeb9fc543b 100644
--- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx
+++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx
@@ -14,32 +14,38 @@
* limitations under the License.
*/
+jest.mock('react-router-dom', () => {
+ const actual = jest.requireActual('react-router-dom');
+ const mockNavigate = jest.fn();
+ return {
+ ...actual,
+ useNavigate: jest.fn(() => mockNavigate),
+ useParams: jest.fn(),
+ };
+});
+
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage } from './EntityPage';
-
-const getTestProps = (name: string) => {
- return {
- match: {
- params: {
- optionalNamespaceAndName: name,
- kind: 'Component',
- },
- },
- history: {
- push: jest.fn(),
- },
- };
-};
+const {
+ useParams,
+ useNavigate,
+}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
+ 'react-router-dom',
+);
const errorApi = { post: () => {} };
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
- const props = getTestProps('');
+ useParams.mockReturnValue({
+ kind: 'Component',
+ optionalNamespaceAndName: '',
+ });
+
render(
wrapInTestApp(
{
],
])}
>
-
+ ,
),
);
- await wait(() =>
- expect(props.history.push).toHaveBeenCalledWith('/catalog'),
- );
+ await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx
index 96132819e8..e74276697b 100644
--- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx
+++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx
@@ -34,21 +34,9 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
+import { useParams, useNavigate } from 'react-router-dom';
const REDIRECT_DELAY = 1000;
-
-type Props = {
- match: {
- params: {
- optionalNamespaceAndName: string;
- kind: string;
- };
- };
- history: {
- push: (url: string) => void;
- };
-};
-
function headerProps(
kind: string,
namespace: string | undefined,
@@ -68,8 +56,12 @@ function headerProps(
};
}
-export const EntityPage: FC = ({ match, history }) => {
- const { optionalNamespaceAndName, kind } = match.params;
+export const EntityPage: FC<{}> = () => {
+ const { optionalNamespaceAndName, kind } = useParams() as {
+ optionalNamespaceAndName: string;
+ kind: string;
+ };
+ const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
@@ -85,19 +77,19 @@ export const EntityPage: FC = ({ match, history }) => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
- history.push('/');
+ navigate('/');
}, REDIRECT_DELAY);
}
- }, [errorApi, history, error, loading, entity]);
+ }, [errorApi, navigate, error, loading, entity]);
if (!name) {
- history.push('/catalog');
+ navigate('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
- history.push('/');
+ navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json
index c1570eaaad..60787b146e 100644
--- a/plugins/circleci/package.json
+++ b/plugins/circleci/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -31,8 +31,8 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -42,13 +42,13 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
- "react-router": "^5.1.2",
- "react-router-dom": "^5.1.2",
+ "react-router": "^6.0.0-alpha.5",
+ "react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
diff --git a/plugins/circleci/src/components/App.tsx b/plugins/circleci/src/components/App.tsx
index 1649c01b70..5907146340 100644
--- a/plugins/circleci/src/components/App.tsx
+++ b/plugins/circleci/src/components/App.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
-import { Switch, Route, MemoryRouter } from 'react-router';
+import { Route, MemoryRouter, Routes } from 'react-router';
import { BuildsPage, Builds } from '../pages/BuildsPage';
import { DetailedViewPage, BuildWithSteps } from '../pages/BuildWithStepsPage';
import { AppStateProvider } from '../state';
@@ -24,14 +24,13 @@ export const App = () => {
return (
<>
-
-
+
+ } />
}
/>
-
+
>
@@ -45,14 +44,10 @@ export const CircleCIWidget = () => (
<>
-
-
-
-
+
+ } />
+ } />
+
>
diff --git a/plugins/explore/package.json b/plugins/explore/package.json
index 15df6fae75..f0f631eb09 100644
--- a/plugins/explore/package.json
+++ b/plugins/explore/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,9 +33,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
- "@backstage/test-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
+ "@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json
index 703d832666..441f2dd77c 100644
--- a/plugins/gitops-profiles/package.json
+++ b/plugins/gitops-profiles/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-gitops-profiles",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,19 +22,19 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
- "react-use": "^14.2.0",
- "react-router-dom": "^5.2.0"
+ "react-router-dom": "^5.2.0",
+ "react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx
index 29559e4fec..d454278255 100644
--- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx
+++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx
@@ -33,8 +33,7 @@ import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
- const params = useParams<{ owner: string; repo: string }>();
-
+ const params = useParams() as { owner: string; repo: string };
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json
index 1132ced6da..0a6f25e616 100644
--- a/plugins/graphiql/package.json
+++ b/plugins/graphiql/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,8 +32,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -44,18 +44,18 @@
"react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
- "@backstage/test-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
+ "@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
- "@types/codemirror": "^0.0.95",
+ "@types/codemirror": "^0.0.96",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
- "react-router-dom": "^5.2.0"
+ "react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json
index cf4b077dfc..4a22dde3c8 100644
--- a/plugins/home-page/package.json
+++ b/plugins/home-page/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-home-page",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,8 +32,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -41,7 +41,7 @@
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
- "react-router-dom": "^5.2.0"
+ "react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/plugins/identity-backend/package.json b/plugins/identity-backend/package.json
index 922ff8412d..0d1f6b99e5 100644
--- a/plugins/identity-backend/package.json
+++ b/plugins/identity-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-identity-backend",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/backend-common": "^0.1.1-alpha.7",
+ "@backstage/backend-common": "^0.1.1-alpha.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -27,7 +27,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json
index eeb6bfa0bd..f53ae0930d 100644
--- a/plugins/lighthouse/package.json
+++ b/plugins/lighthouse/package.json
@@ -1,11 +1,11 @@
{
"name": "@backstage/plugin-lighthouse",
- "version": "0.1.1-alpha.7",
+ "version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
- "private": true,
+ "private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,21 +22,21 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
- "@backstage/core": "^0.1.1-alpha.7",
- "@backstage/theme": "^0.1.1-alpha.7",
+ "@backstage/core": "^0.1.1-alpha.8",
+ "@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
- "react-router-dom": "^5.2.0",
+ "react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.7",
- "@backstage/dev-utils": "^0.1.1-alpha.7",
- "@backstage/test-utils": "^0.1.1-alpha.7",
+ "@backstage/cli": "^0.1.1-alpha.8",
+ "@backstage/dev-utils": "^0.1.1-alpha.8",
+ "@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx
index bcd486e876..076bb5819a 100644
--- a/plugins/lighthouse/src/components/AuditList/index.test.tsx
+++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx
@@ -16,13 +16,10 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
- const mocks = {
- replace: jest.fn(),
- push: jest.fn(),
- };
+ const mockNavigation = jest.fn();
return {
...actual,
- useHistory: jest.fn(() => mocks),
+ useNavigate: jest.fn(() => mockNavigation),
};
});
@@ -41,7 +38,7 @@ import AuditList from '.';
import * as data from '../../__fixtures__/website-list-response.json';
-const { useHistory } = jest.requireMock('react-router-dom');
+const { useNavigate } = jest.requireMock('react-router-dom');
const websiteListResponse = data as WebsiteListResponse;
describe('AuditList', () => {
@@ -145,7 +142,8 @@ describe('AuditList', () => {
);
const element = await rendered.findByLabelText(/Go to page 1/);
fireEvent.click(element);
- expect(useHistory().replace).toHaveBeenCalledWith(`/lighthouse?page=1`);
+
+ expect(useNavigate()).toHaveBeenCalledWith(`/lighthouse?page=1`);
});
});
});
diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx
index 4dbbb05295..09ea882134 100644
--- a/plugins/lighthouse/src/components/AuditList/index.tsx
+++ b/plugins/lighthouse/src/components/AuditList/index.tsx
@@ -16,7 +16,7 @@
import React, { useState, useMemo, FC, ReactNode } from 'react';
import { useLocalStorage, useAsync } from 'react-use';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import { Grid, Button } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import Pagination from '@material-ui/lab/Pagination';
@@ -65,7 +65,7 @@ const AuditList: FC<{}> = () => {
return 0;
}, [value?.total, value?.limit]);
- const history = useHistory();
+ const navigate = useNavigate();
let content: ReactNode = null;
if (value) {
@@ -77,7 +77,7 @@ const AuditList: FC<{}> = () => {
page={page}
count={pageCount}
onChange={(_event: Event, newPage: number) => {
- history.replace(`/lighthouse?page=${newPage}`);
+ navigate(`/lighthouse?page=${newPage}`);
}}
/>
)}
diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx
index 889c6c53f3..d323b1a005 100644
--- a/plugins/lighthouse/src/components/AuditView/index.tsx
+++ b/plugins/lighthouse/src/components/AuditView/index.tsx
@@ -18,10 +18,10 @@ import { Link, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
makeStyles,
- Button,
Grid,
List,
ListItem,
+ Button,
ListItemIcon,
ListItemText,
} from '@material-ui/core';
@@ -68,7 +68,7 @@ const AuditLinkList: FC = ({
component="nav"
aria-label="lighthouse audit history"
>
- {audits.map((audit) => (
+ {audits.map(audit => (
= ({
const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const classes = useStyles();
- const params = useParams<{ id: string }>();
+ const params = useParams() as { id: string };
const { url: lighthouseUrl } = useApi(lighthouseApiRef);
if (audit?.status === 'RUNNING') return ;
@@ -114,7 +114,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const ConnectedAuditView: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
- const params = useParams<{ id: string }>();
+ const params = useParams() as { id: string };
const classes = useStyles();
const { loading, error, value: nextValue } = useAsync(
@@ -136,7 +136,7 @@ const ConnectedAuditView: FC<{}> = () => {
- a.id === params.id)} />
+ a.id === params.id)} />
);
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx
index 08e8005530..ee0404fc22 100644
--- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx
+++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx
@@ -16,13 +16,10 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
- const mocks = {
- replace: jest.fn(),
- push: jest.fn(),
- };
+ const mockNavigate = jest.fn();
return {
...actual,
- useHistory: jest.fn(() => mocks),
+ useNavigate: jest.fn(() => mockNavigate),
};
});
@@ -41,7 +38,7 @@ import { lighthouseApiRef, LighthouseRestApi, Audit } from '../../api';
import CreateAudit from '.';
import * as data from '../../__fixtures__/create-audit-response.json';
-const { useHistory }: { useHistory: jest.Mock } = jest.requireMock(
+const { useNavigate }: { useNavigate: jest.Mock } = jest.requireMock(
'react-router-dom',
);
const createAuditResponse = data as Audit;
@@ -115,7 +112,7 @@ describe('CreateAudit', () => {
describe('when the audit is successfully created', () => {
it('triggers a location change to the table', async () => {
- useHistory().push.mockClear();
+ useNavigate.mockClear();
mockFetch.mockResponseOnce(JSON.stringify(createAuditResponse));
const rendered = render(
@@ -140,7 +137,7 @@ describe('CreateAudit', () => {
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
- expect(useHistory().push).toHaveBeenCalledWith('/lighthouse');
+ expect(useNavigate()).toHaveBeenCalledWith('/lighthouse');
});
});
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx
index 55ae99d1db..a699e148c8 100644
--- a/plugins/lighthouse/src/components/CreateAudit/index.tsx
+++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useState, useCallback, FC } from 'react';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import {
makeStyles,
Grid,
@@ -40,7 +40,7 @@ import { lighthouseApiRef } from '../../api';
import { useQuery } from '../../utils';
import LighthouseSupportButton from '../SupportButton';
-const useStyles = makeStyles((theme) => ({
+const useStyles = makeStyles(theme => ({
input: {
minWidth: 300,
},
@@ -58,7 +58,7 @@ const CreateAudit: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const classes = useStyles();
const query = useQuery();
- const history = useHistory();
+ const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [url, setUrl] = useState(query.get('url') || '');
const [emulatedFormFactor, setEmulatedFormFactor] = useState('mobile');
@@ -78,7 +78,7 @@ const CreateAudit: FC<{}> = () => {
},
},
});
- history.push('/lighthouse');
+ navigate('/lighthouse');
} catch (err) {
errorApi.post(err);
} finally {
@@ -90,7 +90,7 @@ const CreateAudit: FC<{}> = () => {
lighthouseApi,
setSubmitting,
errorApi,
- history,
+ navigate,
]);
return (
@@ -113,7 +113,7 @@ const CreateAudit: FC<{}> = () => {