/api/v3`.
+The authorization for loading org information comes from a configured
+[GitHub integration](locations.md#configuration). When using a personal access
+token, the token needs to have at least the scopes `read:org`, `read:user`, and
+`user:email` in the given `target`.
diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md
index 316f066614..00d83b43ed 100644
--- a/docs/overview/architecture-overview.md
+++ b/docs/overview/architecture-overview.md
@@ -43,13 +43,13 @@ this.
The UI is a thin, client-side wrapper around a set of plugins. It provides some
core UI components and libraries for shared activities such as config
-management. [[live demo](https://backstage-demo.roadie.io/)]
+management. [[live demo](https://demo.backstage.io/catalog)]

Each plugin typically makes itself available in the UI on a dedicated URL. For
example, the Lighthouse plugin is registered with the UI on `/lighthouse`.
-[[live demo](https://backstage-demo.roadie.io/lighthouse)]
+[[learn more](https://backstage.io/blog/2020/04/06/lighthouse-plugin)]

@@ -116,9 +116,9 @@ Architecturally, plugins can take three forms:
#### Standalone plugins
Standalone plugins run entirely in the browser.
-[The Tech Radar plugin](https://backstage-demo.roadie.io/tech-radar), for
-example, simply renders hard-coded information. It doesn't make any API requests
-to other services.
+[The Tech Radar plugin](https://demo.backstage.io/tech-radar), for example,
+simply renders hard-coded information. It doesn't make any API requests to other
+services.

@@ -182,6 +182,21 @@ work but
[aren't tested as fully](https://github.com/backstage/backstage/issues/2460)
yet.
+## Cache
+
+The Backstage backend and its builtin plugins are also able to leverage cache
+stores as a means of improving performance or reliability. Similar to how
+databases are supported, plugins receive logically separated cache connections,
+which are powered by [Keyv](https://github.com/lukechilds/keyv) under the hood.
+
+At this time of writing, Backstage can be configured to use one of two cache
+stores: memory, which is mainly used for local testing, and memcache, which is a
+cache store better suited for production deployment. The right cache store for
+your Backstage instance will depend on your own run-time constraints and those
+required of the plugins you're running.
+
+Contributions supporting other cache stores are welcome!
+
## Containerization
The example Backstage architecture shown above would Dockerize into three
diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md
index 6882f3d6c2..29b9be14b6 100644
--- a/docs/overview/stability-index.md
+++ b/docs/overview/stability-index.md
@@ -108,50 +108,33 @@ Stability: `1`. Mainly intended for internal use.
### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/)
-#### Section: React Components
+The `@backstage/core` and `@backstage/core-api` packages are being phased out
+and replaced by other `@backstage/core-*` packages. They are still in use but
+will not receive any breaking changes.
-All of the React components exported from `src/components/` and `src/layout/`
+### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/)
+
+Stability: See `@backstage/core` above
+
+### `core-app-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-app-api/)
+
+The APIs used exclusively in the app, such as `createApp` and the system icons.
+
+Stability: `2`.
+
+### `core-components` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-components/)
+
+A collection of React components for use in Backstage plugins and apps.
+Previously exported by `@backstage/core`.
Stability: `1`. These components have not received a proper review of the API,
but we also want to ensure stability.
-#### Section: Plugin API
+### `core-plugin-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-plugin-api/)
-The parts of the core API that are used by plugins, and the way plugins expose
-functionality to apps and other plugins. Includes for example `createPlugin`,
-`createRouteRef`, `createApiRef`.
+The core API used to build Backstage plugins and apps.
-Stability: `2`. There are planned breaking changes around the way that plugins
-expose features and do routing. We still commit to keeping a short deprecation
-period so that plugins outside of the main repo have time to migrate.
-
-#### Section: App API
-
-The APIs used exclusively in the app, such as `createApp` and the system icons.
-
-Stability: `2`
-
-#### Section: Utility API Definitions
-
-The type declarations of the core Utility APIs.
-
-Stability: `2`. Changes to the Utility API type declarations need time to
-propagate.
-
-#### Section: Utility API Implementations
-
-The interfaces and default implementations for various Utility APIs, such as
-ErrorApi, IdentityApi, the auth APIs, etc.
-
-Stability: `1`. Most changes to the core utility APIs will not lead to
-widespread breaking changes since most apps rely on the default implementations.
-
-### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/)
-
-The non-visual parts of @backstage/core. Everything in this packages is
-re-exported from @backstage/core, and this package should not be used directly.
-
-Stability: See @backstage/core
+Stability: `2`.
### `cost-insights` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/cost-insights)
diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md
index 516ba4b111..4480cd69e3 100644
--- a/docs/plugins/backend-plugin.md
+++ b/docs/plugins/backend-plugin.md
@@ -1,7 +1,113 @@
---
id: backend-plugin
-title: Backend plugin
-description: Documentation on Backend plugin
+title: Backend plugins
+description: Creating and Developing Backend plugins
---
-## TODO
+This page describes the process of creating and managing backend plugins in your
+Backstage repository.
+
+## Creating a Backend Plugin
+
+A new, bare-bones backend plugin package can be created by issuing the following
+command in your Backstage repository root:
+
+```sh
+yarn create-plugin --backend
+```
+
+Please also see the `--help` flag for the `create-plugin` command for some
+further options that are available, notably the `--scope` and `--no-private`
+flags that control naming and publishing of the newly created package. Your repo
+root `package.json` will probably also have some default values already set up
+for these.
+
+You will be asked to supply a name for the plugin. This is an identifier that
+will be part of the NPM package name, so make it short and containing only
+lowercase characters separated by dashes, for example `carmen`, if it's a
+package that adds an integration with a system named Carmen, for example. The
+full NPM package name would then be something like
+`@internal/plugin-carmen-backend`, depending on the other flags passed to the
+`create-plugin` command, and your settings for the `create-plugin` command in
+your root `package.json`.
+
+Creating the plugin will take a little while, so be patient. It will helpfully
+run the initial installation and build commands, so that your package is ready
+to be hacked on! It will be located in a new folder in your `plugins` directory,
+in this example `plugins/carmen-backend`.
+
+For simple development purposes, a backend plugin can actually be started in a
+standalone mode. You can do a first-light test of your service:
+
+```sh
+cd plugins/carmen-backend
+yarn start
+```
+
+This will think for a bit, and then say `Listening on :7000`. In a different
+terminal window, now run
+
+```sh
+curl localhost:7000/carmen/health
+```
+
+This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it
+again.
+
+## Developing your Backend Plugin
+
+A freshly created backend plugin does basically nothing, in terms of the overall
+app. It has a small set of basic dependencies and exposes an Express router in
+`src/service/router.ts`. This is where you will start adding routes and
+connecting those to actual underlying functionality. But nothing in your
+Backstage application / backend exposes it.
+
+To actually attach and run the plugin router, you will make some modifications
+to your backend.
+
+```sh
+# From the Backstage root directory
+cd packages/backend
+yarn add @internal/plugin-carmen-backend@^0.1.1 # Change this to match the plugin's package.json
+```
+
+Create a new file named `packages/backend/src/plugins/carmen.ts`, and add the
+following to it
+
+```ts
+import { createRouter } from '@internal/plugin-carmen-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin(env: PluginEnvironment) {
+ // Here is where you will add all of the required initialization code that
+ // your backend plugin needs to be able to start!
+
+ // The env contains a lot of goodies, but our router currently only
+ // needs a logger
+ return await createRouter({
+ logger: env.logger,
+ });
+}
+```
+
+And finally, wire this into the overall backend router. Edit
+`packages/backend/src/index.ts`:
+
+```ts
+import carmen from './plugins/carmen';
+// ...
+async function main() {
+ // ...
+ const carmenEnv = useHotMemoize(module, () => createEnv('carmen'));
+ apiRouter.use('/carmen', await carmen(badgesEnv));
+```
+
+After you start the backend (e.g. using `yarn start-backend` from the repo
+root), you should be able to fetch data from it.
+
+```sh
+# Note the extra /api here
+curl localhost:7000/api/carmen/health
+```
+
+This should return `{"status":"ok"}` like before. Success!
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
deleted file mode 100644
index 510b697221..0000000000
--- a/docs/tutorials/quickstart-app-auth.md
+++ /dev/null
@@ -1,371 +0,0 @@
----
-id: quickstart-app-auth
-title: Monorepo App Setup With Authentication
----
-
-###### January 8th 2021 - @backstage/create-app - v0.4.5
-
-
-
-> This document takes you through setting up a Backstage app that runs in your
-> own environment. It starts with a skeleton install and verifying of the
-> monorepo's functionality. Next, authentication is added and tested.
->
-> This document assumes you have Node.js 12 or 14 active along with Yarn and
-> Python. Please note, that at the time of this writing, the current version is
-> v0.4.5. This guide can still be used with future versions, just, verify as you
-> go.
-
-# The Skeleton Application
-
-From the terminal:
-
-1. Create a (monorepo) application: `npx @backstage/create-app`
-1. Enter an `id` for your new app like `mybiz-backstage` I went with
- `simple-backstage-app`
-1. Choose `SQLite` as your database. This is the quickest way to get started as
- PostgreSQL requires additional setup not covered here.
-1. Start your backend: `yarn --cwd packages/backend start`
-
-```zsh
-# You should see positive verbiage in your terminal output
-2020-09-11T22:20:26.712Z backstage info Listening on :7000
-```
-
-5. Finally, start the frontend. Open a new terminal window and from the root of
- your project, run: `yarn start`
-
-```zsh
-# You should see positive verbiage in your terminal output
-ℹ 「wds」: Project is running at http://localhost:3000/
-```
-
-Once the app compiles, a browser window should have popped with your stand-alone
-application loaded at `localhost:3000`. This could take a couple minutes.
-
-```zsh
-# You should see positive verbiage in your terminal output
-ℹℹ 「wdm」: Compiled successfully.
-```
-
-Since there is no auth currently configured, you are automatically entered as a
-guest. Let's fix that now and add auth.
-
-# The Auth Configuration
-
-A default Backstage installation includes multiple authentication providers out
-of the box. The steps to enable new authentication providers in Backstage are
-very similar to each other, the biggest difference is usually configuring the
-external authentication provider. Please see a subset of possible providers and
-instructions to integrate them below. Steps 1 & 2 are described separately for
-each provider and steps beyond that are common for all.
-
-GitHub
-
-
-### 1. Open `app-config.yaml` and change it as follows
-
-_from:_
-
-```yaml
-auth:
- providers: {}
-```
-
-_to:_
-
-```yaml
-auth:
- providers:
- github:
- development:
- clientId: ${AUTH_GITHUB_CLIENT_ID}
- clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
- ## uncomment the following line if using enterprise
- # enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL}
-```
-
-### 2. Generate a GitHub client ID and secret
-
-- Log into http://github.com
-- Navigate to (Settings > Developer Settings > OAuth Apps > New OAuth
- App)[https://github.com/settings/applications/new]
-- Set Homepage URL = `http://localhost:3000`
-- Set Callback URL = `http://localhost:7000/api/auth/github`
-- Click [Register application]
-- On the next page, copy and paste your new Client ID and Client Secret to
- environment variables defined in the `app-config.yaml` file,
- `AUTH_GITHUB_CLIENT_ID` & `AUTH_GITHUB_CLIENT_SECRET`
-
-
-
-
-GitLab
-
-
-### 1. Open `app-config.yaml` and change it as follows
-
-_from:_
-
-```yaml
-auth:
- providers: {}
-```
-
-_to:_
-
-```yaml
-auth:
- providers:
- gitlab:
- development:
- clientId: ${AUTH_GITLAB_CLIENT_ID}
- clientSecret: ${AUTH_GITLAB_CLIENT_SECRET}
- audience: https://gitlab.com # Or your self-hosted GitLab instance URL
-```
-
-### 2. Generate a GitLab Application client ID and secret
-
-- Log into GitLab
-- Navigate to (Profile > Settings >
- Applications)[https://gitlab.com/-/profile/applications]
-- Name your application
-- Set Callback URL = `http://localhost:7000/api/auth/gitlab/handler/frame`
-- Select the following values:
- - `read_user` (Read the authenticated user's personal information)
- - `read_repository` (Allows read-only access to the repository)
- - `write_repository` (Allows read-write access to the repository)
- - `openid` (Authenticate using OpenID Connect)
- - `profile` (Allows read-only access to the user's personal information using
- OpenID Connect)
- - `email` (Allows read-only access to the user's primary email address using
- OpenID Connect)
-- Click [Save application]
-- On the next page, copy and paste your new Application ID and Secret to
- environment variables defined in the `app-config.yaml` file,
- `AUTH_GITLAB_CLIENT_ID` & `AUTH_GITLAB_CLIENT_SECRET`
-
-
-
-
-Google
-
-
-### 1. Open `app-config.yaml` and change it as follows
-
-_from:_
-
-```yaml
-auth:
- providers: {}
-```
-
-_to:_
-
-```yaml
-auth:
- providers:
- google:
- development:
- clientId: ${AUTH_GOOGLE_CLIENT_ID}
- clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET}
-```
-
-### 2. Generate Google Credentials in Google Cloud console
-
-- Log into https://console.cloud.google.com
-- Select or create a new project from the dropdown on the top bar
-- Navigate to (APIs & Services >
- Credentials)[https://console.cloud.google.com/apis/credentials]
-- Click Create Credentials and select [OAuth client ID]
-- Select Web Application as the application type
-- Add new Authorised JavaScript origin = `http://localhost:3000`
-- Add new Authorised redirect URI =
- `http://localhost:7000/api/auth/google/handler/frame`
-- Click [Save application]
-- Google should display a modal with your Client ID and Secret. Copy and paste
- those to environment variables defined in the `app-config.yaml` file,
- `AUTH_GOOGLE_CLIENT_ID` & `AUTH_GOOGLE_CLIENT_SECRET`
-
-
-
-
-Microsoft
-
-
-### 1. Open `app-config.yaml` and change it as follows
-
-_from:_
-
-```yaml
-auth:
- providers: {}
-```
-
-_to:_
-
-```yaml
-auth:
- providers:
- microsoft:
- development:
- clientId: ${AUTH_MICROSOFT_CLIENT_ID}
- clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET}
- tenantId: ${AUTH_MICROSOFT_TENANT_ID}
-```
-
-### 2. Create a Microsoft App Registration in Microsoft Portal
-
-- Log into https://portal.azure.com
-- Navigate to (Azure Active Directory > App
- Registrations)[https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps]
-- Create a New Registration
-- Add new Redirect URI = `http://localhost:3000`
-- Add new Authorised redirect URI =
- `http://localhost:7000/api/auth/microsoft/handler/frame`
-- Click [Save application]
-- Set environment variable `AUTH_MICROSOFT_CLIENT_ID` from
- `Application (client) Id` displayed on the directory page
-- Set environment variable `AUTH_MICROSOFT_TENANT_ID` from
- `Directory (tenant) ID` displayed on the directory page
-- Navigate to Certificates & Secrets section and click [Create a new secret]
-- Set environment variable `AUTH_MICROSOFT_CLIENT_SECRET` from the `value` field
- created.
-
-
-
-
-Auth0
-
-
-### 1. Open `app-config.yaml` and change it as follows
-
-_from:_
-
-```yaml
-auth:
- providers: {}
-```
-
-_to:_
-
-```yaml
-auth:
- providers:
- auth0:
- development:
- clientId: ${AUTH_AUTH0_CLIENT_ID}
- clientSecret: ${AUTH_AUTH0_CLIENT_SECRET}
- domain: ${AUTH_AUTH0_DOMAIN_ID}
-```
-
-### 2. Create an Auth0 application in the Auth0 management console
-
-- Log into https://manage.auth0.com/dashboard/
-- Navigate to Applications
-- Create a New Application
- - Select Single Page Web Application
-- Go to Settings tab
-- Add new line to Allowed Callback URLs =
- `http://localhost:7000/api/auth/auth0/handler/frame`
-- Click [Save Changes]
-- Set environment variables displayed on the Basic Information page
- - `AUTH_AUTH0_CLIENT_ID` from `Client ID` displayed on Auth0 application page
- - `AUTH_AUTH0_CLIENT_SECRET` from `Client Secret` displayed on Auth0
- application page
- - `AUTH_AUTH0_DOMAIN_ID` from `Domain` displayed on Auth0 application page
-
-
-
-
-### 3. Set environment variables in whatever fashion is easiest for you. I chose to
-
-add mine to my `.zshrc` profile.
-
-```zsh
-# For macOS Catalina & Z Shell
-# ------ simple-backstage-app GitHub
-#
-# (Change the name of the environment variables based on your auth setup above)
-export AUTH_GITHUB_CLIENT_ID=xxx
-export AUTH_GITHUB_CLIENT_SECRET=xxx
-# export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://github.{MY_BIZ}.com
-```
-
-### 4. And of course I need to source that file.
-
-```zsh
-# Loading the new variables
-% source ~/.zshrc
-
-# Any other currently opened terminals need to be restarted to pick up the new values
-# verify your setup by running env
-% env
-# should output something like
-> ...
-> AUTH_GITHUB_CLIENT_ID=xxx
-> AUTH_GITHUB_CLIENT_SECRET=xxx
-> ...
-```
-
-### 5. Open and change _root > packages > app > src >_ `App.tsx` to use correct
-
-authentication provider reference
-
-```tsx
-import { githubAuthApiRef, SignInPage } from '@backstage/core';
-```
-
-Modify the imported reference based on the authentication method you selected
-above:
-
-| Auth Provider | Import Name |
-| ------------- | ------------------- |
-| GitHub | githubAuthApiRef |
-| GitLab | gitlabAuthApiRef |
-| Google | googleAuthApiRef |
-| Microsoft | microsoftAuthApiRef |
-| Auth0 | auth0AuthApiRef |
-
-### 6. In the same file, modify createApp
-
-Remember to modify the provider information based on the table above.
-
-```tsx
-const app = createApp({
- apis,
- plugins: Object.values(plugins),
- components: {
- SignInPage: props => (
-
- ),
- },
-});
-```
-
-After finishing setting up one (or multiple) authentication providers defined
-above you can start the backend and frontend as before
-
-When the browser loads, you should be presented with a login page for GitHub.
-Login as usual with your GitHub account. If this is your first time, you will be
-asked to authorize and then are redirected to the catalog page if all is well.
-
-For more information you can clone
-[the backstage-auth-example repository](https://github.com/RoadieHQ/backstage-auth-example).
-Each authentication setting is set up there on a branch named after the
-authentication provider.
-
-# Where to go from here
-
-> You're probably eager to write your first custom plugin. Follow this next
-> tutorial for an in-depth look at a custom GitHub repository browser plugin.
-> [Adding Custom Plugin to Existing Monorepo App](quickstart-app-plugin.md).
diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md
index f1efcf0745..7dca90c14b 100644
--- a/docs/tutorials/quickstart-app-plugin.md
+++ b/docs/tutorials/quickstart-app-plugin.md
@@ -35,8 +35,8 @@ title: Adding Custom Plugin to Existing Monorepo App
1. When the process finishes, let's start the backend:
`yarn --cwd packages/backend start`
1. If you see errors starting, refer to
- [Auth Configuration](https://backstage.io/docs/tutorials/quickstart-app-auth#the-auth-configuration)
- for more information on environment variables.
+ [Auth Configuration](https://backstage.io/docs/auth/) for more information on
+ environment variables.
1. And now the frontend, from a new terminal window and the root of your
project: `yarn start`
1. As usual, a browser window should popup loading the App.
diff --git a/microsite/.npmrc b/microsite/.npmrc
new file mode 100644
index 0000000000..c3c66347fd
--- /dev/null
+++ b/microsite/.npmrc
@@ -0,0 +1,2 @@
+registry=https://registry.npmjs.org/
+engine-strict=true
diff --git a/microsite/blog/2021-05-20-adopting-backstage.md b/microsite/blog/2021-05-20-adopting-backstage.md
new file mode 100644
index 0000000000..191422004b
--- /dev/null
+++ b/microsite/blog/2021-05-20-adopting-backstage.md
@@ -0,0 +1,135 @@
+---
+title: Where do you start when adopting Backstage?
+author: Austin Lamon, Spotify
+authorURL: https://www.linkedin.com/in/austinlamon
+---
+
+
+
+One of the greatest strengths of Backstage also presents a never-ending challenge: Backstage is highly customizable and allows you to easily build a unique developer portal suited to your organization’s needs. The downside of this flexibility is that it can be hard to know where to start. Backstage can do so many things — integrating every part of your tech infrastructure and developer experience — but if you set off building a developer portal without a plan, it’s easy to get overwhelmed by all the possibilities. To help you form your plan, this post will detail how Spotify came to design our internal portal and recommend potential models for you to use when designing and building your own.
+
+
+
+## Infrastructure as tech culture
+
+Before providing recommendations on getting started with Backstage, it’s helpful to have a bit of context as to why Spotify made the design decisions we did. No two companies are identical — and thus, no two Backstage implementations are the same.
+
+Rolling back the clock just a few years, [Spotify was challenged](https://engineering.atspotify.com/2021/05/18/a-product-story-the-lessons-of-backstage-and-spotifys-autonomous-culture/) to continue to scale our engineering team (and the number of features and components built) but retain the speed of product development. Some user research with Spotify developers highlighted a clear problem: there was simply too much non-documented institutional knowledge needed to get things done. No one could find anything and everyone was interrupting everyone else trying to figure things out.
+
+Spotify’s developers were facing three big challenges on a daily basis:
+
+- They not only needed to build software quickly, they also needed to pass along knowledge to new joiners about how best to create new components.
+- They needed to somehow maintain a mental model of the systems their squad owned. (Or, if they were lucky, they found a hopefully-up-to-date spreadsheet tracking this information.)
+- They needed to keep an eye on what squads around them might be building to ensure they could reuse systems when they needed to solve similar problems in the future.
+
+In short, Spotify developers needed to continue building industry leading features at breakneck speed, while simultaneously maintaining a mental model for all the software at Spotify (oh, and help every new joiner develop that mental model as well!).
+
+
+## Three jobs: Create, manage, explore
+
+Around the same time, the [jobs to be done](https://hbr.org/2016/09/know-your-customers-jobs-to-be-done) framework was becoming popular and luckily, a few Spotifiers helped guide the vision for a _sense-making tool for developers_ toward using it. After user research and many failed attempts, we landed on three jobs Spotify developers needed to do consistently:
+
+
+- **Create**: Spotify developers want to delight their customers with incredible features. They create new software to do that.
+- **Manage**: Spotify developers are proud of their work and treat their software and data like products. That requires maintaining all the software they own on an ongoing basis.
+- **Explore**: Spotify developers want to solve new, yet unsolved problems. They try to build on existing systems to help them do that.
+
+So: make software, maintain the software you own throughout its lifecycle, and integrate with other people’s software.
+
+Within today’s complex development environments, there are barriers both big and small that get in the way of those three jobs. Backstage provides the building blocks for removing those barriers, streamlining your development cycle, and letting developers do what they really want to do: build great features. Let’s take a closer look at each of these jobs.
+
+
+
+### Create
+
+**Job:** You’re an engineer, ready to start building a new microservice. Do you just pick whatever framework you want? How do you reserve capacity to run your service in production? What about managing CI/CD?
+
+**Tool:** At Spotify, we use **Backstage Software Templates** to simplify all of this, reducing time–to–Hello World to just a few clicks. Instead of researching Spring Boot versus Helidon, opening a Jira ticket, rummaging through docs, and configuring CI automations, you just choose a template and your project is automatically set up in the repo for you, with the CI already running your first build.
+
+**Result:** By making it easier to start new projects, your engineers get to the good part of coding features faster. And your organization’s best practices are built into the templates, encouraging standards and reducing complexity in your tech ecosystem.
+
+
+
+### Manage
+
+**Job:** You’re on a small team that owns a dozen services. Whenever you update and deploy those services, you’re switching between your CI, the AWS console, a security dashboard, and a CLI so you can try to figure out which Kubernetes cluster your service ended up on. In other words, you have a lot of open windows and tabs, and each step means switching to a new interface.
+
+**Tool:** All of your team’s software components are organized together on one page in the **Backstage Service Catalog**. Go to any service’s page from there and its CI/CD status, [Kubernetes deployment status](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners), documentation, security checks — and everything else related to that service — is grouped together in one seamless interface that shows you only the info you want.
+
+**Result**: One page in Backstage has everything you need to manage the software you own. No more context-switching. No more digging through your cloud provider’s obscure admin features. Outside the repo and your IDE, everything you need to manage your service is right inside Backstage.
+
+
+
+### Explore
+
+**Job:** You're building a new mobile feature that needs to ensure a user is paying for the premium version of your product — but someone must’ve already built a library that handles that, right? A company-wide email and a few calls for help on Slack yield no response, so you resign yourself to building the capability yourself. Turns out someone did build the library you needed. They were just on vacation so they didn’t see your messages. How do you enable better discovery and collaboration across your org?
+
+**Tools:** At Spotify, anyone can find everyone else’s software — because everything is centralized inside Backstage, organized by the **Backstage Service Catalog** and accessible by **search**. Go to any library or services page, and you’ll find the owners and documentation, even its API and how to extend it if need be.
+
+**Result:** One place for everything, one place to search. Developers can more easily share components, build on top of each other’s work, and discover tools, libraries, frameworks, documentation, system designs, org charts, and more.
+
+## Sounds great. Where do I start?
+
+After talking with companies who have already adopted Backstage, we’ve seen a few common strategies for getting started. The different strategies are based on the size of your engineering org (which often also corresponds with how fast you’re growing).
+
+
+
+
+### My org has ~200 engineers (and is growing fast)
+
+You’re big enough to start feeling the pain, and only getting bigger. Onboarding and discoverability are your biggest challenges.
+
+**Pain points:**
+
+- This size seems to be the tipping point — where complexity is taking hold, collaboration starts breaking down, and ad hoc solutions stop working.
+- Often this is also when you’re growing so fast (doubling in size every 6–12 months) that there are now more new engineers than old engineers.
+- New engineers can’t find anything, so they ask around, which pulls all your other engineers off-task with interruptions.
+- Logjams form. One company told us how it was taking 3–4 days for pull requests to get merged.
+
+**Recommendation — Explore, then create:**
+
+- New engineers need an easy way to find out how to do things, so you’re not just adding bodies, you’re adding happy, productive contributors.
+- To streamline onboarding, start with centralizing your documentation and making your tools and software components discoverable in Backstage.
+- At this size, you might not have a dedicated platform/infra team in place yet, but Backstage can provide the framework for centralizing and sharing knowledge — from managing compliance requirements to finding the right API documentation.
+- This allows both new and old engineers to collaborate more efficiently, easily discover best practices, and cuts down on duplicated work (e.g., a new team doesn’t end up rebuilding a database that already exists but nobody can find).
+
+
+
+### My org has ~1,000 engineers
+
+You’re officially big. Lots and lots of teams are managing lots and lots of software — and the frustration of switching between all the various tools to do that is growing exponentially.
+
+**Pain points:**
+
+- Fragmentation and entropy are real threats to productivity. From security requirements to cluster management to a thousand data endpoints, there’s too much to keep track of, leading to cognitive overload.
+- A death by a thousand cuts: constantly logging into new interfaces — from your cloud provider’s console to some brand new security tool then back to your CI/CD — is bogging your engineers down with too much context-switching and a lot of noise.
+- Every day, it’s getting more and more difficult for one team to manage their own microservices, data, and documentation, let alone share their knowledge with other teams.
+
+**Recommendation — Manage, then explore, then create:**
+
+- Backstage will allow your teams to get a handle on the software they own, since everything they need to manage it is in one place.
+- From CI/CD status to documentation to deciphering a monorepo, Backstage makes it easy to manage a service (or other software component) on a day-to-day basis.
+- The service catalog also helps your developers visualize your existing software ecosystem, beyond the software they own. And with Backstage Software Templates, every new software component is already added to the catalog.
+- Even at this scale, a small platform team should be all it takes to build and maintain your own version of Backstage. At Spotify, we have a 4-person team responsible for our internal version of Backstage, which is used by all of Spotify’s 1,600+ developers.
+
+
+
+### My org has 1,000+ engineers
+
+Integrating infrastructure of this size and complexity can seem overwhelming. It’s an even bigger challenge to bring this level of change to a well-established culture with ingrained processes.
+
+**Pain points:**
+
+- When you’re this large, you’ve incorporated a lot of technology and processes, as well as a lot of partners, each with their own technologies and processes.
+- You’re focussed on “replacing cruft” and bringing discoverability and order to your vast ecosystem of components and tools.
+- Getting your tools adopted by your engineers while modernizing your tech stack and coordinating with your infra teams to improve your engineering practices across the whole company… it’s a lot.
+
+**Recommendation — Create, then manage, then explore:**
+
+- The fastest way to bring change to your ecosystem is to start at the beginning of the chain with Backstage Software Templates.
+- With every new component created with your templates, you’re establishing best practices and rewarding your developers with a streamlined experience focused on their needs — all the while building up your new service catalog.
+- The more the templates ease the process of starting up a project, the more your engineers will adopt them, and the faster the other benefits of Backstage will build up, transforming productivity across your organization. ([That’s how we did it at Spotify](https://open.spotify.com/episode/7iuQ3ew1Wwpuiq6LbBKzCl).)
+
+## More questions about adopting Backstage?
+
+[Contact the Backstage team at Spotify.](https://calendly.com/spotify-backstage) We’ll share more about what we’ve learned from our experience here at Spotify — and from other companies who are already using Backstage to transform their developer experience.
diff --git a/microsite/blog/assets/21-05-20/1000-engineers.png b/microsite/blog/assets/21-05-20/1000-engineers.png
new file mode 100644
index 0000000000..b7c9413e99
Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers.png differ
diff --git a/microsite/blog/assets/21-05-20/1000-engineers_v.2.png b/microsite/blog/assets/21-05-20/1000-engineers_v.2.png
new file mode 100644
index 0000000000..86013c61c5
Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers_v.2.png differ
diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers.png b/microsite/blog/assets/21-05-20/1000-plus-engineers.png
new file mode 100644
index 0000000000..63d49dd63e
Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers.png differ
diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png b/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png
new file mode 100644
index 0000000000..1120a28c39
Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png differ
diff --git a/microsite/blog/assets/21-05-20/200-engineers.png b/microsite/blog/assets/21-05-20/200-engineers.png
new file mode 100644
index 0000000000..d0f9bd73be
Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers.png differ
diff --git a/microsite/blog/assets/21-05-20/200-engineers_v.2.png b/microsite/blog/assets/21-05-20/200-engineers_v.2.png
new file mode 100644
index 0000000000..23c6c507a7
Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers_v.2.png differ
diff --git a/microsite/blog/assets/21-05-20/create-manage-explore.gif b/microsite/blog/assets/21-05-20/create-manage-explore.gif
new file mode 100644
index 0000000000..104523a886
Binary files /dev/null and b/microsite/blog/assets/21-05-20/create-manage-explore.gif differ
diff --git a/microsite/blog/assets/21-05-20/create.gif b/microsite/blog/assets/21-05-20/create.gif
new file mode 100644
index 0000000000..ce2c04f384
Binary files /dev/null and b/microsite/blog/assets/21-05-20/create.gif differ
diff --git a/microsite/blog/assets/21-05-20/explore.gif b/microsite/blog/assets/21-05-20/explore.gif
new file mode 100644
index 0000000000..76b527af44
Binary files /dev/null and b/microsite/blog/assets/21-05-20/explore.gif differ
diff --git a/microsite/blog/assets/21-05-20/manage.gif b/microsite/blog/assets/21-05-20/manage.gif
new file mode 100644
index 0000000000..f4cf00d9f5
Binary files /dev/null and b/microsite/blog/assets/21-05-20/manage.gif differ
diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js
index 1471d7b4d1..ec08216367 100644
--- a/microsite/core/Footer.js
+++ b/microsite/core/Footer.js
@@ -85,7 +85,7 @@ class Footer extends React.Component {
- Made with ❤️ at Spotify
+ Made with ❤️ at Spotify
{this.props.config.copyright}
diff --git a/microsite/data/plugins/cortex.yaml b/microsite/data/plugins/cortex.yaml
new file mode 100644
index 0000000000..1f1c57adfd
--- /dev/null
+++ b/microsite/data/plugins/cortex.yaml
@@ -0,0 +1,13 @@
+---
+title: Cortex Service Quality Scorecards
+author: Cortex
+authorUrl: https://www.getcortexapp.com
+category: Monitoring
+description: Grade the quality of your Backstage services using Scorecards. Automate production readiness, migrations, security audits, and more with CQL (Cortex Query Language).
+documentation: https://github.com/cortexapps/backstage-plugin
+iconUrl: img/cortex.png
+npmPackageName: '@cortexapps/backstage-plugin'
+tags:
+ - web
+ - monitoring
+ - sre
diff --git a/microsite/package.json b/microsite/package.json
index 595debcc65..3112bbfe17 100644
--- a/microsite/package.json
+++ b/microsite/package.json
@@ -12,13 +12,15 @@
"write-translations": "docusaurus-write-translations",
"version": "docusaurus-version",
"rename-version": "docusaurus-rename-version",
- "verify:sidebars": "node ./scripts/verify-sidebars"
+ "verify:sidebars": "node ./scripts/verify-sidebars",
+ "lock:check": "yarn-lock-check"
},
"devDependencies": {
"@spotify/prettier-config": "^10.0.0",
"docusaurus": "^2.0.0-alpha.70",
"js-yaml": "^4.1.0",
- "prettier": "^2.3.0"
+ "prettier": "^2.3.1",
+ "yarn-lock-check": "^1.0.4"
},
"prettier": "@spotify/prettier-config"
}
diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js
index 86cb6c786d..e3943d2905 100644
--- a/microsite/pages/en/demos.js
+++ b/microsite/pages/en/demos.js
@@ -18,12 +18,6 @@ const Background = props => {
See us in action
-
- To explore the UI and basic features of Backstage firsthand, go
- to: demo.backstage.io.
- (Tip: click “All” to view all the example components in the
- service catalog.)
-
Watch the videos below to get an introduction to Backstage and to
see how we use different plugins to customize{' '}
@@ -32,13 +26,31 @@ const Background = props => {
.
+
+ To see how other companies have already started using Backstage,
+ watch these presentations from{' '}
+ Expedia,{' '}
+ Zalando, and{' '}
+ TELUS. For more,
+ join our{' '}
+
+ Community Sessions
+
+ .
+
+
+ To explore the UI and basic features of Backstage firsthand, go
+ to: demo.backstage.io.
+ (Tip: click “All” to view all the example components in the
+ service catalog.)
+
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index 3544ebdca3..56be7464a5 100644
--- a/microsite/sidebars.json
+++ b/microsite/sidebars.json
@@ -43,6 +43,7 @@
"features/software-catalog/well-known-statuses",
"features/software-catalog/extending-the-model",
"features/software-catalog/external-integrations",
+ "features/software-catalog/catalog-customization",
"features/software-catalog/software-catalog-api"
]
},
@@ -66,7 +67,8 @@
"features/software-templates/writing-templates",
"features/software-templates/builtin-actions",
"features/software-templates/writing-custom-actions",
- "features/software-templates/template-legacy"
+ "features/software-templates/template-legacy",
+ "features/software-templates/migrating-from-v1alpha1-to-v1beta2"
]
},
{
@@ -238,7 +240,6 @@
],
"Tutorials": [
"tutorials/journey",
- "tutorials/quickstart-app-auth",
"tutorials/quickstart-app-plugin",
"tutorials/switching-sqlite-postgres"
],
diff --git a/microsite/static/img/cortex.png b/microsite/static/img/cortex.png
new file mode 100644
index 0000000000..6a45d0ca06
Binary files /dev/null and b/microsite/static/img/cortex.png differ
diff --git a/microsite/static/img/demo-screen.png b/microsite/static/img/demo-screen.png
new file mode 100644
index 0000000000..c3aa8a6a3b
Binary files /dev/null and b/microsite/static/img/demo-screen.png differ
diff --git a/microsite/yarn.lock b/microsite/yarn.lock
index 4033968a2e..6f71394265 100644
--- a/microsite/yarn.lock
+++ b/microsite/yarn.lock
@@ -9,6 +9,13 @@
dependencies:
"@babel/highlight" "^7.0.0"
+"@babel/code-frame@^7.0.0":
+ version "7.12.13"
+ resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658"
+ integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==
+ dependencies:
+ "@babel/highlight" "^7.12.13"
+
"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11":
version "7.12.11"
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
@@ -220,6 +227,11 @@
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
+"@babel/helper-validator-identifier@^7.14.0":
+ version "7.14.0"
+ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288"
+ integrity sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==
+
"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11":
version "7.12.11"
resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f"
@@ -253,6 +265,15 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
+"@babel/highlight@^7.12.13":
+ version "7.14.0"
+ resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz#3197e375711ef6bf834e67d0daec88e4f46113cf"
+ integrity sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.14.0"
+ chalk "^2.0.0"
+ js-tokens "^4.0.0"
+
"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7":
version "7.12.11"
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79"
@@ -911,7 +932,7 @@
"@spotify/prettier-config@^10.0.0":
version "10.0.0"
- resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-10.0.0.tgz#fa076d98d2e7e6c53dd3d86a696307a7010bd056"
+ resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-10.0.0.tgz#fa076d98d2e7e6c53dd3d86a696307a7010bd056"
integrity sha512-VYOdo8P7lIScAkl02nB9KpUAuOYMManryBIBuKJkAw5D3aVtLobfmdIKvdV6MqEmGMEQPbn7w/UpnjJYhUH+IA==
"@types/cheerio@^0.22.8":
@@ -921,16 +942,39 @@
dependencies:
"@types/node" "*"
+"@types/glob@^7.1.3":
+ version "7.1.3"
+ resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183"
+ integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==
+ dependencies:
+ "@types/minimatch" "*"
+ "@types/node" "*"
+
+"@types/minimatch@*":
+ version "3.0.4"
+ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21"
+ integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
+
"@types/node@*":
version "14.14.20"
resolved "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340"
integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==
+"@types/node@^15.6.1":
+ version "15.9.0"
+ resolved "https://registry.npmjs.org/@types/node/-/node-15.9.0.tgz#0b7f6c33ca5618fe329a9d832b478b4964d325a8"
+ integrity sha512-AR1Vq1Ei1GaA5FjKL5PBqblTZsL5M+monvGSZwe6sSIdGiuu7Xr/pNwWJY+0ZQuN8AapD/XMB5IzBAyYRFbocA==
+
"@types/q@^1.5.1":
version "1.5.4"
resolved "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
+"@yarnpkg/lockfile@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
+ integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
+
accepts@~1.3.7:
version "1.3.7"
resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@@ -1039,7 +1083,7 @@ argparse@^1.0.10, argparse@^1.0.7:
argparse@^2.0.1:
version "2.0.1"
- resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+ resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
arr-diff@^4.0.0:
@@ -1359,15 +1403,15 @@ browserslist@4.7.0:
node-releases "^1.1.29"
browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0:
- version "4.16.0"
- resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b"
- integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ==
+ version "4.16.6"
+ resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"
+ integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==
dependencies:
- caniuse-lite "^1.0.30001165"
- colorette "^1.2.1"
- electron-to-chromium "^1.3.621"
+ caniuse-lite "^1.0.30001219"
+ colorette "^1.2.2"
+ electron-to-chromium "^1.3.723"
escalade "^3.1.1"
- node-releases "^1.1.67"
+ node-releases "^1.1.71"
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
@@ -1405,6 +1449,11 @@ buffer@^5.2.1:
base64-js "^1.3.1"
ieee754 "^1.1.13"
+builtin-modules@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
+ integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=
+
bytes@1:
version "1.0.0"
resolved "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8"
@@ -1498,10 +1547,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165:
- version "1.0.30001173"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz#3c47bbe3cd6d7a9eda7f50ac016d158005569f56"
- integrity sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219:
+ version "1.0.30001235"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001235.tgz#ad5ca75bc5a1f7b12df79ad806d715a43a5ac4ed"
+ integrity sha512-zWEwIVqnzPkSAXOUlQnPW2oKoYb2aLQ4Q5ejdjBcnH63rfypaW34CxaeBn1VMya2XaEU3P/R2qHpWyj+l0BT1A==
caseless@~0.12.0:
version "0.12.0"
@@ -1518,7 +1567,7 @@ caw@^2.0.0, caw@^2.0.1:
tunnel-agent "^0.6.0"
url-to-options "^1.0.1"
-chalk@2.4.2, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -1697,10 +1746,10 @@ color@^3.0.0:
color-convert "^1.9.1"
color-string "^1.5.4"
-colorette@^1.2.1:
- version "1.2.1"
- resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
- integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
+colorette@^1.2.1, colorette@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
+ integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
combined-stream@^1.0.6, combined-stream@~1.0.6:
version "1.0.8"
@@ -1709,7 +1758,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
-commander@^2.8.1:
+commander@^2.12.1, commander@^2.8.1:
version "2.20.3"
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -2181,6 +2230,11 @@ diacritics-map@^0.1.0:
resolved "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz#6dfc0ff9d01000a2edf2865371cac316e94977af"
integrity sha1-bfwP+dAQAKLt8oZTccrDFulJd68=
+diff@^4.0.1:
+ version "4.0.2"
+ resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+ integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+
dir-glob@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
@@ -2355,10 +2409,10 @@ ee-first@1.1.1:
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
-electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.621:
- version "1.3.634"
- resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz#82ea400f520f739c4f6ff00c1f7524827a917d25"
- integrity sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw==
+electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.723:
+ version "1.3.749"
+ resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.749.tgz#0ecebc529ceb49dd2a7c838ae425236644c3439a"
+ integrity sha512-F+v2zxZgw/fMwPz/VUGIggG4ZndDsYy0vlpthi3tjmDZlcfbhN5mYW0evXUsBr2sUtuDANFtle410A9u/sd/4A==
"emoji-regex@>=6.0.0 <=6.1.1":
version "6.1.1"
@@ -3049,6 +3103,18 @@ glob@^7.0.0, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6, glob@~7.1.1:
once "^1.3.0"
path-is-absolute "^1.0.0"
+glob@^7.1.1, glob@^7.1.7:
+ version "7.1.7"
+ resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
+ integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
global-modules@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
@@ -3460,6 +3526,11 @@ ini@^1.3.4, ini@^1.3.5:
resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
+ini@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
+ integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==
+
inquirer@6.5.0:
version "6.5.0"
resolved "https://registry.npmjs.org/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42"
@@ -3567,6 +3638,13 @@ is-core-module@^2.1.0:
dependencies:
has "^1.0.3"
+is-core-module@^2.2.0:
+ version "2.4.0"
+ resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1"
+ integrity sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==
+ dependencies:
+ has "^1.0.3"
+
is-data-descriptor@^0.1.4:
version "0.1.4"
resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -3864,7 +3942,7 @@ js-yaml@^3.13.1, js-yaml@^3.8.1:
js-yaml@^4.1.0:
version "4.1.0"
- resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+ resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
dependencies:
argparse "^2.0.1"
@@ -4142,9 +4220,9 @@ lodash.uniq@^4.5.0:
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20, lodash@~4.17.10:
- version "4.17.20"
- resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
- integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
+ version "4.17.21"
+ resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+ integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
logalot@^2.0.0:
version "2.1.0"
@@ -4379,7 +4457,7 @@ mixin-deep@^1.1.3, mixin-deep@^1.2.0:
for-in "^1.0.2"
is-extendable "^1.0.1"
-mkdirp@^0.5.1, mkdirp@^0.5.5, mkdirp@~0.5.1:
+mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
version "0.5.5"
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@@ -4448,10 +4526,10 @@ node-modules-regexp@^1.0.0:
resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40"
integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=
-node-releases@^1.1.29, node-releases@^1.1.67:
- version "1.1.69"
- resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz#3149dbde53b781610cd8b486d62d86e26c3725f6"
- integrity sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA==
+node-releases@^1.1.29, node-releases@^1.1.71:
+ version "1.1.72"
+ resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe"
+ integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
version "2.5.0"
@@ -5197,10 +5275,10 @@ prepend-http@^2.0.0:
resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
-prettier@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18"
- integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==
+prettier@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6"
+ integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==
prismjs@^1.22.0:
version "1.23.0"
@@ -5603,6 +5681,14 @@ resolve@^1.1.6, resolve@^1.10.0:
is-core-module "^2.1.0"
path-parse "^1.0.6"
+resolve@^1.3.2:
+ version "1.20.0"
+ resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
+ integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
+ dependencies:
+ is-core-module "^2.2.0"
+ path-parse "^1.0.6"
+
responselike@1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
@@ -6351,11 +6437,37 @@ truncate-html@^1.0.3:
"@types/cheerio" "^0.22.8"
cheerio "0.22.0"
-tslib@^1.9.0, tslib@^1.9.3:
+tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+tslint@^6.1.3:
+ version "6.1.3"
+ resolved "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz#5c23b2eccc32487d5523bd3a470e9aa31789d904"
+ integrity sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ builtin-modules "^1.1.1"
+ chalk "^2.3.0"
+ commander "^2.12.1"
+ diff "^4.0.1"
+ glob "^7.1.1"
+ js-yaml "^3.13.1"
+ minimatch "^3.0.4"
+ mkdirp "^0.5.3"
+ resolve "^1.3.2"
+ semver "^5.3.0"
+ tslib "^1.13.0"
+ tsutils "^2.29.0"
+
+tsutils@^2.29.0:
+ version "2.29.0"
+ resolved "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99"
+ integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==
+ dependencies:
+ tslib "^1.8.1"
+
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -6381,6 +6493,11 @@ typedarray@^0.0.6:
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+typescript@^4.3.2:
+ version "4.3.2"
+ resolved "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
+ integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
+
unbzip2-stream@^1.0.9:
version "1.4.3"
resolved "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
@@ -6650,6 +6767,19 @@ yargs@^2.3.0:
dependencies:
wordwrap "0.0.2"
+yarn-lock-check@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.npmjs.org/yarn-lock-check/-/yarn-lock-check-1.0.4.tgz#a0373de051be0c8442d8933070df7a45595263b4"
+ integrity sha512-Gj0wRN85c4OPZUlE7WsQ0a1COv38uyeWWR0YAvJr2Vxw1f32bwK19xySjUZlxt9o4QopJkd8g6x6CLv81OHwYg==
+ dependencies:
+ "@types/glob" "^7.1.3"
+ "@types/node" "^15.6.1"
+ "@yarnpkg/lockfile" "^1.1.0"
+ glob "^7.1.7"
+ ini "^2.0.0"
+ tslint "^6.1.3"
+ typescript "^4.3.2"
+
yauzl@^2.4.2:
version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
diff --git a/mkdocs.yml b/mkdocs.yml
index 0e2e78be26..e4f4f01e8a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -40,6 +40,7 @@ nav:
- Well-known Statuses: 'features/software-catalog/well-known-statuses.md'
- Extending the model: 'features/software-catalog/extending-the-model.md'
- External integrations: 'features/software-catalog/external-integrations.md'
+ - Catalog Customization: 'features/software-catalog/catalog-customization.md'
- API: 'features/software-catalog/api.md'
- Kubernetes:
- Overview: 'features/kubernetes/index.md'
@@ -54,6 +55,8 @@ nav:
- Builtin Actions: 'features/software-templates/builtin-actions.md'
- Writing Custom Actions: 'features/software-templates/writing-custom-actions.md'
- Writing Templates (Legacy): 'features/software-templates/legacy.md'
+ - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md'
+
- Backstage Search:
- Overview: 'features/search/README.md'
- Search Architecture: 'features/search/architecture.md'
@@ -152,7 +155,6 @@ nav:
- Backend: 'api/backend.md'
- Tutorials:
- Future developer journey: 'tutorials/journey.md'
- - Monorepo App Setup With Authentication: 'tutorials/quickstart-app-auth.md'
- Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md'
- Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md'
- Architecture Decision Records (ADRs):
diff --git a/package.json b/package.json
index ed404565e4..6a663c877e 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,8 @@
"lerna": "lerna",
"storybook": "yarn workspace storybook start",
"build-storybook": "yarn workspace storybook build-storybook",
- "prepare": "husky install"
+ "prepare": "husky install",
+ "lock:check": "yarn-lock-check"
},
"workspaces": {
"packages": [
@@ -49,24 +50,25 @@
},
"version": "1.0.0",
"dependencies": {
- "@microsoft/api-extractor": "7.13.2-pr1916.0",
"@microsoft/api-documenter": "^7.12.16",
+ "@microsoft/api-extractor": "7.13.2-pr1916.0",
"@microsoft/api-extractor-model": "^7.12.5"
},
"devDependencies": {
"@changesets/cli": "^2.14.0",
"@octokit/openapi-types": "^2.2.0",
- "@spotify/eslint-config-oss": "^1.0.1",
- "@spotify/prettier-config": "^9.0.0",
+ "@spotify/prettier-config": "^10.0.0",
"command-exists": "^1.2.9",
"concurrently": "^6.0.0",
+ "eslint-plugin-notice": "^0.9.10",
"fs-extra": "^9.0.0",
"husky": "^6.0.0",
"lerna": "^4.0.0",
"lint-staged": "^10.1.0",
"prettier": "^2.2.1",
"recursive-readdir": "^2.2.2",
- "shx": "^0.3.2"
+ "shx": "^0.3.2",
+ "yarn-lock-check": "^1.0.4"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index 1cc00e5ff9..7f9e9e554d 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,111 @@
# example-app
+## 0.2.31
+
+### Patch Changes
+
+- Updated dependencies [497f4ce18]
+- Updated dependencies [ee4eb5b40]
+- Updated dependencies [84160313e]
+- Updated dependencies [3772de8ba]
+- Updated dependencies [7e7c71417]
+- Updated dependencies [f430b6c6f]
+- Updated dependencies [2a942cc9e]
+- Updated dependencies [e7c5e4b30]
+- Updated dependencies [ebe802bc4]
+- Updated dependencies [1cf1d351f]
+- Updated dependencies [90a505a77]
+- Updated dependencies [76f99a1a0]
+- Updated dependencies [deaba2e13]
+- Updated dependencies [1157fa307]
+- Updated dependencies [8e919a6f8]
+- Updated dependencies [2305ab8fc]
+- Updated dependencies [054bcd029]
+- Updated dependencies [aad98c544]
+- Updated dependencies [f46a9e82d]
+ - @backstage/plugin-scaffolder@0.9.7
+ - @backstage/cli@0.6.14
+ - @backstage/plugin-catalog@0.6.1
+ - @backstage/theme@0.2.8
+ - @backstage/catalog-model@0.8.1
+ - @backstage/core@0.7.12
+ - @backstage/plugin-tech-radar@0.4.0
+ - @backstage/plugin-catalog-react@0.2.1
+ - @backstage/plugin-techdocs@0.9.5
+
+## 0.2.30
+
+### Patch Changes
+
+- Updated dependencies [41c3ec421]
+- Updated dependencies [add62a455]
+- Updated dependencies [cc592248b]
+- Updated dependencies [17c497b81]
+- Updated dependencies [1cd0cacd9]
+- Updated dependencies [4ea9df9d3]
+- Updated dependencies [7a7da5146]
+- Updated dependencies [5baf2ff0f]
+- Updated dependencies [bf805b467]
+- Updated dependencies [203ce6f6f]
+- Updated dependencies [7ab5bfe68]
+- Updated dependencies [704875e26]
+- Updated dependencies [3a181cff1]
+ - @backstage/plugin-cost-insights@0.10.1
+ - @backstage/catalog-model@0.8.0
+ - @backstage/core@0.7.11
+ - @backstage/plugin-catalog@0.6.0
+ - @backstage/plugin-catalog-react@0.2.0
+ - @backstage/cli@0.6.13
+ - @backstage/plugin-techdocs@0.9.4
+ - @backstage/plugin-jenkins@0.4.4
+ - @backstage/plugin-api-docs@0.4.15
+ - @backstage/plugin-badges@0.2.2
+ - @backstage/plugin-catalog-import@0.5.8
+ - @backstage/plugin-circleci@0.2.15
+ - @backstage/plugin-cloudbuild@0.2.16
+ - @backstage/plugin-code-coverage@0.1.4
+ - @backstage/plugin-explore@0.3.6
+ - @backstage/plugin-github-actions@0.4.8
+ - @backstage/plugin-kafka@0.2.8
+ - @backstage/plugin-kubernetes@0.4.5
+ - @backstage/plugin-lighthouse@0.2.17
+ - @backstage/plugin-org@0.3.14
+ - @backstage/plugin-pagerduty@0.3.5
+ - @backstage/plugin-rollbar@0.3.6
+ - @backstage/plugin-scaffolder@0.9.6
+ - @backstage/plugin-search@0.3.7
+ - @backstage/plugin-sentry@0.3.11
+ - @backstage/plugin-todo@0.1.2
+
+## 0.2.29
+
+### Patch Changes
+
+- Updated dependencies [7cbfcae48]
+- Updated dependencies [2bfec55a6]
+- Updated dependencies [f7f7783a3]
+- Updated dependencies [65e6c4541]
+- Updated dependencies [68fdbf014]
+- Updated dependencies [5da6a561d]
+- Updated dependencies [ca6e0ab69]
+- Updated dependencies [5914a76d5]
+- Updated dependencies [81d7b9c6f]
+- Updated dependencies [a62cfe068]
+- Updated dependencies [35e091604]
+- Updated dependencies [a53f3d603]
+- Updated dependencies [b203699e9]
+ - @backstage/plugin-cost-insights@0.10.0
+ - @backstage/cli@0.6.12
+ - @backstage/catalog-model@0.7.10
+ - @backstage/plugin-scaffolder@0.9.5
+ - @backstage/core@0.7.10
+ - @backstage/plugin-api-docs@0.4.14
+ - @backstage/plugin-cloudbuild@0.2.15
+ - @backstage/plugin-github-actions@0.4.7
+ - @backstage/plugin-techdocs@0.9.3
+ - @backstage/plugin-catalog-import@0.5.7
+ - @backstage/plugin-catalog@0.5.8
+
## 0.2.28
### Patch Changes
diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js
new file mode 100644
index 0000000000..4e13fc8a0f
--- /dev/null
+++ b/packages/app/cypress/integration/components/search/SearchPage.js
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2021 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.
+ */
+
+const API_ENDPOINT = 'http://localhost:7000/api/search/query';
+
+describe('SearchPage', () => {
+ describe('Given a search context with a term, results, and filter values', () => {
+ it('The results are rendered as expected', () => {
+ const results = [
+ {
+ type: 'software-catalog',
+ document: {
+ title: 'backstage',
+ text: 'Backstage system documentation',
+ location: '/result/location/path',
+ },
+ },
+ ];
+
+ cy.enterAsGuest();
+ cy.visit('/search-next', {
+ onBeforeLoad(win) {
+ cy.stub(win, 'fetch')
+ .withArgs(`${API_ENDPOINT}?term=&pageCursor=`)
+ .resolves({
+ ok: true,
+ json: () => ({ results }),
+ });
+ },
+ });
+ cy.contains('Search');
+
+ cy.contains(results[0].document.title);
+ cy.contains(results[0].document.text);
+ cy.get(`a[href="${results[0].document.location}"]`).should('be.visible');
+ });
+
+ it('The filters are rendered as expected', () => {
+ cy.enterAsGuest();
+ cy.visit(
+ '/search-next?filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B%5D=experimental',
+ {
+ onBeforeLoad(win) {
+ cy.stub(win, 'fetch')
+ .withArgs(
+ `${API_ENDPOINT}?term=&filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B0%5D=experimental&pageCursor=`,
+ )
+ .resolves({
+ ok: true,
+ json: () => ({ results: [] }),
+ });
+ },
+ },
+ );
+ cy.contains('Search');
+
+ // lifecycle
+ cy.contains('lifecycle');
+
+ cy.contains('experimental');
+ cy.get(
+ '[data-testid="search-checkboxfilter-next"] input[value="experimental"]',
+ ).should('have.attr', 'checked');
+
+ cy.contains('production');
+ cy.get(
+ '[data-testid="search-checkboxfilter-next"] input[value="production"]',
+ ).should('not.have.attr', 'checked');
+
+ // kind
+ cy.contains('kind');
+ cy.get(
+ '[data-testid="search-selectfilter-next"] [role="button"][aria-haspopup="listbox"]',
+ ).click();
+
+ cy.contains('All');
+ cy.contains('Template');
+ cy.contains('Component');
+
+ cy.get('[role="option"][data-value="Component"]').should(
+ 'have.attr',
+ 'aria-selected',
+ 'true',
+ );
+ });
+
+ it('The search bar is rendered as expected', () => {
+ cy.enterAsGuest();
+ cy.visit('/search-next?query=backstage', {
+ onBeforeLoad(win) {
+ cy.stub(win, 'fetch')
+ .withArgs(`${API_ENDPOINT}?term=backstage&pageCursor=`)
+ .resolves({
+ ok: true,
+ json: () => ({ results: [] }),
+ });
+ },
+ });
+ cy.contains('Search');
+
+ cy.get('[data-testid="search-bar-next"] input').should(
+ 'have.attr',
+ 'value',
+ 'backstage',
+ );
+ });
+ });
+});
diff --git a/packages/app/cypress/support/commands.js b/packages/app/cypress/support/commands.js
new file mode 100644
index 0000000000..dd2b26634d
--- /dev/null
+++ b/packages/app/cypress/support/commands.js
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 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.
+ */
+Cypress.Commands.add('enterAsGuest', () => {
+ cy.visit('/');
+ cy.get('button').contains('Enter').click();
+});
diff --git a/packages/app/cypress/support/index.js b/packages/app/cypress/support/index.js
index 8fc8ca91f1..c1f930027a 100644
--- a/packages/app/cypress/support/index.js
+++ b/packages/app/cypress/support/index.js
@@ -14,3 +14,4 @@
* limitations under the License.
*/
import '@testing-library/cypress/add-commands';
+import './commands';
diff --git a/packages/app/package.json b/packages/app/package.json
index 6acba6f4cc..03d87dc243 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,49 +1,49 @@
{
"name": "example-app",
- "version": "0.2.28",
+ "version": "0.2.31",
"private": true,
"bundled": true,
"dependencies": {
- "@backstage/catalog-model": "^0.7.9",
- "@backstage/cli": "^0.6.11",
- "@backstage/core": "^0.7.9",
+ "@backstage/catalog-model": "^0.8.1",
+ "@backstage/cli": "^0.6.14",
+ "@backstage/core": "^0.7.12",
"@backstage/integration-react": "^0.1.2",
- "@backstage/plugin-api-docs": "^0.4.13",
- "@backstage/plugin-badges": "^0.2.1",
- "@backstage/plugin-catalog": "^0.5.7",
- "@backstage/plugin-catalog-import": "^0.5.6",
- "@backstage/plugin-catalog-react": "^0.1.6",
- "@backstage/plugin-circleci": "^0.2.14",
- "@backstage/plugin-cloudbuild": "^0.2.14",
- "@backstage/plugin-code-coverage": "^0.1.3",
- "@backstage/plugin-cost-insights": "^0.9.1",
- "@backstage/plugin-explore": "^0.3.5",
+ "@backstage/plugin-api-docs": "^0.4.15",
+ "@backstage/plugin-badges": "^0.2.2",
+ "@backstage/plugin-catalog": "^0.6.1",
+ "@backstage/plugin-catalog-import": "^0.5.8",
+ "@backstage/plugin-catalog-react": "^0.2.1",
+ "@backstage/plugin-circleci": "^0.2.15",
+ "@backstage/plugin-cloudbuild": "^0.2.16",
+ "@backstage/plugin-code-coverage": "^0.1.4",
+ "@backstage/plugin-cost-insights": "^0.10.1",
+ "@backstage/plugin-explore": "^0.3.6",
"@backstage/plugin-gcp-projects": "^0.2.6",
- "@backstage/plugin-github-actions": "^0.4.6",
+ "@backstage/plugin-github-actions": "^0.4.8",
"@backstage/plugin-graphiql": "^0.2.11",
- "@backstage/plugin-jenkins": "^0.4.3",
- "@backstage/plugin-kafka": "^0.2.7",
- "@backstage/plugin-kubernetes": "^0.4.4",
- "@backstage/plugin-lighthouse": "^0.2.16",
+ "@backstage/plugin-jenkins": "^0.4.4",
+ "@backstage/plugin-kafka": "^0.2.8",
+ "@backstage/plugin-kubernetes": "^0.4.5",
+ "@backstage/plugin-lighthouse": "^0.2.17",
"@backstage/plugin-newrelic": "^0.2.7",
- "@backstage/plugin-org": "^0.3.13",
- "@backstage/plugin-pagerduty": "0.3.4",
- "@backstage/plugin-rollbar": "^0.3.5",
- "@backstage/plugin-scaffolder": "^0.9.4",
- "@backstage/plugin-search": "^0.3.6",
- "@backstage/plugin-sentry": "^0.3.10",
+ "@backstage/plugin-org": "^0.3.14",
+ "@backstage/plugin-pagerduty": "0.3.5",
+ "@backstage/plugin-rollbar": "^0.3.6",
+ "@backstage/plugin-scaffolder": "^0.9.7",
+ "@backstage/plugin-search": "^0.3.7",
+ "@backstage/plugin-sentry": "^0.3.11",
"@backstage/plugin-shortcuts": "^0.1.2",
- "@backstage/plugin-tech-radar": "^0.3.11",
- "@backstage/plugin-techdocs": "^0.9.2",
- "@backstage/plugin-todo": "^0.1.1",
+ "@backstage/plugin-tech-radar": "^0.4.0",
+ "@backstage/plugin-techdocs": "^0.9.5",
+ "@backstage/plugin-todo": "^0.1.2",
"@backstage/plugin-user-settings": "^0.2.10",
- "@backstage/theme": "^0.2.7",
+ "@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.5.3",
- "@roadiehq/backstage-plugin-buildkite": "^1.0.0",
- "@roadiehq/backstage-plugin-github-insights": "^1.0.0",
- "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.0",
+ "@roadiehq/backstage-plugin-buildkite": "^1.0.3",
+ "@roadiehq/backstage-plugin-github-insights": "^1.1.11",
+ "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.5",
"@roadiehq/backstage-plugin-travis-ci": "^1.0.0",
"history": "^5.0.0",
"prop-types": "^15.7.2",
@@ -56,7 +56,7 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/test-utils": "^0.1.11",
+ "@backstage/test-utils": "^0.1.13",
"@testing-library/cypress": "^7.0.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
@@ -67,7 +67,7 @@
"@types/react-dom": "^16.9.8",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
- "cypress": "^4.2.0",
+ "cypress": "^7.3.0",
"eslint-plugin-cypress": "^2.10.3",
"start-server-and-test": "^1.10.11"
},
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index c5e5033973..5874465ef6 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -54,6 +54,7 @@ import { Navigate, Route } from 'react-router';
import { apis } from './apis';
import { Root } from './components/Root';
import { entityPage } from './components/catalog/EntityPage';
+import { searchPage } from './components/search/SearchPage';
import { providers } from './identityProviders';
import * as plugins from './plugins';
@@ -119,10 +120,9 @@ const routes = (
} />
} />
} />
- }
- />
+ }>
+ {searchPage}
+
} />
{
getWorkflow: jest.fn(),
getWorkflowRun: jest.fn(),
reRunWorkflow: jest.fn(),
+ listJobsForWorkflowRun: jest.fn(),
downloadJobLogsForWorkflowRun: jest.fn(),
} as jest.Mocked;
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 601134362d..f646992c97 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -28,6 +28,8 @@ import {
import { EntityBadgesDialog } from '@backstage/plugin-badges';
import {
EntityAboutCard,
+ EntityDependsOnComponentsCard,
+ EntityDependsOnResourcesCard,
EntityHasComponentsCard,
EntityHasSubcomponentsCard,
EntityHasSystemsCard,
@@ -37,6 +39,9 @@ import {
EntitySwitch,
isComponentType,
isKind,
+ EntityHasResourcesCard,
+ EntityOrphanWarning,
+ isOrphan,
} from '@backstage/plugin-catalog';
import {
EntityCircleCIContent,
@@ -211,7 +216,15 @@ const errorsContent = (
const overviewContent = (
-
+
+
+
+
+
+
+
+
+
@@ -223,7 +236,7 @@ const overviewContent = (
-
+
@@ -257,7 +270,7 @@ const overviewContent = (
-
+
@@ -288,6 +301,17 @@ const serviceEntityPage = (
+
+
+
+
+
+
+
+
+
+
+
@@ -336,6 +360,17 @@ const websiteEntityPage = (
{errorsContent}
+
+
+
+
+
+
+
+
+
+
+
@@ -466,6 +501,9 @@ const systemPage = (
+
+
+
diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx
new file mode 100644
index 0000000000..221d4a44c4
--- /dev/null
+++ b/packages/app/src/components/search/SearchPage.tsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
+
+import { Content, Header, Lifecycle, Page } from '@backstage/core';
+import { CatalogResultListItem } from '@backstage/plugin-catalog';
+import {
+ SearchBarNext as SearchBar,
+ SearchFilterNext as SearchFilter,
+ SearchResultNext as SearchResult,
+ DefaultResultListItem,
+} from '@backstage/plugin-search';
+
+const useStyles = makeStyles((theme: Theme) => ({
+ bar: {
+ padding: theme.spacing(1, 0),
+ },
+ filters: {
+ padding: theme.spacing(2),
+ },
+ filter: {
+ '& + &': {
+ marginTop: theme.spacing(2.5),
+ },
+ },
+}));
+
+const SearchPage = () => {
+ const classes = useStyles();
+
+ return (
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {({ results }) => (
+
+ {results.map(({ type, document }) => {
+ switch (type) {
+ case 'software-catalog':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ })}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const searchPage = ;
diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md
index 9c66f387d9..728d8fabcd 100644
--- a/packages/backend-common/CHANGELOG.md
+++ b/packages/backend-common/CHANGELOG.md
@@ -1,5 +1,30 @@
# @backstage/backend-common
+## 0.8.1
+
+### Patch Changes
+
+- c7dad9218: All cache-related connection errors are now handled and logged by the cache manager. App Integrators may provide an optional error handler when instantiating the cache manager if custom error handling is needed.
+
+ ```typescript
+ // Providing an error handler
+ const cacheManager = CacheManager.fromConfig(config, {
+ onError: e => {
+ if (isSomehowUnrecoverable(e)) {
+ gracefullyShutThingsDown();
+ process.exit(1);
+ }
+ },
+ });
+ ```
+
+- 65e6c4541: Remove circular dependencies
+- 5001de908: Change GitlabUrlReader to SHA timestamp compare using only commits that modify given file path, if file path given
+- Updated dependencies [65e6c4541]
+- Updated dependencies [290405276]
+ - @backstage/integration@0.5.3
+ - @backstage/config-loader@0.6.2
+
## 0.8.0
### Minor Changes
diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md
index 23e22fe34b..068220cad3 100644
--- a/packages/backend-common/api-report.md
+++ b/packages/backend-common/api-report.md
@@ -73,7 +73,7 @@ export interface CacheClient {
// @public
export class CacheManager {
forPlugin(pluginId: string): PluginCacheManager;
- static fromConfig(config: Config): CacheManager;
+ static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager;
}
// @public (undocumented)
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index 7e647e57d0..4de9c36c8c 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.8.0",
+ "version": "0.8.1",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -31,9 +31,9 @@
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.5",
- "@backstage/config-loader": "^0.6.1",
+ "@backstage/config-loader": "^0.6.2",
"@backstage/errors": "^0.1.1",
- "@backstage/integration": "^0.5.2",
+ "@backstage/integration": "^0.5.3",
"@google-cloud/storage": "^5.8.0",
"@octokit/rest": "^18.5.3",
"@types/cors": "^2.8.6",
@@ -76,8 +76,8 @@
}
},
"devDependencies": {
- "@backstage/cli": "^0.6.11",
- "@backstage/test-utils": "^0.1.11",
+ "@backstage/cli": "^0.6.12",
+ "@backstage/test-utils": "^0.1.12",
"@types/archiver": "^5.1.0",
"@types/compression": "^1.7.0",
"@types/concat-stream": "^1.6.0",
diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts
index 49b5c367dd..3b654e248b 100644
--- a/packages/backend-common/src/cache/CacheClient.ts
+++ b/packages/backend-common/src/cache/CacheClient.ts
@@ -92,7 +92,8 @@ export class DefaultCacheClient implements CacheClient {
const wellFormedKey = Buffer.from(candidateKey).toString('base64');
// Memcache in particular doesn't do well with keys > 250 bytes.
- if (wellFormedKey.length < 250) {
+ // Padded because a plugin ID is also prepended to the key.
+ if (wellFormedKey.length < 200) {
return wellFormedKey;
}
diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts
index ece4687ee6..97c1714c53 100644
--- a/packages/backend-common/src/cache/CacheManager.test.ts
+++ b/packages/backend-common/src/cache/CacheManager.test.ts
@@ -86,6 +86,16 @@ describe('CacheManager', () => {
expect(client).toHaveBeenCalledTimes(1);
});
+ it('attaches error handler to client', () => {
+ const pluginId = 'error-test';
+ manager.forPlugin(pluginId).getClient();
+
+ const client = DefaultCacheClient as jest.Mock;
+ const mockCalls = client.mock.calls.splice(-1);
+ const realClient = mockCalls[0][0].client as Keyv;
+ expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function));
+ });
+
it('provides different plugins different cache clients', async () => {
const plugin1Id = 'test1';
const plugin2Id = 'test2';
@@ -163,4 +173,53 @@ describe('CacheManager', () => {
expect(mockMemcacheCalls[0][0]).toEqual(expectedHost);
});
});
+
+ describe('connection errors', () => {
+ it('uses provided logger', () => {
+ // Set up and inject mock logger.
+ const mockLogger = { child: jest.fn(), error: jest.fn() };
+ mockLogger.child.mockImplementation(() => mockLogger as any);
+ const manager = CacheManager.fromConfig(defaultConfig(), {
+ logger: mockLogger as any,
+ });
+
+ // Set up a cache client using the configured manager.
+ manager.forPlugin('error-logger-test').getClient();
+
+ // Retrieve the error handler attached to the cache client.
+ const client = DefaultCacheClient as jest.Mock;
+ const mockCalls = client.mock.calls.splice(-1);
+ const realClient = mockCalls[0][0].client as Keyv;
+ const realOnError = realClient.on as jest.Mock;
+ const realHandler = realOnError.mock.calls.splice(-1)[0][1];
+
+ // Invoke the actual error handler.
+ const expectedError = new Error('some error');
+ realHandler(expectedError);
+ expect(mockLogger.error).toHaveBeenCalledWith(expectedError);
+ });
+
+ it('calls provided handler', () => {
+ // Set up and inject mock logger.
+ const mockHandler = jest.fn();
+ const manager = CacheManager.fromConfig(defaultConfig(), {
+ onError: mockHandler,
+ });
+
+ // Set up a cache client using the configured manager.
+ manager.forPlugin('error-handler-test').getClient();
+
+ // Retrieve the error handler attached to the cache client.
+ const client = DefaultCacheClient as jest.Mock;
+ const mockCalls = client.mock.calls.splice(-1);
+ const realClient = mockCalls[0][0].client as Keyv;
+ const realOnError = realClient.on as jest.Mock;
+ const realHandler = realOnError.mock.calls.splice(-1)[0][1];
+
+ // Invoke the actual error handler.
+ const expectedError = new Error('some error');
+ realHandler(expectedError);
+ expect(mockHandler).toHaveBeenCalledWith(expectedError);
+ });
+ });
});
diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts
index fbd7e3ba0a..e6699e628e 100644
--- a/packages/backend-common/src/cache/CacheManager.ts
+++ b/packages/backend-common/src/cache/CacheManager.ts
@@ -18,9 +18,15 @@ import { Config } from '@backstage/config';
import Keyv from 'keyv';
// @ts-expect-error
import KeyvMemcache from 'keyv-memcache';
+import { Logger } from 'winston';
+import { getRootLogger } from '../logging';
import { DefaultCacheClient, CacheClient } from './CacheClient';
import { NoStore } from './NoStore';
-import { PluginCacheManager } from './types';
+import {
+ CacheManagerOptions,
+ OptionalOnError,
+ PluginCacheManager,
+} from './types';
/**
* Implements a Cache Manager which will automatically create new cache clients
@@ -38,8 +44,10 @@ export class CacheManager {
none: this.getNoneClient,
};
+ private readonly logger: Logger;
private readonly store: keyof CacheManager['storeFactories'];
private readonly connection: string;
+ private readonly errorHandler: OptionalOnError;
/**
* Creates a new CacheManager instance by reading from the `backend` config
@@ -47,25 +55,38 @@ export class CacheManager {
*
* @param config The loaded application configuration.
*/
- static fromConfig(config: Config): CacheManager {
+ static fromConfig(
+ config: Config,
+ options: CacheManagerOptions = {},
+ ): CacheManager {
// If no `backend.cache` config is provided, instantiate the CacheManager
// with a "NoStore" cache client.
const store = config.getOptionalString('backend.cache.store') || 'none';
const connectionString =
config.getOptionalString('backend.cache.connection') || '';
- return new CacheManager(store, connectionString);
+ const logger = (options.logger || getRootLogger()).child({
+ type: 'cacheManager',
+ });
+ return new CacheManager(store, connectionString, logger, options.onError);
}
- private constructor(store: string, connectionString: string) {
+ private constructor(
+ store: string,
+ connectionString: string,
+ logger: Logger,
+ errorHandler: OptionalOnError,
+ ) {
if (!this.storeFactories.hasOwnProperty(store)) {
throw new Error(`Unknown cache store: ${store}`);
}
+ this.logger = logger;
this.store = store as keyof CacheManager['storeFactories'];
this.connection = connectionString;
+ this.errorHandler = errorHandler;
}
/**
- * Generates a CacheManagerInstance for consumption by plugins.
+ * Generates a PluginCacheManager for consumption by plugins.
*
* @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique.
*/
@@ -73,6 +94,18 @@ export class CacheManager {
return {
getClient: (opts = {}): CacheClient => {
const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl);
+
+ // Always provide an error handler to avoid killing the process.
+ concreteClient.on('error', (err: Error) => {
+ // In all cases, just log the error.
+ this.logger.error(err);
+
+ // Invoke any custom error handler if provided.
+ if (typeof this.errorHandler === 'function') {
+ this.errorHandler(err);
+ }
+ });
+
return new DefaultCacheClient({
client: concreteClient,
});
diff --git a/packages/backend-common/src/cache/types.ts b/packages/backend-common/src/cache/types.ts
index 4dd3cf7d1b..30db53d420 100644
--- a/packages/backend-common/src/cache/types.ts
+++ b/packages/backend-common/src/cache/types.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { Logger } from 'winston';
import { CacheClient } from './CacheClient';
type ClientOptions = {
@@ -25,6 +26,21 @@ type ClientOptions = {
defaultTtl?: number;
};
+export type OptionalOnError = ((err: Error) => void) | undefined;
+
+export type CacheManagerOptions = {
+ /**
+ * An optional logger for use by the PluginCacheManager.
+ */
+ logger?: Logger;
+
+ /**
+ * An optional handler for connection errors emitted from the underlying data
+ * store.
+ */
+ onError?: OptionalOnError;
+};
+
/**
* The PluginCacheManager manages access to cache stores that Plugins get.
*/
diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts
index bb76181287..c4a0fe0466 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.test.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts
@@ -29,11 +29,11 @@ import path from 'path';
import { NotModifiedError } from '@backstage/errors';
import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
const logger = getVoidLogger();
-const treeResponseFactory = ReadTreeResponseFactory.create({
+const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts
index 16e7628ec8..e2b230a05f 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.ts
@@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
-import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
+ ReadTreeResponseFactory,
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index aa78bf27d5..e06122e04b 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -28,9 +28,9 @@ import os from 'os';
import path from 'path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
-const treeResponseFactory = ReadTreeResponseFactory.create({
+const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts
index f102be7283..251743cb25 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts
@@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
-import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
+ ReadTreeResponseFactory,
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts
index 8dc4aba29b..2363a16d1a 100644
--- a/packages/backend-common/src/reading/FetchUrlReader.test.ts
+++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts
@@ -19,7 +19,7 @@ import { msw } from '@backstage/test-utils';
import { setupServer } from 'msw/node';
import { getVoidLogger } from '../logging';
import { FetchUrlReader } from './FetchUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
describe('FetchUrlReader', () => {
const worker = setupServer();
@@ -45,7 +45,7 @@ describe('FetchUrlReader', () => {
},
}),
logger: getVoidLogger(),
- treeResponseFactory: ReadTreeResponseFactory.create({
+ treeResponseFactory: DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
}),
});
diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts
index 67e7f27069..cbe61f2e66 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts
@@ -35,9 +35,9 @@ import {
GhTreeResponse,
GithubUrlReader,
} from './GithubUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
-const treeResponseFactory = ReadTreeResponseFactory.create({
+const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts
index 77e33cbd54..2b1247fe77 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.ts
@@ -26,8 +26,8 @@ import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
-import { ReadTreeResponseFactory } from './tree';
import {
+ ReadTreeResponseFactory,
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
index 9dcc92a4a6..b5592c09f5 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -24,7 +24,7 @@ import os from 'os';
import path from 'path';
import { getVoidLogger } from '../logging';
import { GitlabUrlReader } from './GitlabUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
import { NotModifiedError, NotFoundError } from '@backstage/errors';
import {
GitLabIntegration,
@@ -33,7 +33,7 @@ import {
const logger = getVoidLogger();
-const treeResponseFactory = ReadTreeResponseFactory.create({
+const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
@@ -190,11 +190,17 @@ describe('GitlabUrlReader', () => {
default_branch: 'main',
};
- const branchGitlabApiResponse = {
- commit: {
+ const commitsGitlabApiResponse = [
+ {
id: 'sha123abc',
},
- };
+ ];
+
+ const specificPathCommitsGitlabApiResponse = [
+ {
+ id: 'sha456def',
+ },
+ ];
beforeEach(() => {
worker.use(
@@ -221,17 +227,29 @@ describe('GitlabUrlReader', () => {
),
),
rest.get(
- 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
- (_, res, ctx) =>
- res(
- ctx.status(200),
- ctx.set('Content-Type', 'application/json'),
- ctx.json(branchGitlabApiResponse),
- ),
- ),
- rest.get(
- 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist',
- (_, res, ctx) => res(ctx.status(404)),
+ 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits',
+ (req, res, ctx) => {
+ const refName = req.url.searchParams.get('ref_name');
+ if (refName === 'main') {
+ const filepath = req.url.searchParams.get('path');
+ if (filepath === 'testFilepath') {
+ return res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/json'),
+ ctx.json(specificPathCommitsGitlabApiResponse),
+ );
+ }
+ return res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/json'),
+ ctx.json(commitsGitlabApiResponse),
+ );
+ }
+ if (refName === 'branchDoesNotExist') {
+ return res(ctx.status(404));
+ }
+ return res();
+ },
),
rest.get(
'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock',
@@ -243,13 +261,26 @@ describe('GitlabUrlReader', () => {
),
),
rest.get(
- 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
- (_, res, ctx) =>
- res(
- ctx.status(200),
- ctx.set('Content-Type', 'application/json'),
- ctx.json(branchGitlabApiResponse),
- ),
+ 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/commits',
+ (req, res, ctx) => {
+ const refName = req.url.searchParams.get('ref_name');
+ if (refName === 'main') {
+ const filepath = req.url.searchParams.get('path');
+ if (filepath === 'testFilepath') {
+ return res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/json'),
+ ctx.json(specificPathCommitsGitlabApiResponse),
+ );
+ }
+ return res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/json'),
+ ctx.json(commitsGitlabApiResponse),
+ );
+ }
+ return res();
+ },
),
rest.get(
'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main',
@@ -351,7 +382,7 @@ describe('GitlabUrlReader', () => {
).resolves.toBe('# Test\n');
});
- it('throws a NotModifiedError when given a etag in options', async () => {
+ it('throws a NotModifiedError when given a etag in options matching last commit', async () => {
const fnGitlab = async () => {
await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', {
etag: 'sha123abc',
@@ -371,6 +402,29 @@ describe('GitlabUrlReader', () => {
await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError);
});
+ it('throws a NotModifiedError when given a etag in options matching last commit affecting specified filepath', async () => {
+ const fnGitlab = async () => {
+ await gitlabProcessor.readTree(
+ 'https://gitlab.com/backstage/mock/blob/main/testFilepath',
+ {
+ etag: 'sha456def',
+ },
+ );
+ };
+
+ const fnHostedGitlab = async () => {
+ await hostedGitlabProcessor.readTree(
+ 'https://gitlab.mycompany.com/backstage/mock/blob/main/testFilepath',
+ {
+ etag: 'sha456def',
+ },
+ );
+ };
+
+ await expect(fnGitlab).rejects.toThrow(NotModifiedError);
+ await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError);
+ });
+
it('should not throw error when given an outdated etag in options', async () => {
const response = await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/main',
@@ -389,12 +443,12 @@ describe('GitlabUrlReader', () => {
});
it('should throw error on missing branch', async () => {
- const fnGithub = async () => {
+ const fnGitlab = async () => {
await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/branchDoesNotExist',
);
};
- await expect(fnGithub).rejects.toThrow(NotFoundError);
+ await expect(fnGitlab).rejects.toThrow(NotFoundError);
});
});
@@ -408,11 +462,11 @@ describe('GitlabUrlReader', () => {
default_branch: 'main',
};
- const branchGitlabApiResponse = {
- commit: {
+ const commitsGitlabApiResponse = [
+ {
id: 'sha123abc',
},
- };
+ ];
beforeEach(() => {
worker.use(
@@ -439,13 +493,18 @@ describe('GitlabUrlReader', () => {
),
),
rest.get(
- 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
- (_, res, ctx) =>
- res(
- ctx.status(200),
- ctx.set('Content-Type', 'application/json'),
- ctx.json(branchGitlabApiResponse),
- ),
+ 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits',
+ (req, res, ctx) => {
+ const refName = req.url.searchParams.get('ref_name');
+ if (refName === 'main') {
+ return res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/json'),
+ ctx.json(commitsGitlabApiResponse),
+ );
+ }
+ return res();
+ },
),
);
});
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts
index c93103b2da..635565c8a0 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.ts
@@ -25,9 +25,9 @@ import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
-import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
+ ReadTreeResponseFactory,
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
@@ -106,25 +106,30 @@ export class GitlabUrlReader implements UrlReader {
// ref is an empty string if no branch is set in provided url to readTree.
const branch = ref || projectGitlabResponseJson.default_branch;
- // Fetch the latest commit in the provided or default branch to compare against
- // the provided sha.
- const branchGitlabResponse = await fetch(
+ // Fetch the latest commit that modifies the the filepath in the provided or default branch
+ // to compare against the provided sha.
+ const commitsReqParams = new URLSearchParams();
+ commitsReqParams.set('ref_name', branch);
+ if (!!filepath) {
+ commitsReqParams.set('path', filepath);
+ }
+ const commitsGitlabResponse = await fetch(
new URL(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
- )}/repository/branches/${branch}`,
+ )}/repository/commits?${commitsReqParams.toString()}`,
).toString(),
getGitLabRequestOptions(this.integration.config),
);
- if (!branchGitlabResponse.ok) {
- const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`;
- if (branchGitlabResponse.status === 404) {
+ if (!commitsGitlabResponse.ok) {
+ const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`;
+ if (commitsGitlabResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
- const commitSha = (await branchGitlabResponse.json()).commit.id;
+ const commitSha = (await commitsGitlabResponse.json())[0].id;
if (options?.etag && options.etag === commitSha) {
throw new NotModifiedError();
diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts
index e201361a91..5d4b01da44 100644
--- a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts
@@ -16,7 +16,7 @@
import { ConfigReader, JsonObject } from '@backstage/config';
import { getVoidLogger } from '../logging';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
import { GoogleGcsUrlReader } from './GoogleGcsUrlReader';
import { UrlReaderPredicateTuple } from './types';
@@ -25,7 +25,7 @@ describe('GcsUrlReader', () => {
return GoogleGcsUrlReader.factory({
config: new ConfigReader(config),
logger: getVoidLogger(),
- treeResponseFactory: ReadTreeResponseFactory.create({
+ treeResponseFactory: DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
}),
});
diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts
index 06c6fdcdcf..8f27d058df 100644
--- a/packages/backend-common/src/reading/UrlReaders.ts
+++ b/packages/backend-common/src/reading/UrlReaders.ts
@@ -22,7 +22,7 @@ import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
-import { ReadTreeResponseFactory } from './tree';
+import { DefaultReadTreeResponseFactory } from './tree';
import { FetchUrlReader } from './FetchUrlReader';
import { GoogleGcsUrlReader } from './GoogleGcsUrlReader';
@@ -44,7 +44,9 @@ export class UrlReaders {
*/
static create({ logger, config, factories }: CreateOptions): UrlReader {
const mux = new UrlReaderPredicateMux();
- const treeResponseFactory = ReadTreeResponseFactory.create({ config });
+ const treeResponseFactory = DefaultReadTreeResponseFactory.create({
+ config,
+ });
for (const factory of factories ?? []) {
const tuples = factory({ config, logger: logger, treeResponseFactory });
diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
index 05ecdb0fc3..1ce7555a30 100644
--- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
+++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
@@ -15,27 +15,18 @@
*/
import os from 'os';
-import { Readable } from 'stream';
import { Config } from '@backstage/config';
-import { ReadTreeResponse } from '../types';
+import {
+ ReadTreeResponse,
+ FromArchiveOptions,
+ ReadTreeResponseFactory,
+} from '../types';
import { TarArchiveResponse } from './TarArchiveResponse';
import { ZipArchiveResponse } from './ZipArchiveResponse';
-type FromArchiveOptions = {
- // A binary stream of a tar archive.
- stream: Readable;
- // If unset, the files at the root of the tree will be read.
- // subpath must not contain the name of the top level directory.
- subpath?: string;
- // etag of the blob
- etag: string;
- // Filter passed on from the ReadTreeOptions
- filter?: (path: string) => boolean;
-};
-
-export class ReadTreeResponseFactory {
- static create(options: { config: Config }): ReadTreeResponseFactory {
- return new ReadTreeResponseFactory(
+export class DefaultReadTreeResponseFactory implements ReadTreeResponseFactory {
+ static create(options: { config: Config }): DefaultReadTreeResponseFactory {
+ return new DefaultReadTreeResponseFactory(
options.config.getOptionalString('backend.workingDirectory') ??
os.tmpdir(),
);
diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts
index 858cf15877..3126907c1e 100644
--- a/packages/backend-common/src/reading/tree/index.ts
+++ b/packages/backend-common/src/reading/tree/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { ReadTreeResponseFactory } from './ReadTreeResponseFactory';
+export { DefaultReadTreeResponseFactory } from './ReadTreeResponseFactory';
diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts
index 4caa76841a..7ba806197d 100644
--- a/packages/backend-common/src/reading/types.ts
+++ b/packages/backend-common/src/reading/types.ts
@@ -14,9 +14,9 @@
* limitations under the License.
*/
+import { Readable } from 'stream';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
-import { ReadTreeResponseFactory } from './tree';
/**
* A generic interface for fetching plain data from URLs.
@@ -105,6 +105,23 @@ export type ReadTreeResponseFile = {
content(): Promise;
};
+export type FromArchiveOptions = {
+ // A binary stream of a tar archive.
+ stream: Readable;
+ // If unset, the files at the root of the tree will be read.
+ // subpath must not contain the name of the top level directory.
+ subpath?: string;
+ // etag of the blob
+ etag: string;
+ // Filter passed on from the ReadTreeOptions
+ filter?: (path: string) => boolean;
+};
+
+export interface ReadTreeResponseFactory {
+ fromTarArchive(options: FromArchiveOptions): Promise;
+ fromZipArchive(options: FromArchiveOptions): Promise;
+}
+
/**
* An options object for search operations.
*/
diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
index c17545779a..45eba03157 100644
--- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
@@ -55,7 +55,6 @@ const DEFAULT_CSP = {
'script-src': ["'self'", "'unsafe-eval'"],
'script-src-attr': ["'none'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
- 'upgrade-insecure-requests': [] as string[],
};
export class ServiceBuilderImpl implements ServiceBuilder {
diff --git a/packages/backend-test-utils/.eslintrc.js b/packages/backend-test-utils/.eslintrc.js
new file mode 100644
index 0000000000..16a033dbc6
--- /dev/null
+++ b/packages/backend-test-utils/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
+};
diff --git a/packages/backend-test-utils/README.md b/packages/backend-test-utils/README.md
new file mode 100644
index 0000000000..38756fa5d7
--- /dev/null
+++ b/packages/backend-test-utils/README.md
@@ -0,0 +1,18 @@
+# @backstage/backend-test-utils
+
+Test helpers library for Backstage backends.
+
+## Usage
+
+Add the library as a `devDependency` to your backend package:
+
+```sh
+# From the Backstage root directory, go to your backend package, or to a backend plugin
+cd plugins/my-plugin-backend
+yarn add --dev @backstage/backend-test-utils
+```
+
+## Documentation
+
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md
new file mode 100644
index 0000000000..28d4a00463
--- /dev/null
+++ b/packages/backend-test-utils/api-report.md
@@ -0,0 +1,31 @@
+## API Report File for "@backstage/backend-test-utils"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { Knex } from 'knex';
+
+// @public (undocumented)
+export function isDockerDisabledForTests(): boolean;
+
+// @public
+export type TestDatabaseId = 'POSTGRES_13' | 'POSTGRES_9' | 'MYSQL_8' | 'SQLITE_3';
+
+// @public
+export class TestDatabases {
+ static create(options?: {
+ ids?: TestDatabaseId[];
+ disableDocker?: boolean;
+ }): TestDatabases;
+ // (undocumented)
+ eachSupportedId(): [TestDatabaseId][];
+ init(id: TestDatabaseId): Promise;
+ // (undocumented)
+ supports(id: TestDatabaseId): boolean;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json
new file mode 100644
index 0000000000..95276651ca
--- /dev/null
+++ b/packages/backend-test-utils/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@backstage/backend-test-utils",
+ "description": "Test helpers library for Backstage backends",
+ "version": "0.1.1",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "packages/backend-test-utils"
+ },
+ "keywords": [
+ "backstage",
+ "test"
+ ],
+ "license": "Apache-2.0",
+ "scripts": {
+ "build": "backstage-cli build --outputs cjs,types",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "^0.8.0",
+ "@backstage/cli": "^0.6.10",
+ "@backstage/config": "^0.1.5",
+ "knex": "^0.95.1",
+ "mysql2": "^2.2.5",
+ "pg": "^8.3.0",
+ "sqlite3": "^5.0.0",
+ "testcontainers": "^7.10.0",
+ "uuid": "^8.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.6.10",
+ "jest": "^26.0.1"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/backend-test-utils/src/database/TestDatabases.test.ts b/packages/backend-test-utils/src/database/TestDatabases.test.ts
new file mode 100644
index 0000000000..7c1e6e1bdd
--- /dev/null
+++ b/packages/backend-test-utils/src/database/TestDatabases.test.ts
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2021 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 knexFactory from 'knex';
+import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
+import { startMysqlContainer } from './startMysqlContainer';
+import { startPostgresContainer } from './startPostgresContainer';
+import { TestDatabases } from './TestDatabases';
+
+const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
+
+describe('TestDatabases', () => {
+ const OLD_ENV = process.env;
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...OLD_ENV };
+ });
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('each', () => {
+ const dbs = TestDatabases.create();
+
+ it.each(dbs.eachSupportedId())(
+ 'creates distinct %p databases',
+ async databaseId => {
+ if (!dbs.supports(databaseId)) {
+ return;
+ }
+ const db1 = await dbs.init(databaseId);
+ const db2 = await dbs.init(databaseId);
+ await db1.schema.createTable('a', table => table.string('x').primary());
+ await db2.schema.createTable('a', table => table.string('y').primary());
+ await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([
+ { a: 1 },
+ ]);
+ },
+ 60_000,
+ );
+ });
+
+ itIfDocker(
+ 'obeys a provided connection string for postgres 13',
+ async () => {
+ const dbs = TestDatabases.create();
+ const { host, port, user, password, stop } = await startPostgresContainer(
+ 'postgres:13',
+ );
+
+ try {
+ // Leave a mark
+ process.env.BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
+ const input = await dbs.init('POSTGRES_13');
+ await input.schema.createTable('a', table =>
+ table.string('x').primary(),
+ );
+ await input.insert({ x: 'y' }).into('a');
+
+ // Look for the mark
+ const database = 'backstage_plugin_0';
+ const output = knexFactory({
+ client: 'pg',
+ connection: { host, port, user, password, database },
+ });
+ // eslint-disable-next-line jest/no-standalone-expect
+ await expect(output.select('x').from('a')).resolves.toEqual([
+ { x: 'y' },
+ ]);
+ } finally {
+ await stop();
+ }
+ },
+ 60_000,
+ );
+
+ itIfDocker(
+ 'obeys a provided connection string for postgres 9',
+ async () => {
+ const dbs = TestDatabases.create();
+ const { host, port, user, password, stop } = await startPostgresContainer(
+ 'postgres:9',
+ );
+
+ try {
+ // Leave a mark
+ process.env.BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
+ const input = await dbs.init('POSTGRES_9');
+ await input.schema.createTable('a', table =>
+ table.string('x').primary(),
+ );
+ await input.insert({ x: 'y' }).into('a');
+
+ // Look for the mark
+ const database = 'backstage_plugin_0';
+ const output = knexFactory({
+ client: 'pg',
+ connection: { host, port, user, password, database },
+ });
+ // eslint-disable-next-line jest/no-standalone-expect
+ await expect(output.select('x').from('a')).resolves.toEqual([
+ { x: 'y' },
+ ]);
+ } finally {
+ await stop();
+ }
+ },
+ 60_000,
+ );
+
+ itIfDocker(
+ 'obeys a provided connection string for mysql 8',
+ async () => {
+ const dbs = TestDatabases.create();
+ const { host, port, user, password, stop } = await startMysqlContainer(
+ 'mysql:8',
+ );
+
+ try {
+ // Leave a mark
+ process.env.BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING = `mysql://${user}:${password}@${host}:${port}/ignored`;
+ const input = await dbs.init('MYSQL_8');
+ await input.schema.createTable('a', table =>
+ table.string('x').primary(),
+ );
+ await input.insert({ x: 'y' }).into('a');
+
+ // Look for the mark
+ const database = 'backstage_plugin_0';
+ const output = knexFactory({
+ client: 'mysql2',
+ connection: { host, port, user, password, database },
+ });
+ // eslint-disable-next-line jest/no-standalone-expect
+ await expect(output.select('x').from('a')).resolves.toEqual([
+ { x: 'y' },
+ ]);
+ } finally {
+ await stop();
+ }
+ },
+ 60_000,
+ );
+});
diff --git a/packages/backend-test-utils/src/database/TestDatabases.ts b/packages/backend-test-utils/src/database/TestDatabases.ts
new file mode 100644
index 0000000000..a7b08cd593
--- /dev/null
+++ b/packages/backend-test-utils/src/database/TestDatabases.ts
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2021 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 { SingleConnectionDatabaseManager } from '@backstage/backend-common';
+import { ConfigReader } from '@backstage/config';
+import { Knex } from 'knex';
+import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
+import { startMysqlContainer } from './startMysqlContainer';
+import { startPostgresContainer } from './startPostgresContainer';
+import {
+ allDatabases,
+ Instance,
+ TestDatabaseId,
+ TestDatabaseProperties,
+} from './types';
+
+/**
+ * Encapsulates the creation of ephemeral test database instances for use
+ * inside unit or integration tests.
+ */
+export class TestDatabases {
+ private readonly instanceById: Map;
+ private readonly supportedIds: TestDatabaseId[];
+ private lastDatabaseIndex: number;
+
+ /**
+ * Creates an empty `TestDatabases` instance, and sets up Jest to clean up
+ * all of its acquired resources after all tests finish.
+ *
+ * You typically want to create just a single instance like this at the top
+ * of your test file or `describe` block, and then call `init` many times on
+ * that instance inside the individual tests. Spinning up a "physical"
+ * database instance takes a considerable amount of time, slowing down tests.
+ * But initializing a new logical database inside that instance using `init`
+ * is very fast.
+ */
+ static create(options?: {
+ ids?: TestDatabaseId[];
+ disableDocker?: boolean;
+ }): TestDatabases {
+ const defaultOptions = {
+ ids: Object.keys(allDatabases) as TestDatabaseId[],
+ disableDocker: isDockerDisabledForTests(),
+ };
+
+ const { ids, disableDocker } = Object.assign(defaultOptions, options ?? {});
+
+ const supportedIds = ids.filter(id => {
+ const properties = allDatabases[id];
+ if (!properties) {
+ return false;
+ }
+ // If the caller has set up the env with an explicit connection string,
+ // we'll assume that this database will work
+ if (
+ properties.connectionStringEnvironmentVariableName &&
+ process.env[properties.connectionStringEnvironmentVariableName]
+ ) {
+ return true;
+ }
+ // If the database doesn't require docker at all, there's nothing to worry
+ // about
+ if (!properties.dockerImageName) {
+ return true;
+ }
+ // If the database requires docker, but docker is disabled, we will fail.
+ if (disableDocker) {
+ return false;
+ }
+ return true;
+ });
+
+ const databases = new TestDatabases(supportedIds);
+
+ afterAll(async () => {
+ await databases.shutdown();
+ });
+
+ return databases;
+ }
+
+ private constructor(supportedIds: TestDatabaseId[]) {
+ this.instanceById = new Map();
+ this.supportedIds = supportedIds;
+ this.lastDatabaseIndex = 0;
+ }
+
+ supports(id: TestDatabaseId): boolean {
+ return this.supportedIds.includes(id);
+ }
+
+ eachSupportedId(): [TestDatabaseId][] {
+ return this.supportedIds.map(id => [id]);
+ }
+
+ /**
+ * Returns a fresh, unique, empty logical database on an instance of the
+ * given database ID platform.
+ *
+ * @param id The ID of the database platform to use, e.g. 'POSTGRES_13'
+ * @returns A `Knex` connection object
+ */
+ async init(id: TestDatabaseId): Promise {
+ const properties = allDatabases[id];
+ if (!properties) {
+ const candidates = Object.keys(allDatabases).join(', ');
+ throw new Error(
+ `Unknown test database ${id}, possible values are ${candidates}`,
+ );
+ }
+ if (!this.supportedIds.includes(id)) {
+ const candidates = this.supportedIds.join(', ');
+ throw new Error(
+ `Unsupported test database ${id} for this environment, possible values are ${candidates}`,
+ );
+ }
+
+ let instance: Instance | undefined = this.instanceById.get(id);
+
+ // Ensure that a testcontainers instance is up for this ID
+ if (!instance) {
+ instance = await this.initAny(properties);
+ this.instanceById.set(id, instance);
+ }
+
+ // Ensure that a unique logical database is created in the instance
+ const connection = await instance.databaseManager
+ .forPlugin(String(this.lastDatabaseIndex++))
+ .getClient();
+
+ instance.connections.push(connection);
+
+ return connection;
+ }
+
+ private async initAny(properties: TestDatabaseProperties): Promise {
+ // Use the connection string if provided
+ if (properties.driver === 'pg' || properties.driver === 'mysql2') {
+ const envVarName = properties.connectionStringEnvironmentVariableName;
+ if (envVarName) {
+ const connectionString = process.env[envVarName];
+ if (connectionString) {
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: properties.driver,
+ connection: connectionString,
+ },
+ },
+ }),
+ );
+ return {
+ databaseManager,
+ connections: [],
+ };
+ }
+ }
+ }
+
+ // Otherwise start a container for the purpose
+ switch (properties.driver) {
+ case 'pg':
+ return this.initPostgres(properties);
+ case 'mysql2':
+ return this.initMysql(properties);
+ case 'sqlite3':
+ return this.initSqlite(properties);
+ default:
+ throw new Error(`Unknown database driver ${properties.driver}`);
+ }
+ }
+
+ private async initPostgres(
+ properties: TestDatabaseProperties,
+ ): Promise {
+ const { host, port, user, password, stop } = await startPostgresContainer(
+ properties.dockerImageName!,
+ );
+
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'pg',
+ connection: { host, port, user, password },
+ },
+ },
+ }),
+ );
+
+ return {
+ stopContainer: stop,
+ databaseManager,
+ connections: [],
+ };
+ }
+
+ private async initMysql(
+ properties: TestDatabaseProperties,
+ ): Promise {
+ const { host, port, user, password, stop } = await startMysqlContainer(
+ properties.dockerImageName!,
+ );
+
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'mysql2',
+ connection: { host, port, user, password },
+ },
+ },
+ }),
+ );
+
+ return {
+ stopContainer: stop,
+ databaseManager,
+ connections: [],
+ };
+ }
+
+ private async initSqlite(
+ _properties: TestDatabaseProperties,
+ ): Promise {
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'sqlite3',
+ connection: ':memory:',
+ },
+ },
+ }),
+ );
+
+ return {
+ databaseManager,
+ connections: [],
+ };
+ }
+
+ private async shutdown() {
+ const instances = [...this.instanceById.values()];
+ await Promise.all(
+ instances.map(async ({ stopContainer, connections }) => {
+ try {
+ await Promise.all(connections.map(c => c.destroy()));
+ } catch {
+ // ignore
+ }
+ try {
+ await stopContainer?.();
+ } catch {
+ // ignore
+ }
+ }),
+ );
+ }
+}
diff --git a/packages/backend-test-utils/src/database/index.ts b/packages/backend-test-utils/src/database/index.ts
new file mode 100644
index 0000000000..6988f0b80b
--- /dev/null
+++ b/packages/backend-test-utils/src/database/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 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 { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
+export { TestDatabases } from './TestDatabases';
+export type { TestDatabaseId } from './types';
diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.test.ts b/packages/backend-test-utils/src/database/startMysqlContainer.test.ts
new file mode 100644
index 0000000000..e210ccb413
--- /dev/null
+++ b/packages/backend-test-utils/src/database/startMysqlContainer.test.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2021 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 createConnection from 'knex';
+import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
+import { startMysqlContainer } from './startMysqlContainer';
+
+const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
+
+describe('startMysqlContainer', () => {
+ itIfDocker(
+ 'successfully launches the container',
+ async () => {
+ const { stop, ...connection } = await startMysqlContainer('mysql:8');
+ const db = createConnection({ client: 'mysql2', connection });
+ try {
+ const result = await db.select(db.raw('version() AS version'));
+ // eslint-disable-next-line jest/no-standalone-expect
+ expect(result[0]?.version).toContain('8.');
+ } finally {
+ await db.destroy();
+ await stop();
+ }
+ },
+ 60_000,
+ );
+});
diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.ts b/packages/backend-test-utils/src/database/startMysqlContainer.ts
new file mode 100644
index 0000000000..739298dfe5
--- /dev/null
+++ b/packages/backend-test-utils/src/database/startMysqlContainer.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 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 createConnection, { Knex } from 'knex';
+import { GenericContainer } from 'testcontainers';
+import { v4 as uuid } from 'uuid';
+
+async function waitForMysqlReady(
+ connection: Knex.MySqlConnectionConfig,
+): Promise {
+ const startTime = Date.now();
+ const db = createConnection({ client: 'mysql2', connection });
+
+ try {
+ for (;;) {
+ try {
+ const result = await db.select(db.raw('version() AS version'));
+ if (result[0]?.version) {
+ return;
+ }
+ } catch (e) {
+ if (Date.now() - startTime > 30_000) {
+ throw new Error(
+ `Timed out waiting for the database to be ready for connections, ${e}`,
+ );
+ }
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ } finally {
+ db.destroy();
+ }
+}
+
+export async function startMysqlContainer(image: string) {
+ const user = 'root';
+ const password = uuid();
+
+ const container = await new GenericContainer(image)
+ .withExposedPorts(3306)
+ .withEnv('MYSQL_ROOT_PASSWORD', password)
+ .withTmpFs({ '/var/lib/mysql': 'rw' })
+ .start();
+
+ const host = container.getHost();
+ const port = container.getMappedPort(3306);
+ const stop = async () => {
+ await container.stop({ timeout: 10_000 });
+ };
+
+ await waitForMysqlReady({ host, port, user, password });
+
+ return { host, port, user, password, stop };
+}
diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.test.ts b/packages/backend-test-utils/src/database/startPostgresContainer.test.ts
new file mode 100644
index 0000000000..4e8acc8410
--- /dev/null
+++ b/packages/backend-test-utils/src/database/startPostgresContainer.test.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2021 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 createConnection from 'knex';
+import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
+import { startPostgresContainer } from './startPostgresContainer';
+
+const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
+
+describe('startPostgresContainer', () => {
+ itIfDocker(
+ 'successfully launches the container',
+ async () => {
+ const { stop, ...connection } = await startPostgresContainer(
+ 'postgres:13',
+ );
+ const db = createConnection({ client: 'pg', connection });
+ try {
+ const result = await db.select(db.raw('version()'));
+ // eslint-disable-next-line jest/no-standalone-expect
+ expect(result[0]?.version).toContain('PostgreSQL');
+ } finally {
+ await db.destroy();
+ await stop();
+ }
+ },
+ 60_000,
+ );
+});
diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.ts b/packages/backend-test-utils/src/database/startPostgresContainer.ts
new file mode 100644
index 0000000000..1c2ecdcb60
--- /dev/null
+++ b/packages/backend-test-utils/src/database/startPostgresContainer.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 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 createConnection, { Knex } from 'knex';
+import { GenericContainer } from 'testcontainers';
+import { v4 as uuid } from 'uuid';
+
+async function waitForPostgresReady(
+ connection: Knex.PgConnectionConfig,
+): Promise {
+ const startTime = Date.now();
+ const db = createConnection({ client: 'pg', connection });
+
+ try {
+ for (;;) {
+ try {
+ const result = await db.select(db.raw('version()'));
+ if (Array.isArray(result) && result[0]?.version) {
+ return;
+ }
+ } catch (e) {
+ if (Date.now() - startTime > 30_000) {
+ throw new Error(
+ `Timed out waiting for the database to be ready for connections, ${e}`,
+ );
+ }
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ } finally {
+ db.destroy();
+ }
+}
+
+export async function startPostgresContainer(image: string) {
+ const user = 'postgres';
+ const password = uuid();
+
+ const container = await new GenericContainer(image)
+ .withExposedPorts(5432)
+ .withEnv('POSTGRES_PASSWORD', password)
+ .withTmpFs({ '/var/lib/postgresql/data': 'rw' })
+ .start();
+
+ const host = container.getHost();
+ const port = container.getMappedPort(5432);
+ const stop = async () => {
+ await container.stop({ timeout: 10_000 });
+ };
+
+ await waitForPostgresReady({ host, port, user, password });
+
+ return { host, port, user, password, stop };
+}
diff --git a/packages/backend-test-utils/src/database/types.ts b/packages/backend-test-utils/src/database/types.ts
new file mode 100644
index 0000000000..791cf09b7b
--- /dev/null
+++ b/packages/backend-test-utils/src/database/types.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2021 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 { SingleConnectionDatabaseManager } from '@backstage/backend-common';
+import { Knex } from 'knex';
+
+/**
+ * The possible databases to test against.
+ */
+export type TestDatabaseId =
+ | 'POSTGRES_13'
+ | 'POSTGRES_9'
+ | 'MYSQL_8'
+ | 'SQLITE_3';
+
+export type TestDatabaseProperties = {
+ name: string;
+ driver: string;
+ dockerImageName?: string;
+ connectionStringEnvironmentVariableName?: string;
+};
+
+export type Instance = {
+ stopContainer?: () => Promise;
+ databaseManager: SingleConnectionDatabaseManager;
+ connections: Array;
+};
+
+export const allDatabases: Record<
+ TestDatabaseId,
+ TestDatabaseProperties
+> = Object.freeze({
+ POSTGRES_13: {
+ name: 'Postgres 13.x',
+ driver: 'pg',
+ dockerImageName: 'postgres:13',
+ connectionStringEnvironmentVariableName:
+ 'BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING',
+ },
+ POSTGRES_9: {
+ name: 'Postgres 9.x',
+ driver: 'pg',
+ dockerImageName: 'postgres:9',
+ connectionStringEnvironmentVariableName:
+ 'BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING',
+ },
+ MYSQL_8: {
+ name: 'MySQL 8.x',
+ driver: 'mysql2',
+ dockerImageName: 'mysql:8',
+ connectionStringEnvironmentVariableName:
+ 'BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING',
+ },
+ SQLITE_3: {
+ name: 'SQLite 3.x',
+ driver: 'sqlite3',
+ },
+});
diff --git a/packages/backend-test-utils/src/index.ts b/packages/backend-test-utils/src/index.ts
new file mode 100644
index 0000000000..ad3e422f44
--- /dev/null
+++ b/packages/backend-test-utils/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './database';
+export * from './util';
diff --git a/packages/backend-test-utils/src/setupTests.ts b/packages/backend-test-utils/src/setupTests.ts
new file mode 100644
index 0000000000..ba33cf996b
--- /dev/null
+++ b/packages/backend-test-utils/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {};
diff --git a/packages/backend-test-utils/src/util/index.ts b/packages/backend-test-utils/src/util/index.ts
new file mode 100644
index 0000000000..e4e0e97ac1
--- /dev/null
+++ b/packages/backend-test-utils/src/util/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 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 { isDockerDisabledForTests } from './isDockerDisabledForTests';
diff --git a/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts
new file mode 100644
index 0000000000..60d27bc154
--- /dev/null
+++ b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 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 function isDockerDisabledForTests() {
+ return Boolean(process.env.BACKSTAGE_TEST_DISABLE_DOCKER);
+}
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index 977588b555..4a8fd1b86f 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,26 @@
# example-backend
+## 0.2.30
+
+### Patch Changes
+
+- Updated dependencies [0fd4ea443]
+- Updated dependencies [add62a455]
+- Updated dependencies [260aaa684]
+- Updated dependencies [704875e26]
+ - @backstage/plugin-catalog-backend@0.10.0
+ - @backstage/catalog-client@0.3.12
+ - @backstage/catalog-model@0.8.0
+ - @backstage/plugin-scaffolder-backend@0.11.4
+ - example-app@0.2.30
+ - @backstage/plugin-auth-backend@0.3.12
+ - @backstage/plugin-badges-backend@0.1.6
+ - @backstage/plugin-code-coverage-backend@0.1.6
+ - @backstage/plugin-kafka-backend@0.2.6
+ - @backstage/plugin-kubernetes-backend@0.3.8
+ - @backstage/plugin-techdocs-backend@0.8.2
+ - @backstage/plugin-todo-backend@0.1.6
+
## 0.2.28
### Patch Changes
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 4044a6632f..be843e4d38 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.2.28",
+ "version": "0.2.30",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -28,29 +28,29 @@
},
"dependencies": {
"@backstage/backend-common": "^0.8.0",
- "@backstage/catalog-client": "^0.3.11",
- "@backstage/catalog-model": "^0.7.9",
+ "@backstage/catalog-client": "^0.3.12",
+ "@backstage/catalog-model": "^0.8.0",
"@backstage/config": "^0.1.5",
"@backstage/plugin-app-backend": "^0.3.13",
- "@backstage/plugin-auth-backend": "^0.3.10",
- "@backstage/plugin-badges-backend": "^0.1.4",
- "@backstage/plugin-catalog-backend": "^0.9.0",
- "@backstage/plugin-code-coverage-backend": "^0.1.5",
+ "@backstage/plugin-auth-backend": "^0.3.12",
+ "@backstage/plugin-badges-backend": "^0.1.6",
+ "@backstage/plugin-catalog-backend": "^0.10.0",
+ "@backstage/plugin-code-coverage-backend": "^0.1.6",
"@backstage/plugin-graphql-backend": "^0.1.8",
- "@backstage/plugin-kubernetes-backend": "^0.3.7",
- "@backstage/plugin-kafka-backend": "^0.2.5",
+ "@backstage/plugin-kubernetes-backend": "^0.3.8",
+ "@backstage/plugin-kafka-backend": "^0.2.6",
"@backstage/plugin-proxy-backend": "^0.2.8",
"@backstage/plugin-rollbar-backend": "^0.1.11",
- "@backstage/plugin-scaffolder-backend": "^0.11.1",
+ "@backstage/plugin-scaffolder-backend": "^0.11.4",
"@backstage/plugin-search-backend": "^0.1.5",
"@backstage/plugin-search-backend-node": "^0.1.3",
- "@backstage/plugin-techdocs-backend": "^0.8.1",
- "@backstage/plugin-todo-backend": "^0.1.5",
- "@gitbeaker/node": "^28.0.2",
+ "@backstage/plugin-techdocs-backend": "^0.8.2",
+ "@backstage/plugin-todo-backend": "^0.1.6",
+ "@gitbeaker/node": "^30.2.0",
"@octokit/rest": "^18.5.3",
"azure-devops-node-api": "^10.1.1",
"dockerode": "^3.2.1",
- "example-app": "^0.2.28",
+ "example-app": "^0.2.30",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^0.95.1",
@@ -60,7 +60,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.6.11",
+ "@backstage/cli": "^0.6.13",
"@types/dockerode": "^3.2.1",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5"
diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts
index 1691f53485..6688b9c158 100644
--- a/packages/backend/src/plugins/search.ts
+++ b/packages/backend/src/plugins/search.ts
@@ -30,7 +30,6 @@ export default async function createPlugin({
const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
- type: 'software-catalog',
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
});
diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md
index e5eb166ec2..e1ef7b759b 100644
--- a/packages/catalog-client/CHANGELOG.md
+++ b/packages/catalog-client/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/catalog-client
+## 0.3.12
+
+### Patch Changes
+
+- add62a455: Foundation for standard entity status values
+- Updated dependencies [add62a455]
+- Updated dependencies [704875e26]
+ - @backstage/catalog-model@0.8.0
+
## 0.3.11
### Patch Changes
diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md
index 4b489ff2e4..1f911c84e9 100644
--- a/packages/catalog-client/api-report.md
+++ b/packages/catalog-client/api-report.md
@@ -76,6 +76,9 @@ export type CatalogListResponse = {
items: T[];
};
+// @public
+export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = "backstage.io/catalog-processing";
+
// (No @packageDocumentation comment for this package)
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index f9638dee0d..0cf8d7d1b8 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-client",
- "version": "0.3.11",
+ "version": "0.3.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,13 +29,13 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/catalog-model": "^0.7.8",
+ "@backstage/catalog-model": "^0.8.0",
"@backstage/config": "^0.1.5",
"@backstage/errors": "^0.1.1",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
- "@backstage/cli": "^0.6.10",
+ "@backstage/cli": "^0.6.13",
"@types/jest": "^26.0.7",
"msw": "^0.21.2"
},
diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts
index 359e3a2c60..98b378f5b0 100644
--- a/packages/catalog-client/src/CatalogClient.test.ts
+++ b/packages/catalog-client/src/CatalogClient.test.ts
@@ -18,7 +18,8 @@ import { Entity } from '@backstage/catalog-model';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
-import { CatalogListResponse, DiscoveryApi } from './types';
+import { CatalogListResponse } from './types/api';
+import { DiscoveryApi } from './types/discovery';
const server = setupServer();
const token = 'fake-token';
diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts
index 063db5a6a3..141929f3da 100644
--- a/packages/catalog-client/src/CatalogClient.ts
+++ b/packages/catalog-client/src/CatalogClient.ts
@@ -31,8 +31,8 @@ import {
CatalogEntitiesRequest,
CatalogListResponse,
CatalogRequestOptions,
- DiscoveryApi,
-} from './types';
+} from './types/api';
+import { DiscoveryApi } from './types/discovery';
export class CatalogClient implements CatalogApi {
private readonly discoveryApi: DiscoveryApi;
@@ -134,12 +134,6 @@ export class CatalogClient implements CatalogApi {
throw new Error(`Location wasn't added: ${target}`);
}
- // TODO(jhaals): This will throw using the experimental catalog since all discovered entities are deferred.
- if (entities.length === 0) {
- throw new Error(
- `Location was added but has no entities specified yet: ${target}`,
- );
- }
return {
location,
entities,
diff --git a/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts
index c5a626e25b..59c652f9fd 100644
--- a/packages/catalog-client/src/index.ts
+++ b/packages/catalog-client/src/index.ts
@@ -15,10 +15,4 @@
*/
export { CatalogClient } from './CatalogClient';
-export type {
- AddLocationRequest,
- AddLocationResponse,
- CatalogApi,
- CatalogEntitiesRequest,
- CatalogListResponse,
-} from './types';
+export * from './types';
diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types/api.ts
similarity index 91%
rename from packages/catalog-client/src/types.ts
rename to packages/catalog-client/src/types/api.ts
index ef907eafa9..f9ba2e9d8d 100644
--- a/packages/catalog-client/src/types.ts
+++ b/packages/catalog-client/src/types/api.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 Spotify AB
+ * Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -81,10 +81,3 @@ export type AddLocationResponse = {
location: Location;
entities: Entity[];
};
-
-/**
- * This is a copy of the core DiscoveryApi, to avoid importing core.
- */
-export type DiscoveryApi = {
- getBaseUrl(pluginId: string): Promise;
-};
diff --git a/packages/catalog-client/src/types/discovery.ts b/packages/catalog-client/src/types/discovery.ts
new file mode 100644
index 0000000000..90eb748b2f
--- /dev/null
+++ b/packages/catalog-client/src/types/discovery.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2021 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.
+ */
+
+/**
+ * This is a copy of the core DiscoveryApi, to avoid importing core.
+ */
+export type DiscoveryApi = {
+ getBaseUrl(pluginId: string): Promise;
+};
diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts
new file mode 100644
index 0000000000..c1670659ec
--- /dev/null
+++ b/packages/catalog-client/src/types/index.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type {
+ AddLocationRequest,
+ AddLocationResponse,
+ CatalogApi,
+ CatalogEntitiesRequest,
+ CatalogListResponse,
+} from './api';
+export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
diff --git a/packages/catalog-client/src/types/status.ts b/packages/catalog-client/src/types/status.ts
new file mode 100644
index 0000000000..7990c4b121
--- /dev/null
+++ b/packages/catalog-client/src/types/status.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The entity `status.items[].type` for the status of the processing engine in
+ * regards to an entity.
+ */
+export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE =
+ 'backstage.io/catalog-processing';
diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md
index 312a329649..94b4662a97 100644
--- a/packages/catalog-model/CHANGELOG.md
+++ b/packages/catalog-model/CHANGELOG.md
@@ -1,5 +1,40 @@
# @backstage/catalog-model
+## 0.8.1
+
+### Patch Changes
+
+- ebe802bc4: Remove the explicit connection from `EntityEnvelope` and `Entity`.
+
+## 0.8.0
+
+### Minor Changes
+
+- 704875e26: Breaking changes:
+
+ - The long-deprecated `schemaValidator` export is finally removed.
+
+ Additions:
+
+ - The `EntityEnvelope` type, which is a supertype of `Entity`.
+ - The `entityEnvelopeSchemaValidator` function, which returns a validator for an `EntityEnvelope` or its subtypes, based on a JSON schema.
+ - The `entitySchemaValidator` function, which returns a validator for an `Entity` or its subtypes, based on a JSON schema.
+ - The `entityKindSchemaValidator` function, which returns a specialized validator for custom `Entity` kinds, based on a JSON schema.
+
+### Patch Changes
+
+- add62a455: Foundation for standard entity status values
+
+## 0.7.10
+
+### Patch Changes
+
+- f7f7783a3: Add Owner field in template card and new data distribution
+ Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema
+ Add relations ownedBy and ownerOf into Template entity
+ Template documentation updated
+- 68fdbf014: Add the `status` field to the Entity envelope
+
## 0.7.9
### Patch Changes
diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md
index 00eaf8410e..80da310ef1 100644
--- a/packages/catalog-model/api-report.md
+++ b/packages/catalog-model/api-report.md
@@ -7,6 +7,7 @@
import { JsonObject } from '@backstage/config';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/config';
+import { SerializedError } from '@backstage/errors';
import * as yup from 'yup';
// @public (undocumented)
@@ -17,9 +18,9 @@ export const analyzeLocationSchema: yup.ObjectSchema<{
// @public (undocumented)
interface ApiEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND;
+ kind: 'API';
// (undocumented)
spec: {
type: string;
@@ -53,9 +54,9 @@ export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName,
// @public (undocumented)
interface ComponentEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_2[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_2;
+ kind: 'Component';
// (undocumented)
spec: {
type: string;
@@ -86,9 +87,9 @@ export class DefaultNamespaceEntityPolicy implements EntityPolicy {
// @public (undocumented)
interface DomainEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_3[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_3;
+ kind: 'Domain';
// (undocumented)
spec: {
owner: string;
@@ -112,7 +113,7 @@ export type Entity = {
metadata: EntityMeta;
spec?: JsonObject;
relations?: EntityRelation[];
- status?: Record;
+ status?: UNSTABLE_EntityStatus;
};
// @public
@@ -121,9 +122,25 @@ export const ENTITY_DEFAULT_NAMESPACE = "default";
// @public
export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"];
+// @public
+export type EntityEnvelope = {
+ apiVersion: string;
+ kind: string;
+ metadata: {
+ name: string;
+ namespace?: string;
+ };
+};
+
+// @public
+export function entityEnvelopeSchemaValidator(schema?: unknown): (data: unknown) => T;
+
// @public
export function entityHasChanges(previous: Entity, next: Entity): boolean;
+// @public
+export function entityKindSchemaValidator(schema: unknown): (data: unknown) => T | false;
+
// @public
export type EntityLink = {
url: string;
@@ -183,6 +200,9 @@ export type EntityRelationSpec = {
target: EntityName;
};
+// @public
+export function entitySchemaValidator(schema?: unknown): (data: unknown) => T;
+
// @public
export class FieldFormatEntityPolicy implements EntityPolicy {
constructor(validators?: Validators);
@@ -211,9 +231,9 @@ export function getEntitySourceLocation(entity: Entity): {
// @public (undocumented)
interface GroupEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_4[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_4;
+ kind: 'Group';
// (undocumented)
spec: {
type: string;
@@ -278,9 +298,9 @@ export const LOCATION_ANNOTATION = "backstage.io/managed-by-location";
// @public (undocumented)
interface LocationEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_5[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_5;
+ kind: 'Location';
// (undocumented)
spec: {
type?: string;
@@ -404,9 +424,9 @@ export const RELATION_PROVIDES_API = "providesApi";
// @public (undocumented)
interface ResourceEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_6[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_6;
+ kind: 'Resource';
// (undocumented)
spec: {
type: string;
@@ -423,9 +443,6 @@ export { ResourceEntityV1alpha1 }
// @public (undocumented)
export const resourceEntityV1alpha1Validator: KindValidator;
-// @public @deprecated (undocumented)
-export function schemaValidator(kind: string, apiVersion: readonly string[], schema: yup.Schema): KindValidator;
-
// @public
export class SchemaValidEntityPolicy implements EntityPolicy {
// (undocumented)
@@ -458,9 +475,9 @@ export function stringifyLocationReference(ref: {
// @public (undocumented)
interface SystemEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_7[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_7;
+ kind: 'System';
// (undocumented)
spec: {
owner: string;
@@ -478,15 +495,16 @@ export const systemEntityV1alpha1Validator: KindValidator;
// @public (undocumented)
interface TemplateEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_8[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_8;
+ kind: 'Template';
// (undocumented)
spec: {
type: string;
templater: string;
path?: string;
schema: JSONSchema;
+ owner?: string;
};
}
@@ -500,9 +518,9 @@ export const templateEntityV1alpha1Validator: KindValidator;
// @public (undocumented)
export interface TemplateEntityV1beta2 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_9[number];
+ apiVersion: 'backstage.io/v1beta2';
// (undocumented)
- kind: typeof KIND_9;
+ kind: 'Template';
// (undocumented)
metadata: EntityMeta & {
title?: string;
@@ -520,18 +538,35 @@ export interface TemplateEntityV1beta2 extends Entity {
output?: {
[name: string]: string;
};
+ owner?: string;
};
}
// @public (undocumented)
export const templateEntityV1beta2Validator: KindValidator;
+// @alpha
+export type UNSTABLE_EntityStatus = {
+ items?: UNSTABLE_EntityStatusItem[];
+};
+
+// @alpha
+export type UNSTABLE_EntityStatusItem = {
+ type: string;
+ level: UNSTABLE_EntityStatusLevel;
+ message: string;
+ error?: SerializedError;
+};
+
+// @alpha
+export type UNSTABLE_EntityStatusLevel = 'info' | 'warning' | 'error';
+
// @public (undocumented)
interface UserEntityV1alpha1 extends Entity {
// (undocumented)
- apiVersion: typeof API_VERSION_10[number];
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
- kind: typeof KIND_10;
+ kind: 'User';
// (undocumented)
spec: {
profile?: {
diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
index edd9b8fcf9..88fd33ac0d 100644
--- a/packages/catalog-model/examples/components/artist-lookup-component.yaml
+++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml
@@ -33,3 +33,4 @@ spec:
lifecycle: experimental
owner: team-a
system: artist-engagement-portal
+ dependsOn: ['resource:artists-db']
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index 6b5b096cb6..edcffaa691 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
- "version": "0.7.9",
+ "version": "0.8.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,16 +30,18 @@
},
"dependencies": {
"@backstage/config": "^0.1.5",
+ "@backstage/errors": "^0.1.1",
"@types/json-schema": "^7.0.5",
"@types/yup": "^0.29.8",
"ajv": "^7.0.3",
"json-schema": "^0.3.0",
+ "typescript-json-schema": "^0.49.0",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
"yup": "^0.29.3"
},
"devDependencies": {
- "@backstage/cli": "^0.6.11",
+ "@backstage/cli": "^0.6.14",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts
index 1b5d38dee9..07b3d939aa 100644
--- a/packages/catalog-model/src/entity/Entity.ts
+++ b/packages/catalog-model/src/entity/Entity.ts
@@ -16,9 +16,10 @@
import { JsonObject } from '@backstage/config';
import { EntityName } from '../types';
+import { UNSTABLE_EntityStatus } from './EntityStatus';
/**
- * The format envelope that's common to all versions/kinds of entity.
+ * The parts of the format that's common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
@@ -55,7 +56,7 @@ export type Entity = {
* The keys are implementation defined and the values can be any JSON object
* with semantics that match that implementation.
*/
- status?: Record;
+ status?: UNSTABLE_EntityStatus;
};
/**
diff --git a/packages/catalog-model/src/entity/EntityEnvelope.ts b/packages/catalog-model/src/entity/EntityEnvelope.ts
new file mode 100644
index 0000000000..631a8873c7
--- /dev/null
+++ b/packages/catalog-model/src/entity/EntityEnvelope.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The envelope skeleton parts of an entity - whatever is necessary to be able
+ * to give it a ref and pass to further validation / policy checking.
+ *
+ * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
+ */
+export type EntityEnvelope = {
+ /**
+ * The version of specification format for this particular entity that
+ * this is written against.
+ */
+ apiVersion: string;
+
+ /**
+ * The high level entity type being described.
+ */
+ kind: string;
+
+ /**
+ * Metadata related to the entity.
+ */
+ metadata: {
+ /**
+ * The name of the entity.
+ *
+ * Must be unique within the catalog at any given point in time, for any
+ * given namespace + kind pair.
+ */
+ name: string;
+
+ /**
+ * The namespace that the entity belongs to.
+ */
+ namespace?: string;
+ };
+};
diff --git a/packages/catalog-model/src/entity/EntityStatus.ts b/packages/catalog-model/src/entity/EntityStatus.ts
new file mode 100644
index 0000000000..67090f2bc2
--- /dev/null
+++ b/packages/catalog-model/src/entity/EntityStatus.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2021 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 { SerializedError } from '@backstage/errors';
+
+/**
+ * The current status of the entity, as claimed by various sources.
+ * @alpha
+ */
+export type UNSTABLE_EntityStatus = {
+ /**
+ * Specific status item on a well known format.
+ */
+ items?: UNSTABLE_EntityStatusItem[];
+};
+
+/**
+ * A specific status item on a well known format.
+ * @alpha
+ */
+export type UNSTABLE_EntityStatusItem = {
+ /**
+ * The type of status as a unique key per source.
+ */
+ type: string;
+ /**
+ * The level / severity of the status item. If the level is "error", the
+ * processing of the entity may be entirely blocked. In this case the status
+ * entry may apply to a different, newer version of the data than what is
+ * being returned in the catalog response.
+ */
+ level: UNSTABLE_EntityStatusLevel;
+ /**
+ * A brief message describing the status, intended for human consumption.
+ */
+ message: string;
+ /**
+ * An optional serialized error object related to the status.
+ */
+ error?: SerializedError;
+};
+
+/**
+ * Each entity status item has a level, describing its severity.
+ * @alpha
+ */
+export type UNSTABLE_EntityStatusLevel =
+ | 'info' // Only informative data
+ | 'warning' // Warnings were found
+ | 'error'; // Errors were found
diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts
index 572df63557..ae2c0bf503 100644
--- a/packages/catalog-model/src/entity/index.ts
+++ b/packages/catalog-model/src/entity/index.ts
@@ -15,10 +15,10 @@
*/
export {
+ EDIT_URL_ANNOTATION,
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
VIEW_URL_ANNOTATION,
- EDIT_URL_ANNOTATION,
} from './constants';
export type {
Entity,
@@ -27,6 +27,12 @@ export type {
EntityRelation,
EntityRelationSpec,
} from './Entity';
+export type { EntityEnvelope } from './EntityEnvelope';
+export type {
+ UNSTABLE_EntityStatus,
+ UNSTABLE_EntityStatusItem,
+ UNSTABLE_EntityStatusLevel,
+} from './EntityStatus';
export * from './policies';
export {
compareEntityToRef,
diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
index 5432cafdeb..37d5a4fba0 100644
--- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/API.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'API' as const;
-
export interface ApiEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'API';
spec: {
type: string;
lifecycle: string;
@@ -37,8 +31,5 @@ export interface ApiEntityV1alpha1 extends Entity {
}
export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index 489aa8b1bd..6ef45fda6e 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Component.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Component' as const;
-
export interface ComponentEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Component';
spec: {
type: string;
lifecycle: string;
@@ -40,8 +34,5 @@ export interface ComponentEntityV1alpha1 extends Entity {
}
export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
index 7aab35e367..f23c330a87 100644
--- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
@@ -16,25 +16,16 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Domain.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Domain' as const;
-
export interface DomainEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Domain';
spec: {
owner: string;
};
}
export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index 74ca4f221a..62a6edbc5e 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Group.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Group' as const;
-
export interface GroupEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Group';
spec: {
type: string;
profile?: {
@@ -41,8 +35,5 @@ export interface GroupEntityV1alpha1 extends Entity {
}
export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
index fb452b6ac7..dc79ff1921 100644
--- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Location.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Location' as const;
-
export interface LocationEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Location';
spec: {
type?: string;
target?: string;
@@ -35,8 +29,5 @@ export interface LocationEntityV1alpha1 extends Entity {
}
export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
index fd71500f40..4c79209c9c 100644
--- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Resource.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Resource' as const;
-
export interface ResourceEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Resource';
spec: {
type: string;
owner: string;
@@ -36,8 +30,5 @@ export interface ResourceEntityV1alpha1 extends Entity {
}
export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
index 1ee19466f0..41203083a5 100644
--- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/System.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'System' as const;
-
export interface SystemEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'System';
spec: {
owner: string;
domain?: string;
@@ -34,8 +28,5 @@ export interface SystemEntityV1alpha1 extends Entity {
}
export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
index e3d29aad4f..bfb27b4ed6 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
@@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => {
},
},
},
+ owner: 'team-a@example.com',
},
};
});
@@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => {
(entity as any).spec.templater = '';
await expect(validator.check(entity)).rejects.toThrow(/templater/);
});
+ it('accepts missing owner', async () => {
+ delete (entity as any).spec.owner;
+ await expect(validator.check(entity)).resolves.toBe(true);
+ });
+ it('rejects empty owner', async () => {
+ (entity as any).spec.owner = '';
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
+ });
+ it('rejects wrong type owner', async () => {
+ (entity as any).spec.owner = 5;
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
+ });
});
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
index 72479c7efa..0600c58278 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
@@ -16,29 +16,21 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Template.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import type { JSONSchema } from '../types';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'Template' as const;
-
export interface TemplateEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'Template';
spec: {
type: string;
templater: string;
path?: string;
schema: JSONSchema;
+ owner?: string;
};
}
export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts
index 958a486687..458bdcd0aa 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts
@@ -59,6 +59,7 @@ describe('templateEntityV1beta2Validator', () => {
output: {
fetchUrl: '{{ steps.fetch.output.targetUrl }}',
},
+ owner: 'team-b@example.com',
},
};
});
@@ -121,4 +122,16 @@ describe('templateEntityV1beta2Validator', () => {
delete (entity as any).spec.steps[0].action;
await expect(validator.check(entity)).rejects.toThrow(/action/);
});
+ it('accepts missing owner', async () => {
+ delete (entity as any).spec.owner;
+ await expect(validator.check(entity)).resolves.toBe(true);
+ });
+ it('rejects empty owner', async () => {
+ (entity as any).spec.owner = '';
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
+ });
+ it('rejects wrong type owner', async () => {
+ (entity as any).spec.owner = 5;
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
+ });
});
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts
index 1f4e3bcd90..98b953c9e0 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts
@@ -14,20 +14,14 @@
* limitations under the License.
*/
+import { JsonObject } from '@backstage/config';
import type { Entity, EntityMeta } from '../entity/Entity';
import schema from '../schema/kinds/Template.v1beta2.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-import { JsonObject } from '@backstage/config';
-
-const API_VERSION = ['backstage.io/v1beta2'] as const;
-const KIND = 'Template' as const;
export interface TemplateEntityV1beta2 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1beta2';
+ kind: 'Template';
metadata: EntityMeta & {
title?: string;
};
@@ -41,12 +35,10 @@ export interface TemplateEntityV1beta2 extends Entity {
input?: JsonObject;
}>;
output?: { [name: string]: string };
+ owner?: string;
};
}
export const templateEntityV1beta2Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
index a8700a496e..d73fa7aaf3 100644
--- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
@@ -16,17 +16,11 @@
import type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/User.v1alpha1.schema.json';
-import entitySchema from '../schema/Entity.schema.json';
-import entityMetaSchema from '../schema/EntityMeta.schema.json';
-import commonSchema from '../schema/shared/common.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
-const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
-const KIND = 'User' as const;
-
export interface UserEntityV1alpha1 extends Entity {
- apiVersion: typeof API_VERSION[number];
- kind: typeof KIND;
+ apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
+ kind: 'User';
spec: {
profile?: {
displayName?: string;
@@ -38,8 +32,5 @@ export interface UserEntityV1alpha1 extends Entity {
}
export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
- KIND,
- API_VERSION,
schema,
- [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts
index 4ae2db483a..e36575f51d 100644
--- a/packages/catalog-model/src/kinds/index.ts
+++ b/packages/catalog-model/src/kinds/index.ts
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-export { schemaValidator } from './util';
-export type { KindValidator } from './types';
export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
@@ -59,6 +57,7 @@ export type {
} from './TemplateEntityV1alpha1';
export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2';
export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2';
+export type { KindValidator } from './types';
export { userEntityV1alpha1Validator } from './UserEntityV1alpha1';
export type {
UserEntityV1alpha1 as UserEntity,
diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts
index 1e9e7b7521..a907df7f8a 100644
--- a/packages/catalog-model/src/kinds/util.ts
+++ b/packages/catalog-model/src/kinds/util.ts
@@ -14,66 +14,18 @@
* limitations under the License.
*/
-import Ajv, { AnySchema } from 'ajv';
-import * as yup from 'yup';
+import { entityKindSchemaValidator } from '../validation';
import { KindValidator } from './types';
-/**
- * @deprecated We no longer use yup for the catalog model. This utility method will be removed.
- */
-export function schemaValidator(
- kind: string,
- apiVersion: readonly string[],
- schema: yup.Schema,
-): KindValidator {
+// TODO(freben): Left here as a compatibility helper. It would be nicer to
+// just export the inner validator directly. However, all of the already
+// exported kind validators have the `KindValidator` signature which is
+// different. So let's postpone that change until a later time.
+export function ajvCompiledJsonSchemaValidator(schema: unknown): KindValidator {
+ const validator = entityKindSchemaValidator(schema);
return {
- async check(envelope) {
- if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
- return false;
- }
- await schema.validate(envelope, { strict: true });
- return true;
- },
- };
-}
-
-export function ajvCompiledJsonSchemaValidator(
- kind: string,
- apiVersion: readonly string[],
- schema: AnySchema,
- extraSchemas?: AnySchema[],
-): KindValidator {
- const ajv = new Ajv({ allowUnionTypes: true });
- if (extraSchemas) {
- ajv.addSchema(extraSchemas, undefined, undefined, true);
- }
- const validate = ajv.compile(schema);
-
- return {
- async check(envelope) {
- if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
- return false;
- }
-
- const result = validate(envelope);
- if (result === true) {
- return true;
- }
-
- const [error] = validate.errors || [];
- if (!error) {
- throw new TypeError(`Malformed ${kind}, Unknown error`);
- }
-
- throw new TypeError(
- `Malformed ${kind}, ${error.dataPath || ''} ${error.message}${
- error.params
- ? ` - ${Object.entries(error.params)
- .map(([key, val]) => `${key}: ${val}`)
- .join(', ')}`
- : ''
- }`,
- );
+ async check(data) {
+ return validator(data) === data;
},
};
}
diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json
index e6b004dfd0..90eb9dd5c6 100644
--- a/packages/catalog-model/src/schema/Entity.schema.json
+++ b/packages/catalog-model/src/schema/Entity.schema.json
@@ -1,7 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "Entity",
- "description": "The format envelope that's common to all versions/kinds of entity.",
+ "description": "The parts of the format that's common to all versions/kinds of entity.",
"examples": [
{
"apiVersion": "backstage.io/v1alpha1",
@@ -64,13 +64,7 @@
}
},
"status": {
- "type": "object",
- "description": "The current status of the entity, as claimed by various sources.",
- "patternProperties": {
- "^.+$": {
- "$ref": "common#status"
- }
- }
+ "$ref": "common#status"
}
}
}
diff --git a/packages/catalog-model/src/schema/EntityEnvelope.schema.json b/packages/catalog-model/src/schema/EntityEnvelope.schema.json
new file mode 100644
index 0000000000..7e64039c75
--- /dev/null
+++ b/packages/catalog-model/src/schema/EntityEnvelope.schema.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "EntityEnvelope",
+ "description": "The envelope skeleton parts of an entity - whatever is necessary to be able to give it a ref and pass to further validation / policy checking.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Component",
+ "metadata": {
+ "name": "LoremService"
+ }
+ }
+ ],
+ "type": "object",
+ "required": ["apiVersion", "kind", "metadata"],
+ "additionalProperties": true,
+ "properties": {
+ "apiVersion": {
+ "type": "string",
+ "description": "The version of specification format for this particular entity that this is written against.",
+ "minLength": 1,
+ "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"]
+ },
+ "kind": {
+ "type": "string",
+ "description": "The high level entity type being described.",
+ "minLength": 1,
+ "examples": [
+ "API",
+ "Component",
+ "Domain",
+ "Group",
+ "Location",
+ "Resource",
+ "System",
+ "Template",
+ "User"
+ ]
+ },
+ "metadata": {
+ "type": "object",
+ "required": ["name"],
+ "additionalProperties": true,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.",
+ "examples": ["metadata-proxy"],
+ "minLength": 1
+ },
+ "namespace": {
+ "type": "string",
+ "description": "The namespace that the entity belongs to.",
+ "default": "default",
+ "examples": ["default", "admin"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+}
diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
index 53109ac7ee..5a0c0efa73 100644
--- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
+++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
@@ -85,6 +85,11 @@
"schema": {
"type": "object",
"description": "The JSONSchema describing the inputs for the template."
+ },
+ "owner": {
+ "type": "string",
+ "description": "The user (or group) owner of the template",
+ "minLength": 1
}
}
}
diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
index b88b344626..a3d8e6ba86 100644
--- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
+++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
@@ -168,6 +168,11 @@
"additionalProperties": {
"type": "string"
}
+ },
+ "owner": {
+ "type": "string",
+ "description": "The user (or group) owner of the template",
+ "minLength": 1
}
}
}
diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json
index cb4d82cdb9..a9082d53e4 100644
--- a/packages/catalog-model/src/schema/shared/common.schema.json
+++ b/packages/catalog-model/src/schema/shared/common.schema.json
@@ -13,15 +13,18 @@
"properties": {
"kind": {
"type": "string",
- "description": "The kind field of the entity."
+ "description": "The kind field of the entity.",
+ "minLength": 1
},
"namespace": {
"type": "string",
- "description": "The metadata.namespace field of the entity."
+ "description": "The metadata.namespace field of the entity.",
+ "minLength": 1
},
"name": {
"type": "string",
- "description": "The metadata.name field of the entity."
+ "description": "The metadata.name field of the entity.",
+ "minLength": 1
}
}
},
@@ -46,8 +49,75 @@
"status": {
"$id": "#status",
"type": "object",
- "description": "A specific status of an entity.",
- "additionalProperties": true
+ "description": "The current status of the entity, as claimed by various sources.",
+ "required": [],
+ "additionalProperties": true,
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#statusItem"
+ }
+ }
+ }
+ },
+ "statusItem": {
+ "$id": "#statusItem",
+ "type": "object",
+ "description": "A specific status item on a well known format.",
+ "required": ["type", "level", "message"],
+ "additionalProperties": true,
+ "properties": {
+ "type": {
+ "type": "string",
+ "minLength": 1
+ },
+ "level": {
+ "$ref": "#statusLevel",
+ "description": "The status level / severity of the status item."
+ },
+ "message": {
+ "type": "string",
+ "description": "A brief message describing the status, intended for human consumption."
+ },
+ "error": {
+ "$ref": "#error",
+ "description": "An optional serialized error object related to the status."
+ }
+ }
+ },
+ "statusLevel": {
+ "$id": "#statusLevel",
+ "type": "string",
+ "description": "A status level / severity.",
+ "enum": ["info", "warning", "error"]
+ },
+ "error": {
+ "$id": "#error",
+ "type": "object",
+ "description": "A serialized error object.",
+ "required": ["name", "message"],
+ "additionalProperties": true,
+ "properties": {
+ "name": {
+ "type": "string",
+ "examples": ["Error", "InputError"],
+ "description": "The type name of the error",
+ "minLength": 1
+ },
+ "message": {
+ "type": "string",
+ "description": "The message of the error"
+ },
+ "code": {
+ "type": "string",
+ "description": "An error code associated with the error"
+ },
+ "stack": {
+ "type": "string",
+ "description": "An error stack trace"
+ }
+ }
}
}
}
diff --git a/packages/catalog-model/src/validation/ajv.ts b/packages/catalog-model/src/validation/ajv.ts
new file mode 100644
index 0000000000..02d53fcd15
--- /dev/null
+++ b/packages/catalog-model/src/validation/ajv.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2021 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 Ajv, { Schema, ValidateFunction } from 'ajv';
+import entitySchema from '../schema/Entity.schema.json';
+import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+
+// A local cache of compiled schemas, to avoid duplicate work.
+// The keys are JSON stringified versions of the schema
+const compiledSchemaCache = new Map>();
+
+// The core schemas that others can depend on
+const refDependencyCandidates = [
+ entityEnvelopeSchema,
+ entitySchema,
+ entityMetaSchema,
+ commonSchema,
+];
+
+export function throwAjvError(
+ errors: ValidateFunction['errors'],
+): never {
+ if (!errors?.length) {
+ throw new TypeError('Unknown error');
+ }
+
+ const error = errors[0];
+ throw new TypeError(
+ `${error.dataPath || ''} ${error.message}${
+ error.params
+ ? ` - ${Object.entries(error.params)
+ .map(([key, val]) => `${key}: ${val}`)
+ .join(', ')}`
+ : ''
+ }`,
+ );
+}
+
+// Compiles the given schema, and makes sure to also grab any core dependencies
+// that it depends on
+export function compileAjvSchema(
+ schema: Schema,
+ options: { disableCache?: boolean } = {},
+): ValidateFunction {
+ const disableCache = options?.disableCache ?? false;
+ const cacheKey = disableCache ? '' : JSON.stringify(schema);
+
+ if (!disableCache) {
+ const cached = compiledSchemaCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+ }
+
+ const extraSchemas = getExtraSchemas(schema);
+ const ajv = new Ajv({
+ allowUnionTypes: true,
+ allErrors: true,
+ validateSchema: true,
+ });
+ if (extraSchemas.length) {
+ ajv.addSchema(extraSchemas, undefined, undefined, true);
+ }
+ const compiled = ajv.compile(schema);
+
+ if (!disableCache) {
+ compiledSchemaCache.set(cacheKey, compiled);
+ }
+
+ return compiled;
+}
+
+// Find refs in the given schema and recursively in all known schemas it
+// targets, collecting that list of schemas as we go
+function getExtraSchemas(schema: Schema): Schema[] {
+ if (typeof schema !== 'object') {
+ return [];
+ }
+
+ const seen = new Set();
+ if (schema.$id) {
+ seen.add(schema.$id);
+ }
+
+ const selected = new Array();
+
+ const todo: Schema[] = [schema];
+ while (todo.length) {
+ const current = todo.pop()!;
+
+ for (const ref of getAllRefs(current)) {
+ if (!seen.has(ref)) {
+ seen.add(ref);
+
+ const match = refDependencyCandidates.find(c => c.$id === ref);
+ if (match) {
+ selected.push(match);
+ todo.push(match);
+ }
+ }
+ }
+ }
+
+ return selected;
+}
+
+// Naively step through the entire schema looking for "$ref": "x" pairs. The
+// resulting iterator may contain duplicates. Ignores fragments, i.e. for a ref
+// of "a#b", it will just yield "a".
+function* getAllRefs(schema: Schema): Iterable {
+ const todo: any[] = [schema];
+ while (todo.length) {
+ const current = todo.pop()!;
+ if (typeof current === 'object' && current) {
+ for (const [key, value] of Object.entries(current)) {
+ if (key === '$ref' && typeof value === 'string') {
+ yield value.split('#')[0];
+ } else {
+ todo.push(value);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts
new file mode 100644
index 0000000000..7c6613936c
--- /dev/null
+++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2021 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 { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator';
+
+describe('entityEnvelopeSchemaValidator', () => {
+ const validator = entityEnvelopeSchemaValidator();
+ let entity: any;
+
+ beforeEach(() => {
+ entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'test',
+ namespace: 'ns',
+ },
+ };
+ });
+
+ it('happy path: accepts valid data', () => {
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ //
+ // apiVersion and kind
+ //
+
+ it('rejects wrong root type', () => {
+ expect(() => validator(7)).toThrow(/object/);
+ });
+
+ it('accepts unknown root fields', () => {
+ entity.blah = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects missing apiVersion', () => {
+ delete entity.apiVersion;
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects bad apiVersion type', () => {
+ entity.apiVersion = 7;
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects empty apiVersion', () => {
+ entity.apiVersion = '';
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects missing kind', () => {
+ delete entity.kind;
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ it('rejects bad kind type', () => {
+ entity.kind = 7;
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ it('rejects empty kind', () => {
+ entity.kind = '';
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ //
+ // metadata
+ //
+
+ it('rejects missing metadata', () => {
+ delete entity.metadata;
+ expect(() => validator(entity)).toThrow(/metadata/);
+ });
+
+ it('rejects bad metadata type', () => {
+ entity.metadata = 7;
+ expect(() => validator(entity)).toThrow(/metadata/);
+ });
+
+ it('rejects missing name', () => {
+ delete entity.metadata.name;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects empty name', () => {
+ entity.metadata.name = '';
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects bad name type', () => {
+ entity.metadata.name = 7;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('accepts missing namespace', () => {
+ delete entity.metadata.namespace;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects empty namespace', () => {
+ entity.metadata.namespace = '';
+ expect(() => validator(entity)).toThrow(/namespace/);
+ });
+
+ it('rejects bad namespace type', () => {
+ entity.metadata.namespace = 7;
+ expect(() => validator(entity)).toThrow(/namespace/);
+ });
+
+ it('accepts unknown metadata fields', () => {
+ entity.metadata.blah = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+});
diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts
new file mode 100644
index 0000000000..52ae00e399
--- /dev/null
+++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2021 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 { Schema } from 'ajv';
+import { EntityEnvelope } from '../entity/EntityEnvelope';
+import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json';
+import { compileAjvSchema, throwAjvError } from './ajv';
+
+/**
+ * Creates a validation function that takes some arbitrary data, and either
+ * returns that data cast to an {@link EntityEnvelope} (or the given subtype)
+ * if it matches that schema, or throws a {@link TypeError} describing the
+ * errors.
+ *
+ * Note that this validator is only meant for applying the base schema checks;
+ * it does not take custom policies or additional processor based validation
+ * into account.
+ *
+ * By default, the plain `EntityEnvelope` schema is used. If you pass in your
+ * own, it may contain `$ref` references to the following, which are resolved
+ * automatically for you:
+ *
+ * - EntityEnvelope
+ * - Entity
+ * - EntityMeta
+ * - common#
+ *
+ * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema
+ */
+export function entityEnvelopeSchemaValidator<
+ T extends EntityEnvelope = EntityEnvelope
+>(schema?: unknown): (data: unknown) => T {
+ const validate = compileAjvSchema(
+ schema ? (schema as Schema) : entityEnvelopeSchema,
+ );
+
+ return data => {
+ const result = validate(data);
+ if (result === true) {
+ return data as T;
+ }
+
+ throw throwAjvError(validate.errors);
+ };
+}
diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts
new file mode 100644
index 0000000000..4b258aed14
--- /dev/null
+++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2021 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 { entityKindSchemaValidator } from './entityKindSchemaValidator';
+import componentSchema from '../schema/kinds/Component.v1alpha1.schema.json';
+
+describe('entityKindSchemaValidator', () => {
+ const validator = entityKindSchemaValidator(componentSchema);
+ let entity: any;
+
+ beforeEach(() => {
+ entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521',
+ etag: 'lsndfkjsndfkjnsdfkjnsd==',
+ generation: 13,
+ name: 'test',
+ namespace: 'ns',
+ labels: {
+ 'backstage.io/custom': 'ValueStuff',
+ },
+ annotations: {
+ 'example.com/bindings': 'are-secret',
+ },
+ tags: ['java', 'data'],
+ links: [
+ {
+ url: 'https://example.com',
+ title: 'Website',
+ icon: 'website',
+ },
+ ],
+ },
+ spec: {
+ type: 'service',
+ lifecycle: 'production',
+ owner: 'me',
+ },
+ relations: [
+ { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } },
+ ],
+ status: {
+ items: [
+ {
+ type: 't',
+ level: 'error',
+ message: 'm',
+ error: { name: 'n', message: 'm', code: '1', stack: 's' },
+ },
+ ],
+ },
+ };
+ });
+
+ it('works in the happy path', () => {
+ expect(validator(entity)).toBe(entity);
+ });
+
+ it('nicely rejects an unknown kind', () => {
+ entity.kind = 'Unknown';
+ expect(validator(entity)).toBe(false);
+ });
+
+ it('nicely rejects an unknown apiVersion', () => {
+ entity.apiVersion = 'backstage.io/v1alpha7';
+ expect(validator(entity)).toBe(false);
+ });
+
+ it('nicely rejects when both kind and apiVersion mismatch', () => {
+ entity.apiVersion = 'backstage.io/v1alpha7';
+ entity.kind = 'Unknown';
+ expect(validator(entity)).toBe(false);
+ });
+
+ it('rejects when the kind is actually breaking other rules than enum', () => {
+ entity.kind = 7;
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ it('rejects when the apiVersion is actually breaking other rules than enum', () => {
+ entity.apiVersion = 7;
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects nicely when there is both a nice mismatch and a fatal error', () => {
+ entity.kind = 'Unknown';
+ entity.metadata = 7;
+ expect(validator(entity)).toBe(false);
+ });
+
+ it('rejects on errors in other parts of the schema', () => {
+ entity.spec = 7;
+ expect(() => validator(entity)).toThrow(/spec/);
+ });
+});
diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts
new file mode 100644
index 0000000000..c722687f9e
--- /dev/null
+++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2021 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 { Schema } from 'ajv';
+import { Entity } from '../entity';
+import { compileAjvSchema, throwAjvError } from './ajv';
+
+/**
+ * Creates a validation function that takes some arbitrary data, and either
+ * returns that data cast to a `T` if it matches that schema, or `false` if the
+ * schema apiVersion/kind didn't apply to that data, or throws a
+ * {@link TypeError} describing actual errors.
+ *
+ * This validator is highly specialized, in that it has special treatment of
+ * the `kind` and `apiVersion` root keys. This only works if your schema has
+ * their rule set to `"enum"`:
+ *
+ *
+ * "apiVersion": {
+ * "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ * },
+ * "kind": {
+ * "enum": ["Group"]
+ * },
+ *
+ *
+ * In the above example, the created validator will return `false` if and only
+ * if the kind and/or apiVersion mismatch.
+ *
+ * Note that this validator is only meant for applying the base schema checks;
+ * it does not take custom policies or additional processor based validation
+ * into account.
+ *
+ * The given schema may contain `$ref` references to the following, which are
+ * resolved automatically for you:
+ *
+ * - EntityEnvelope
+ * - Entity
+ * - EntityMeta
+ * - common#
+ *
+ * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema
+ */
+export function entityKindSchemaValidator(
+ schema: unknown,
+): (data: unknown) => T | false {
+ const validate = compileAjvSchema(schema as Schema);
+
+ return data => {
+ const result = validate(data);
+ if (result === true) {
+ return data as T;
+ }
+
+ // Only in the case where kind and/or apiVersion have enum mismatches AND
+ // have NO other errors, we call it a soft error.
+ const softCandidates = validate.errors?.filter(e =>
+ ['/kind', '/apiVersion'].includes(e.dataPath),
+ );
+ if (
+ softCandidates?.length &&
+ softCandidates.every(e => e.keyword === 'enum')
+ ) {
+ return false;
+ }
+
+ throw throwAjvError(validate.errors);
+ };
+}
diff --git a/packages/catalog-model/src/validation/entitySchemaValidator.test.ts b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts
new file mode 100644
index 0000000000..6ab2744c0f
--- /dev/null
+++ b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts
@@ -0,0 +1,596 @@
+/*
+ * Copyright 2021 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 { entitySchemaValidator } from './entitySchemaValidator';
+
+describe('entitySchemaValidator', () => {
+ const validator = entitySchemaValidator();
+ let entity: any;
+
+ beforeEach(() => {
+ entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521',
+ etag: 'lsndfkjsndfkjnsdfkjnsd==',
+ generation: 13,
+ name: 'test',
+ namespace: 'ns',
+ labels: {
+ 'backstage.io/custom': 'ValueStuff',
+ },
+ annotations: {
+ 'example.com/bindings': 'are-secret',
+ },
+ tags: ['java', 'data'],
+ links: [
+ {
+ url: 'https://example.com',
+ title: 'Website',
+ icon: 'website',
+ },
+ ],
+ },
+ spec: {
+ type: 'service',
+ lifecycle: 'production',
+ owner: 'me',
+ },
+ relations: [
+ { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } },
+ ],
+ status: {
+ items: [
+ {
+ type: 't',
+ level: 'error',
+ message: 'm',
+ error: { name: 'n', message: 'm', code: '1', stack: 's' },
+ },
+ ],
+ },
+ };
+ });
+
+ it('happy path: accepts valid data', () => {
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ //
+ // apiVersion and kind
+ //
+
+ it('rejects wrong root type', () => {
+ expect(() => validator(7)).toThrow(/object/);
+ });
+
+ it('rejects missing apiVersion', () => {
+ delete entity.apiVersion;
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects bad apiVersion type', () => {
+ entity.apiVersion = 7;
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects empty apiVersion', () => {
+ entity.apiVersion = '';
+ expect(() => validator(entity)).toThrow(/apiVersion/);
+ });
+
+ it('rejects missing kind', () => {
+ delete entity.kind;
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ it('rejects bad kind type', () => {
+ entity.kind = 7;
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ it('rejects empty kind', () => {
+ entity.kind = '';
+ expect(() => validator(entity)).toThrow(/kind/);
+ });
+
+ //
+ // metadata
+ //
+
+ it('rejects missing metadata', () => {
+ delete entity.metadata;
+ expect(() => validator(entity)).toThrow(/metadata/);
+ });
+
+ it('rejects bad metadata type', () => {
+ entity.metadata = 7;
+ expect(() => validator(entity)).toThrow(/metadata/);
+ });
+
+ it('accepts missing uid', () => {
+ delete entity.metadata.uid;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad uid type', () => {
+ entity.metadata.uid = 7;
+ expect(() => validator(entity)).toThrow(/uid/);
+ });
+
+ it('rejects empty uid', () => {
+ entity.metadata.uid = '';
+ expect(() => validator(entity)).toThrow(/uid/);
+ });
+
+ it('accepts missing etag', () => {
+ delete entity.metadata.etag;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad etag type', () => {
+ entity.metadata.etag = 7;
+ expect(() => validator(entity)).toThrow(/etag/);
+ });
+
+ it('rejects empty etag', () => {
+ entity.metadata.etag = '';
+ expect(() => validator(entity)).toThrow(/etag/);
+ });
+
+ it('accepts missing generation', () => {
+ delete entity.metadata.generation;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad generation type', () => {
+ entity.metadata.generation = 'a';
+ expect(() => validator(entity)).toThrow(/generation/);
+ });
+
+ it('rejects zero generation', () => {
+ entity.metadata.generation = 0;
+ expect(() => validator(entity)).toThrow(/generation/);
+ });
+
+ it('rejects non-integer generation', () => {
+ entity.metadata.generation = 1.5;
+ expect(() => validator(entity)).toThrow(/generation/);
+ });
+
+ it('rejects missing name', () => {
+ delete entity.metadata.name;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects bad name type', () => {
+ entity.metadata.name = 7;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('accepts missing namespace', () => {
+ delete entity.metadata.namespace;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad namespace type', () => {
+ entity.metadata.namespace = 7;
+ expect(() => validator(entity)).toThrow(/namespace/);
+ });
+
+ it('accepts missing description', () => {
+ delete entity.metadata.description;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad description type', () => {
+ entity.metadata.description = 7;
+ expect(() => validator(entity)).toThrow(/description/);
+ });
+
+ it('accepts missing labels', () => {
+ delete entity.metadata.labels;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty labels', () => {
+ entity.metadata.labels = {};
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad labels type', () => {
+ entity.metadata.labels = 7;
+ expect(() => validator(entity)).toThrow(/labels/);
+ });
+
+ it('accepts missing annotations', () => {
+ delete entity.metadata.annotations;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty annotations object', () => {
+ entity.metadata.annotations = {};
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad annotations type', () => {
+ entity.metadata.annotations = 7;
+ expect(() => validator(entity)).toThrow(/annotations/);
+ });
+
+ it('rejects bad tags type', () => {
+ entity.metadata.tags = 7;
+ expect(() => validator(entity)).toThrow(/tags/);
+ });
+
+ it('accepts empty tags', () => {
+ entity.metadata.tags = [];
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects empty tag', () => {
+ entity.metadata.tags[0] = '';
+ expect(() => validator(entity)).toThrow(/tags/);
+ });
+
+ it('rejects bad tag type', () => {
+ entity.metadata.tags[0] = 7;
+ expect(() => validator(entity)).toThrow(/tags/);
+ });
+
+ it('accepts missing links', () => {
+ delete entity.metadata.links;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty links', () => {
+ entity.metadata.links = [];
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects empty links.url', () => {
+ entity.metadata.links[0].url = '';
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('rejects missing links.url', () => {
+ delete entity.metadata.links[0].url;
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('rejects bad links.url type', () => {
+ entity.metadata.links[0].url = 7;
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('rejects empty links.title', () => {
+ entity.metadata.links[0].title = '';
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('accepts missing links.title', () => {
+ delete entity.metadata.links[0].title;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad links.title type', () => {
+ entity.metadata.links[0].title = 7;
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('rejects empty links.icon', () => {
+ entity.metadata.links[0].icon = '';
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('accepts missing links.icon', () => {
+ delete entity.metadata.links[0].icon;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad links.icon type', () => {
+ entity.metadata.links[0].icon = 7;
+ expect(() => validator(entity)).toThrow(/links/);
+ });
+
+ it('accepts unknown metadata field', () => {
+ entity.metadata.unknown = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ //
+ // spec
+ //
+
+ it('accepts missing spec', () => {
+ delete entity.spec;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects non-object spec', () => {
+ entity.spec = 7;
+ expect(() => validator(entity)).toThrow(/spec/);
+ });
+
+ it('accepts unknown spec field', () => {
+ entity.spec.unknown = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ //
+ // Relations
+ //
+
+ it('accepts missing relations', () => {
+ delete entity.relations;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty relations', () => {
+ entity.relations = [];
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad relations type', () => {
+ entity.relations = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects missing relations.type', () => {
+ delete entity.relations[0].type;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects empty relations.type', () => {
+ entity.relations[0].type = '';
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects wrong relations.type type', () => {
+ entity.relations[0].type = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects missing relations.target', () => {
+ delete entity.relations[0].target;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects empty relations.target', () => {
+ entity.relations[0].target = '';
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects wrong relations.target type', () => {
+ entity.relations[0].target = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects missing relations.target.kind', () => {
+ delete entity.relations[0].target.kind;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects empty relations.target.kind', () => {
+ entity.relations[0].target.kind = '';
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects wrong relations.target.kind type', () => {
+ entity.relations[0].target.kind = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects missing relations.target.namespace', () => {
+ delete entity.relations[0].target.namespace;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects empty relations.target.namespace', () => {
+ entity.relations[0].target.namespace = '';
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects wrong relations.target.namespace type', () => {
+ entity.relations[0].target.namespace = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects missing relations.target.name', () => {
+ delete entity.relations[0].target.name;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects empty relations.target.name', () => {
+ entity.relations[0].target.name = '';
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects wrong relations.target.name type', () => {
+ entity.relations[0].target.name = 7;
+ expect(() => validator(entity)).toThrow(/relations/);
+ });
+
+ it('rejects unknown relation field', () => {
+ entity.relations[0].unknown = 7;
+ expect(() => validator(entity)).toThrow(/unknown/);
+ });
+
+ //
+ // Status
+ //
+
+ it('accepts missing status', () => {
+ delete entity.status;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty status', () => {
+ entity.status = {};
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status type', () => {
+ entity.status = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('accepts missing status.items', () => {
+ delete entity.status.items;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty status.items', () => {
+ entity.status.items = [];
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status.items type', () => {
+ entity.status.items = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects bad status.items item type', () => {
+ entity.status.items[0] = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects missing status.items.type', () => {
+ delete entity.status.items[0].type;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects empty status.items.type', () => {
+ entity.status.items[0].type = '';
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects bad status.items.type type', () => {
+ entity.status.items[0].type = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects missing status.items.level', () => {
+ delete entity.status.items[0].level;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects empty status.items.level', () => {
+ entity.status.items[0].level = '';
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects bad status.items.level type', () => {
+ entity.status.items[0].level = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects bad status.items.level enum', () => {
+ entity.status.items[0].level = 'unknown';
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('rejects missing status.items.message', () => {
+ delete entity.status.items[0].message;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('accepts empty status.items.message', () => {
+ entity.status.items[0].message = '';
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status.items.message type', () => {
+ entity.status.items[0].message = 7;
+ expect(() => validator(entity)).toThrow(/status/);
+ });
+
+ it('accepts missing status.items.error', () => {
+ delete entity.status.items[0].error;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects missing status.items.error.name', () => {
+ delete entity.status.items[0].error.name;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects empty status.items.error.name', () => {
+ entity.status.items[0].error.name = '';
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects bad status.items.error.name type', () => {
+ entity.status.items[0].error.name = 7;
+ expect(() => validator(entity)).toThrow(/name/);
+ });
+
+ it('rejects missing status.items.error.message', () => {
+ delete entity.status.items[0].error.message;
+ expect(() => validator(entity)).toThrow(/message/);
+ });
+
+ it('accepts empty status.items.error.message', () => {
+ entity.status.items[0].error.message = '';
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status.items.error.message type', () => {
+ entity.status.items[0].error.message = 7;
+ expect(() => validator(entity)).toThrow(/message/);
+ });
+
+ it('accepts missing status.items.error.code', () => {
+ delete entity.status.items[0].error.code;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty status.items.error.code', () => {
+ entity.status.items[0].error.code = '';
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status.items.error.code type', () => {
+ entity.status.items[0].error.code = 7;
+ expect(() => validator(entity)).toThrow(/code/);
+ });
+
+ it('accepts missing status.items.error.stack', () => {
+ delete entity.status.items[0].error.stack;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts empty status.items.error.stack', () => {
+ entity.status.items[0].error.stack = '';
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('rejects bad status.items.error.stack type', () => {
+ entity.status.items[0].error.stack = 7;
+ expect(() => validator(entity)).toThrow(/stack/);
+ });
+
+ it('accepts unknown status.items field', () => {
+ entity.status.items[0].unknown = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+
+ it('accepts unknown status.items.error field', () => {
+ entity.status.items[0].error.unknown = 7;
+ expect(() => validator(entity)).not.toThrow();
+ });
+});
diff --git a/packages/catalog-model/src/validation/entitySchemaValidator.ts b/packages/catalog-model/src/validation/entitySchemaValidator.ts
new file mode 100644
index 0000000000..8a30f09d31
--- /dev/null
+++ b/packages/catalog-model/src/validation/entitySchemaValidator.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2021 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 { Schema } from 'ajv';
+import { Entity } from '../entity/Entity';
+import entitySchema from '../schema/Entity.schema.json';
+import { compileAjvSchema, throwAjvError } from './ajv';
+
+/**
+ * Creates a validation function that takes some arbitrary data, and either
+ * returns that data cast to an {@link Entity} (or the given subtype) if it
+ * matches that schema, or throws a {@link TypeError} describing the errors.
+ *
+ * Note that this validator is only meant for applying the base schema checks;
+ * it does not take custom policies or additional processor based validation
+ * into account.
+ *
+ * By default, the plain `Entity` schema is used. If you pass in your own, it
+ * may contain `$ref` references to the following, which are resolved
+ * automatically for you:
+ *
+ * - EntityEnvelope
+ * - Entity
+ * - EntityMeta
+ * - common#
+ *
+ * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema
+ */
+export function entitySchemaValidator(
+ schema?: unknown,
+): (data: unknown) => T {
+ const validate = compileAjvSchema(schema ? (schema as Schema) : entitySchema);
+
+ return data => {
+ const result = validate(data);
+ if (result === true) {
+ return data as T;
+ }
+
+ throw throwAjvError(validate.errors);
+ };
+}
diff --git a/packages/catalog-model/src/validation/index.ts b/packages/catalog-model/src/validation/index.ts
index d679a5323c..bdf812b4ad 100644
--- a/packages/catalog-model/src/validation/index.ts
+++ b/packages/catalog-model/src/validation/index.ts
@@ -15,6 +15,9 @@
*/
export { CommonValidatorFunctions } from './CommonValidatorFunctions';
+export { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator';
+export { entityKindSchemaValidator } from './entityKindSchemaValidator';
+export { entitySchemaValidator } from './entitySchemaValidator';
export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
export { makeValidator } from './makeValidator';
export type { Validators } from './types';
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index cf28960f13..da06bfea17 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,35 @@
# @backstage/cli
+## 0.6.14
+
+### Patch Changes
+
+- ee4eb5b40: Adjust the Webpack `devtool` module filename template to correctly resolve via the source maps to the source files.
+- 84160313e: Mark the `create-github-app` command as ready for use and reveal it in the command list.
+- 7e7c71417: Exclude core packages from package dependency diff.
+- e7c5e4b30: Update installation instructions in README.
+- 2305ab8fc: chore(deps): bump `@spotify/eslint-config-react` from 9.0.0 to 10.0.0
+- 054bcd029: Deprecated the `backend:build-image` command, pointing to the newer `backend:bundle` command.
+
+## 0.6.13
+
+### Patch Changes
+
+- 1cd0cacd9: Add support for transforming yaml files in jest with 'yaml-jest'
+- 7a7da5146: Bumped `eslint-config-prettier` to `8.x`.
+- 3a181cff1: Bump webpack-node-externals from `2.5.2` to `3.0.0`.
+- Updated dependencies [2cf98d279]
+- Updated dependencies [438a512eb]
+ - @backstage/config-loader@0.6.3
+
+## 0.6.12
+
+### Patch Changes
+
+- 2bfec55a6: Update `fork-ts-checker-webpack-plugin`
+- Updated dependencies [290405276]
+ - @backstage/config-loader@0.6.2
+
## 0.6.11
### Patch Changes
diff --git a/packages/cli/README.md b/packages/cli/README.md
index eb5e0e461b..1aa4233032 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -4,13 +4,7 @@ This package provides a CLI for developing Backstage plugins and apps.
## Installation
-Install the package via npm or Yarn:
-
-```sh
-npm install --save @backstage/cli
-```
-
-or
+Install the package via Yarn:
```sh
yarn add @backstage/cli
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index e7fd4cb688..e860e426d1 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -19,7 +19,6 @@ module.exports = {
'@spotify/eslint-config-base',
'@spotify/eslint-config-typescript',
'prettier',
- 'prettier/@typescript-eslint',
'plugin:jest/recommended',
'plugin:monorepo/recommended',
],
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index fe45f264b5..6448136769 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -20,8 +20,6 @@ module.exports = {
'@spotify/eslint-config-react',
'@spotify/eslint-config-typescript',
'prettier',
- 'prettier/react',
- 'prettier/@typescript-eslint',
'plugin:jest/recommended',
'plugin:monorepo/recommended',
],
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index 94cf059cbd..4a2ae7119c 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -89,6 +89,7 @@ async function getConfig() {
'\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve(
'./jestFileTransform.js',
),
+ '\\.(yaml)$': require.resolve('yaml-jest'),
},
// A bit more opinionated
diff --git a/packages/cli/package.json b/packages/cli/package.json
index b17bc8cbda..3f075824dc 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.6.11",
+ "version": "0.6.14",
"private": false,
"publishConfig": {
"access": "public"
@@ -32,7 +32,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.4.4",
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.5",
- "@backstage/config-loader": "^0.6.1",
+ "@backstage/config-loader": "^0.6.3",
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^4.0.0",
"@lerna/project": "^4.0.0",
@@ -42,7 +42,7 @@
"@rollup/plugin-node-resolve": "^11.2.0",
"@rollup/plugin-yaml": "^2.1.1",
"@spotify/eslint-config-base": "^9.0.0",
- "@spotify/eslint-config-react": "^9.0.0",
+ "@spotify/eslint-config-react": "^10.0.0",
"@spotify/eslint-config-typescript": "^9.0.0",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.5.x",
@@ -52,7 +52,7 @@
"@types/start-server-webpack-plugin": "^2.2.0",
"@types/webpack-env": "^1.15.2",
"@types/webpack-node-externals": "^2.5.0",
- "@typescript-eslint/eslint-plugin": "^v4.15.2",
+ "@typescript-eslint/eslint-plugin": "^v4.26.0",
"@typescript-eslint/parser": "^v4.14.0",
"@yarnpkg/lockfile": "^1.1.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -65,7 +65,7 @@
"diff": "^5.0.0",
"esbuild": "^0.8.56",
"eslint": "^7.1.0",
- "eslint-config-prettier": "^6.0.0",
+ "eslint-config-prettier": "^8.3.0",
"eslint-formatter-friendly": "^7.0.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jest": "^24.1.0",
@@ -75,7 +75,7 @@
"eslint-plugin-react-hooks": "^4.0.0",
"express": "^4.17.1",
"file-loader": "^6.2.0",
- "fork-ts-checker-webpack-plugin": "^4.0.5",
+ "fork-ts-checker-webpack-plugin": "^6.2.9",
"fs-extra": "^9.0.0",
"handlebars": "^4.7.3",
"html-webpack-plugin": "^4.3.0",
@@ -111,18 +111,19 @@
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "3.11.0",
- "webpack-node-externals": "^2.5.0",
+ "webpack-node-externals": "^3.0.0",
"yaml": "^1.10.0",
+ "yaml-jest": "^1.0.5",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/backend-common": "^0.8.0",
+ "@backstage/backend-common": "^0.8.1",
"@backstage/config": "^0.1.5",
- "@backstage/core": "^0.7.9",
- "@backstage/dev-utils": "^0.1.14",
- "@backstage/test-utils": "^0.1.11",
- "@backstage/theme": "^0.2.7",
+ "@backstage/core": "^0.7.12",
+ "@backstage/dev-utils": "^0.1.17",
+ "@backstage/test-utils": "^0.1.13",
+ "@backstage/theme": "^0.2.8",
"@types/diff": "^5.0.0",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts
index 654f51cd10..8434017cd9 100644
--- a/packages/cli/src/commands/backend/buildImage.ts
+++ b/packages/cli/src/commands/backend/buildImage.ts
@@ -15,6 +15,7 @@
*/
import { Command } from 'commander';
+import { yellow } from 'chalk';
import fs from 'fs-extra';
import { join as joinPath, relative as relativePath } from 'path';
import { createDistWorkspace } from '../../lib/packager';
@@ -31,6 +32,15 @@ export default async (cmd: Command) => {
return;
}
+ console.warn(
+ yellow(`
+The backend:build-image command is deprecated and will be removed in the future.
+Please use the backend:bundle command instead along with your own Docker setup.
+
+ https://backstage.io/docs/deployment/docker
+`),
+ );
+
const pkgPath = paths.resolveTarget(PKG_PATH);
const pkg = await fs.readJson(pkgPath);
const appConfigs = await findAppConfigs();
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index 9fc5531dfa..5ea8cdfbbc 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -205,7 +205,7 @@ export default async (cmd: Command) => {
return chalk.red('Please enter an ID for the plugin');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
- 'Plugin IDs must be kebab-cased and contain only letters, digits, and dashes.',
+ 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.',
);
}
return true;
diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts
index ae39941106..431659a972 100644
--- a/packages/cli/src/commands/index.ts
+++ b/packages/cli/src/commands/index.ts
@@ -60,11 +60,7 @@ export function registerCommands(program: CommanderStatic) {
.helpOption(', --backstage-cli-help') // Let docker handle --help
.option('--build', 'Build packages before packing them into the image')
.description(
- // TODO: Add example use cases in Backstage documentation.
- // For example, if a $NPM_TOKEN needs to be exposed, run `backend:build-image --secret
- // id=NPM_TOKEN,src=/NPM_TOKEN.txt`.
- 'Bundles the package into a docker image. All extra args are forwarded to ' +
- '`docker image build`.',
+ 'Bundles the package into a docker image. This command is deprecated and will be removed.',
)
.action(lazy(() => import('./backend/buildImage').then(m => m.default)));
@@ -225,10 +221,8 @@ export function registerCommands(program: CommanderStatic) {
.action(lazy(() => import('./buildWorkspace').then(m => m.default)));
program
- .command('create-github-app ', { hidden: true })
- .description(
- 'Create new GitHub App in your organization. This command is experimental and may change in the future.',
- )
+ .command('create-github-app ')
+ .description('Create new GitHub App in your organization.')
.action(lazy(() => import('./create-github-app').then(m => m.default)));
}
diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts
index 81d8322ffa..72d619e0bc 100644
--- a/packages/cli/src/commands/remove-plugin/removePlugin.ts
+++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts
@@ -189,7 +189,7 @@ export default async () => {
return chalk.red('Please enter an ID for the plugin');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
- 'Plugin IDs must be kebab-cased and contain only letters, digits and dashes.',
+ 'Plugin IDs must be lowercase and contain only letters, digits and dashes.',
);
}
return true;
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index 8837554f22..7270f7b01a 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -97,15 +97,18 @@ export async function createConfig(
if (checksEnabled) {
plugins.push(
new ForkTsCheckerWebpackPlugin({
- tsconfig: paths.targetTsConfig,
- eslint: true,
- eslintOptions: {
- parserOptions: {
- project: paths.targetTsConfig,
- tsconfigRootDir: paths.targetPath,
+ typescript: {
+ configFile: paths.targetTsConfig,
+ },
+ eslint: {
+ files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
+ options: {
+ parserOptions: {
+ project: paths.targetTsConfig,
+ tsconfigRootDir: paths.targetPath,
+ },
},
},
- reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
);
}
@@ -194,6 +197,15 @@ export async function createConfig(
chunkFilename: isDev
? '[name].chunk.js'
: 'static/[name].[chunkhash:8].chunk.js',
+ ...(isDev
+ ? {
+ devtoolModuleFilenameTemplate: info =>
+ `file:///${resolvePath(info.absoluteResourcePath).replace(
+ /\\/g,
+ '/',
+ )}`,
+ }
+ : {}),
},
plugins,
};
@@ -278,7 +290,11 @@ export async function createBackendConfig(
: '[name].[chunkhash:8].chunk.js',
...(isDev
? {
- devtoolModuleFilenameTemplate: 'file:///[absolute-resource-path]',
+ devtoolModuleFilenameTemplate: info =>
+ `file:///${resolvePath(info.absoluteResourcePath).replace(
+ /\\/g,
+ '/',
+ )}`,
}
: {}),
},
@@ -291,15 +307,18 @@ export async function createBackendConfig(
...(checksEnabled
? [
new ForkTsCheckerWebpackPlugin({
- tsconfig: paths.targetTsConfig,
- eslint: true,
- eslintOptions: {
- parserOptions: {
- project: paths.targetTsConfig,
- tsconfigRootDir: paths.targetPath,
+ typescript: {
+ configFile: paths.targetTsConfig,
+ },
+ eslint: {
+ files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
+ options: {
+ parserOptions: {
+ project: paths.targetTsConfig,
+ tsconfigRootDir: paths.targetPath,
+ },
},
},
- reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
]
: []),
diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts
index a210986b2a..36e163d67e 100644
--- a/packages/cli/src/lib/diff/handlers.ts
+++ b/packages/cli/src/lib/diff/handlers.ts
@@ -172,6 +172,11 @@ class PackageJsonHandler {
if (this.variant === 'app' && key.startsWith('plugin-')) {
continue;
}
+ // Skip checking of the core packages, since we're migrating over
+ if (key.startsWith('@backstage/core')) {
+ continue;
+ }
+
await this.syncField(key, pkgDeps, targetDeps, fieldName);
}
}
diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md
index d6b1c9a751..67ed7472b5 100644
--- a/packages/config-loader/CHANGELOG.md
+++ b/packages/config-loader/CHANGELOG.md
@@ -1,5 +1,18 @@
# @backstage/config-loader
+## 0.6.3
+
+### Patch Changes
+
+- 2cf98d279: Resolve the path to app-config.yaml from the current working directory. This will allow use of `yarn link` or running the CLI in other directories and improve the experience for local backstage development.
+- 438a512eb: Fixed configuration schema parsing when using TypeScript `4.3`.
+
+## 0.6.2
+
+### Patch Changes
+
+- 290405276: Updated dependencies
+
## 0.6.1
### Patch Changes
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 77646ee521..de0adb4a4a 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
- "version": "0.6.1",
+ "version": "0.6.3",
"private": false,
"publishConfig": {
"access": "public",
diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts
index 611413931e..5baada2d6f 100644
--- a/packages/config-loader/src/lib/schema/collect.ts
+++ b/packages/config-loader/src/lib/schema/collect.ts
@@ -124,7 +124,9 @@ export async function collectConfigSchemas(
);
}
- await Promise.all(packageNames.map(name => processItem({ name })));
+ await Promise.all(
+ packageNames.map(name => processItem({ name, parentPath: currentDir })),
+ );
const tsSchemas = compileTsSchemas(tsSchemaPaths);
@@ -166,6 +168,23 @@ function compileTsSchemas(paths: string[]) {
},
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
) as JsonObject | null;
+
+ // This is a workaround for an API change in TypeScript 4.3 where doc comments no
+ // longer are represented by a single string, but instead an array of objects.
+ // This isn't handled by typescript-json-schema so we do the conversion here instead.
+ value = JSON.parse(JSON.stringify(value), (key, prop) => {
+ if (key === 'visibility' && Array.isArray(prop)) {
+ const text = prop[0]?.text;
+ if (!text) {
+ const propStr = JSON.stringify(prop);
+ throw new Error(
+ `Failed conversion of visibility schema, got ${propStr}`,
+ );
+ }
+ return text;
+ }
+ return prop;
+ });
} catch (error) {
if (error.message !== 'type Config not found') {
throw error;
diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md
index 3652cf6bc0..c06e2b6789 100644
--- a/packages/core-api/CHANGELOG.md
+++ b/packages/core-api/CHANGELOG.md
@@ -1,5 +1,37 @@
# @backstage/core-api
+## 0.2.21
+
+### Patch Changes
+
+- 0160678b1: Made the `RouteRef*` types compatible with the ones exported from `@backstage/core-plugin-api`.
+- Updated dependencies [031ccd45f]
+- Updated dependencies [e7c5e4b30]
+ - @backstage/core-plugin-api@0.1.1
+ - @backstage/theme@0.2.8
+
+## 0.2.20
+
+### Patch Changes
+
+- d597a50c6: Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined.
+
+## 0.2.19
+
+### Patch Changes
+
+- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`.
+
+ In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage:
+
+ ```ts
+ [Symbol.observable]() {
+ return this;
+ },
+ ```
+
+- 65e6c4541: Remove circular dependencies
+
## 0.2.18
### Patch Changes
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
index b5cd8cba69..1c3e707a29 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.2.18",
+ "version": "0.2.21",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,7 +30,8 @@
},
"dependencies": {
"@backstage/config": "^0.1.4",
- "@backstage/theme": "^0.2.6",
+ "@backstage/core-plugin-api": "^0.1.1",
+ "@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
@@ -42,8 +43,8 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/cli": "^0.6.11",
- "@backstage/test-utils": "^0.1.11",
+ "@backstage/cli": "^0.6.14",
+ "@backstage/test-utils": "^0.1.13",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
index b9776ed037..fc4f1ef166 100644
--- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
+++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { IconComponent } from '../../icons';
+import { IconComponent } from '../../icons/types';
import { Observable } from '../../types';
import { ApiRef, createApiRef } from '../system';
diff --git a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
index 19da7a3765..f18829d99c 100644
--- a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
+++ b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { PublishSubject } from '../../../lib';
+import { PublishSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
import { AlertApi, AlertMessage } from '../../definitions';
diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts
index 43b52a254b..837c5f54db 100644
--- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts
+++ b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts
@@ -15,7 +15,7 @@
*/
import { AppThemeApi, AppTheme } from '../../definitions';
-import { BehaviorSubject } from '../../../lib';
+import { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
const STORAGE_KEY = 'theme';
diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts
index 60a5b815e7..24468fdcb6 100644
--- a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts
+++ b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts
@@ -14,8 +14,4 @@
* limitations under the License.
*/
-// This folder contains implementations for all core APIs.
-//
-// Plugins should rely on these APIs for functionality as much as possible.
-
export { UrlPatternDiscovery } from './UrlPatternDiscovery';
diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts
index 51548708d0..5993ede367 100644
--- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts
+++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { PublishSubject } from '../../../lib';
+import { PublishSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
import { ErrorApi, ErrorContext } from '../../definitions';
diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts
index e616ece5ae..f0710cb466 100644
--- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts
+++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { BehaviorSubject } from '../../../lib';
+import { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
type RequestQueueEntry = {
diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts
index 23e9e1ddc4..a0a01d1bc9 100644
--- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts
+++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts
@@ -21,7 +21,7 @@ import {
AuthRequesterOptions,
} from '../../definitions';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
-import { BehaviorSubject } from '../../../lib';
+import { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
/**
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index cf3aaac931..df0f65f33f 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -15,13 +15,12 @@
*/
import { ComponentType } from 'react';
-import { IconComponent, IconComponentMap, IconKey } from '../icons';
+import { IconComponent, IconComponentMap, IconKey } from '../icons/types';
import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types';
-import { ExternalRouteRef, RouteRef } from '../routing';
-import { AnyApiFactory } from '../apis';
+import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types';
+import { AnyApiFactory } from '../apis/system';
import { AppTheme, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
-import { SubRouteRef } from '../routing/types';
export type BootErrorPageProps = {
step: 'load-config' | 'load-chunk';
diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts
index 1c7e92daa8..1a1bab0eb9 100644
--- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts
+++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts
@@ -14,8 +14,8 @@
* limitations under the License.
*/
-import { AuthRequester } from '../../apis';
import {
+ AuthRequester,
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts
index 6cc9cf5237..af3d8bc9b6 100644
--- a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts
+++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { SessionState } from '../../apis';
+import { SessionState } from '../../apis/definitions';
import { Observable } from '../../types';
import { BehaviorSubject } from '../subjects';
diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts
index f57afd6760..332824e438 100644
--- a/packages/core-api/src/lib/AuthSessionManager/types.ts
+++ b/packages/core-api/src/lib/AuthSessionManager/types.ts
@@ -15,7 +15,7 @@
*/
import { Observable } from '../../types';
-import { SessionState } from '../../apis';
+import { SessionState } from '../../apis/definitions';
export type GetSessionOptions = {
optional?: boolean;
diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts
index b04c7b41c4..bbcca88e98 100644
--- a/packages/core-api/src/plugin/collectors.ts
+++ b/packages/core-api/src/plugin/collectors.ts
@@ -13,21 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-/*
- * 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 { BackstagePlugin } from './types';
import { getComponentData } from '../extensions';
diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts
index 7ce2b2afbd..4603b03a30 100644
--- a/packages/core-api/src/routing/RouteRef.ts
+++ b/packages/core-api/src/routing/RouteRef.ts
@@ -23,7 +23,7 @@ import {
ParamKeys,
OptionalParams,
} from './types';
-import { IconComponent } from '../icons';
+import { IconComponent } from '../icons/types';
// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused
export type RouteRefConfig = {
diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts
index 1fabd39cc4..e88b2f748c 100644
--- a/packages/core-api/src/routing/types.ts
+++ b/packages/core-api/src/routing/types.ts
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import { IconComponent } from '../icons';
+import { IconComponent } from '../icons/types';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
+import { RouteRef as NewRouteRef } from '@backstage/core-plugin-api';
export type AnyParams = { [param in string]: string } | undefined;
export type ParamKeys = keyof Params extends never
@@ -34,7 +35,11 @@ export type RouteFunc = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
-export const routeRefType: unique symbol = getOrCreateGlobalSingleton(
+type RouteRefType = Exclude<
+ keyof NewRouteRef,
+ 'params' | 'path' | 'title' | 'icon'
+>;
+export const routeRefType: RouteRefType = getOrCreateGlobalSingleton(
'route-ref-type',
() => Symbol('route-ref-type'),
);
diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts
index 8782280be0..ab0aa56a1b 100644
--- a/packages/core-api/src/types.ts
+++ b/packages/core-api/src/types.ts
@@ -42,6 +42,14 @@ export type Subscription = {
readonly closed: boolean;
};
+// Declares the global well-known Symbol.observable
+// We get the actual runtime polyfill from zen-observable
+declare global {
+ interface SymbolConstructor {
+ readonly observable: symbol;
+ }
+}
+
/**
* Observable sequence of values and errors, see TC39.
*
diff --git a/packages/core-app-api/.eslintrc.js b/packages/core-app-api/.eslintrc.js
new file mode 100644
index 0000000000..d592a653c8
--- /dev/null
+++ b/packages/core-app-api/.eslintrc.js
@@ -0,0 +1,8 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+ rules: {
+ // TODO: add prop types to JS and remove
+ 'react/prop-types': 0,
+ 'jest/expect-expect': 0,
+ },
+};
diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md
new file mode 100644
index 0000000000..f98cf7eeb1
--- /dev/null
+++ b/packages/core-app-api/CHANGELOG.md
@@ -0,0 +1,12 @@
+# @backstage/core-app-api
+
+## 0.1.1
+
+### Patch Changes
+
+- e7c5e4b30: Update installation instructions in README.
+- Updated dependencies [031ccd45f]
+- Updated dependencies [e7c5e4b30]
+ - @backstage/core-plugin-api@0.1.1
+ - @backstage/core-components@0.1.1
+ - @backstage/theme@0.2.8
diff --git a/packages/core-app-api/README.md b/packages/core-app-api/README.md
new file mode 100644
index 0000000000..f98021250f
--- /dev/null
+++ b/packages/core-app-api/README.md
@@ -0,0 +1,17 @@
+# @backstage/core-app-api
+
+This package provides the core API used by Backstage apps.
+
+## Installation
+
+Install the package via Yarn:
+
+```sh
+cd packages/app
+yarn add @backstage/core-app-api
+```
+
+## Documentation
+
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://backstage.io/docs)
diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md
new file mode 100644
index 0000000000..36c4f7419b
--- /dev/null
+++ b/packages/core-app-api/api-report.md
@@ -0,0 +1,398 @@
+## API Report File for "@backstage/core-app-api"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { AlertApi } from '@backstage/core-plugin-api';
+import { AlertMessage } from '@backstage/core-plugin-api';
+import { AnyApiFactory } from '@backstage/core-plugin-api';
+import { AnyApiRef } from '@backstage/core-plugin-api';
+import { ApiFactory } from '@backstage/core-plugin-api';
+import { ApiHolder } from '@backstage/core-plugin-api';
+import { ApiRef } from '@backstage/core-plugin-api';
+import { AppConfig } from '@backstage/config';
+import { AppTheme } from '@backstage/core-plugin-api';
+import { AppThemeApi } from '@backstage/core-plugin-api';
+import { auth0AuthApiRef } from '@backstage/core-plugin-api';
+import { AuthProvider } from '@backstage/core-plugin-api';
+import { AuthRequester } from '@backstage/core-plugin-api';
+import { AuthRequesterOptions } from '@backstage/core-plugin-api';
+import { AuthRequestOptions } from '@backstage/core-plugin-api';
+import { BackstageIdentity } from '@backstage/core-plugin-api';
+import { BackstageIdentityApi } from '@backstage/core-plugin-api';
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { ComponentType } from 'react';
+import { ConfigReader } from '@backstage/config';
+import { DiscoveryApi } from '@backstage/core-plugin-api';
+import { ErrorApi } from '@backstage/core-plugin-api';
+import { ErrorContext } from '@backstage/core-plugin-api';
+import { ExternalRouteRef } from '@backstage/core-plugin-api';
+import { FeatureFlag } from '@backstage/core-plugin-api';
+import { FeatureFlagsApi } from '@backstage/core-plugin-api';
+import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api';
+import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
+import { googleAuthApiRef } from '@backstage/core-plugin-api';
+import { IconComponent } from '@backstage/core-plugin-api';
+import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
+import { OAuthApi } from '@backstage/core-plugin-api';
+import { OAuthRequestApi } from '@backstage/core-plugin-api';
+import { Observable } from '@backstage/core-plugin-api';
+import { oktaAuthApiRef } from '@backstage/core-plugin-api';
+import { oneloginAuthApiRef } from '@backstage/core-plugin-api';
+import { OpenIdConnectApi } from '@backstage/core-plugin-api';
+import { PendingAuthRequest } from '@backstage/core-plugin-api';
+import { ProfileInfo } from '@backstage/core-plugin-api';
+import { ProfileInfoApi } from '@backstage/core-plugin-api';
+import { PropsWithChildren } from 'react';
+import PropTypes from 'prop-types';
+import { ReactNode } from 'react';
+import { RouteRef } from '@backstage/core-plugin-api';
+import { SessionApi } from '@backstage/core-plugin-api';
+import { SessionState } from '@backstage/core-plugin-api';
+import { StorageApi } from '@backstage/core-plugin-api';
+import { StorageValueChange } from '@backstage/core-plugin-api';
+import { SubRouteRef } from '@backstage/core-plugin-api';
+
+// @public
+export class AlertApiForwarder implements AlertApi {
+ // (undocumented)
+ alert$(): Observable;
+ // (undocumented)
+ post(alert: AlertMessage): void;
+ }
+
+// @public (undocumented)
+export type ApiFactoryHolder = {
+ get(api: ApiRef): ApiFactory | undefined;
+};
+
+// @public
+export class ApiFactoryRegistry implements ApiFactoryHolder {
+ // (undocumented)
+ get(api: ApiRef): ApiFactory | undefined;
+ // (undocumented)
+ getAllApis(): Set;
+ register(scope: ApiFactoryScope, factory: ApiFactory): boolean;
+}
+
+// @public (undocumented)
+export const ApiProvider: {
+ ({ apis, children, }: PropsWithChildren): JSX.Element;
+ propTypes: {
+ apis: PropTypes.Validator any>;
+ }>>;
+ children: PropTypes.Requireable;
+ };
+};
+
+// @public (undocumented)
+export class ApiRegistry implements ApiHolder {
+ constructor(apis: Map);
+ // (undocumented)
+ static builder(): ApiRegistryBuilder;
+ // (undocumented)
+ static from(apis: ApiImpl[]): ApiRegistry;
+ // (undocumented)
+ get(api: ApiRef): T | undefined;
+ static with(api: ApiRef, impl: T): ApiRegistry;
+ with(api: ApiRef, impl: T): ApiRegistry;
+}
+
+// @public (undocumented)
+export class ApiResolver implements ApiHolder {
+ constructor(factories: ApiFactoryHolder);
+ // (undocumented)
+ get(ref: ApiRef): T | undefined;
+ static validateFactories(factories: ApiFactoryHolder, apis: Iterable): void;
+}
+
+// @public (undocumented)
+export type AppComponents = {
+ NotFoundErrorPage: ComponentType<{}>;
+ BootErrorPage: ComponentType;
+ Progress: ComponentType<{}>;
+ Router: ComponentType<{}>;
+ SignInPage?: ComponentType;
+};
+
+// @public
+export type AppConfigLoader = () => Promise;
+
+// @public (undocumented)
+export type AppContext = {
+ getPlugins(): BackstagePlugin[];
+ getSystemIcon(key: string): IconComponent | undefined;
+ getComponents(): AppComponents;
+};
+
+// @public (undocumented)
+export type AppOptions = {
+ apis?: Iterable;
+ icons?: AppIcons & {
+ [key in string]: IconComponent;
+ };
+ plugins?: BackstagePlugin[];
+ components?: Partial;
+ themes?: AppTheme[];
+ configLoader?: AppConfigLoader;
+ bindRoutes?(context: {
+ bind: AppRouteBinder;
+ }): void;
+};
+
+// @public (undocumented)
+export type AppRouteBinder = (externalRoutes: ExternalRoutes, targetRoutes: PartialKeys, KeysWithType>>) => void;
+
+// @public (undocumented)
+export class AppThemeSelector implements AppThemeApi {
+ constructor(themes: AppTheme[]);
+ // (undocumented)
+ activeThemeId$(): Observable;
+ // (undocumented)
+ static createWithStorage(themes: AppTheme[]): AppThemeSelector;
+ // (undocumented)
+ getActiveThemeId(): string | undefined;
+ // (undocumented)
+ getInstalledThemes(): AppTheme[];
+ // (undocumented)
+ setActiveThemeId(themeId?: string): void;
+ }
+
+// @public (undocumented)
+export class Auth0Auth {
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
+}
+
+// @public (undocumented)
+export type BackstageApp = {
+ getPlugins(): BackstagePlugin[];
+ getSystemIcon(key: string): IconComponent | undefined;
+ getProvider(): ComponentType<{}>;
+ getRouter(): ComponentType<{}>;
+};
+
+// @public (undocumented)
+export type BootErrorPageProps = {
+ step: 'load-config' | 'load-chunk';
+ error: Error;
+};
+
+export { ConfigReader }
+
+// @public
+export function createApp(options?: AppOptions): PrivateAppImpl;
+
+// @public
+export class ErrorAlerter implements ErrorApi {
+ constructor(alertApi: AlertApi, errorApi: ErrorApi);
+ // (undocumented)
+ error$(): Observable<{
+ error: {
+ name: string;
+ message: string;
+ stack?: string | undefined;
+ };
+ context?: ErrorContext | undefined;
+ }>;
+ // (undocumented)
+ post(error: Error, context?: ErrorContext): void;
+}
+
+// @public
+export class ErrorApiForwarder implements ErrorApi {
+ // (undocumented)
+ error$(): Observable<{
+ error: Error;
+ context?: ErrorContext;
+ }>;
+ // (undocumented)
+ post(error: Error, context?: ErrorContext): void;
+ }
+
+// @public (undocumented)
+export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
+
+// @public (undocumented)
+export class GithubAuth implements OAuthApi, SessionApi {
+ constructor(sessionManager: SessionManager);
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): GithubAuth;
+ // (undocumented)
+ getAccessToken(scope?: string, options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getBackstageIdentity(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getProfile(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ static normalizeScope(scope?: string): Set;
+ // (undocumented)
+ sessionState$(): Observable;
+ // (undocumented)
+ signIn(): Promise;
+ // (undocumented)
+ signOut(): Promise;
+}
+
+// @public (undocumented)
+export type GithubSession = {
+ providerInfo: {
+ accessToken: string;
+ scopes: Set;
+ expiresAt: Date;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
+// @public (undocumented)
+export class GitlabAuth {
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
+}
+
+// @public (undocumented)
+export class GoogleAuth {
+ // (undocumented)
+ static create({ discoveryApi, oauthRequestApi, environment, provider, defaultScopes, }: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
+}
+
+// @public
+export class LocalStorageFeatureFlags implements FeatureFlagsApi {
+ // (undocumented)
+ getRegisteredFlags(): FeatureFlag[];
+ // (undocumented)
+ isActive(name: string): boolean;
+ // (undocumented)
+ registerFlag(flag: FeatureFlag): void;
+ // (undocumented)
+ save(options: FeatureFlagsSaveOptions): void;
+}
+
+// @public (undocumented)
+export class MicrosoftAuth {
+ // (undocumented)
+ static create({ environment, provider, oauthRequestApi, discoveryApi, defaultScopes, }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
+}
+
+// @public (undocumented)
+export class OAuth2 implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, BackstageIdentityApi, SessionApi {
+ constructor(options: Options);
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, scopeTransform, }: CreateOptions): OAuth2;
+ // (undocumented)
+ getAccessToken(scope?: string | string[], options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getBackstageIdentity(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getIdToken(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getProfile(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ sessionState$(): Observable;
+ // (undocumented)
+ signIn(): Promise;
+ // (undocumented)
+ signOut(): Promise;
+}
+
+// @public (undocumented)
+export type OAuth2Session = {
+ providerInfo: {
+ idToken: string;
+ accessToken: string;
+ scopes: Set;
+ expiresAt: Date;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
+// @public
+export class OAuthRequestManager implements OAuthRequestApi {
+ // (undocumented)
+ authRequest$(): Observable;
+ // (undocumented)
+ createAuthRequester(options: AuthRequesterOptions): AuthRequester;
+ }
+
+// @public (undocumented)
+export class OktaAuth {
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
+}
+
+// @public (undocumented)
+export class OneLoginAuth {
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, oauthRequestApi, }: CreateOptions_2): typeof oneloginAuthApiRef.T;
+}
+
+// @public (undocumented)
+export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
+ constructor(sessionManager: SessionManager);
+ // (undocumented)
+ static create({ discoveryApi, environment, provider, }: AuthApiCreateOptions): SamlAuth;
+ // (undocumented)
+ getBackstageIdentity(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ getProfile(options?: AuthRequestOptions): Promise;
+ // (undocumented)
+ sessionState$(): Observable;
+ // (undocumented)
+ signIn(): Promise;
+ // (undocumented)
+ signOut(): Promise;
+}
+
+// @public (undocumented)
+export type SignInPageProps = {
+ onResult(result: SignInResult): void;
+};
+
+// @public (undocumented)
+export type SignInResult = {
+ userId: string;
+ profile: ProfileInfo;
+ getIdToken?: () => Promise;
+ signOut?: () => Promise;
+};
+
+// @public
+export class UrlPatternDiscovery implements DiscoveryApi {
+ static compile(pattern: string): UrlPatternDiscovery;
+ // (undocumented)
+ getBaseUrl(pluginId: string): Promise;
+ }
+
+// @public (undocumented)
+export class WebStorage implements StorageApi {
+ constructor(namespace: string, errorApi: ErrorApi);
+ // (undocumented)
+ static create(options: CreateStorageApiOptions): WebStorage;
+ // (undocumented)
+ forBucket(name: string): WebStorage;
+ // (undocumented)
+ get(key: string): T | undefined;
+ // (undocumented)
+ observe$(key: string): Observable>;
+ // (undocumented)
+ remove(key: string): Promise;
+ // (undocumented)
+ set(key: string, data: T): Promise;
+ }
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts
new file mode 100644
index 0000000000..0e1d531226
--- /dev/null
+++ b/packages/core-app-api/config.d.ts
@@ -0,0 +1,113 @@
+/*
+ * 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 interface Config {
+ /**
+ * Generic frontend configuration.
+ */
+ app: {
+ /**
+ * The public absolute root URL that the frontend.
+ * @visibility frontend
+ */
+ baseUrl: string;
+
+ /**
+ * The title of the app, as shown in the Backstage web interface.
+ * @visibility frontend
+ */
+ title?: string;
+
+ /**
+ * Information about support of this Backstage instance and how to contact the integrator team.
+ */
+ support?: {
+ /**
+ * The primary support url.
+ * @visibility frontend
+ */
+ url: string;
+ /**
+ * A list of categorized support item groupings.
+ */
+ items: Array<{
+ /**
+ * The title of the support item grouping.
+ * @visibility frontend
+ */
+ title: string;
+ /**
+ * An optional icon for the support item grouping.
+ * @visibility frontend
+ */
+ icon?: string;
+ /**
+ * A list of support links for the Backstage instance inside this grouping.
+ */
+ links: Array<{
+ /** @visibility frontend */
+ url: string;
+ /** @visibility frontend */
+ title?: string;
+ }>;
+ }>;
+ };
+ };
+
+ /**
+ * Generic backend configuration.
+ */
+ backend: {
+ /**
+ * The public absolute root URL that the backend is reachable at, from the browser's perspective.
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+
+ /**
+ * Configuration that provides information about the organization that the app is for.
+ */
+ organization?: {
+ /**
+ * The name of the organization that the app belongs to.
+ * @visibility frontend
+ */
+ name?: string;
+ };
+
+ homepage?: {
+ clocks?: Array<{
+ /** @visibility frontend */
+ label: string;
+ /** @visibility frontend */
+ timezone: string;
+ }>;
+ };
+
+ /**
+ * Configuration that provides information on available configured authentication providers.
+ */
+ auth?: {
+ /**
+ * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`.
+ * default value: 'development'
+ * optional values: 'development' | 'production'
+ * @visibility frontend
+ */
+ environment?: string;
+ };
+}
diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json
new file mode 100644
index 0000000000..ddb598ce9b
--- /dev/null
+++ b/packages/core-app-api/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@backstage/core-app-api",
+ "description": "Core app API used by Backstage apps",
+ "version": "0.1.1",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "packages/core-app-api"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "license": "Apache-2.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "backstage-cli build --outputs types,esm",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/core-components": "^0.1.1",
+ "@backstage/config": "^0.1.3",
+ "@backstage/core-plugin-api": "^0.1.1",
+ "@backstage/theme": "^0.2.8",
+ "@material-ui/core": "^4.11.0",
+ "@material-ui/icons": "^4.9.1",
+ "@types/react": "^16.9",
+ "@types/prop-types": "^15.7.3",
+ "prop-types": "^15.7.2",
+ "react": "^16.12.0",
+ "react-router-dom": "6.0.0-beta.0",
+ "react-use": "^17.2.4",
+ "zen-observable": "^0.8.15"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.6.14",
+ "@backstage/test-utils": "^0.1.13",
+ "@backstage/test-utils-core": "^0.1.1",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^11.2.5",
+ "@testing-library/react-hooks": "^3.4.2",
+ "@testing-library/user-event": "^13.1.8",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^14.14.32",
+ "@types/zen-observable": "^0.8.0",
+ "cross-fetch": "^3.0.6",
+ "msw": "^0.21.3"
+ },
+ "files": [
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
+}
diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
similarity index 51%
rename from plugins/catalog/src/filter/useFilteredEntities.ts
rename to packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
index 2d7dcfd89d..28cb5bc068 100644
--- a/plugins/catalog/src/filter/useFilteredEntities.ts
+++ b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts
@@ -13,25 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-import { useContext } from 'react';
-import { filterGroupsContext } from './context';
+import { AlertApi, AlertMessage, Observable } from '@backstage/core-plugin-api';
+import { PublishSubject } from '../../../lib/subjects';
/**
- * Hook that exposes the result of applying a set of filter groups.
+ * Base implementation for the AlertApi that simply forwards alerts to consumers.
*/
-export function useFilteredEntities() {
- const context = useContext(filterGroupsContext);
- if (!context) {
- throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
+export class AlertApiForwarder implements AlertApi {
+ private readonly subject = new PublishSubject();
+
+ post(alert: AlertMessage) {
+ this.subject.next(alert);
}
- return {
- loading: context.loading,
- error: context.error,
- matchingEntities: context.matchingEntities,
- availableTags: context.availableTags,
- isCatalogEmpty: context.isCatalogEmpty,
- reload: context.reload,
- };
+ alert$(): Observable {
+ return this.subject;
+ }
}
diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/index.ts b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts
new file mode 100644
index 0000000000..12ab8bc60c
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { AlertApiForwarder } from './AlertApiForwarder';
diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts
new file mode 100644
index 0000000000..8d36e6b10e
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { AppTheme } from '@backstage/core-plugin-api';
+import { AppThemeSelector } from './AppThemeSelector';
+
+describe('AppThemeSelector', () => {
+ it('should should select new themes', async () => {
+ const selector = new AppThemeSelector([]);
+
+ expect(selector.getInstalledThemes()).toEqual([]);
+
+ const subFn = jest.fn();
+ selector.activeThemeId$().subscribe(subFn);
+ expect(selector.getActiveThemeId()).toBe(undefined);
+ await 'wait a tick';
+ expect(subFn).toHaveBeenLastCalledWith(undefined);
+
+ selector.setActiveThemeId('x');
+ expect(subFn).toHaveBeenLastCalledWith('x');
+ expect(selector.getActiveThemeId()).toBe('x');
+
+ selector.setActiveThemeId(undefined);
+ expect(subFn).toHaveBeenLastCalledWith(undefined);
+ expect(selector.getActiveThemeId()).toBe(undefined);
+ });
+
+ it('should return a new array of themes', () => {
+ const themes = new Array();
+ const selector = new AppThemeSelector(themes);
+
+ expect(selector.getInstalledThemes()).toEqual(themes);
+ expect(selector.getInstalledThemes()).not.toBe(themes);
+ });
+
+ it('should store theme in local storage', async () => {
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
+ undefined,
+ );
+ localStorage.setItem('theme', 'x');
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
+ localStorage.removeItem('theme');
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
+ undefined,
+ );
+
+ const addListenerSpy = jest.spyOn(window, 'addEventListener');
+ const selector = AppThemeSelector.createWithStorage([]);
+
+ expect(addListenerSpy).toHaveBeenCalledTimes(1);
+ expect(addListenerSpy).toHaveBeenCalledWith(
+ 'storage',
+ expect.any(Function),
+ );
+
+ selector.setActiveThemeId('y');
+ await 'wait a tick';
+ expect(localStorage.getItem('theme')).toBe('y');
+
+ selector.setActiveThemeId(undefined);
+ await 'wait a tick';
+ expect(localStorage.getItem('theme')).toBe(null);
+
+ localStorage.setItem('theme', 'z');
+ expect(selector.getActiveThemeId()).toBe(undefined);
+
+ const listener = addListenerSpy.mock.calls[0][1] as EventListener;
+ listener({ key: 'theme' } as StorageEvent);
+
+ expect(selector.getActiveThemeId()).toBe('z');
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts
new file mode 100644
index 0000000000..3bd4d764ea
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppThemeApi, AppTheme, Observable } from '@backstage/core-plugin-api';
+import { BehaviorSubject } from '../../../lib/subjects';
+
+const STORAGE_KEY = 'theme';
+
+export class AppThemeSelector implements AppThemeApi {
+ static createWithStorage(themes: AppTheme[]) {
+ const selector = new AppThemeSelector(themes);
+
+ if (!window.localStorage) {
+ return selector;
+ }
+
+ const initialThemeId =
+ window.localStorage.getItem(STORAGE_KEY) ?? undefined;
+
+ selector.setActiveThemeId(initialThemeId);
+
+ selector.activeThemeId$().subscribe(themeId => {
+ if (themeId) {
+ window.localStorage.setItem(STORAGE_KEY, themeId);
+ } else {
+ window.localStorage.removeItem(STORAGE_KEY);
+ }
+ });
+
+ window.addEventListener('storage', event => {
+ if (event.key === STORAGE_KEY) {
+ const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
+ selector.setActiveThemeId(themeId);
+ }
+ });
+
+ return selector;
+ }
+
+ private activeThemeId: string | undefined;
+ private readonly subject = new BehaviorSubject(undefined);
+
+ constructor(private readonly themes: AppTheme[]) {}
+
+ getInstalledThemes(): AppTheme[] {
+ return this.themes.slice();
+ }
+
+ activeThemeId$(): Observable {
+ return this.subject;
+ }
+
+ getActiveThemeId(): string | undefined {
+ return this.activeThemeId;
+ }
+
+ setActiveThemeId(themeId?: string): void {
+ this.activeThemeId = themeId;
+ this.subject.next(themeId);
+ }
+}
diff --git a/plugins/catalog/src/components/CatalogFilter/index.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts
similarity index 92%
rename from plugins/catalog/src/components/CatalogFilter/index.ts
rename to packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts
index 5103b16307..cb42c0f875 100644
--- a/plugins/catalog/src/components/CatalogFilter/index.ts
+++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { CatalogFilter } from './CatalogFilter';
+export * from './AppThemeSelector';
diff --git a/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts
new file mode 100644
index 0000000000..7c7f88a3e5
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { ConfigReader } from '@backstage/config';
diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts
new file mode 100644
index 0000000000..9597443b98
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { UrlPatternDiscovery } from './UrlPatternDiscovery';
+
+describe('UrlPatternDiscovery', () => {
+ it('should not require interpolation', async () => {
+ const discoveryApi = UrlPatternDiscovery.compile('http://example.com');
+ await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
+ 'http://example.com',
+ );
+ });
+
+ it('should use a plain pattern', async () => {
+ const discoveryApi = UrlPatternDiscovery.compile(
+ 'http://localhost:7000/{{ pluginId }}',
+ );
+ await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
+ 'http://localhost:7000/my-plugin',
+ );
+ });
+
+ it('should allow for multiple interpolation points', async () => {
+ const discoveryApi = UrlPatternDiscovery.compile(
+ 'https://{{pluginId }}.example.com/api/{{ pluginId}}',
+ );
+ await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
+ 'https://my-plugin.example.com/api/my-plugin',
+ );
+ });
+
+ it('should validate that the pattern is a valid URL', () => {
+ expect(() => {
+ UrlPatternDiscovery.compile('example.com');
+ }).toThrow('Invalid discovery URL pattern, Invalid URL: example.com');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://');
+ }).toThrow('Invalid discovery URL pattern, Invalid URL: http://');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('abc123');
+ }).toThrow('Invalid discovery URL pattern, Invalid URL: abc123');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://example.com:{{pluginId}}');
+ }).toThrow(
+ 'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId',
+ );
+
+ expect(() => {
+ UrlPatternDiscovery.compile('/{{pluginId}}');
+ }).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden');
+ }).toThrow('Invalid discovery URL pattern, URL must not have a query');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden');
+ }).toThrow('Invalid discovery URL pattern, URL must not have a hash');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/');
+ }).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
+
+ expect(() => {
+ UrlPatternDiscovery.compile('http://localhost/');
+ }).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts
new file mode 100644
index 0000000000..a9decd9ef9
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 { DiscoveryApi } from '@backstage/core-plugin-api';
+
+/**
+ * UrlPatternDiscovery is a lightweight DiscoveryApi implementation.
+ * It uses a single template string to construct URLs for each plugin.
+ */
+export class UrlPatternDiscovery implements DiscoveryApi {
+ /**
+ * Creates a new UrlPatternDiscovery given a template. The the only
+ * interpolation done for the template is to replace instances of `{{pluginId}}`
+ * with the ID of the plugin being requested.
+ *
+ * Example pattern: `http://localhost:7000/api/{{ pluginId }}`
+ */
+ static compile(pattern: string): UrlPatternDiscovery {
+ const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/);
+
+ try {
+ const urlStr = parts.join('pluginId');
+ const url = new URL(urlStr);
+ if (url.hash) {
+ throw new Error('URL must not have a hash');
+ }
+ if (url.search) {
+ throw new Error('URL must not have a query');
+ }
+ if (urlStr.endsWith('/')) {
+ throw new Error('URL must not end with a slash');
+ }
+ } catch (error) {
+ throw new Error(`Invalid discovery URL pattern, ${error.message}`);
+ }
+
+ return new UrlPatternDiscovery(parts);
+ }
+
+ private constructor(private readonly parts: string[]) {}
+
+ async getBaseUrl(pluginId: string): Promise {
+ return this.parts.join(pluginId);
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts
new file mode 100644
index 0000000000..24468fdcb6
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { UrlPatternDiscovery } from './UrlPatternDiscovery';
diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts
new file mode 100644
index 0000000000..73d5042c11
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api';
+
+/**
+ * Decorates an ErrorApi by also forwarding error messages
+ * to the alertApi with an 'error' severity.
+ */
+export class ErrorAlerter implements ErrorApi {
+ constructor(
+ private readonly alertApi: AlertApi,
+ private readonly errorApi: ErrorApi,
+ ) {}
+
+ post(error: Error, context?: ErrorContext) {
+ if (!context?.hidden) {
+ this.alertApi.post({ message: error.message, severity: 'error' });
+ }
+
+ return this.errorApi.post(error, context);
+ }
+
+ error$() {
+ return this.errorApi.error$();
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts
new file mode 100644
index 0000000000..875d07c0a3
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ErrorApi, ErrorContext, Observable } from '@backstage/core-plugin-api';
+import { PublishSubject } from '../../../lib/subjects';
+
+/**
+ * Base implementation for the ErrorApi that simply forwards errors to consumers.
+ */
+export class ErrorApiForwarder implements ErrorApi {
+ private readonly subject = new PublishSubject<{
+ error: Error;
+ context?: ErrorContext;
+ }>();
+
+ post(error: Error, context?: ErrorContext) {
+ this.subject.next({ error, context });
+ }
+
+ error$(): Observable<{ error: Error; context?: ErrorContext }> {
+ return this.subject;
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts
new file mode 100644
index 0000000000..757dfd0d8f
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { ErrorAlerter } from './ErrorAlerter';
+export { ErrorApiForwarder } from './ErrorApiForwarder';
diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
new file mode 100644
index 0000000000..a100f01a52
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
@@ -0,0 +1,222 @@
+/*
+ * 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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
+import { FeatureFlagState, FeatureFlagsApi } from '@backstage/core-plugin-api';
+
+describe('FeatureFlags', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ describe('getFlags', () => {
+ let featureFlags: FeatureFlagsApi;
+
+ beforeEach(() => {
+ featureFlags = new LocalStorageFeatureFlags();
+ });
+
+ it('returns no flags', () => {
+ expect(featureFlags.getRegisteredFlags()).toEqual([]);
+ });
+
+ it('loads flags from local storage', () => {
+ window.localStorage.setItem(
+ 'featureFlags',
+ JSON.stringify({
+ 'feature-flag-one': 1,
+ 'feature-flag-two': 1,
+ 'feature-flag-three': 0,
+ 'feature-flag-four': 2,
+ 'feature-flag-five': 'not-valid',
+ }),
+ );
+
+ expect(featureFlags.isActive('feature-flag-one')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-four')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-five')).toBe(false);
+ });
+
+ it('sets the correct values', () => {
+ featureFlags.save({
+ states: {
+ 'feature-flag-zero': FeatureFlagState.Active,
+ },
+ });
+
+ expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
+ expect(window.localStorage.getItem('featureFlags')).toEqual(
+ '{"feature-flag-zero":1}',
+ );
+ });
+
+ it('deletes the correct values', () => {
+ window.localStorage.setItem(
+ 'featureFlags',
+ JSON.stringify({
+ 'feature-flag-one': 1,
+ 'feature-flag-two': 0,
+ 'feature-flag-tree': 1,
+ 'feature-flag-four': 0,
+ }),
+ );
+
+ featureFlags.save({
+ states: {
+ 'feature-flag-one': FeatureFlagState.None,
+ 'feature-flag-two': FeatureFlagState.Active,
+ },
+ });
+
+ expect(window.localStorage.getItem('featureFlags')).toEqual(
+ '{"feature-flag-two":1}',
+ );
+ });
+
+ it('clears all values', () => {
+ window.localStorage.setItem(
+ 'featureFlags',
+ JSON.stringify({
+ 'feature-flag-one': 1,
+ 'feature-flag-two': 1,
+ 'feature-flag-three': 0,
+ }),
+ );
+
+ expect(featureFlags.isActive('feature-flag-one')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
+
+ featureFlags.save({ states: {} });
+
+ expect(featureFlags.isActive('feature-flag-one')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
+
+ expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
+ });
+ });
+
+ describe('getRegisteredFlags', () => {
+ let featureFlags: FeatureFlagsApi;
+
+ beforeEach(() => {
+ featureFlags = new LocalStorageFeatureFlags();
+ featureFlags.registerFlag({
+ name: 'registered-flag-1',
+ pluginId: 'plugin-one',
+ });
+ featureFlags.registerFlag({
+ name: 'registered-flag-2',
+ pluginId: 'plugin-one',
+ });
+ featureFlags.registerFlag({
+ name: 'registered-flag-3',
+ pluginId: 'plugin-two',
+ });
+ });
+
+ it('should return an empty list', () => {
+ featureFlags = new LocalStorageFeatureFlags();
+ expect(featureFlags.getRegisteredFlags()).toEqual([]);
+ });
+
+ it('should return an valid list', () => {
+ expect(featureFlags.getRegisteredFlags()).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ { name: 'registered-flag-3', pluginId: 'plugin-two' },
+ ]);
+ });
+
+ it('should provide a copy of the list of flags', () => {
+ const flags = featureFlags.getRegisteredFlags();
+ expect(flags).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ { name: 'registered-flag-3', pluginId: 'plugin-two' },
+ ]);
+ flags.splice(2, 1);
+ expect(flags).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ ]);
+ expect(featureFlags.getRegisteredFlags()).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ { name: 'registered-flag-3', pluginId: 'plugin-two' },
+ ]);
+ });
+
+ it('should get the correct values', () => {
+ const getByName = (name: string) =>
+ featureFlags.getRegisteredFlags().find(flag => flag.name === name);
+
+ expect(getByName('registered-flag-0')).toBeUndefined();
+ expect(getByName('registered-flag-1')).toEqual({
+ name: 'registered-flag-1',
+ pluginId: 'plugin-one',
+ });
+ expect(getByName('registered-flag-2')).toEqual({
+ name: 'registered-flag-2',
+ pluginId: 'plugin-one',
+ });
+ expect(getByName('registered-flag-3')).toEqual({
+ name: 'registered-flag-3',
+ pluginId: 'plugin-two',
+ });
+ });
+
+ it('throws an error if length is less than three characters', () => {
+ expect(() =>
+ featureFlags.registerFlag({
+ name: 'ab',
+ pluginId: 'plugin-three',
+ }),
+ ).toThrow(/minimum length of three characters/i);
+ });
+
+ it('throws an error if length is greater than 150 characters', () => {
+ expect(() =>
+ featureFlags.registerFlag({
+ name:
+ 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
+ pluginId: 'plugin-three',
+ }),
+ ).toThrow(/not exceed 150 characters/i);
+ });
+
+ it('throws an error if name does not start with a lowercase letter', () => {
+ expect(() =>
+ featureFlags.registerFlag({
+ name: '123456789',
+ pluginId: 'plugin-three',
+ }),
+ ).toThrow(/start with a lowercase letter/i);
+ });
+
+ it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
+ expect(() =>
+ featureFlags.registerFlag({
+ name: 'Invalid_Feature_Flag',
+ pluginId: 'plugin-three',
+ }),
+ ).toThrow(/only contain lowercase letters, numbers and hyphens/i);
+ });
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
new file mode 100644
index 0000000000..800ae98f24
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
@@ -0,0 +1,109 @@
+/*
+ * 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 {
+ FeatureFlagState,
+ FeatureFlagsApi,
+ FeatureFlag,
+ FeatureFlagsSaveOptions,
+} from '@backstage/core-plugin-api';
+
+export function validateFlagName(name: string): void {
+ if (name.length < 3) {
+ throw new Error(
+ `The '${name}' feature flag must have a minimum length of three characters.`,
+ );
+ }
+
+ if (name.length > 150) {
+ throw new Error(
+ `The '${name}' feature flag must not exceed 150 characters.`,
+ );
+ }
+
+ if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
+ throw new Error(
+ `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
+ 'Examples: feature-flag-one, alpha, release-2020',
+ );
+ }
+}
+
+/**
+ * Create the FeatureFlags implementation based on the API.
+ */
+export class LocalStorageFeatureFlags implements FeatureFlagsApi {
+ private registeredFeatureFlags: FeatureFlag[] = [];
+ private flags?: Map;
+
+ registerFlag(flag: FeatureFlag) {
+ validateFlagName(flag.name);
+ this.registeredFeatureFlags.push(flag);
+ }
+
+ getRegisteredFlags(): FeatureFlag[] {
+ return this.registeredFeatureFlags.slice();
+ }
+
+ isActive(name: string): boolean {
+ if (!this.flags) {
+ this.flags = this.load();
+ }
+ return this.flags.get(name) === FeatureFlagState.Active;
+ }
+
+ save(options: FeatureFlagsSaveOptions): void {
+ if (!this.flags) {
+ this.flags = this.load();
+ }
+ if (!options.merge) {
+ this.flags.clear();
+ }
+ for (const [name, state] of Object.entries(options.states)) {
+ this.flags.set(name, state);
+ }
+
+ const enabled = Array.from(this.flags.entries()).filter(
+ ([, state]) => state === FeatureFlagState.Active,
+ );
+ window.localStorage.setItem(
+ 'featureFlags',
+ JSON.stringify(Object.fromEntries(enabled)),
+ );
+ }
+
+ private load(): Map {
+ try {
+ const jsonStr = window.localStorage.getItem('featureFlags');
+ if (!jsonStr) {
+ return new Map();
+ }
+ const json = JSON.parse(jsonStr) as unknown;
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
+ return new Map();
+ }
+
+ const entries = Object.entries(json).filter(([name, value]) => {
+ validateFlagName(name);
+ return value === FeatureFlagState.Active;
+ });
+
+ return new Map(entries);
+ } catch {
+ return new Map();
+ }
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts
new file mode 100644
index 0000000000..33990584f3
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts
new file mode 100644
index 0000000000..32170acc6f
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts
@@ -0,0 +1,101 @@
+/*
+ * 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 MockOAuthApi from './MockOAuthApi';
+import PowerIcon from '@material-ui/icons/Power';
+
+describe('MockOAuthApi', () => {
+ it('should trigger all requests', async () => {
+ const authResult = { is: 'done' };
+ const mock = new MockOAuthApi();
+
+ const authHandler1 = jest.fn().mockImplementation(() => authResult);
+ const requester1 = mock.createAuthRequester({
+ provider: { icon: PowerIcon, title: 'Test' },
+ onAuthRequest: authHandler1,
+ });
+
+ const authHandler2 = jest.fn().mockResolvedValue('other');
+ const requester2 = mock.createAuthRequester({
+ provider: { icon: PowerIcon, title: 'Test' },
+ onAuthRequest: authHandler2,
+ });
+
+ const promises = [
+ requester1(new Set(['a'])),
+ requester1(new Set(['b'])),
+ requester2(new Set(['a', 'b'])),
+ requester2(new Set(['b', 'c'])),
+ requester2(new Set(['c', 'a'])),
+ ];
+
+ await expect(
+ Promise.race([Promise.all(promises), 'waiting']),
+ ).resolves.toBe('waiting');
+
+ await mock.triggerAll();
+
+ await expect(Promise.all(promises)).resolves.toEqual([
+ authResult,
+ authResult,
+ 'other',
+ 'other',
+ 'other',
+ ]);
+
+ expect(authHandler1).toHaveBeenCalledTimes(1);
+ expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b']));
+ expect(authHandler2).toHaveBeenCalledTimes(1);
+ expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c']));
+ });
+
+ it('should reject all requests', async () => {
+ const mock = new MockOAuthApi();
+
+ const authHandler1 = jest.fn();
+ const requester1 = mock.createAuthRequester({
+ provider: { icon: PowerIcon, title: 'Test' },
+ onAuthRequest: authHandler1,
+ });
+
+ const authHandler2 = jest.fn();
+ const requester2 = mock.createAuthRequester({
+ provider: { icon: PowerIcon, title: 'Test' },
+ onAuthRequest: authHandler2,
+ });
+
+ const promises = [
+ requester1(new Set(['a'])),
+ requester1(new Set(['b'])),
+ requester2(new Set(['a', 'b'])),
+ requester2(new Set(['b', 'c'])),
+ requester2(new Set(['c', 'a'])),
+ ];
+
+ await expect(
+ Promise.race([Promise.all(promises), 'waiting']),
+ ).resolves.toBe('waiting');
+
+ await mock.rejectAll();
+
+ for (const promise of promises) {
+ await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
+ }
+
+ expect(authHandler1).not.toHaveBeenCalled();
+ expect(authHandler2).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts
new file mode 100644
index 0000000000..46d620c329
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 {
+ OAuthRequestApi,
+ AuthRequesterOptions,
+} from '@backstage/core-plugin-api';
+import { OAuthRequestManager } from './OAuthRequestManager';
+
+export default class MockOAuthApi implements OAuthRequestApi {
+ private readonly real = new OAuthRequestManager();
+
+ createAuthRequester(options: AuthRequesterOptions) {
+ return this.real.createAuthRequester(options);
+ }
+
+ authRequest$() {
+ return this.real.authRequest$();
+ }
+
+ async triggerAll() {
+ await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
+
+ return new Promise(resolve => {
+ const subscription = this.authRequest$().subscribe(requests => {
+ subscription.unsubscribe();
+ Promise.all(requests.map(request => request.trigger())).then(() =>
+ resolve(),
+ );
+ });
+ });
+ }
+
+ async rejectAll() {
+ await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
+
+ return new Promise(resolve => {
+ const subscription = this.authRequest$().subscribe(requests => {
+ subscription.unsubscribe();
+ requests.map(request => request.reject());
+ resolve();
+ });
+ });
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts
new file mode 100644
index 0000000000..280321daa0
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 { waitFor } from '@testing-library/react';
+import { OAuthPendingRequests } from './OAuthPendingRequests';
+
+describe('OAuthPendingRequests', () => {
+ it('notifies new observers about current state', async () => {
+ const target = new OAuthPendingRequests();
+ const next = jest.fn();
+ const error = jest.fn();
+
+ const input = new Set(['a', 'b']);
+ target.pending().subscribe({ next, error });
+ target.request(input);
+
+ await waitFor(() => expect(next).toBeCalledTimes(2));
+ expect(next.mock.calls[0][0].scopes).toBeUndefined();
+ expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
+ expect(error.mock.calls.length).toBe(0);
+ });
+
+ it('resolves requests and notifies observers', async () => {
+ const target = new OAuthPendingRequests();
+ const next = jest.fn();
+ const error = jest.fn();
+
+ const request1 = target.request(new Set(['a']));
+ const request2 = target.request(new Set(['a']));
+ target.pending().subscribe({ next, error });
+ target.resolve(new Set(['a']), 'session1');
+ target.resolve(new Set(['a']), 'session2');
+
+ await expect(request1).resolves.toBe('session1');
+ await expect(request2).resolves.toBe('session1');
+ expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
+ expect(error).toBeCalledTimes(0);
+ });
+
+ it('can resolve through the observable', async () => {
+ const target = new OAuthPendingRequests();
+ const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
+ const error = jest.fn();
+
+ const request1 = target.request(new Set(['a']));
+ target.pending().subscribe({ next, error });
+
+ await expect(request1).resolves.toBe('done');
+ expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
+ expect(error).toBeCalledTimes(0);
+ });
+
+ it('rejects requests and notifies observers only once', async () => {
+ const target = new OAuthPendingRequests();
+ const next = jest.fn();
+ const error = jest.fn();
+ const rejection = new Error('eek');
+
+ const request1 = target.request(new Set(['a']));
+ const request2 = target.request(new Set(['a']));
+ target.pending().subscribe({ next, error });
+ target.reject(rejection);
+ target.resolve(new Set(['a']), 'session');
+
+ await expect(request1).rejects.toBe(rejection);
+ await expect(request2).rejects.toBe(rejection);
+ expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
+ expect(error).toBeCalledTimes(0);
+ });
+
+ it('can reject through the observable', async () => {
+ const target = new OAuthPendingRequests();
+ const rejection = new Error('nope');
+ const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
+ const error = jest.fn();
+
+ const request1 = target.request(new Set(['a']));
+ target.pending().subscribe({ next, error });
+
+ await expect(request1).rejects.toBe(rejection);
+ expect(next).toBeCalledTimes(2);
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts
new file mode 100644
index 0000000000..370ba9a3a1
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts
@@ -0,0 +1,126 @@
+/*
+ * 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 { Observable } from '@backstage/core-plugin-api';
+import { BehaviorSubject } from '../../../lib/subjects';
+
+type RequestQueueEntry = {
+ scopes: Set;
+ resolve: (value: ResultType | PromiseLike) => void;
+ reject: (reason: Error) => void;
+};
+
+export type PendingRequest = {
+ scopes: Set | undefined;
+ resolve: (value: ResultType) => void;
+ reject: (reason: Error) => void;
+};
+
+export function hasScopes(
+ searched: Set,
+ searchFor: Set,
+): boolean {
+ for (const scope of searchFor) {
+ if (!searched.has(scope)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function joinScopes(
+ scopes: Set,
+ ...moreScopess: Set[]
+): Set {
+ const result = new Set(scopes);
+
+ for (const moreScopes of moreScopess) {
+ for (const scope of moreScopes) {
+ result.add(scope);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * The OAuthPendingRequests class is a utility for managing and observing
+ * a stream of requests for oauth scopes for a single provider, and resolving
+ * them correctly once requests are fulfilled.
+ */
+export class OAuthPendingRequests {
+ private requests: RequestQueueEntry[] = [];
+ private subject = new BehaviorSubject>(
+ this.getCurrentPending(),
+ );
+
+ request(scopes: Set): Promise {
+ return new Promise((resolve, reject) => {
+ this.requests.push({ scopes, resolve, reject });
+
+ this.subject.next(this.getCurrentPending());
+ });
+ }
+
+ resolve(scopes: Set, result: ResultType): void {
+ this.requests = this.requests.filter(request => {
+ if (hasScopes(scopes, request.scopes)) {
+ request.resolve(result);
+ return false;
+ }
+ return true;
+ });
+
+ this.subject.next(this.getCurrentPending());
+ }
+
+ reject(error: Error) {
+ this.requests.forEach(request => request.reject(error));
+ this.requests = [];
+
+ this.subject.next(this.getCurrentPending());
+ }
+
+ pending(): Observable> {
+ return this.subject;
+ }
+
+ private getCurrentPending(): PendingRequest {
+ const currentScopes =
+ this.requests.length === 0
+ ? undefined
+ : this.requests
+ .slice(1)
+ .reduce(
+ (acc, current) => joinScopes(acc, current.scopes),
+ this.requests[0].scopes,
+ );
+
+ return {
+ scopes: currentScopes,
+ resolve: (value: ResultType) => {
+ if (currentScopes) {
+ this.resolve(currentScopes, value);
+ }
+ },
+ reject: (reason: Error) => {
+ if (currentScopes) {
+ this.reject(reason);
+ }
+ },
+ };
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts
new file mode 100644
index 0000000000..46a5362f35
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 ProviderIcon from '@material-ui/icons/AcUnit';
+import { OAuthRequestManager } from './OAuthRequestManager';
+
+describe('OAuthRequestManager', () => {
+ it('should forward a requests', async () => {
+ const manager = new OAuthRequestManager();
+
+ const reqSpy = jest.fn();
+ manager.authRequest$().subscribe(reqSpy);
+
+ const requester = manager.createAuthRequester({
+ provider: {
+ title: 'My Provider',
+ icon: ProviderIcon,
+ },
+ onAuthRequest: async () => 'hello',
+ });
+
+ expect(reqSpy).toHaveBeenCalledTimes(0);
+ await 'a tick';
+ expect(reqSpy).toHaveBeenCalledTimes(2);
+ expect(reqSpy).toHaveBeenLastCalledWith([]);
+
+ const req = requester(new Set(['my-scope']));
+
+ expect(reqSpy).toHaveBeenCalledTimes(3);
+ expect(reqSpy).toHaveBeenLastCalledWith([
+ expect.objectContaining({
+ reject: expect.any(Function),
+ trigger: expect.any(Function),
+ }),
+ ]);
+
+ await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe(
+ 'not yet',
+ );
+
+ const [request] = reqSpy.mock.calls[2][0];
+ request.trigger();
+
+ await expect(req).resolves.toBe('hello');
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts
new file mode 100644
index 0000000000..e50c18d271
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 {
+ OAuthRequestApi,
+ PendingAuthRequest,
+ AuthRequester,
+ AuthRequesterOptions,
+ Observable,
+} from '@backstage/core-plugin-api';
+import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
+import { BehaviorSubject } from '../../../lib/subjects';
+
+/**
+ * The OAuthRequestManager is an implementation of the OAuthRequestApi.
+ *
+ * The purpose of this class and the API is to read a stream of incoming requests
+ * of OAuth access tokens from different providers with varying scope, and funnel
+ * them all together into a single request for each OAuth provider.
+ */
+export class OAuthRequestManager implements OAuthRequestApi {
+ private readonly subject = new BehaviorSubject([]);
+ private currentRequests: PendingAuthRequest[] = [];
+ private handlerCount = 0;
+
+ createAuthRequester(options: AuthRequesterOptions): AuthRequester {
+ const handler = new OAuthPendingRequests();
+
+ const index = this.handlerCount;
+ this.handlerCount++;
+
+ handler.pending().subscribe({
+ next: scopeRequest => {
+ const newRequests = this.currentRequests.slice();
+ const request = this.makeAuthRequest(scopeRequest, options);
+ if (!request) {
+ delete newRequests[index];
+ } else {
+ newRequests[index] = request;
+ }
+ this.currentRequests = newRequests;
+ // Convert from sparse array to array of present items only
+ this.subject.next(newRequests.filter(Boolean));
+ },
+ });
+
+ return scopes => {
+ return handler.request(scopes);
+ };
+ }
+
+ // Converts the pending request and popup options into a popup request that we can forward to subscribers.
+ private makeAuthRequest(
+ request: PendingRequest,
+ options: AuthRequesterOptions,
+ ): PendingAuthRequest | undefined {
+ const { scopes } = request;
+ if (!scopes) {
+ return undefined;
+ }
+
+ return {
+ provider: options.provider,
+ trigger: async () => {
+ const result = await options.onAuthRequest(scopes);
+ request.resolve(result);
+ },
+ reject: () => {
+ const error = new Error('Login failed, rejected by user');
+ error.name = 'RejectedError';
+ request.reject(error);
+ },
+ };
+ }
+
+ authRequest$(): Observable {
+ return this.subject;
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts
new file mode 100644
index 0000000000..0fe9bfae0f
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { OAuthRequestManager } from './OAuthRequestManager';
diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts
new file mode 100644
index 0000000000..da5b2509db
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts
@@ -0,0 +1,173 @@
+/*
+ * 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 { CreateStorageApiOptions, WebStorage } from './WebStorage';
+import { StorageApi } from '@backstage/core-plugin-api';
+
+describe('WebStorage Storage API', () => {
+ const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
+ const createWebStorage = (
+ args?: Partial,
+ ): StorageApi => {
+ return WebStorage.create({
+ errorApi: mockErrorApi,
+ ...args,
+ });
+ };
+ it('should return undefined for values which are unset', async () => {
+ const storage = createWebStorage();
+
+ expect(storage.get('myfakekey')).toBeUndefined();
+ });
+
+ it('should allow the setting and getting of the simple data structures', async () => {
+ const storage = createWebStorage();
+
+ await storage.set('myfakekey', 'helloimastring');
+ await storage.set('mysecondfakekey', 1234);
+ await storage.set('mythirdfakekey', true);
+ expect(storage.get('myfakekey')).toBe('helloimastring');
+ expect(storage.get('mysecondfakekey')).toBe(1234);
+ expect(storage.get('mythirdfakekey')).toBe(true);
+ });
+
+ it('should allow setting of complex datastructures', async () => {
+ const storage = createWebStorage();
+
+ const mockData = {
+ something: 'here',
+ is: [{ super: { complex: [{ but: 'something', why: true }] } }],
+ };
+
+ await storage.set('myfakekey', mockData);
+
+ expect(storage.get('myfakekey')).toEqual(mockData);
+ });
+
+ it('should subscribe to key changes when setting a new value', async () => {
+ const storage = createWebStorage();
+
+ const wrongKeyNextHandler = jest.fn();
+ const selectedKeyNextHandler = jest.fn();
+ const mockData = { hello: 'im a great new value' };
+
+ await new Promise(resolve => {
+ storage.observe$('correctKey').subscribe({
+ next: (...args) => {
+ selectedKeyNextHandler(...args);
+ resolve();
+ },
+ });
+
+ storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
+
+ storage.set('correctKey', mockData);
+ });
+
+ expect(wrongKeyNextHandler).not.toHaveBeenCalled();
+ expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
+ expect(selectedKeyNextHandler).toHaveBeenCalledWith({
+ key: 'correctKey',
+ newValue: mockData,
+ });
+ });
+
+ it('should subscribe to key changes when deleting a value', async () => {
+ const storage = createWebStorage();
+
+ const wrongKeyNextHandler = jest.fn();
+ const selectedKeyNextHandler = jest.fn();
+ const mockData = { hello: 'im a great new value' };
+
+ storage.set('correctKey', mockData);
+
+ await new Promise(resolve => {
+ storage.observe$('correctKey').subscribe({
+ next: (...args) => {
+ selectedKeyNextHandler(...args);
+ resolve();
+ },
+ });
+
+ storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
+
+ storage.remove('correctKey');
+ });
+
+ expect(wrongKeyNextHandler).not.toHaveBeenCalled();
+ expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
+ expect(selectedKeyNextHandler).toHaveBeenCalledWith({
+ key: 'correctKey',
+ newValue: undefined,
+ });
+ });
+
+ it('should be able to create different buckets for different uses', async () => {
+ const rootStorage = createWebStorage();
+
+ const firstStorage = rootStorage.forBucket('userSettings');
+ const secondStorage = rootStorage.forBucket('profileSettings');
+ const keyName = 'blobby';
+
+ await firstStorage.set(keyName, 'boop');
+ await secondStorage.set(keyName, 'deerp');
+
+ expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
+ expect(firstStorage.get(keyName)).toBe('boop');
+ expect(secondStorage.get(keyName)).toBe('deerp');
+ });
+
+ it('should not clash with other namesapces when creating buckets', async () => {
+ const rootStorage = createWebStorage();
+
+ // when getting key test2 it will translate to /profile/something/deep/test2
+ const firstStorage = rootStorage
+ .forBucket('profile')
+ .forBucket('something')
+ .forBucket('deep');
+ // when getting key deep/test2 it will translate to /profile/something/deep/test2
+ const secondStorage = rootStorage.forBucket('profile/something');
+
+ await firstStorage.set('test2', { error: true });
+
+ expect(secondStorage.get('deep/test2')).toBe(undefined);
+ });
+
+ it('should call the error api when the json can not be parsed in local storage', async () => {
+ const rootStorage = createWebStorage({
+ namespace: '/Test/Mock/Thing',
+ });
+
+ localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
+
+ const value = rootStorage.get('key');
+
+ expect(value).toBe(undefined);
+ expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
+ expect(mockErrorApi.post).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Error when parsing JSON config from storage for: key',
+ }),
+ );
+ });
+
+ it('should return a singleton for the same namespace and same bucket', async () => {
+ const rootStorage = createWebStorage({
+ namespace: '/Test/Mock/Thing/Thing ',
+ });
+
+ expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts
new file mode 100644
index 0000000000..3f49b644f6
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 {
+ StorageApi,
+ StorageValueChange,
+ ErrorApi,
+ Observable,
+} from '@backstage/core-plugin-api';
+import ObservableImpl from 'zen-observable';
+
+const buckets = new Map();
+
+export type CreateStorageApiOptions = {
+ errorApi: ErrorApi;
+ namespace?: string;
+};
+
+export class WebStorage implements StorageApi {
+ constructor(
+ private readonly namespace: string,
+ private readonly errorApi: ErrorApi,
+ ) {}
+
+ static create(options: CreateStorageApiOptions): WebStorage {
+ return new WebStorage(options.namespace ?? '', options.errorApi);
+ }
+
+ get(key: string): T | undefined {
+ try {
+ const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
+ return storage ?? undefined;
+ } catch (e) {
+ this.errorApi.post(
+ new Error(`Error when parsing JSON config from storage for: ${key}`),
+ );
+ }
+
+ return undefined;
+ }
+
+ forBucket(name: string): WebStorage {
+ const bucketPath = `${this.namespace}/${name}`;
+ if (!buckets.has(bucketPath)) {
+ buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
+ }
+ return buckets.get(bucketPath)!;
+ }
+
+ async set(key: string, data: T): Promise {
+ localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
+ this.notifyChanges({ key, newValue: data });
+ }
+
+ async remove(key: string): Promise {
+ localStorage.removeItem(this.getKeyName(key));
+ this.notifyChanges({ key, newValue: undefined });
+ }
+
+ observe$(key: string): Observable> {
+ return this.observable.filter(({ key: messageKey }) => messageKey === key);
+ }
+
+ private getKeyName(key: string) {
+ return `${this.namespace}/${encodeURIComponent(key)}`;
+ }
+
+ private notifyChanges(message: StorageValueChange) {
+ for (const subscription of this.subscribers) {
+ subscription.next(message);
+ }
+ }
+
+ private subscribers = new Set<
+ ZenObservable.SubscriptionObserver
+ >();
+
+ private readonly observable = new ObservableImpl(
+ subscriber => {
+ this.subscribers.add(subscriber);
+ return () => {
+ this.subscribers.delete(subscriber);
+ };
+ },
+ );
+}
diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts
new file mode 100644
index 0000000000..33b0094551
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { WebStorage } from './WebStorage';
diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
new file mode 100644
index 0000000000..391a709935
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.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 Auth0Icon from '@material-ui/icons/AcUnit';
+import { auth0AuthApiRef } from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+import { OAuthApiCreateOptions } from '../types';
+
+const DEFAULT_PROVIDER = {
+ id: 'auth0',
+ title: 'Auth0',
+ icon: Auth0Icon,
+};
+
+class Auth0Auth {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = ['openid', `email`, `profile`],
+ }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ });
+ }
+}
+
+export default Auth0Auth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts
new file mode 100644
index 0000000000..dda27d0fa3
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as Auth0Auth } from './Auth0Auth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts
new file mode 100644
index 0000000000..3ff29cc6ef
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import GithubAuth from './GithubAuth';
+
+describe('GithubAuth', () => {
+ it('should get access token', async () => {
+ const getSession = jest
+ .fn()
+ .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
+ const githubAuth = new GithubAuth({ getSession } as any);
+
+ expect(await githubAuth.getAccessToken()).toBe('access-token');
+ expect(getSession).toBeCalledTimes(1);
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts
new file mode 100644
index 0000000000..2ab0e5390f
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.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 GithubIcon from '@material-ui/icons/AcUnit';
+import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
+import { GithubSession } from './types';
+import {
+ OAuthApi,
+ SessionApi,
+ SessionState,
+ ProfileInfo,
+ BackstageIdentity,
+ AuthRequestOptions,
+ Observable,
+} from '@backstage/core-plugin-api';
+import { SessionManager } from '../../../../lib/AuthSessionManager/types';
+import {
+ AuthSessionStore,
+ StaticAuthSessionManager,
+} from '../../../../lib/AuthSessionManager';
+import { OAuthApiCreateOptions } from '../types';
+
+export type GithubAuthResponse = {
+ providerInfo: {
+ accessToken: string;
+ scope: string;
+ expiresInSeconds: number;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'github',
+ title: 'GitHub',
+ icon: GithubIcon,
+};
+
+class GithubAuth implements OAuthApi, SessionApi {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = ['read:user'],
+ }: OAuthApiCreateOptions) {
+ const connector = new DefaultAuthConnector({
+ discoveryApi,
+ environment,
+ provider,
+ oauthRequestApi: oauthRequestApi,
+ sessionTransform(res: GithubAuthResponse): GithubSession {
+ return {
+ ...res,
+ providerInfo: {
+ accessToken: res.providerInfo.accessToken,
+ scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
+ expiresAt: new Date(
+ Date.now() + res.providerInfo.expiresInSeconds * 1000,
+ ),
+ },
+ };
+ },
+ });
+
+ const sessionManager = new StaticAuthSessionManager({
+ connector,
+ defaultScopes: new Set(defaultScopes),
+ sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
+ });
+
+ const authSessionStore = new AuthSessionStore({
+ manager: sessionManager,
+ storageKey: `${provider.id}Session`,
+ sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
+ });
+
+ return new GithubAuth(authSessionStore);
+ }
+
+ constructor(private readonly sessionManager: SessionManager) {}
+
+ async signIn() {
+ await this.getAccessToken();
+ }
+
+ async signOut() {
+ await this.sessionManager.removeSession();
+ }
+
+ sessionState$(): Observable {
+ return this.sessionManager.sessionState$();
+ }
+
+ async getAccessToken(scope?: string, options?: AuthRequestOptions) {
+ const session = await this.sessionManager.getSession({
+ ...options,
+ scopes: GithubAuth.normalizeScope(scope),
+ });
+ return session?.providerInfo.accessToken ?? '';
+ }
+
+ async getBackstageIdentity(
+ options: AuthRequestOptions = {},
+ ): Promise {
+ const session = await this.sessionManager.getSession(options);
+ return session?.backstageIdentity;
+ }
+
+ async getProfile(options: AuthRequestOptions = {}) {
+ const session = await this.sessionManager.getSession(options);
+ return session?.profile;
+ }
+
+ static normalizeScope(scope?: string): Set {
+ if (!scope) {
+ return new Set();
+ }
+
+ const scopeList = Array.isArray(scope)
+ ? scope
+ : scope.split(/[\s|,]/).filter(Boolean);
+
+ return new Set(scopeList);
+ }
+}
+export default GithubAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/github/index.ts b/packages/core-app-api/src/apis/implementations/auth/github/index.ts
new file mode 100644
index 0000000000..9e1722f4a4
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/github/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './types';
+export { default as GithubAuth } from './GithubAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/github/types.ts b/packages/core-app-api/src/apis/implementations/auth/github/types.ts
new file mode 100644
index 0000000000..95beef3668
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/github/types.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
+
+export type GithubSession = {
+ providerInfo: {
+ accessToken: string;
+ scopes: Set;
+ expiresAt: Date;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts
new file mode 100644
index 0000000000..6a592c7fbe
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
+import { UrlPatternDiscovery } from '../../DiscoveryApi';
+import GitlabAuth from './GitlabAuth';
+
+const getSession = jest.fn();
+
+jest.mock('../../../../lib/AuthSessionManager', () => ({
+ ...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
+ RefreshingAuthSessionManager: class {
+ getSession = getSession;
+ },
+}));
+
+describe('GitlabAuth', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it.each([
+ [
+ 'read_user api write_repository',
+ ['read_user', 'api', 'write_repository'],
+ ],
+ ['read_repository sudo', ['read_repository', 'sudo']],
+ ])(`should normalize scopes correctly - %p`, (scope, scopes) => {
+ const gitlabAuth = GitlabAuth.create({
+ oauthRequestApi: new MockOAuthApi(),
+ discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
+ });
+
+ gitlabAuth.getAccessToken(scope);
+ expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
new file mode 100644
index 0000000000..fbb9509e7d
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import GitlabIcon from '@material-ui/icons/AcUnit';
+import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+import { OAuthApiCreateOptions } from '../types';
+
+const DEFAULT_PROVIDER = {
+ id: 'gitlab',
+ title: 'GitLab',
+ icon: GitlabIcon,
+};
+
+class GitlabAuth {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = ['read_user'],
+ }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ });
+ }
+}
+export default GitlabAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts
new file mode 100644
index 0000000000..42d7210551
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as GitlabAuth } from './GitlabAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts
new file mode 100644
index 0000000000..9e8569c5cf
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 GoogleAuth from './GoogleAuth';
+import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
+import { UrlPatternDiscovery } from '../../DiscoveryApi';
+
+const PREFIX = 'https://www.googleapis.com/auth/';
+
+const getSession = jest.fn();
+
+jest.mock('../../../../lib/AuthSessionManager', () => ({
+ ...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
+ RefreshingAuthSessionManager: class {
+ getSession = getSession;
+ },
+}));
+
+describe('GoogleAuth', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it.each([
+ ['email', [`${PREFIX}userinfo.email`]],
+ ['profile', [`${PREFIX}userinfo.profile`]],
+ ['openid', ['openid']],
+ ['userinfo.email', [`${PREFIX}userinfo.email`]],
+ [
+ 'userinfo.profile email',
+ [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
+ ],
+ [
+ `profile ${PREFIX}userinfo.email`,
+ [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
+ ],
+ [`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]],
+ ['a', [`${PREFIX}a`]],
+ ['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]],
+ [`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]],
+ [`${PREFIX}a`, [`${PREFIX}a`]],
+
+ // Some incorrect scopes that we don't try to fix
+ [`${PREFIX}email`, [`${PREFIX}email`]],
+ [`${PREFIX}profile`, [`${PREFIX}profile`]],
+ [`${PREFIX}openid`, [`${PREFIX}openid`]],
+ ])(`should normalize scopes correctly - %p`, (scope, scopes) => {
+ const googleAuth = GoogleAuth.create({
+ oauthRequestApi: new MockOAuthApi(),
+ discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
+ });
+
+ googleAuth.getAccessToken(scope);
+ expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts
new file mode 100644
index 0000000000..1074a192ef
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 GoogleIcon from '@material-ui/icons/AcUnit';
+import { googleAuthApiRef } from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+import { OAuthApiCreateOptions } from '../types';
+
+const DEFAULT_PROVIDER = {
+ id: 'google',
+ title: 'Google',
+ icon: GoogleIcon,
+};
+
+const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
+
+class GoogleAuth {
+ static create({
+ discoveryApi,
+ oauthRequestApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ defaultScopes = [
+ 'openid',
+ `${SCOPE_PREFIX}userinfo.email`,
+ `${SCOPE_PREFIX}userinfo.profile`,
+ ],
+ }: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ scopeTransform(scopes: string[]) {
+ return scopes.map(scope => {
+ if (scope === 'openid') {
+ return scope;
+ }
+
+ if (scope === 'profile' || scope === 'email') {
+ return `${SCOPE_PREFIX}userinfo.${scope}`;
+ }
+
+ if (scope.startsWith(SCOPE_PREFIX)) {
+ return scope;
+ }
+
+ return `${SCOPE_PREFIX}${scope}`;
+ });
+ },
+ });
+ }
+}
+export default GoogleAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/google/index.ts b/packages/core-app-api/src/apis/implementations/auth/google/index.ts
new file mode 100644
index 0000000000..2521d46046
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/google/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as GoogleAuth } from './GoogleAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts
new file mode 100644
index 0000000000..7ef78d19cf
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/index.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './github';
+export * from './gitlab';
+export * from './google';
+export * from './oauth2';
+export * from './okta';
+export * from './saml';
+export * from './auth0';
+export * from './microsoft';
+export * from './onelogin';
diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
new file mode 100644
index 0000000000..490e74b532
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import MicrosoftIcon from '@material-ui/icons/AcUnit';
+import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+import { OAuthApiCreateOptions } from '../types';
+
+const DEFAULT_PROVIDER = {
+ id: 'microsoft',
+ title: 'Microsoft',
+ icon: MicrosoftIcon,
+};
+
+class MicrosoftAuth {
+ static create({
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ discoveryApi,
+ defaultScopes = [
+ 'openid',
+ 'offline_access',
+ 'profile',
+ 'email',
+ 'User.Read',
+ ],
+ }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ });
+ }
+}
+
+export default MicrosoftAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts
new file mode 100644
index 0000000000..77328d8557
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as MicrosoftAuth } from './MicrosoftAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts
new file mode 100644
index 0000000000..93db2c732d
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts
@@ -0,0 +1,152 @@
+/*
+ * 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 OAuth2 from './OAuth2';
+
+const theFuture = new Date(Date.now() + 3600000);
+const thePast = new Date(Date.now() - 10);
+
+const PREFIX = 'https://www.googleapis.com/auth/';
+
+const scopeTransform = (x: string[]) => x;
+
+describe('OAuth2', () => {
+ it('should get refreshed access token', async () => {
+ const getSession = jest.fn().mockResolvedValue({
+ providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
+ });
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform,
+ });
+
+ expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
+ 'access-token',
+ );
+ expect(getSession).toBeCalledTimes(1);
+ expect(getSession.mock.calls[0][0].scopes).toEqual(
+ new Set(['my-scope', 'my-scope2']),
+ );
+ });
+
+ it('should transform scopes', async () => {
+ const getSession = jest.fn().mockResolvedValue({
+ providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
+ });
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
+ });
+
+ expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
+ expect(getSession).toBeCalledTimes(1);
+ expect(getSession.mock.calls[0][0].scopes).toEqual(
+ new Set(['my-prefix/my-scope']),
+ );
+ });
+
+ it('should get refreshed id token', async () => {
+ const getSession = jest.fn().mockResolvedValue({
+ providerInfo: { idToken: 'id-token', expiresAt: theFuture },
+ });
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform,
+ });
+
+ expect(await oauth2.getIdToken()).toBe('id-token');
+ expect(getSession).toBeCalledTimes(1);
+ });
+
+ it('should get optional id token', async () => {
+ const getSession = jest.fn().mockResolvedValue({
+ providerInfo: { idToken: 'id-token', expiresAt: theFuture },
+ });
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform,
+ });
+
+ expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
+ expect(getSession).toBeCalledTimes(1);
+ });
+
+ it('should share popup closed errors', async () => {
+ const error = new Error('NOPE');
+ error.name = 'RejectedError';
+ const getSession = jest
+ .fn()
+ .mockResolvedValueOnce({
+ providerInfo: {
+ accessToken: 'access-token',
+ expiresAt: theFuture,
+ scopes: new Set([`${PREFIX}not-enough`]),
+ },
+ })
+ .mockRejectedValue(error);
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform,
+ });
+
+ // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
+ await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
+
+ const promise1 = oauth2.getAccessToken('more');
+ const promise2 = oauth2.getAccessToken('more');
+ await expect(promise1).rejects.toBe(error);
+ await expect(promise2).rejects.toBe(error);
+ expect(getSession).toBeCalledTimes(3);
+ });
+
+ it('should wait for all session refreshes', async () => {
+ const initialSession = {
+ providerInfo: {
+ idToken: 'token1',
+ expiresAt: theFuture,
+ scopes: new Set(),
+ },
+ };
+ const getSession = jest
+ .fn()
+ .mockResolvedValueOnce(initialSession)
+ .mockResolvedValue({
+ providerInfo: {
+ idToken: 'token2',
+ expiresAt: theFuture,
+ scopes: new Set(),
+ },
+ });
+ const oauth2 = new OAuth2({
+ sessionManager: { getSession } as any,
+ scopeTransform,
+ });
+
+ // Grab the expired session first
+ await expect(oauth2.getIdToken()).resolves.toBe('token1');
+ expect(getSession).toBeCalledTimes(1);
+
+ initialSession.providerInfo.expiresAt = thePast;
+
+ const promise1 = oauth2.getIdToken();
+ const promise2 = oauth2.getIdToken();
+ const promise3 = oauth2.getIdToken();
+ await expect(promise1).resolves.toBe('token2');
+ await expect(promise2).resolves.toBe('token2');
+ await expect(promise3).resolves.toBe('token2');
+ expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts
new file mode 100644
index 0000000000..c6c251a4f5
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts
@@ -0,0 +1,179 @@
+/*
+ * 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 OAuth2Icon from '@material-ui/icons/AcUnit';
+import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
+import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
+import { SessionManager } from '../../../../lib/AuthSessionManager/types';
+import {
+ AuthRequestOptions,
+ BackstageIdentity,
+ OAuthApi,
+ OpenIdConnectApi,
+ ProfileInfo,
+ ProfileInfoApi,
+ SessionState,
+ SessionApi,
+ BackstageIdentityApi,
+ Observable,
+} from '@backstage/core-plugin-api';
+import { OAuth2Session } from './types';
+import { OAuthApiCreateOptions } from '../types';
+
+type Options = {
+ sessionManager: SessionManager;
+ scopeTransform: (scopes: string[]) => string[];
+};
+
+type CreateOptions = OAuthApiCreateOptions & {
+ scopeTransform?: (scopes: string[]) => string[];
+};
+
+export type OAuth2Response = {
+ providerInfo: {
+ accessToken: string;
+ idToken: string;
+ scope: string;
+ expiresInSeconds: number;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'oauth2',
+ title: 'Your Identity Provider',
+ icon: OAuth2Icon,
+};
+
+class OAuth2
+ implements
+ OAuthApi,
+ OpenIdConnectApi,
+ ProfileInfoApi,
+ BackstageIdentityApi,
+ SessionApi {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = [],
+ scopeTransform = x => x,
+ }: CreateOptions) {
+ const connector = new DefaultAuthConnector({
+ discoveryApi,
+ environment,
+ provider,
+ oauthRequestApi: oauthRequestApi,
+ sessionTransform(res: OAuth2Response): OAuth2Session {
+ return {
+ ...res,
+ providerInfo: {
+ idToken: res.providerInfo.idToken,
+ accessToken: res.providerInfo.accessToken,
+ scopes: OAuth2.normalizeScopes(
+ scopeTransform,
+ res.providerInfo.scope,
+ ),
+ expiresAt: new Date(
+ Date.now() + res.providerInfo.expiresInSeconds * 1000,
+ ),
+ },
+ };
+ },
+ });
+
+ const sessionManager = new RefreshingAuthSessionManager({
+ connector,
+ defaultScopes: new Set(defaultScopes),
+ sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
+ sessionShouldRefresh: (session: OAuth2Session) => {
+ const expiresInSec =
+ (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
+ return expiresInSec < 60 * 5;
+ },
+ });
+
+ return new OAuth2({ sessionManager, scopeTransform });
+ }
+
+ private readonly sessionManager: SessionManager;
+ private readonly scopeTransform: (scopes: string[]) => string[];
+
+ constructor(options: Options) {
+ this.sessionManager = options.sessionManager;
+ this.scopeTransform = options.scopeTransform;
+ }
+
+ async signIn() {
+ await this.getAccessToken();
+ }
+
+ async signOut() {
+ await this.sessionManager.removeSession();
+ }
+
+ sessionState$(): Observable {
+ return this.sessionManager.sessionState$();
+ }
+
+ async getAccessToken(
+ scope?: string | string[],
+ options?: AuthRequestOptions,
+ ) {
+ const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
+ const session = await this.sessionManager.getSession({
+ ...options,
+ scopes: normalizedScopes,
+ });
+ return session?.providerInfo.accessToken ?? '';
+ }
+
+ async getIdToken(options: AuthRequestOptions = {}) {
+ const session = await this.sessionManager.getSession(options);
+ return session?.providerInfo.idToken ?? '';
+ }
+
+ async getBackstageIdentity(
+ options: AuthRequestOptions = {},
+ ): Promise {
+ const session = await this.sessionManager.getSession(options);
+ return session?.backstageIdentity;
+ }
+
+ async getProfile(options: AuthRequestOptions = {}) {
+ const session = await this.sessionManager.getSession(options);
+ return session?.profile;
+ }
+
+ private static normalizeScopes(
+ scopeTransform: (scopes: string[]) => string[],
+ scopes?: string | string[],
+ ): Set {
+ if (!scopes) {
+ return new Set();
+ }
+
+ const scopeList = Array.isArray(scopes)
+ ? scopes
+ : scopes.split(/[\s|,]/).filter(Boolean);
+
+ return new Set(scopeTransform(scopeList));
+ }
+}
+
+export default OAuth2;
diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts
new file mode 100644
index 0000000000..52bcb1df2c
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as OAuth2 } from './OAuth2';
+export * from './types';
diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts
new file mode 100644
index 0000000000..ade0f1a6c6
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
+
+export type OAuth2Session = {
+ providerInfo: {
+ idToken: string;
+ accessToken: string;
+ scopes: Set;
+ expiresAt: Date;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts
new file mode 100644
index 0000000000..d6b1a07d9d
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import OktaAuth from './OktaAuth';
+import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
+import { UrlPatternDiscovery } from '../../DiscoveryApi';
+
+const PREFIX = 'okta.';
+
+const getSession = jest.fn();
+
+jest.mock('../../../../lib/AuthSessionManager', () => ({
+ ...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
+ RefreshingAuthSessionManager: class {
+ getSession = getSession;
+ },
+}));
+
+describe('OktaAuth', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it.each([
+ ['openid', ['openid']],
+ ['profile email', ['profile', 'email']],
+ [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]],
+ ['groups.read', [`${PREFIX}groups.read`]],
+ [
+ `${PREFIX}groups.manage groups.read, openid`,
+ [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'],
+ ],
+ [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]],
+
+ // Some incorrect scopes that we don't try to fix
+ [`${PREFIX}email`, [`${PREFIX}email`]],
+ [`${PREFIX}profile`, [`${PREFIX}profile`]],
+ [`${PREFIX}openid`, [`${PREFIX}openid`]],
+ ])(`should normalize scopes correctly - %p`, (scope, scopes) => {
+ const auth = OktaAuth.create({
+ oauthRequestApi: new MockOAuthApi(),
+ discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
+ });
+
+ auth.getAccessToken(scope);
+ expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts
new file mode 100644
index 0000000000..a86e3f272d
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts
@@ -0,0 +1,71 @@
+/*
+ * 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 OktaIcon from '@material-ui/icons/AcUnit';
+import { oktaAuthApiRef } from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+import { OAuthApiCreateOptions } from '../types';
+
+const DEFAULT_PROVIDER = {
+ id: 'okta',
+ title: 'Okta',
+ icon: OktaIcon,
+};
+
+const OKTA_OIDC_SCOPES: Set = new Set([
+ 'openid',
+ 'profile',
+ 'email',
+ 'phone',
+ 'address',
+ 'groups',
+ 'offline_access',
+]);
+
+const OKTA_SCOPE_PREFIX: string = 'okta.';
+
+class OktaAuth {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
+ }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ scopeTransform(scopes) {
+ return scopes.map(scope => {
+ if (OKTA_OIDC_SCOPES.has(scope)) {
+ return scope;
+ }
+
+ if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
+ return scope;
+ }
+
+ return `${OKTA_SCOPE_PREFIX}${scope}`;
+ });
+ },
+ });
+ }
+}
+
+export default OktaAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/index.ts b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts
new file mode 100644
index 0000000000..4cc774b26b
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as OktaAuth } from './OktaAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts
new file mode 100644
index 0000000000..2e8c7ac2e8
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import OneLoginIcon from '@material-ui/icons/AcUnit';
+import {
+ oneloginAuthApiRef,
+ OAuthRequestApi,
+ AuthProvider,
+ DiscoveryApi,
+} from '@backstage/core-plugin-api';
+import { OAuth2 } from '../oauth2';
+
+type CreateOptions = {
+ discoveryApi: DiscoveryApi;
+ oauthRequestApi: OAuthRequestApi;
+
+ environment?: string;
+ provider?: AuthProvider & { id: string };
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'onelogin',
+ title: 'onelogin',
+ icon: OneLoginIcon,
+};
+
+const OIDC_SCOPES: Set = new Set([
+ 'openid',
+ 'profile',
+ 'email',
+ 'phone',
+ 'address',
+ 'groups',
+ 'offline_access',
+]);
+
+const SCOPE_PREFIX: string = 'onelogin.';
+
+class OneLoginAuth {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ }: CreateOptions): typeof oneloginAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
+ scopeTransform(scopes) {
+ return scopes.map(scope => {
+ if (OIDC_SCOPES.has(scope)) {
+ return scope;
+ }
+
+ if (scope.startsWith(SCOPE_PREFIX)) {
+ return scope;
+ }
+
+ return `${SCOPE_PREFIX}${scope}`;
+ });
+ },
+ });
+ }
+}
+
+export default OneLoginAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts
new file mode 100644
index 0000000000..1d163207db
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default as OneLoginAuth } from './OneLoginAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts
new file mode 100644
index 0000000000..7f747d6977
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 SamlIcon from '@material-ui/icons/AcUnit';
+import { DirectAuthConnector } from '../../../../lib/AuthConnector';
+import { SessionManager } from '../../../../lib/AuthSessionManager/types';
+import {
+ ProfileInfo,
+ BackstageIdentity,
+ SessionState,
+ AuthRequestOptions,
+ ProfileInfoApi,
+ BackstageIdentityApi,
+ SessionApi,
+ Observable,
+} from '@backstage/core-plugin-api';
+import { SamlSession } from './types';
+import {
+ AuthSessionStore,
+ StaticAuthSessionManager,
+} from '../../../../lib/AuthSessionManager';
+import { AuthApiCreateOptions } from '../types';
+
+export type SamlAuthResponse = {
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'saml',
+ title: 'SAML',
+ icon: SamlIcon,
+};
+
+class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ }: AuthApiCreateOptions) {
+ const connector = new DirectAuthConnector({
+ discoveryApi,
+ environment,
+ provider,
+ });
+
+ const sessionManager = new StaticAuthSessionManager({
+ connector,
+ });
+
+ const authSessionStore = new AuthSessionStore({
+ manager: sessionManager,
+ storageKey: `${provider.id}Session`,
+ });
+
+ return new SamlAuth(authSessionStore);
+ }
+
+ sessionState$(): Observable {
+ return this.sessionManager.sessionState$();
+ }
+
+ constructor(private readonly sessionManager: SessionManager) {}
+
+ async signIn() {
+ await this.getBackstageIdentity({});
+ }
+ async signOut() {
+ await this.sessionManager.removeSession();
+ }
+
+ async getBackstageIdentity(
+ options: AuthRequestOptions = {},
+ ): Promise {
+ const session = await this.sessionManager.getSession(options);
+ return session?.backstageIdentity;
+ }
+
+ async getProfile(options: AuthRequestOptions = {}) {
+ const session = await this.sessionManager.getSession(options);
+ return session?.profile;
+ }
+}
+
+export default SamlAuth;
diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts
new file mode 100644
index 0000000000..c2436ab435
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 { default as SamlAuth } from './SamlAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts
new file mode 100644
index 0000000000..b62826b2ea
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
+
+export type SamlSession = {
+ userId: string;
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts
new file mode 100644
index 0000000000..a752e68eba
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/types.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ AuthProvider,
+ DiscoveryApi,
+ OAuthRequestApi,
+} from '@backstage/core-plugin-api';
+
+export type OAuthApiCreateOptions = AuthApiCreateOptions & {
+ oauthRequestApi: OAuthRequestApi;
+ defaultScopes?: string[];
+};
+
+export type AuthApiCreateOptions = {
+ discoveryApi: DiscoveryApi;
+ environment?: string;
+ provider?: AuthProvider & { id: string };
+};
diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts
new file mode 100644
index 0000000000..d0df2760ab
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/index.ts
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+// This folder contains implementations for all core APIs.
+//
+// Plugins should rely on these APIs for functionality as much as possible.
+
+export * from './auth';
+
+export * from './AlertApi';
+export * from './AppThemeApi';
+export * from './ConfigApi';
+export * from './DiscoveryApi';
+export * from './ErrorApi';
+export * from './FeatureFlagsApi';
+export * from './OAuthRequestApi';
+export * from './StorageApi';
diff --git a/packages/core-app-api/src/apis/index.ts b/packages/core-app-api/src/apis/index.ts
new file mode 100644
index 0000000000..5652dadf79
--- /dev/null
+++ b/packages/core-app-api/src/apis/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './system';
+export * from './implementations';
diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.test.ts b/packages/core-app-api/src/apis/system/ApiAggregator.test.ts
new file mode 100644
index 0000000000..c38aa08fad
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiAggregator.test.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 '@backstage/core-plugin-api';
+import { ApiAggregator } from './ApiAggregator';
+import { ApiRegistry } from './ApiRegistry';
+
+describe('ApiAggregator', () => {
+ const apiARef = createApiRef({ id: 'a' });
+ const apiBRef = createApiRef({ id: 'b' });
+
+ it('should forward implementations', () => {
+ const agg = new ApiAggregator(
+ ApiRegistry.from([
+ [apiARef, 5],
+ [apiBRef, 10],
+ ]),
+ );
+ expect(agg.get(apiARef)).toBe(5);
+ expect(agg.get(apiBRef)).toBe(10);
+ });
+
+ it('should return the first implementation', () => {
+ const agg = new ApiAggregator(
+ ApiRegistry.from([
+ [apiARef, 1],
+ [apiARef, 2],
+ ]),
+ );
+ expect(agg.get(apiARef)).toBe(2);
+ expect(agg.get(apiBRef)).toBe(undefined);
+ });
+});
diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.ts b/packages/core-app-api/src/apis/system/ApiAggregator.ts
new file mode 100644
index 0000000000..1299e38da0
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiAggregator.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { ApiRef, ApiHolder } from '@backstage/core-plugin-api';
+
+/**
+ * An ApiHolder that queries multiple other holders from for
+ * an Api implementation, returning the first one encountered..
+ */
+export class ApiAggregator implements ApiHolder {
+ private readonly holders: ApiHolder[];
+
+ constructor(...holders: ApiHolder[]) {
+ this.holders = holders;
+ }
+
+ get(apiRef: ApiRef): T | undefined {
+ for (const holder of this.holders) {
+ const api = holder.get(apiRef);
+ if (api) {
+ return api;
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts
new file mode 100644
index 0000000000..160019604e
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.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 { createApiRef } from '@backstage/core-plugin-api';
+import { ApiFactoryRegistry } from './ApiFactoryRegistry';
+
+const aRef = createApiRef({ id: 'a' });
+const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
+const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
+const bRef = createApiRef({ id: 'b' });
+const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
+const cRef = createApiRef({ id: 'c' });
+const cFactory = { api: cRef, deps: {}, factory: () => 'y' };
+
+describe('ApiFactoryRegistry', () => {
+ it('should be empty when created', () => {
+ const registry = new ApiFactoryRegistry();
+ expect(registry.getAllApis()).toEqual(new Set());
+ });
+
+ it('should register a factory', () => {
+ const registry = new ApiFactoryRegistry();
+ expect(registry.register('default', aFactory1)).toBe(true);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.getAllApis()).toEqual(new Set([aRef]));
+ });
+
+ it('should prioritize factories based on scope', () => {
+ const registry = new ApiFactoryRegistry();
+ expect(registry.register('default', aFactory1)).toBe(true);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.register('default', aFactory2)).toBe(false);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.register('app', aFactory2)).toBe(true);
+ expect(registry.get(aRef)).toBe(aFactory2);
+ expect(registry.register('default', aFactory1)).toBe(false);
+ expect(registry.get(aRef)).toBe(aFactory2);
+ expect(registry.register('static', aFactory1)).toBe(true);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.register('static', aFactory2)).toBe(false);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.register('app', aFactory2)).toBe(false);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.getAllApis()).toEqual(new Set([aRef]));
+ });
+
+ it('should register multiple factories without conflict', () => {
+ const registry = new ApiFactoryRegistry();
+ expect(registry.register('static', aFactory1)).toBe(true);
+ expect(registry.register('default', bFactory)).toBe(true);
+ expect(registry.register('app', cFactory)).toBe(true);
+ expect(registry.get(aRef)).toBe(aFactory1);
+ expect(registry.get(bRef)).toBe(bFactory);
+ expect(registry.get(cRef)).toBe(cFactory);
+ expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef]));
+ });
+
+ it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => {
+ const ref1 = createApiRef({ id: 'a' });
+ const ref2 = createApiRef({ id: 'a' });
+
+ const factory1 = { api: ref1, deps: {}, factory: () => 3 };
+ const factory2 = { api: ref2, deps: {}, factory: () => 3 };
+
+ const registry = new ApiFactoryRegistry();
+ expect(registry.register('default', factory1)).toBe(true);
+ expect(registry.register('default', factory2)).toBe(false);
+ expect(registry.get(ref1)).toEqual(factory1);
+ expect(registry.get(ref2)).toEqual(factory1);
+ expect(registry.getAllApis()).toEqual(new Set([ref1]));
+
+ expect(registry.register('app', factory2)).toBe(true);
+ expect(registry.get(ref1)).toEqual(factory2);
+ expect(registry.get(ref2)).toEqual(factory2);
+ expect(Array.from(registry.getAllApis())[0]).toBe(ref2);
+ expect(Array.from(registry.getAllApis())[0]).not.toBe(ref1);
+ });
+});
diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts
new file mode 100644
index 0000000000..fe17f5a600
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 { ApiFactoryHolder } from './types';
+import {
+ ApiRef,
+ ApiFactory,
+ AnyApiRef,
+ AnyApiFactory,
+} from '@backstage/core-plugin-api';
+
+type ApiFactoryScope =
+ | 'default' // Default factories registered by core and plugins
+ | 'app' // Factories registered in the app, overriding default ones
+ | 'static'; // APIs that can't be overridden, e.g. config
+
+enum ScopePriority {
+ default = 10,
+ app = 50,
+ static = 100,
+}
+
+type FactoryTuple = {
+ priority: number;
+ factory: AnyApiFactory;
+};
+
+/**
+ * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables
+ * registration of API Factories with different scope.
+ *
+ * Each scope has an assigned priority, where factories registered with
+ * higher priority scopes override ones with lower priority.
+ */
+export class ApiFactoryRegistry implements ApiFactoryHolder {
+ private readonly factories = new Map();
+
+ /**
+ * Register a new API factory. Returns true if the factory was added
+ * to the registry.
+ *
+ * A factory will not be added to the registry if there is already
+ * an existing factory with the same or higher priority.
+ */
+ register(
+ scope: ApiFactoryScope,
+ factory: ApiFactory,
+ ) {
+ const priority = ScopePriority[scope];
+ const existing = this.factories.get(factory.api.id);
+ if (existing && existing.priority >= priority) {
+ return false;
+ }
+
+ this.factories.set(factory.api.id, { priority, factory });
+ return true;
+ }
+
+ get(
+ api: ApiRef,
+ ): ApiFactory | undefined {
+ const tuple = this.factories.get(api.id);
+ if (!tuple) {
+ return undefined;
+ }
+ return tuple.factory as ApiFactory;
+ }
+
+ getAllApis(): Set {
+ const refs = new Set();
+ for (const { factory } of this.factories.values()) {
+ refs.add(factory.api);
+ }
+ return refs;
+ }
+}
diff --git a/packages/core-app-api/src/apis/system/ApiProvider.test.tsx b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx
new file mode 100644
index 0000000000..7d38ec3cf9
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { Context, useContext } from 'react';
+import {
+ useApi,
+ createApiRef,
+ withApis,
+ ApiHolder,
+ ApiRef,
+} from '@backstage/core-plugin-api';
+import { ApiProvider } from './ApiProvider';
+import { ApiRegistry } from './ApiRegistry';
+import { render } from '@testing-library/react';
+import { withLogCollector } from '@backstage/test-utils-core';
+import { getGlobalSingleton } from '../../lib/globalObject';
+import { VersionedValue } from '../../lib/versionedValues';
+
+describe('ApiProvider', () => {
+ type Api = () => string;
+ const apiRef = createApiRef({ id: 'x' });
+ const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
+
+ const MyHookConsumer = () => {
+ const api = useApi(apiRef);
+ return hook message: {api()}
;
+ };
+
+ const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => {
+ return hoc message: {getMessage()}
;
+ });
+
+ it('should provide apis', () => {
+ const renderedHook = render(
+
+
+ ,
+ );
+ renderedHook.getByText('hook message: hello');
+
+ const renderedHoc = render(
+
+
+ ,
+ );
+ renderedHoc.getByText('hoc message: hello');
+ });
+
+ it('should provide nested access to apis', () => {
+ const aRef = createApiRef({ id: 'a' });
+ const bRef = createApiRef({ id: 'b' });
+
+ const MyComponent = () => {
+ const a = useApi(aRef);
+ const b = useApi(bRef);
+ return (
+
+ a={a} b={b}
+
+ );
+ };
+
+ const renderedHook = render(
+
+
+
+
+ ,
+ );
+ renderedHook.getByText('a=z b=y');
+ });
+
+ it('should ignore deps in prototype', () => {
+ // 100% coverage + happy typescript = hasOwnProperty + this atrocity
+ const xRef = createApiRef({ id: 'x' });
+
+ const proto = { x: xRef };
+ const props = { getMessage: { enumerable: true, value: apiRef } };
+ const obj = Object.create(proto, props) as {
+ getMessage: typeof apiRef;
+ x: typeof xRef;
+ };
+
+ const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => {
+ return hoc message: {getMessage()}
;
+ });
+
+ const renderedHoc = render(
+
+
+ ,
+ );
+ renderedHoc.getByText('hoc message: hello');
+ });
+
+ it('should error if no provider is available', () => {
+ expect(
+ withLogCollector(['error'], () => {
+ expect(() => {
+ render();
+ }).toThrow(/^No provider available for api-context context/);
+ }).error,
+ ).toEqual([
+ expect.stringMatching(
+ /^Error: Uncaught \[Error: No provider available for api-context context/,
+ ),
+ expect.stringMatching(
+ /^The above error occurred in the component/,
+ ),
+ ]);
+
+ expect(
+ withLogCollector(['error'], () => {
+ expect(() => {
+ render();
+ }).toThrow(/^No provider available for api-context context/);
+ }).error,
+ ).toEqual([
+ expect.stringMatching(
+ /^Error: Uncaught \[Error: No provider available for api-context context/,
+ ),
+ expect.stringMatching(
+ /^The above error occurred in the component/,
+ ),
+ ]);
+ });
+
+ it('should error if api is not available', () => {
+ expect(
+ withLogCollector(['error'], () => {
+ expect(() => {
+ render(
+
+
+ ,
+ );
+ }).toThrow('No implementation available for apiRef{x}');
+ }).error,
+ ).toEqual([
+ expect.stringMatching(
+ /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
+ ),
+ expect.stringMatching(
+ /^The above error occurred in the component/,
+ ),
+ ]);
+
+ expect(
+ withLogCollector(['error'], () => {
+ expect(() => {
+ render(
+
+
+ ,
+ );
+ }).toThrow('No implementation available for apiRef{x}');
+ }).error,
+ ).toEqual([
+ expect.stringMatching(
+ /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
+ ),
+ expect.stringMatching(
+ /^The above error occurred in the component/,
+ ),
+ ]);
+ });
+});
+
+describe('v1 consumer', () => {
+ const ApiContext = getGlobalSingleton<
+ Context>
+ >('api-context');
+
+ function useMockApiV1(apiRef: ApiRef): T {
+ const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef);
+ if (!impl) {
+ throw new Error('no impl');
+ }
+ return impl;
+ }
+
+ type Api = () => string;
+ const apiRef = createApiRef({ id: 'x' });
+ const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
+
+ const MyHookConsumerV1 = () => {
+ const api = useMockApiV1(apiRef);
+ return hook message: {api()}
;
+ };
+
+ it('should provide apis', () => {
+ const renderedHook = render(
+
+
+ ,
+ );
+ renderedHook.getByText('hook message: hello');
+ });
+});
diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx
new file mode 100644
index 0000000000..93d20b12fc
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, {
+ createContext,
+ useContext,
+ ReactNode,
+ PropsWithChildren,
+} from 'react';
+import PropTypes from 'prop-types';
+import { ApiHolder } from '@backstage/core-plugin-api';
+import { ApiAggregator } from './ApiAggregator';
+import { getOrCreateGlobalSingleton } from '../../lib/globalObject';
+import {
+ VersionedValue,
+ createVersionedValueMap,
+} from '../../lib/versionedValues';
+
+type ApiProviderProps = {
+ apis: ApiHolder;
+ children: ReactNode;
+};
+
+type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined;
+const ApiContext = getOrCreateGlobalSingleton('api-context', () =>
+ createContext(undefined),
+);
+
+export const ApiProvider = ({
+ apis,
+ children,
+}: PropsWithChildren) => {
+ const parentHolder = useContext(ApiContext)?.atVersion(1);
+ const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
+
+ return (
+
+ );
+};
+
+ApiProvider.propTypes = {
+ apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
+ children: PropTypes.node,
+};
diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts
new file mode 100644
index 0000000000..0931e2c103
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createApiRef } from '@backstage/core-plugin-api';
+import { ApiRegistry } from './ApiRegistry';
+
+describe('ApiRegistry', () => {
+ const x1Ref = createApiRef({ id: 'x1' });
+ const x1DuplicateRef = createApiRef({ id: 'x1' });
+ const x2Ref = createApiRef({ id: 'x2' });
+
+ it('should be created', () => {
+ const registry = ApiRegistry.from([]);
+ expect(registry.get(x1Ref)).toBe(undefined);
+ });
+
+ it('should be created with APIs', () => {
+ const registry = ApiRegistry.from([
+ [x1Ref, 3],
+ [x2Ref, 'y'],
+ ]);
+ expect(registry.get(x1Ref)).toBe(3);
+ expect(registry.get(x1DuplicateRef)).toBe(3);
+ expect(registry.get(x2Ref)).toBe('y');
+ });
+
+ it('should be built', () => {
+ const registry = ApiRegistry.builder().build();
+ expect(registry.get(x1Ref)).toBe(undefined);
+ expect(registry.get(x1DuplicateRef)).toBe(undefined);
+ });
+
+ it('should be built with APIs', () => {
+ const builder = ApiRegistry.builder();
+ builder.add(x1Ref, 3);
+ builder.add(x2Ref, 'y');
+
+ const registry = builder.build();
+ expect(registry.get(x1Ref)).toBe(3);
+ expect(registry.get(x1DuplicateRef)).toBe(3);
+ expect(registry.get(x2Ref)).toBe('y');
+ });
+
+ it('should be created with API', () => {
+ const reg1 = ApiRegistry.with(x1Ref, 3);
+ const reg2 = reg1.with(x2Ref, 'y');
+ const reg3 = reg2.with(x2Ref, 'z');
+ const reg4 = reg3.with(x1Ref, 2);
+ const reg5 = reg3.with(x1DuplicateRef, 4);
+
+ expect(reg1.get(x1Ref)).toBe(3);
+ expect(reg1.get(x2Ref)).toBe(undefined);
+ expect(reg2.get(x1Ref)).toBe(3);
+ expect(reg2.get(x2Ref)).toBe('y');
+ expect(reg3.get(x1Ref)).toBe(3);
+ expect(reg3.get(x2Ref)).toBe('z');
+ expect(reg4.get(x1Ref)).toBe(2);
+ expect(reg4.get(x2Ref)).toBe('z');
+ expect(reg5.get(x1Ref)).toBe(4);
+ expect(reg5.get(x2Ref)).toBe('z');
+ });
+});
diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts
new file mode 100644
index 0000000000..4571d52cce
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { ApiRef, ApiHolder } from '@backstage/core-plugin-api';
+
+type ApiImpl = readonly [ApiRef, T];
+
+class ApiRegistryBuilder {
+ private apis: [string, unknown][] = [];
+
+ add(api: ApiRef, impl: I): I {
+ this.apis.push([api.id, impl]);
+ return impl;
+ }
+
+ build(): ApiRegistry {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ return new ApiRegistry(new Map(this.apis));
+ }
+}
+
+export class ApiRegistry implements ApiHolder {
+ static builder() {
+ return new ApiRegistryBuilder();
+ }
+
+ static from(apis: ApiImpl[]) {
+ return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl])));
+ }
+
+ /**
+ * Creates a new ApiRegistry with a single API implementation.
+ *
+ * @param api ApiRef for the API to add
+ * @param impl Implementation of the API to add
+ */
+ static with(api: ApiRef, impl: T): ApiRegistry {
+ return new ApiRegistry(new Map([[api.id, impl]]));
+ }
+
+ constructor(private readonly apis: Map) {}
+
+ /**
+ * Returns a new ApiRegistry with the provided API added to the existing ones.
+ *
+ * @param api ApiRef for the API to add
+ * @param impl Implementation of the API to add
+ */
+ with(api: ApiRef, impl: T): ApiRegistry {
+ return new ApiRegistry(new Map([...this.apis, [api.id, impl]]));
+ }
+
+ get(api: ApiRef): T | undefined {
+ return this.apis.get(api.id) as T | undefined;
+ }
+}
diff --git a/packages/core-app-api/src/apis/system/ApiResolver.test.ts b/packages/core-app-api/src/apis/system/ApiResolver.test.ts
new file mode 100644
index 0000000000..af8dbc28c6
--- /dev/null
+++ b/packages/core-app-api/src/apis/system/ApiResolver.test.ts
@@ -0,0 +1,263 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createApiRef } from '@backstage/core-plugin-api';
+import { ApiResolver } from './ApiResolver';
+import { ApiFactoryRegistry } from './ApiFactoryRegistry';
+
+const aRef = createApiRef({ id: 'a' });
+const otherARef = createApiRef