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(value => { + if (typeof value !== 'object' || value === null) { + return yup.object().required().label('secret'); + } + + const loaderTypes = Object.keys( + secretLoaderSchemas, + ) as (keyof typeof secretLoaderSchemas)[]; + + for (const key of loaderTypes) { + if (key in value) { + return secretLoaderSchemas[key]; + } + } + throw new yup.ValidationError( + `Secret must contain one of '${loaderTypes.join("', '")}'`, + value, + '$secret', + ); +}); + +// Parsers for each type of data secret file. +const dataSecretParser: { + [ext in string]: (content: string) => Promise; +} = { + '.json': async content => JSON.parse(content), + '.yaml': async content => yaml.parse(content), + '.yml': async content => yaml.parse(content), +}; + +/** + * Transforms a secret description into the actual secret value. + */ +export async function readSecret( + data: JsonObject, + ctx: ReaderContext, +): Promise { + const secret = secretSchema.validateSync(data, { strict: true }) as Secret; + + if ('file' in secret) { + return ctx.readFile(secret.file); + } + if ('env' in secret) { + return ctx.env[secret.env]; + } + if ('data' in secret) { + const ext = extname(secret.data); + const parser = dataSecretParser[ext]; + if (!parser) { + throw new Error(`No data secret parser available for extension ${ext}`); + } + + const content = await ctx.readFile(secret.data); + + const { path } = secret; + const parts = typeof path === 'string' ? path.split('.') : path; + + let value: JsonValue = await parser(content); + for (const [index, part] of parts.entries()) { + if (!isObject(value)) { + const errPath = parts.slice(0, index).join('.'); + throw new Error( + `Value is not an object at ${errPath} in ${secret.data}`, + ); + } + value = value[part]; + } + return String(value); + } + + isNever(); + throw new Error('Secret was left unhandled'); +} diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts new file mode 100644 index 0000000000..c0f8b99dcf --- /dev/null +++ b/packages/config-loader/src/lib/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/config'; + +export type ReadFileFunc = (path: string) => Promise; +export type ReadSecretFunc = (desc: JsonObject) => Promise; + +/** + * Common context that provides all the necessary hooks for reading configuration files. + */ +export type ReaderContext = { + env: { [name in string]?: string }; + readFile: ReadFileFunc; + readSecret: ReadSecretFunc; +}; diff --git a/packages/config-loader/src/lib/utils.ts b/packages/config-loader/src/lib/utils.ts new file mode 100644 index 0000000000..37145ec971 --- /dev/null +++ b/packages/config-loader/src/lib/utils.ts @@ -0,0 +1,31 @@ +/* + * 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 { JsonValue, JsonObject } from '@backstage/config'; + +export function isObject(obj: JsonValue | undefined): obj is JsonObject { + if (typeof obj !== 'object') { + return false; + } else if (Array.isArray(obj)) { + return false; + } + return obj !== null; +} + +// A thing to make sure we've narrowed the type down to never +export function isNever() { + return void 0 as T; +} diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index c8b5bb538d..1b6c8f34da 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -15,87 +15,46 @@ */ import fs from 'fs-extra'; -import yaml from 'yaml'; -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, dirname } from 'path'; import { AppConfig, JsonObject } from '@backstage/config'; -import { findRootPath } from './paths'; -import { LoadConfigOptions } from './types'; +import { + resolveStaticConfig, + readConfigFile, + readEnv, + readSecret, +} from './lib'; -const ENV_PREFIX = 'APP_CONFIG_'; +export type LoadConfigOptions = { + // Config path, defaults to app-config.yaml in project root + configPath?: string; -// 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; + // Whether to read secrets or omit them, defaults to false. + shouldReadSecrets?: boolean; +}; -export function readEnv(env: { - [name: string]: string | undefined; -}): AppConfig[] { - let config: JsonObject | undefined = undefined; +class Context { + constructor( + private readonly options: { + env: { [name in string]?: string }; + rootPath: string; + shouldReadSecrets: boolean; + }, + ) {} - 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}`, - ); - } - } - } - } + get env() { + return this.options.env; } - return config ? [config] : []; -} - -export async function readStaticConfig( - options: LoadConfigOptions, -): Promise { - // TODO: We'll want this to be a bit more elaborate, probably adding configs for - // specific env, and maybe local config for plugins. - let { configPath } = options; - if (!configPath) { - configPath = resolvePath( - findRootPath(fs.realpathSync(process.cwd())), - 'app-config.yaml', - ); + async readFile(path: string): Promise { + return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8'); } - try { - const configYaml = await fs.readFile(configPath, 'utf8'); - const config = yaml.parse(configYaml); - return [config]; - } catch (error) { - throw new Error(`Failed to read static configuration file, ${error}`); + async readSecret(desc: JsonObject): Promise { + if (!this.options.shouldReadSecrets) { + return undefined; + } + + return readSecret(desc, this); } } @@ -105,7 +64,27 @@ export async function loadConfig( const configs = []; configs.push(...readEnv(process.env)); - configs.push(...(await readStaticConfig(options))); + + const configPaths = await resolveStaticConfig(options); + + try { + for (const configPath of configPaths) { + const config = await readConfigFile( + configPath, + new Context({ + env: process.env, + rootPath: dirname(configPath), + shouldReadSecrets: Boolean(options.shouldReadSecrets), + }), + ); + + configs.push(config); + } + } catch (error) { + throw new Error( + `Failed to read static configuration file: ${error.message}`, + ); + } return configs; } diff --git a/packages/core-api/package.json b/packages/core-api/package.json index f08b03a77e..80a94b2e34 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "private": false, "publishConfig": { "access": "public", @@ -31,18 +31,18 @@ }, "dependencies": { "@backstage/config": "^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", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", "prop-types": "^15.7.2", "react": "^16.12.0", - "react-router-dom": "^5.2.0", + "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.7", + "@backstage/cli": "^0.1.1-alpha.8", "@backstage/test-utils-core": "^0.1.1-alpha.7", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts new file mode 100644 index 0000000000..bbfab0ef35 --- /dev/null +++ b/packages/core-api/src/apis/definitions/IdentityApi.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 { createApiRef } from '../ApiRef'; + +/** + * The Identity API used to identify and get information about the signed in user. + */ +export type IdentityApi = { + /** + * The ID of the signed in user. This ID is not meant to be presented to the user, but used + * as an opaque string to pass on to backends or use in frontend logic. + * + * TODO: The intention of the user ID is to be able to tie the user to an identity + * that is known by the catalog and/or identity backend. It should for example + * be possible to fetch all owned components using this ID. + */ + getUserId(): string; + + /** + * An OpenID Connect ID Token which proves the identity of the signed in user. + * + * The ID token will be undefined if the signed in user does not have a verified + * identity, such as a demo user or mocked user for e2e tests. + */ + getIdToken(): string | undefined; + + // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. +}; + +export const identifyApiRef = createApiRef({ + id: 'core.identity', + description: 'Provides access to the identity of the signed in user', +}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 41fbc839f9..c5d4a15117 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -27,5 +27,6 @@ export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index ac05b718e5..f4a7092f79 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -57,7 +57,7 @@ class GithubAuth implements OAuthApi, SessionStateApi { static create({ apiOrigin, basePath, - environment = 'dev', + environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, }: CreateOptions) { diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index 1fc6f4f6b8..183ea592b4 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -66,7 +66,7 @@ class GoogleAuth static create({ apiOrigin, basePath, - environment = 'dev', + environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, }: CreateOptions) { diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 972acbd8ea..d7920e6bbd 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React, { ComponentType, FC, useMemo } from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { Route, Routes, Navigate } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; import { BackstagePlugin } from '../plugin'; @@ -90,50 +89,31 @@ export class PrivateAppImpl implements BackstageApp { for (const output of plugin.output()) { switch (output.type) { case 'legacy-route': { - const { path, component, options = {} } = output; - const { exact = true } = options; + const { path, component: Component } = output; routes.push( - , + } />, ); break; } case 'route': { - const { target, component, options = {} } = output; - const { exact = true } = options; + const { target, component: Component } = output; routes.push( } />, ); break; } case 'legacy-redirect-route': { - const { path, target, options = {} } = output; - const { exact = true } = options; - routes.push( - , - ); + const { path, target } = output; + routes.push(); break; } case 'redirect-route': { - const { from, to, options = {} } = output; - const { exact = true } = options; - routes.push( - , - ); + const { from, to } = output; + routes.push(); break; } case 'feature-flag': { @@ -155,10 +135,10 @@ export class PrivateAppImpl implements BackstageApp { } const rendered = ( - + {routes} - - + } /> + ); return () => rendered; @@ -225,7 +205,7 @@ export class PrivateAppImpl implements BackstageApp { - {children} + {children} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 44201d1464..e30c79dd73 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -25,12 +25,11 @@ export type BootErrorPageProps = { step: 'load-config'; error: Error; }; - export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; - Router: ComponentType<{ basename?: string }>; + Router: ComponentType<{}>; }; /** diff --git a/packages/core/package.json b/packages/core/package.json index 8a476a58d5..a412af3fa1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "private": false, "publishConfig": { "access": "public", @@ -31,13 +31,12 @@ }, "dependencies": { "@backstage/config": "^0.1.1-alpha.7", - "@backstage/core-api": "^0.1.1-alpha.7", - "@backstage/theme": "^0.1.1-alpha.7", + "@backstage/core-api": "^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", "@types/react": "^16.9", - "@types/react-router-dom": "^5.1.5", "@types/react-sparklines": "^1.7.0", "classnames": "^2.2.6", "clsx": "^1.1.0", @@ -48,15 +47,15 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-helmet": "6.0.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", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^12.2.1", "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.7", - "@backstage/test-utils": "^0.1.1-alpha.7", + "@backstage/cli": "^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/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx index f7bbe45abd..aaca389f82 100644 --- a/packages/core/src/components/Button/Button.stories.tsx +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -15,12 +15,7 @@ */ import React, { FunctionComponentFactory } from 'react'; import { Button } from './Button'; -import { - MemoryRouter, - Route, - useLocation, - Link as RouterLink, -} from 'react-router-dom'; +import { MemoryRouter, Route, useLocation } from 'react-router-dom'; import { createRouteRef } from '@backstage/core-api'; const Location = () => { @@ -70,14 +65,7 @@ export const PassProps = () => { return ( <> -  has props for both material-ui's component as well as for diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.jsx index 2563d367c2..115835be3f 100644 --- a/packages/core/src/components/Button/Button.test.jsx +++ b/packages/core/src/components/Button/Button.test.jsx @@ -15,11 +15,10 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { Button } from './Button'; -import { MemoryRouter, Route } from 'react-router'; -import { act } from 'react-dom/test-utils'; +import { Route, Routes } from 'react-router'; describe(' - {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<{}> = () => {
{ + onSubmit={ev => { ev.preventDefault(); triggerAudit(); }} @@ -128,7 +128,7 @@ const CreateAudit: FC<{}> = () => { helperText="The target URL for Lighthouse to use." required disabled={submitting} - onChange={(ev) => setUrl(ev.target.value)} + onChange={ev => setUrl(ev.target.value)} value={url} inputProps={{ 'aria-label': 'URL' }} /> @@ -142,7 +142,7 @@ const CreateAudit: FC<{}> = () => { select required disabled={submitting} - onChange={(ev) => setEmulatedFormFactor(ev.target.value)} + onChange={ev => setEmulatedFormFactor(ev.target.value)} value={emulatedFormFactor} inputProps={{ 'aria-label': 'Emulated form factor' }} > diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index f1b28e99d2..d5fc49405d 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "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,23 +22,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.7", - "@backstage/core": "^0.1.1-alpha.7", - "@backstage/plugin-catalog": "^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-catalog": "^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-hook-form": "^5.7.2", - "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", "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/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 2fa5462fcc..1d7e3d4388 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "main": "dist", "types": "src/index.ts", "license": "Apache-2.0", @@ -14,7 +14,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", "dockerode": "^3.2.0", @@ -27,7 +27,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.7", + "@backstage/cli": "^0.1.1-alpha.8", "@types/fs-extra": "^9.0.1", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" diff --git a/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts b/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts index fa3967f30a..b8e3131b63 100644 --- a/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { StorageBase, createStorage } from '.'; -import winston from 'winston'; +import * as winston from 'winston'; describe('Storage Interface Test', () => { const mockStore = new (class MockStorage implements StorageBase { diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index bc54e5f7c5..66738acea6 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "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-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/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/sentry-backend/package.json b/plugins/sentry-backend/package.json index c9d7a585e4..fd9638afe3 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry-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", "axios": "^0.19.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -28,7 +28,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/sentry-backend/src/index.ts b/plugins/sentry-backend/src/index.ts index 28c43aee33..7612c392a2 100644 --- a/plugins/sentry-backend/src/index.ts +++ b/plugins/sentry-backend/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export * from './service/router'; diff --git a/plugins/sentry-backend/src/service/router.ts b/plugins/sentry-backend/src/service/router.ts index a949f3583f..e7978042d3 100644 --- a/plugins/sentry-backend/src/service/router.ts +++ b/plugins/sentry-backend/src/service/router.ts @@ -22,13 +22,19 @@ export async function createRouter(logger: Logger): Promise { const router = Router(); const SENTRY_TOKEN = process.env.SENTRY_TOKEN; if (!SENTRY_TOKEN) { - throw new Error( - 'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.', + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.', + ); + } + logger.warn( + 'Failed to initialize Sentry backend, set SENTRY_TOKEN environment variable to start the API.', ); - } - const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger); + } else { + const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger); - router.use(sentryForwarder); + router.use(sentryForwarder); + } return router; } diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index cdf2025cb8..dcec4eceb0 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "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", @@ -34,8 +34,8 @@ "timeago.js": "^4.0.2" }, "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/tech-radar/package.json b/plugins/tech-radar/package.json index a5befefe2f..00d008bbd1 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "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,9 +22,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.7", + "@backstage/core": "^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", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,8 +37,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", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index fd6aa917b4..388f0335a7 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "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 @@ "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-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/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/tsconfig.json b/tsconfig.json index 2b36617ad9..1a6128b5c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "@backstage/cli/config/tsconfig.json", - "include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"], + "include": [ + "packages/*/src", + "plugins/*/src", + "plugins/*/dev", + "plugins/*/migrations" + ], "compilerOptions": { "outDir": "dist" } diff --git a/yarn.lock b/yarn.lock index 7e25de4324..3fc867d2e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3426,10 +3426,10 @@ dependencies: "@types/node" "*" -"@types/codemirror@^0.0.95": - version "0.0.95" - resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.95.tgz#8fe424989cd5a6d7848f124774d42ef34d91adb8" - integrity sha512-E7w4HS8/rR7Rxkga6j68n3/Mi4BJ870/OJJKRqytyWiM659KnbviSng/NPfM/FOjg7YL+5ruFF69FqoLChnPBw== +"@types/codemirror@^0.0.96": + version "0.0.96" + resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.96.tgz#73b52e784a246cebef31d544fef45ea764de5bad" + integrity sha512-GTswEV26Bl1byRxpD3sKd1rT2AISr0rK9ImlJgEzfvqhcVWeu4xQKFQI6UgSC95NT5swNG4st/oRMeGVZgPj9w== dependencies: "@types/tern" "*" @@ -3743,11 +3743,11 @@ "@types/node" "*" "@types/morgan@^1.9.0": - version "1.9.0" - resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.0.tgz#342119ae57fe67d36b91537143fc5aef16c2479f" - integrity sha512-warrzirh5dlTMaETytBTKR886pRXwr+SMZD87ZE13gLMR8Pzz69SiYFkvoDaii78qGP1iyBIUYz5GiXyryO//A== + version "1.9.1" + resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz#6457872df95647c1dbc6b3741e8146b71ece74bf" + integrity sha512-2j5IKrgJpEP6xw/uiVb2Xfga0W0sSVD9JP9t7EZLvpBENdB0OKgcnoKS8IsjNeNnZ/86robdZ61Orl0QCFGOXg== dependencies: - "@types/express" "*" + "@types/node" "*" "@types/node-fetch@^2.5.7": version "2.5.7" @@ -3904,23 +3904,6 @@ "@types/react" "*" immutable ">=3.8.2" -"@types/react-router-dom@^5.1.3", "@types/react-router-dom@^5.1.5": - version "5.1.5" - resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" - integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router@*": - version "5.1.4" - resolved "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.4.tgz#7d70bd905543cb6bcbdcc6bd98902332054f31a6" - integrity sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-sparklines@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/react-sparklines/-/react-sparklines-1.7.0.tgz#f956d0f7b0e746ad445ce1cd250fe81f8a384684" @@ -4504,6 +4487,11 @@ acorn@^7.1.1: resolved "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== +acorn@^7.2.0: + version "7.3.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" + integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -5975,9 +5963,9 @@ chalk@^3.0.0: supports-color "^7.1.0" chalk@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72" - integrity sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A== + version "4.1.0" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== dependencies: ansi-styles "^4.1.0" supports-color "^7.1.0" @@ -6231,11 +6219,6 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" -clone@2.x: - version "2.1.2" - resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - clone@^1.0.2: version "1.0.4" resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -8273,10 +8256,10 @@ eslint-scope@^4.0.3: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9" - integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw== +eslint-scope@^5.0.0, eslint-scope@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz#d0f971dfe59c69e0cada684b23d49dbf82600ce5" + integrity sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w== dependencies: esrecurse "^4.1.0" estraverse "^4.1.1" @@ -8295,15 +8278,15 @@ eslint-utils@^2.0.0: dependencies: eslint-visitor-keys "^1.1.0" -eslint-visitor-keys@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" - integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" + integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== eslint@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.1.0.tgz#d9a1df25e5b7859b0a3d86bb05f0940ab676a851" - integrity sha512-DfS3b8iHMK5z/YLSme8K5cge168I8j8o1uiVmFCgnnjxZQbCGyraF8bMl7Ju4yfBmCuxD7shOF7eqGkcuIHfsA== + version "7.2.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-7.2.0.tgz#d41b2e47804b30dbabb093a967fb283d560082e6" + integrity sha512-B3BtEyaDKC5MlfDa2Ha8/D6DsS4fju95zs0hjS3HdGazw+LNayai38A25qMppK37wWGWNYSPOR6oYzlz5MHsRQ== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" @@ -8311,10 +8294,10 @@ eslint@^7.1.0: cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" - eslint-scope "^5.0.0" + eslint-scope "^5.1.0" eslint-utils "^2.0.0" - eslint-visitor-keys "^1.1.0" - espree "^7.0.0" + eslint-visitor-keys "^1.2.0" + espree "^7.1.0" esquery "^1.2.0" esutils "^2.0.2" file-entry-cache "^5.0.1" @@ -8347,14 +8330,14 @@ esm@^3.2.25: resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== -espree@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/espree/-/espree-7.0.0.tgz#8a7a60f218e69f120a842dc24c5a88aa7748a74e" - integrity sha512-/r2XEx5Mw4pgKdyb7GNLQNsu++asx/dltf/CI8RFi9oGHxmQFgvLbc5Op4U6i8Oaj+kdslhJtVlEZeAqH5qOTw== +espree@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/espree/-/espree-7.1.0.tgz#a9c7f18a752056735bf1ba14cb1b70adc3a5ce1c" + integrity sha512-dcorZSyfmm4WTuTnE5Y7MEN1DyoPYy1ZR783QW1FJoenn7RailyWFsq/UL6ZAAA7uXurN9FIpYyUs3OfiIW+Qw== dependencies: - acorn "^7.1.1" + acorn "^7.2.0" acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.1.0" + eslint-visitor-keys "^1.2.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" @@ -10001,6 +9984,13 @@ highlight.js@~9.15.0, highlight.js@~9.15.1: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== +history@5.0.0-beta.9: + version "5.0.0-beta.9" + resolved "https://registry.npmjs.org/history/-/history-5.0.0-beta.9.tgz#fe230706c18c5f7f132001e55215e71b4aaab6d6" + integrity sha512-iLpu0fzu3iM041KDMNsawyB6YZjPLB+Bn+Pvq2lMnY7xxpxDIYvEz7r4et3Na8FthWzbYeukjl74ZKGWXcLhIA== + dependencies: + "@babel/runtime" "^7.7.6" + history@^4.9.0: version "4.10.1" resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -13305,13 +13295,6 @@ nocache@2.1.0: resolved "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== -node-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.1.tgz#5fcc887176b23bdcd19cd1461b9544d2d501e786" - integrity sha512-bJ9nH25Z51HG2QIu66K4dMVyMs6o8bNQpviDnXzG+O/gfNxPU9IpIig0j4pzlO707GcGZ6QA4rWhlRxjJsjnZw== - dependencies: - clone "2.x" - node-cleanup@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c" @@ -15633,7 +15616,15 @@ react-redux@^7.0.3: prop-types "^15.7.2" react-is "^16.9.0" -react-router-dom@^5.1.2, react-router-dom@^5.2.0: +react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5: + version "6.0.0-alpha.5" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-alpha.5.tgz#3c3e22226ee610eb91042a351741ce3f53596323" + integrity sha512-xo3VM55aE563uyZBPoUplfCPOYKJmTP2oA8wamm0k4K07e/6T4x4DDunS5Gu2VIy+m2+5mZp8n0rT6S+tYCb6Q== + dependencies: + history "5.0.0-beta.9" + prop-types "^15.7.2" + +react-router-dom@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== @@ -15646,7 +15637,7 @@ react-router-dom@^5.1.2, react-router-dom@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router@5.2.0, react-router@^5.1.2, react-router@^5.2.0: +react-router@5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== @@ -15662,6 +15653,14 @@ react-router@5.2.0, react-router@^5.1.2, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: + version "6.0.0-alpha.5" + resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" + integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw== + dependencies: + history "5.0.0-beta.9" + prop-types "^15.7.2" + react-side-effect@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3" @@ -16050,12 +16049,7 @@ regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -regexpp@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.0.0.tgz#dd63982ee3300e67b41c1956f850aa680d9d330e" - integrity sha512-Z+hNr7RAVWxznLPuA7DIh8UNX1j9CDrUQxskw9IrBE1Dxue2lyXT+shqEIeLUjrokxIP8CMy1WkjgG3rTsd5/g== - -regexpp@^3.1.0: +regexpp@^3.0.0, regexpp@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== @@ -17759,6 +17753,13 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +swr@^0.2.2: + version "0.2.2" + resolved "https://registry.npmjs.org/swr/-/swr-0.2.2.tgz#6e1b3e5c0e545c4fdb36ae3aa38cd94d0f9a88b7" + integrity sha512-D/z+PTUchZhoUA0tNC8TNJivf7Hc61WPxbUdXPi+VxRloddWYNP1ZicaEgyAph42ZnKl1L7twcZr4q6d0UMXcg== + dependencies: + fast-deep-equal "2.0.1" + symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -18417,9 +18418,9 @@ typedarray@^0.0.6: integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= typescript@^3.9.2, typescript@^3.9.3: - version "3.9.3" - resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a" - integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ== + version "3.9.5" + resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36" + integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6"