Merge branch 'master' of github.com:backstage/backstage into blam/isomorphic-git

* 'master' of github.com:backstage/backstage: (86 commits)
  Fix typos (#3646)
  fix(core): React descendant P tag warning (#3641)
  build(deps): bump @svgr/rollup from 5.4.0 to 5.5.0 (#3643)
  build(deps): bump jest from 26.5.3 to 26.6.3 (#3644)
  Update 2020-09-30-plugin-marketplace.md
  Update 2020-09-30-backstage-design-system.md
  Update 2020-09-23-backstage-cncf-sandbox.md
  Update 2020-09-08-announcing-tech-docs.md
  Update 2020-08-05-announcing-backstage-software-templates.md
  Update 2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md
  Update 2020-06-22-backstage-service-catalog-alpha.md
  Update 2020-05-22-phase-2-service-catalog.md
  Update 2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md
  Update 2020-05-14-tech-radar-plugin.md
  Update 2020-04-30-how-to-quickly-set-up-backstage.md
  Update 2020-10-22-cost-insights-plugin.md
  Update 2020-04-06-lighthouse-plugin.md
  Update 2020-03-18-what-is-backstage.md
  Update 2020-03-16-announcing-backstage.md
  Add standard NPM metadata
  ...
This commit is contained in:
blam
2020-12-10 14:11:02 +01:00
305 changed files with 8429 additions and 3539 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-jenkins': patch
---
Avoid loading data from Jenkins twice. Don't load data when navigating through the pages as all data from all pages is already loaded.
+65
View File
@@ -0,0 +1,65 @@
---
'@backstage/plugin-sentry': minor
'@backstage/plugin-sentry-backend': minor
---
The plugin uses the `proxy-backend` instead of a custom `sentry-backend`.
It requires a proxy configuration:
`app-config.yaml`:
```yaml
proxy:
'/sentry/api':
target: https://sentry.io/api/
allowedMethods: ['GET']
headers:
Authorization:
$env: SENTRY_TOKEN # export SENTRY_TOKEN="Bearer <your-sentry-token>"
```
The `MockApiBackend` is no longer configured by the `NODE_ENV` variable.
Instead, the mock backend can be used with an api-override:
`packages/app/src/apis.ts`:
```ts
import { createApiFactory } from '@backstage/core';
import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry';
export const apis = [
// ...
createApiFactory(sentryApiRef, new MockSentryApi()),
];
```
If you already use the Sentry backend, you must remove it from the backend:
Delete `packages/backend/src/plugins/sentry.ts`.
```diff
# packages/backend/package.json
...
"@backstage/plugin-scaffolder-backend": "^0.3.2",
- "@backstage/plugin-sentry-backend": "^0.1.3",
"@backstage/plugin-techdocs-backend": "^0.3.0",
...
```
```diff
// packages/backend/src/index.html
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
- apiRouter.use('/sentry', await sentry(sentryEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/graphql', await graphql(graphqlEnv));
apiRouter.use(notFoundHandler());
```
@@ -0,0 +1,7 @@
---
'@backstage/plugin-cost-insights': minor
---
Add support for multiple types of entity cost breakdown.
This change is backwards-incompatible with plugin-cost-insights 0.3.x; the `entities` field on Entity returned in product cost queries changed from `Entity[]` to `Record<string, Entity[]`.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': minor
---
Remove calendar MoM period option and fix quarter end date logic
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Fixes a big in the bundling logic that caused `node_modules` inside local monorepo packages to be transformed.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
refreshAllLocations uses a child logger of the HigherOrderOperation with a meta `component` : `catalog-all-locations-refresh`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
fix react-hooks/exhaustive-deps error
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-common': patch
'@backstage/integration': patch
---
Move the core url and auth logic to integration for the four major providers
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Add the basics of cross-integration concerns
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-jenkins': patch
---
Improve loading speed of the CI/CD page.
Only request the necessary fields from Jenkins to keep the request size low.
In addition everything is loaded in a single request, instead of requesting
each job and build individually. As this (and also the previous behavior) can
lead to a big amount of data, this limits the amount of jobs to 50.
For each job, only the latest build is loaded. Loading the full build history
of a job can lead to excessive load on the Jenkins instance.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Fix React warning of descendant paragraph tag
@@ -1,21 +1,34 @@
/*
* 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 prom from 'prom-client';
import promBundle from 'express-prom-bundle';
---
'@backstage/backend-common': minor
---
Removes the Prometheus integration from `backend-common`.
Rational behind this change is to keep the metrics integration of Backstage
generic. Instead of directly relying on Prometheus, Backstage will expose
metrics in a generic way. Integrators can then export the metrics in their
desired format. For example using Prometheus.
To keep the existing behavior, you need to integrate Prometheus in your
backend:
First, add a dependency on `express-prom-bundle` and `prom-client` to your backend.
```diff
// packages/backend/package.json
"dependencies": {
+ "express-prom-bundle": "^6.1.0",
+ "prom-client": "^12.0.0",
```
Then, add a handler for metrics and a simple instrumentation for the endpoints.
```typescript
// packages/backend/src/metrics.ts
import { useHotCleanup } from '@backstage/backend-common';
import { RequestHandler } from 'express';
import promBundle from 'express-prom-bundle';
import prom from 'prom-client';
import * as url from 'url';
const rootRegEx = new RegExp('^/([^/]*)/.*');
@@ -38,7 +51,7 @@ export function normalizePath(req: any): string {
*/
export function metricsHandler(): RequestHandler {
// We can only initialize the metrics once and have to clean them up between hot reloads
prom.register.clear();
useHotCleanup(module, () => prom.register.clear());
return promBundle({
includeMethod: true,
@@ -51,3 +64,20 @@ export function metricsHandler(): RequestHandler {
promClient: { collectDefaultMetrics: {} },
});
}
```
Last, extend your router configuration with the `metricsHandler`:
```diff
+import { metricsHandler } from './metrics';
...
const service = createServiceBuilder(module)
.loadConfig(config)
.addRouter('', await healthcheck(healthcheckEnv))
+ .addRouter('', metricsHandler())
.addRouter('/api', apiRouter);
```
Your Prometheus metrics will be available at the `/metrics` endpoint.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/core-api': patch
---
Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement.
Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`.
Add an as of yet unused `params` option to `createRouteRef`.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-api-docs': minor
---
Stop exposing a custom router from the `api-docs` plugin. Instead, use the
widgets exported by the plugin to compose your custom entity pages.
Instead of displaying the API definitions directly in the API tab of the
component, it now contains tables linking to the API entities. This also adds
new widgets to display relationships (bot provides & consumes relationships)
between components and APIs.
See the changelog of `create-app` for a migration guide.
+136
View File
@@ -0,0 +1,136 @@
---
'@backstage/create-app': patch
---
Adjust template to the latest changes in the `api-docs` plugin.
## Template Changes
While updating to the latest `api-docs` plugin, the following changes are
necessary for the `create-app` template in your
`app/src/components/catalog/EntityPage.tsx`. This adds:
- A custom entity page for API entities
- Changes the API tab to include the new `ConsumedApisCard` and
`ProvidedApisCard` that link to the API entity.
```diff
import {
+ ApiDefinitionCard,
- Router as ApiDocsRouter,
+ ConsumedApisCard,
+ ProvidedApisCard,
+ ConsumedApisCard,
+ ConsumingComponentsCard,
+ ProvidedApisCard,
+ ProvidingComponentsCard
} from '@backstage/plugin-api-docs';
...
+const ComponentApisContent = ({ entity }: { entity: Entity }) => (
+ <Grid container spacing={3} alignItems="stretch">
+ <Grid item md={6}>
+ <ProvidedApisCard entity={entity} />
+ </Grid>
+ <Grid item md={6}>
+ <ConsumedApisCard entity={entity} />
+ </Grid>
+ </Grid>
+);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/api/*"
title="API"
- element={<ApiDocsRouter entity={entity} />}
+ element={<ComponentApisContent entity={entity} />}
/>
...
-export const EntityPage = () => {
- const { entity } = useEntity();
- switch (entity?.spec?.type) {
- case 'service':
- return <ServiceEntityPage entity={entity} />;
- case 'website':
- return <WebsiteEntityPage entity={entity} />;
- default:
- return <DefaultEntityPage entity={entity} />;
- }
-};
+export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
+ switch (entity?.spec?.type) {
+ case 'service':
+ return <ServiceEntityPage entity={entity} />;
+ case 'website':
+ return <WebsiteEntityPage entity={entity} />;
+ default:
+ return <DefaultEntityPage entity={entity} />;
+ }
+};
+
+const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
+ <Grid container spacing={3}>
+ <Grid item md={6}>
+ <AboutCard entity={entity} />
+ </Grid>
+ <Grid container item md={12}>
+ <Grid item md={6}>
+ <ProvidingComponentsCard entity={entity} />
+ </Grid>
+ <Grid item md={6}>
+ <ConsumingComponentsCard entity={entity} />
+ </Grid>
+ </Grid>
+ </Grid>
+);
+
+const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
+ <Grid container spacing={3}>
+ <Grid item xs={12}>
+ <ApiDefinitionCard apiEntity={entity} />
+ </Grid>
+ </Grid>
+);
+
+const ApiEntityPage = ({ entity }: { entity: Entity }) => (
+ <EntityPageLayout>
+ <EntityPageLayout.Content
+ path="/*"
+ title="Overview"
+ element={<ApiOverviewContent entity={entity} />}
+ />
+ <EntityPageLayout.Content
+ path="/definition/*"
+ title="Definition"
+ element={<ApiDefinitionContent entity={entity as ApiEntity} />}
+ />
+ </EntityPageLayout>
+);
+
+export const EntityPage = () => {
+ const { entity } = useEntity();
+
+ switch (entity?.kind?.toLowerCase()) {
+ case 'component':
+ return <ComponentEntityPage entity={entity} />;
+ case 'api':
+ return <ApiEntityPage entity={entity} />;
+ default:
+ return <DefaultEntityPage entity={entity} />;
+ }
+};
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': minor
---
Removed modifyCss transformer and moved the css to injectCss transformer
Fixed issue where some internal doc links would cause a reload of the page
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Export the `defaultConfigLoader` implementation
+3 -2
View File
@@ -67,9 +67,9 @@ Firekube
freben
Fredrik
github
Github
GitHub
gitlab
Gitlab
GitLab
Grafana
graphql
graphviz
@@ -198,6 +198,7 @@ talkdesk
Talkdesk
tasklist
techdocs
Telenor
templated
templater
Templater
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
- name: prepare nightly release
run: yarn changeset version --snapshot nightly
# Publishes the nightly release to NPM, by using tag we make sure the release is
# Publishes the nightly release to npm, by using tag we make sure the release is
# not flagged as the latest release, which means that people will not get this
# version of the package unless requested explicitly
- name: publish nightly release
+1 -1
View File
@@ -1,7 +1,7 @@
name: Automatically add new TechDocs Issues and PRs to the GitHub project board
# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1
# New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board.
# Caveat: New PRs created from forks will not be added since GitHub actions don't share credentials with forks.
# Caveat: New PRs created from forks will not be added since GitHub Actions don't share credentials with forks.
on:
issues:
+16 -15
View File
@@ -1,15 +1,16 @@
| Organization | Contact | Description of Use |
| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. |
| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
| Organization | Contact | Description of Use |
| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. |
| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks |
+7
View File
@@ -58,6 +58,13 @@ proxy:
Authorization:
$env: BUILDKITE_TOKEN
'/sentry/api':
target: https://sentry.io/api/
allowedMethods: ['GET']
headers:
Authorization:
$env: SENTRY_TOKEN
organization:
name: My Company
@@ -20,4 +20,5 @@ stringData:
AZURE_TOKEN: {{ .Values.auth.azure.api.token }}
NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }}
TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }}
PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }}
{{- end }}
+1
View File
@@ -250,3 +250,4 @@ auth:
gitlabToken: g
newRelicRestApiKey: r
travisciAuthToken: fake-travis-ci-auth-token
pagerdutyToken: h
+35
View File
@@ -117,6 +117,41 @@ through the proxy.
Learn more about [the different components](overview/what-is-backstage.md) that
make up Backstage.
### How do I keep my Backstage app up to date?
In many ways one can view Backstage as a library rather than an application or
service. The `@backstage/create-app` tool that is used to create your own
Backstage app is similar to
[`create-react-app`](https://github.com/facebook/create-react-app) in that it
gives you a starting point. The code you get is meant to be evolved, and most of
the functionality you get out of the box is brought in via npm dependencies.
Keeping your app up to date generally means keeping your dependencies up to
date. The Backstage CLI provides a command to help you with that. Simply run
`yarn backstage-cli versions:bump` at the root of your repo, and the latest
versions of all Backstage packages will be installed.
While staying up to date with new releases and changes will keep your app up to
date, it can often be convenient to use the changes done to the
`@backstage/create-app` template as another method to stay up to date. For that
purpose, any changes done to the template are documented along with upgrade
instructions in the
[changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md)
of the `@backstage/create-app` package.
### Why can't I dynamically install plugins without modifications the app?
This decision is part of the core architecture and development flow of
Backstage. Plugins have a lot of freedom in what they provide and how they are
integrated into the app, and it would therefore add a lot of complexity to allow
plugins to be integrated via configuration the same way as they can be
integrated with code.
By bundling all plugins and their dependencies into one app bundle it is also
possible to do significant optimizations to the app load time by allowing
plugins to share dependencies between each other when possible. This contributes
to Backstage being fast, which is an important part of the user and developer
experience.
### Do I have to write plugins in TypeScript?
No, you can use JavaScript if you prefer. We want to keep the Backstage core
@@ -58,7 +58,7 @@ discover existing functionality in the ecosystem.
APIs are implemented by components and make their boundaries explicit. They
might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data
schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g.
framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs
framework APIs in Swift, Kotlin, Java, C++, TypeScript etc). In any case, APIs
exposed by components need to be in a known machine-readable format so we can
build further tooling and analysis on top.
@@ -6,7 +6,7 @@ description: Architecture Decision Record (ADR) log on Avoid React.FC and React.
## Context
Facebook has removed `React.FC` from their base template for a Typescript
Facebook has removed `React.FC` from their base template for a TypeScript
project. The reason for this was that it was found to be an unnecessary feature
with next to no benefits in combination with a few downsides.
@@ -407,7 +407,7 @@ The current set of well-known and common values for this field is:
- `service` - a backend service, typically exposing an API
- `website` - a website
- `library` - a software library, such as an NPM module or a Java library
- `library` - a software library, such as an npm module or a Java library
### `spec.lifecycle` [required]
@@ -558,7 +558,7 @@ The current set of well-known and common values for this field is:
- `service` - a backend service, typically exposing an API
- `website` - a website
- `library` - a software library, such as an NPM module or a Java library
- `library` - a software library, such as an npm module or a Java library
### `spec.templater` [required]
+2 -2
View File
@@ -21,7 +21,7 @@ looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to
render on the page in TechDocs/Backstage.
The static files consist of HTML, CSS and Images generated by MkDocs. We remove
all the Javascript before adding them to Backstage for security reasons. And
all the JavaScript before adding them to Backstage for security reasons. And
there are some additional techdocs metadata JSON files that TechDocs needs to
render a site.
@@ -59,7 +59,7 @@ Similar to how it is done in the Basic setup, the TechDocs Reader requests
your configured storage solution for the necessary files and returns them to
TechDocs Reader.
We will provide instructions, scripts and/or templates (e.g. GitHub actions) to
We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to
build docs in your CI/CD system.
[Track progress here.](https://github.com/backstage/backstage/issues/3400) You
will be able to use `techdocs-cli` to build docs and publish the generated docs
@@ -11,7 +11,7 @@ add an existing plugin to it. We are using the
[CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md)
plugin in this example.
1. Add the plugin's NPM package to the repo:
1. Add the plugin's npm package to the repo:
```bash
yarn add @backstage/plugin-circleci
+1 -1
View File
@@ -87,7 +87,7 @@ You may encounter the following error message:
Couldn't find any versions for "file-saver" that matches "eligrey-FileSaver.js-1.3.8.tar.gz-art-external"
```
This is likely because you have a globally configured NPM proxy, which breaks
This is likely because you have a globally configured npm proxy, which breaks
the installation of the `material-table` dependency. This is a known issue and
being worked on in `material-table`, but for now you can work around it using
the following:
+1 -1
View File
@@ -10,7 +10,7 @@ you're planning to do.
Creating a standalone instance makes it simpler to customize the application for
your needs whilst staying up to date with the project. You will also depend on
`@backstage` packages from NPM, making the project much smaller. This is the
`@backstage` packages from npm, making the project much smaller. This is the
recommended approach if you want to kick the tyres of Backstage or setup your
own instance.
+4 -4
View File
@@ -1,16 +1,16 @@
---
id: publishing
title: Publishing
description: Documentation on Publishing NPM packages
description: Documentation on Publishing npm packages
---
## NPM
## npm
NPM packages are published through CI/CD in the
npm packages are published through CI/CD in the
[.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml)
workflow. Every commit that is merged to master will be checked for new versions
of all public packages, and any new versions will automatically be published to
NPM.
npm.
### Creating a new release
+2 -2
View File
@@ -234,7 +234,7 @@ This is especially true for edge cases!
## Non-React Classes
Testing a Javascript object which is _not_ a React component follows a lot of
Testing a JavaScript object which is _not_ a React component follows a lot of
the same principles as testing objects in other languages.
### API Testing Principles
@@ -243,7 +243,7 @@ Testing an API involves verifying four things:
1. Invalid inputs are caught before being sent to the server.
2. Valid inputs translate into a valid browser request.
3. Server response is translated into an expected Javascript object.
3. Server response is translated into an expected JavaScript object.
4. Server errors are handled gracefully.
### Mocking API Calls
+1 -1
View File
@@ -161,7 +161,7 @@ are separated out into their own folder, see further down.
- [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) -
Uses the
[Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API)
[TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API)
to read out definitions and generate documentation for it.
- [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) -
+1 -1
View File
@@ -22,7 +22,7 @@ music and wants to have a theme tune for every service in Backstage.
Sam built a Spotify plugin for Backstage that allows service owners to define a
theme tune for their service. The theme tune plays whenever a user visits the
service page in Backstage. The plugin is published to NPM and available for any
service page in Backstage. The plugin is published to npm and available for any
organization to easily install and add to their Backstage installation.
# 1. A New Plugin
@@ -1,6 +1,6 @@
---
title: Announcing Backstage
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: http://twitter.com/stalund
authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg
---
@@ -1,6 +1,6 @@
---
title: What the heck is Backstage anyway?
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: http://twitter.com/stalund
authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg
---
@@ -1,6 +1,6 @@
---
title: Introducing Lighthouse for Backstage
author: Paul Marbach
author: Paul Marbach, Spotify
authorURL: http://twitter.com/fastfrwrd
authorImageURL: https://pbs.twimg.com/profile_images/1224058798958088192/JPxS8uzR_400x400.jpg
---
@@ -1,6 +1,6 @@
---
title: How to quickly set up Backstage
author: Marcus Eide
author: Marcus Eide, Spotify
authorURL: https://github.com/marcuseide
authorImageURL: https://secure.gravatar.com/avatar/20223f1e03673c7c1e6282fbebaf6942
---
@@ -1,6 +1,6 @@
---
title: Introducing Tech Radar for Backstage
author: Bilawal Hameed
author: Bilawal Hameed, Spotify
authorURL: http://twitter.com/bilawalhameed
authorImageURL: https://avatars0.githubusercontent.com/bih
---
@@ -1,6 +1,6 @@
---
title: Weaveworks COVID-19 app uses Backstage UI
author: Jeff Feng
author: Jeff Feng, Spotify
authorURL: https://github.com/fengypants
authorImageURL: https://avatars2.githubusercontent.com/u/46946747
---
@@ -1,6 +1,6 @@
---
title: Starting Phase 2: The Service Catalog
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: http://twitter.com/stalund
authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg
---
@@ -1,6 +1,6 @@
---
title: Backstage Service Catalog released in alpha
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: http://twitter.com/stalund
image: https://backstage.io/blog/assets/6/header.png
---
@@ -1,6 +1,6 @@
---
title: How to enable authentication in Backstage using Passport
author: Lee Mills
author: Lee Mills, Spotify
authorURL: https://github.com/leemills83
authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4
---
@@ -1,6 +1,6 @@
---
title: Announcing Backstage Software Templates
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: https://twitter.com/stalund
---
@@ -1,6 +1,6 @@
---
title: Announcing TechDocs: Spotifys docs-like-code plugin for Backstage
author: Gary Niemen
author: Gary Niemen, Spotify
authorURL: https://github.com/garyniemen
---
@@ -1,6 +1,6 @@
---
title: Backstage has been accepted into the CNCF Sandbox
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: https://twitter.com/stalund
---
@@ -1,6 +1,6 @@
---
title: How to design for Backstage (even if youre not a designer)
author: Kat Zhou
author: Kat Zhou, Spotify
authorURL: http://twitter.com/katherinemzhou
---
@@ -1,6 +1,6 @@
---
title: The Plugin Marketplace is open
author: Stefan Ålund
author: Stefan Ålund, Spotify
authorURL: https://twitter.com/stalund
---
@@ -1,6 +1,6 @@
---
title: New Cost Insights plugin: The engineers solution to taming cloud costs
author: Janisa Anandamohan
author: Janisa Anandamohan, Spotify
authorURL: https://twitter.com/janisa_a
---
+9
View File
@@ -54,6 +54,12 @@ class Footer extends React.Component {
<a href={this.props.config.fossWebsite}>
Open Source @ {this.props.config.organizationName}
</a>
<a href="https://engineering.atspotify.com/">
Spotify Engineering Blog
</a>
<a href="https://developer.spotify.com/">Spotify for Developers</a>
<a href={this.props.config.repoUrl}>GitHub</a>
<a
className="github-button"
@@ -78,6 +84,9 @@ class Footer extends React.Component {
)}
</div>
</section>
<p style={{ textAlign: 'center' }}>
<a href="https://spotify.github.io">Made with &nbsp; at Spotify</a>
</p>
<p className="copyright">{this.props.config.copyright}</p>
</footer>
);
+12
View File
@@ -0,0 +1,12 @@
---
title: Argo CD
author: roadie.io
authorUrl: https://roadie.io
category: CI
description: View Argo CD status for your projects in Backstage.
documentation: https://roadie.io/backstage/plugins/argo-cd
iconUrl: https://roadie.io/images/logos/argo.png
npmPackageName: '@roadiehq/backstage-plugin-argo-cd'
tags:
- cd
- ci
+1 -1
View File
@@ -16,7 +16,7 @@
"devDependencies": {
"@spotify/prettier-config": "^9.0.0",
"docusaurus": "^2.0.0-alpha.378053ac5",
"js-yaml": "^3.14.0",
"js-yaml": "^3.14.1",
"prettier": "^2.2.1"
},
"prettier": "@spotify/prettier-config"
+1 -9
View File
@@ -8,11 +8,8 @@
// See https://docusaurus.io/docs/site-config for all the possible
// site configuration options.
// List of projects/orgs using your project for the users page.
const users = [];
const siteConfig = {
title: 'Backstage', // Title for your website.
title: 'Backstage Service Catalog and Developer Platform', // Title for your website.
tagline: 'An open platform for building developer portals',
url: 'https://backstage.io', // Your website URL
cname: 'backstage.io',
@@ -72,11 +69,6 @@ const siteConfig = {
navGroupSubcategoryTitleColor: '#9e9e9e',
},
/* Colors for syntax highlighting */
highlight: {
theme: 'dark',
},
// This copyright info is used in /core/Footer.js and blog RSS/Atom feeds.
copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`,
+4 -4
View File
@@ -3921,10 +3921,10 @@ js-tokens@^3.0.2:
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.1:
version "3.14.0"
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482"
integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==
js-yaml@^3.13.1, js-yaml@^3.14.1, js-yaml@^3.8.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
dependencies:
argparse "^1.0.7"
esprima "^4.0.0"
+1 -1
View File
@@ -69,7 +69,7 @@ nav:
- Testing:
- Overview: 'plugins/testing.md'
- Publishing:
- Open source and NPM: 'plugins/publishing.md'
- Open source and npm: 'plugins/publishing.md'
- Private/internal (non-open source): 'plugins/publish-private.md'
- Configuration:
- Overview: 'conf/index.md'
+1
View File
@@ -18,6 +18,7 @@
"@backstage/plugin-github-actions": "^0.2.3",
"@backstage/plugin-gitops-profiles": "^0.2.1",
"@backstage/plugin-graphiql": "^0.2.1",
"@backstage/plugin-org": "^0.3.0",
"@backstage/plugin-jenkins": "^0.3.2",
"@backstage/plugin-kubernetes": "^0.3.1",
"@backstage/plugin-lighthouse": "^0.2.4",
@@ -13,11 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity, Entity } from '@backstage/catalog-model';
import {
ApiEntity,
Entity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core';
import {
ApiDefinitionCard,
Router as ApiDocsRouter,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
import {
AboutCard,
@@ -48,6 +56,12 @@ import {
isPluginApplicableToEntity as isLighthouseAvailable,
LastLighthouseAuditCard,
} from '@backstage/plugin-lighthouse';
import {
OwnershipCard,
MembersListCard,
GroupProfileCard,
UserProfileCard,
} from '@backstage/plugin-org';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
@@ -176,6 +190,17 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
</Grid>
);
const ComponentApisContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<ProvidedApisCard entity={entity} />
</Grid>
<Grid item md={6}>
<ConsumedApisCard entity={entity} />
</Grid>
</Grid>
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
@@ -196,7 +221,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/api/*"
title="API"
element={<ApiDocsRouter entity={entity} />}
element={<ComponentApisContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -297,6 +322,14 @@ const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid item md={6}>
<AboutCard entity={entity} />
</Grid>
<Grid container item md={12}>
<Grid item md={6}>
<ProvidingComponentsCard entity={entity} />
</Grid>
<Grid item md={6}>
<ConsumingComponentsCard entity={entity} />
</Grid>
</Grid>
</Grid>
);
@@ -323,6 +356,51 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => (
</EntityPageLayout>
);
const UserOverviewContent = ({ entity }: { entity: UserEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<UserProfileCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<OwnershipCard entity={entity} variant="gridItem" />
</Grid>
</Grid>
);
const UserEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<UserOverviewContent entity={entity as UserEntity} />}
/>
</EntityPageLayout>
);
const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<GroupProfileCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<OwnershipCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12}>
<MembersListCard entity={entity} />
</Grid>
</Grid>
);
const GroupEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<GroupOverviewContent entity={entity as GroupEntity} />}
/>
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
@@ -331,6 +409,10 @@ export const EntityPage = () => {
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
case 'group':
return <GroupEntityPage entity={entity} />;
case 'user':
return <UserEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
+1
View File
@@ -42,3 +42,4 @@ export { plugin as UserSettings } from '@backstage/plugin-user-settings';
export { plugin as PagerDuty } from '@backstage/plugin-pagerduty';
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
export { plugin as Org } from '@backstage/plugin-org';
-2
View File
@@ -41,7 +41,6 @@
"cors": "^2.8.5",
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-prom-bundle": "^6.1.0",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.1",
"git-url-parse": "^11.4.0",
@@ -51,7 +50,6 @@
"logform": "^2.1.1",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
"prom-client": "^12.0.0",
"selfsigned": "^1.10.7",
"stoppable": "^1.1.0",
"tar": "^6.0.5",
@@ -20,7 +20,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader';
import { AzureUrlReader } from './AzureUrlReader';
import { msw } from '@backstage/test-utils';
import { ReadTreeResponseFactory } from './tree';
@@ -111,13 +111,13 @@ describe('AzureUrlReader', () => {
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
},
{
url: 'com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
{
url: '',
@@ -178,21 +178,4 @@ describe('AzureUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
});
describe('getDownloadUrl', () => {
it('do not add scopePath if no path is specified', async () => {
const result = getDownloadUrl(
'https://dev.azure.com/organization/project/_git/repository',
);
expect(result.searchParams.get('scopePath')).toBeNull();
});
it('add scopePath if a path is specified', async () => {
const result = getDownloadUrl(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs',
);
expect(result.searchParams.get('scopePath')).toEqual('docs');
});
});
});
@@ -17,10 +17,12 @@
import {
AzureIntegrationConfig,
readAzureIntegrationConfigs,
getAzureFileFetchUrl,
getAzureDownloadUrl,
getAzureRequestOptions,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import { Readable } from 'stream';
import parseGitUri from 'git-url-parse';
import { NotFoundError } from '../errors';
import {
ReaderFactory,
@@ -30,28 +32,6 @@ import {
} from './types';
import { ReadTreeResponseFactory } from './tree';
export function getDownloadUrl(url: string): URL {
const {
name: repoName,
owner: project,
organization,
protocol,
resource,
filepath,
} = parseGitUri(url);
// scopePath will limit the downloaded content
// /docs will only download the docs folder and everything below it
// /docs/index.md will only download index.md but put it in the root of the archive
const scopePath = filepath
? `&scopePath=${encodeURIComponent(filepath)}`
: '';
return new URL(
`${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`,
);
}
export class AzureUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const configs = readAzureIntegrationConfigs(
@@ -76,11 +56,11 @@ export class AzureUrlReader implements UrlReader {
}
async read(url: string): Promise<Buffer> {
const builtUrl = this.buildRawUrl(url);
const builtUrl = getAzureFileFetchUrl(url);
let response: Response;
try {
response = await fetch(builtUrl.toString(), this.getRequestOptions());
response = await fetch(builtUrl, getAzureRequestOptions(this.options));
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -102,8 +82,8 @@ export class AzureUrlReader implements UrlReader {
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const response = await fetch(
getDownloadUrl(url).toString(),
this.getRequestOptions({ Accept: 'application/zip' }),
getAzureDownloadUrl(url),
getAzureRequestOptions(this.options, { Accept: 'application/zip' }),
);
if (!response.ok) {
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
@@ -119,80 +99,6 @@ export class AzureUrlReader implements UrlReader {
});
}
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch}
private buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
project,
srcKeyword,
repoName,
] = url.pathname.split('/');
const path = url.searchParams.get('path') || '';
const ref = url.searchParams.get('version')?.substr(2);
if (
url.hostname !== 'dev.azure.com' ||
empty !== '' ||
userOrOrg === '' ||
project === '' ||
srcKeyword !== '_git' ||
repoName === '' ||
path === '' ||
ref === ''
) {
throw new Error('Wrong Azure Devops URL or Invalid file path');
}
// transform to api
url.pathname = [
empty,
userOrOrg,
project,
'_apis',
'git',
'repositories',
repoName,
'items',
].join('/');
const queryParams = [`path=${path}`];
if (ref) {
queryParams.push(`version=${ref}`);
}
url.search = queryParams.join('&');
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
private getRequestOptions(additionalHeaders?: {
[key: string]: string;
}): RequestInit {
const headers: HeadersInit = additionalHeaders ?? {};
if (this.options.token) {
headers.Authorization = `Basic ${Buffer.from(
`:${this.options.token}`,
'utf8',
).toString('base64')}`;
}
return { headers };
}
toString() {
const { host, token } = this.options;
return `azure{host=${host},authed=${Boolean(token)}}`;
@@ -14,94 +14,9 @@
* limitations under the License.
*/
import { BitbucketIntegrationConfig } from '@backstage/integration';
import {
BitbucketUrlReader,
getApiRequestOptions,
getApiUrl,
} from './BitbucketUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
describe('BitbucketUrlReader', () => {
describe('getApiRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getApiRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
it('insert basic auth when needed', () => {
const withUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getApiRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});
describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for Bitbucket Cloud', () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getApiUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
).toEqual(
new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
),
);
});
it('happy path for Bitbucket Server', () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
};
expect(
getApiUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
),
);
});
});
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new BitbucketUrlReader({
@@ -16,69 +16,14 @@
import {
BitbucketIntegrationConfig,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
readBitbucketIntegrationConfigs,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUri from 'git-url-parse';
import { NotFoundError } from '../errors';
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
export function getApiRequestOptions(
provider: BitbucketIntegrationConfig,
): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
headers.Authorization = `Bearer ${provider.token}`;
} else if (provider.username && provider.appPassword) {
headers.Authorization = `Basic ${Buffer.from(
`${provider.username}:${provider.appPassword}`,
'utf8',
).toString('base64')}`;
}
return {
headers,
};
}
// Converts for example
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
export function getApiUrl(
target: string,
provider: BitbucketIntegrationConfig,
): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
!owner ||
!name ||
(filepathtype !== 'browse' &&
filepathtype !== 'raw' &&
filepathtype !== 'src')
) {
throw new Error('Invalid Bitbucket URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
if (provider.host === 'bitbucket.org') {
if (!ref) {
throw new Error('Invalid Bitbucket URL or file path');
}
return new URL(
`${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
);
}
return new URL(
`${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
);
} catch (e) {
throw new Error(`Incorrect URL: ${target}, ${e}`);
}
}
/**
* A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as
* the one exposed by Bitbucket Cloud itself.
@@ -116,9 +61,8 @@ export class BitbucketUrlReader implements UrlReader {
}
async read(url: string): Promise<Buffer> {
const bitbucketUrl = getApiUrl(url, this.config);
const options = getApiRequestOptions(this.config);
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.config);
const options = getBitbucketRequestOptions(this.config);
let response: Response;
try {
@@ -15,19 +15,12 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { msw } from '@backstage/test-utils';
import fs from 'fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'path';
import {
getApiRequestOptions,
getApiUrl,
getRawRequestOptions,
getRawUrl,
GithubUrlReader,
} from './GithubUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { ReadTreeResponseFactory } from './tree';
const treeResponseFactory = ReadTreeResponseFactory.create({
@@ -35,143 +28,6 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
});
describe('GithubUrlReader', () => {
describe('getApiRequestOptions', () => {
it('sets the correct API version', () => {
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect((getApiRequestOptions(config).headers as any).Accept).toEqual(
'application/vnd.github.v3.raw',
);
});
it('inserts a token when needed', () => {
const withToken: GitHubIntegrationConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: GitHubIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withToken).headers as any).Authorization,
).toEqual('token A');
expect(
(getApiRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
});
describe('getRawRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: GitHubIntegrationConfig = {
host: '',
rawBaseUrl: '',
token: 'A',
};
const withoutToken: GitHubIntegrationConfig = {
host: '',
rawBaseUrl: '',
};
expect(
(getRawRequestOptions(withToken).headers as any).Authorization,
).toEqual('token A');
expect(
(getRawRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
});
describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for github', () => {
const config: GitHubIntegrationConfig = {
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
};
expect(
getApiUrl(
'https://github.com/a/b/blob/branchname/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname',
),
);
expect(
getApiUrl(
'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname',
),
);
});
it('happy path for ghe', () => {
const config: GitHubIntegrationConfig = {
host: 'ghe.mycompany.net',
apiBaseUrl: 'https://ghe.mycompany.net/api/v3',
};
expect(
getApiUrl(
'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname',
),
);
});
});
describe('getRawUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for github', () => {
const config: GitHubIntegrationConfig = {
host: 'github.com',
rawBaseUrl: 'https://raw.githubusercontent.com',
};
expect(
getRawUrl(
'https://github.com/a/b/blob/branchname/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml',
),
);
});
it('happy path for ghe', () => {
const config: GitHubIntegrationConfig = {
host: 'ghe.mycompany.net',
rawBaseUrl: 'https://ghe.mycompany.net/raw',
};
expect(
getRawUrl(
'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml',
config,
),
).toEqual(
new URL('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'),
);
});
});
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new GithubUrlReader(
@@ -17,6 +17,8 @@
import {
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
getGitHubFileFetchUrl,
getGitHubRequestOptions,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUri from 'git-url-parse';
@@ -30,92 +32,6 @@ import {
UrlReader,
} from './types';
export function getApiRequestOptions(
provider: GitHubIntegrationConfig,
): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
};
if (provider.token) {
headers.Authorization = `token ${provider.token}`;
}
return {
headers,
};
}
export function getRawRequestOptions(
provider: GitHubIntegrationConfig,
): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
headers.Authorization = `token ${provider.token}`;
}
return {
headers,
};
}
// Converts for example
// from: https://github.com/a/b/blob/branchname/path/to/c.yaml
// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname
export function getApiUrl(
target: string,
provider: GitHubIntegrationConfig,
): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
!owner ||
!name ||
!ref ||
(filepathtype !== 'blob' && filepathtype !== 'raw')
) {
throw new Error('Invalid GitHub URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
return new URL(
`${provider.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`,
);
} catch (e) {
throw new Error(`Incorrect URL: ${target}, ${e}`);
}
}
// Converts for example
// from: https://github.com/a/b/blob/branchname/c.yaml
// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml
export function getRawUrl(
target: string,
provider: GitHubIntegrationConfig,
): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
!owner ||
!name ||
!ref ||
(filepathtype !== 'blob' && filepathtype !== 'raw')
) {
throw new Error('Invalid GitHub URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
return new URL(
`${provider.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`,
);
} catch (e) {
throw new Error(`Incorrect URL: ${target}, ${e}`);
}
}
/**
* A processor that adds the ability to read files from GitHub v3 APIs, such as
* the one exposed by GitHub itself.
@@ -144,14 +60,8 @@ export class GithubUrlReader implements UrlReader {
}
async read(url: string): Promise<Buffer> {
const useApi =
this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl);
const ghUrl = useApi
? getApiUrl(url, this.config)
: getRawUrl(url, this.config);
const options = useApi
? getApiRequestOptions(this.config)
: getRawRequestOptions(this.config);
const ghUrl = getGitHubFileFetchUrl(url, this.config);
const options = getGitHubRequestOptions(this.config);
let response: Response;
try {
@@ -196,7 +106,7 @@ export class GithubUrlReader implements UrlReader {
new URL(
`${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`,
).toString(),
getRawRequestOptions(this.config),
getGitHubRequestOptions(this.config),
);
if (!response.ok) {
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
@@ -15,6 +15,8 @@
*/
import {
getGitLabFileFetchUrl,
getGitLabRequestOptions,
GitLabIntegrationConfig,
readGitLabIntegrationConfigs,
} from '@backstage/integration';
@@ -37,20 +39,11 @@ export class GitlabUrlReader implements UrlReader {
constructor(private readonly options: GitLabIntegrationConfig) {}
async read(url: string): Promise<Buffer> {
// TODO(Rugvip): merged the old GitlabReaderProcessor in here and used
// the existence of /~/blob/ to switch the logic. Don't know if this
// makes sense and it might require some more work.
let builtUrl: URL;
if (url.includes('/-/blob/')) {
const projectID = await this.getProjectID(url);
builtUrl = this.buildProjectUrl(url, projectID);
} else {
builtUrl = this.buildRawUrl(url);
}
const builtUrl = await getGitLabFileFetchUrl(url, this.options);
let response: Response;
try {
response = await fetch(builtUrl.toString(), this.getRequestOptions());
response = await fetch(builtUrl, getGitLabRequestOptions(this.options));
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -70,109 +63,6 @@ export class GitlabUrlReader implements UrlReader {
throw new Error('GitlabUrlReader does not implement readTree');
}
// Converts
// from: https://gitlab.example.com/a/b/blob/master/c.yaml
// to: https://gitlab.example.com/a/b/raw/master/c.yaml
private buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
repoName,
blobKeyword,
...restOfPath
] = url.pathname.split('/');
if (
empty !== '' ||
userOrOrg === '' ||
repoName === '' ||
blobKeyword !== 'blob' ||
!restOfPath.join('/').match(/\.yaml$/)
) {
throw new Error('Wrong GitLab URL');
}
// Replace 'blob' with 'raw'
url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join(
'/',
);
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
// convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
// to https://gitlab.com/api/v4/projects/<PROJECTID>/repository/files/filepath?ref=branch
private buildProjectUrl(target: string, projectID: Number): URL {
try {
const url = new URL(target);
const branchAndFilePath = url.pathname.split('/-/blob/')[1];
const [branch, ...filePath] = branchAndFilePath.split('/');
url.pathname = [
'/api/v4/projects',
projectID,
'repository/files',
encodeURIComponent(filePath.join('/')),
'raw',
].join('/');
url.search = `?ref=${branch}`;
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
private async getProjectID(target: string): Promise<Number> {
const url = new URL(target);
if (
// absPaths to gitlab files should contain /-/blob
// ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
!url.pathname.match(/\/\-\/blob\//)
) {
throw new Error('Please provide full path to yaml file from Gitlab');
}
try {
const repo = url.pathname.split('/-/blob/')[0];
// Find ProjectID from url
// convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath'
// to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo'
const repoIDLookup = new URL(
`${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent(
repo.replace(/^\//, ''),
)}`,
);
const response = await fetch(
repoIDLookup.toString(),
this.getRequestOptions(),
);
const projectIDJson = await response.json();
const projectID: Number = projectIDJson.id;
return projectID;
} catch (e) {
throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`);
}
}
private getRequestOptions(): RequestInit {
return {
headers: {
['PRIVATE-TOKEN']: this.options.token ?? '',
},
};
}
toString() {
const { host, token } = this.options;
return `gitlab{host=${host},authed=${Boolean(token)}}`;
+17
View File
@@ -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.
*/
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
@@ -39,7 +39,6 @@ import {
readHttpsSettings,
} from './config';
import { createHttpServer, createHttpsServer } from './hostFactory';
import { metricsHandler } from './metrics';
export const DEFAULT_PORT = 7000;
// '' is express default, which listens to all interfaces
@@ -66,7 +65,6 @@ export class ServiceBuilderImpl implements ServiceBuilder {
private corsOptions: cors.CorsOptions | undefined;
private cspOptions: Record<string, string[] | false> | undefined;
private httpsSettings: HttpsSettings | undefined;
private enableMetrics: boolean = true;
private routers: [string, Router][];
// Reference to the module where builder is created - needed for hot module
// reloading
@@ -109,9 +107,6 @@ export class ServiceBuilderImpl implements ServiceBuilder {
this.httpsSettings = httpsSettings;
}
// For now, configuration of metrics is a simple boolean and active by default
this.enableMetrics = backendConfig.getOptionalBoolean('metrics') !== false;
return this;
}
@@ -166,9 +161,6 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(cors(corsOptions));
}
app.use(compression());
if (this.enableMetrics) {
app.use(metricsHandler());
}
app.use(requestLoggingHandler());
for (const [root, route] of this.routers) {
app.use(root, route);
@@ -1,37 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { normalizePath } from './metrics';
describe('normalizePath', () => {
it('should normalize /path to /path', async () => {
const path = normalizePath({ url: 'http://server/path' });
expect(path).toBe('/path');
});
it('should normalize /path/test to /path', async () => {
const path = normalizePath({ url: 'http://server/path/test' });
expect(path).toBe('/path');
});
it('should normalize /api/plugin-name/test to /api/plugin-name', async () => {
const path = normalizePath({ url: 'http://server/api/plugin-name/test' });
expect(path).toBe('/api/plugin-name');
});
});
+10 -2
View File
@@ -3,11 +3,20 @@
"version": "0.2.5",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
"license": "Apache-2.0",
"private": true,
"engines": {
"node": "12 || 14"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/backend"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli backend:build",
"build-image": "backstage-cli backend:build-image --build --tag example-backend",
@@ -29,7 +38,6 @@
"@backstage/plugin-proxy-backend": "^0.2.2",
"@backstage/plugin-rollbar-backend": "^0.1.4",
"@backstage/plugin-scaffolder-backend": "^0.3.3",
"@backstage/plugin-sentry-backend": "^0.1.3",
"@backstage/plugin-techdocs-backend": "^0.3.1",
"@gitbeaker/node": "^25.2.0",
"@octokit/rest": "^18.0.0",
+2 -5
View File
@@ -25,13 +25,13 @@
import Router from 'express-promise-router';
import {
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
loadBackendConfig,
notFoundHandler,
SingleConnectionDatabaseManager,
SingleHostDiscovery,
UrlReaders,
useHotMemoize,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
@@ -40,7 +40,6 @@ import catalog from './plugins/catalog';
import kubernetes from './plugins/kubernetes';
import rollbar from './plugins/rollbar';
import scaffolder from './plugins/scaffolder';
import sentry from './plugins/sentry';
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import graphql from './plugins/graphql';
@@ -76,7 +75,6 @@ async function main() {
const authEnv = useHotMemoize(module, () => createEnv('auth'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar'));
const sentryEnv = useHotMemoize(module, () => createEnv('sentry'));
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes'));
const graphqlEnv = useHotMemoize(module, () => createEnv('graphql'));
@@ -86,7 +84,6 @@ async function main() {
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/sentry', await sentry(sentryEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
+9
View File
@@ -11,6 +11,15 @@
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/catalog-client"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
@@ -10,3 +10,5 @@ spec:
- ./apis/spotify-api.yaml
- ./apis/streetlights-api.yaml
- ./apis/swapi-graphql.yaml
- ./apis/wayback-archive-api.yaml
- ./apis/wayback-search-api.yaml
@@ -14,3 +14,5 @@ spec:
- ./components/playback-lib-component.yaml
- ./components/www-artist-component.yaml
- ./components/shuffle-api-component.yaml
- ./components/wayback-archive-component.yaml
- ./components/wayback-search-component.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: wayback-archive
description: Archive API for the wayback machine
spec:
type: openapi
lifecycle: production
owner: archive@example.com
definition:
$text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/wayback/1.0.0/openapi.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: wayback-search
description: Search API for the wayback machine
spec:
type: openapi
lifecycle: production
owner: archive@example.com
definition:
$text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/search/1.0.0/openapi.yaml
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: wayback-archive
description: Archive of the wayback machine
spec:
type: service
lifecycle: production
owner: archive@example.com
providesApis:
- wayback-archive
@@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: wayback-search
description: Search of the wayback machine
spec:
type: service
lifecycle: production
owner: archive@example.com
providesApis:
- wayback-search
consumesApis:
- wayback-archive
+9
View File
@@ -11,6 +11,15 @@
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/catalog-model"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
+1 -1
View File
@@ -4,7 +4,7 @@ This package provides a CLI for developing Backstage plugins and apps.
## Installation
Install the package via npm or yarn:
Install the package via npm or Yarn:
```sh
$ npm install --save @backstage/cli
+1 -1
View File
@@ -44,7 +44,7 @@
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.4.x",
"@svgr/plugin-svgo": "5.4.x",
"@svgr/rollup": "5.4.x",
"@svgr/rollup": "5.5.x",
"@svgr/webpack": "5.4.x",
"@types/start-server-webpack-plugin": "^2.2.0",
"@types/webpack-env": "^1.15.2",
+3 -3
View File
@@ -87,9 +87,9 @@ export function registerCommands(program: CommanderStatic) {
'Create plugin with the backend dependencies as default',
)
.description('Creates a new plugin in the current repository')
.option('--scope <scope>', 'NPM scope')
.option('--npm-registry <URL>', 'NPM registry URL')
.option('--no-private', 'Public NPM Package')
.option('--scope <scope>', 'npm scope')
.option('--npm-registry <URL>', 'npm registry URL')
.option('--no-private', 'Public npm package')
.action(
lazy(() => import('./create-plugin/createPlugin').then(m => m.default)),
);
+6 -1
View File
@@ -35,7 +35,12 @@ export const transforms = (options: TransformOptions): Transforms => {
const extraTransforms = isDev ? ['react-hot-loader'] : [];
const transformExcludeCondition = {
and: [/node_modules/, { not: externalTransforms }],
or: [
// This makes sure we don't transform node_modules inside any of the local monorepo packages
/node_modules.*node_modules/,
// This excludes the local monorepo packages from the excludes, meaning they will be transformed
{ and: [/node_modules/, { not: externalTransforms }] },
],
};
const loaders = [
+1 -1
View File
@@ -90,7 +90,7 @@ type Options = {
* will be suitable for packaging e.g. into a docker image.
*
* This creates a structure that is functionally similar to if the packages where
* installed from NPM, but uses yarn workspaces to link to them at runtime.
* installed from npm, but uses Yarn workspaces to link to them at runtime.
*/
export async function createDistWorkspace(
packageNames: string[],
+1 -1
View File
@@ -37,7 +37,7 @@ type LockfileQueryEntry = {
version: string;
};
/** Entries that have an invalid version range, for example an NPM tag */
/** Entries that have an invalid version range, for example an npm tag */
type AnalyzeResultInvalidRange = {
name: string;
range: string;
+1 -1
View File
@@ -62,7 +62,7 @@
![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png)
- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the
- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the
default scope is configurable when overwriting the Core Api in the app.
```
@@ -45,7 +45,7 @@ export type GithubAuthResponse = {
const DEFAULT_PROVIDER = {
id: 'github',
title: 'Github',
title: 'GitHub',
icon: GithubIcon,
};
@@ -21,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'Gitlab',
title: 'GitLab',
icon: GitlabIcon,
};
@@ -41,11 +41,14 @@ describe('discovery', () => {
root,
discoverers: [childDiscoverer],
collectors: {
names: createCollector(Array<string>(), (acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
}),
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
@@ -85,11 +88,14 @@ describe('discovery', () => {
),
],
collectors: {
names: createCollector(Array<string>(), (acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
}),
names: createCollector(
() => Array<string>(),
(acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
},
),
},
});
@@ -23,7 +23,7 @@ export type Collector<Result, Context> = () => {
visit(
accumulator: Result,
element: ReactElement,
parent: ReactElement,
parent: ReactElement | undefined,
context: Context,
): Context;
};
@@ -33,7 +33,7 @@ export type Collector<Result, Context> = () => {
* varying methods to discover child nodes and collect data along the way.
*/
export function traverseElementTree<Results>(options: {
root: ReactElement;
root: ReactNode;
discoverers: Discoverer[];
collectors: { [name in keyof Results]: Collector<Results[name], any> };
}): Results {
@@ -52,14 +52,14 @@ export function traverseElementTree<Results>(options: {
// Internal representation of an element in the tree that we're iterating over
type QueueItem = {
node: ReactNode;
parent: ReactElement;
parent: ReactElement | undefined;
contexts: { [name in string]: unknown };
};
const queue = [
{
node: Children.toArray(options.root),
parent: options.root,
parent: undefined,
contexts: {},
} as QueueItem,
];
@@ -120,10 +120,10 @@ export function traverseElementTree<Results>(options: {
}
export function createCollector<Result, Context>(
initialResult: Result,
accumulatorFactory: () => Result,
visit: ReturnType<Collector<Result, Context>>['visit'],
): Collector<Result, Context> {
return () => ({ accumulator: initialResult, visit });
return () => ({ accumulator: accumulatorFactory(), visit });
}
export function childDiscoverer(element: ReactElement): ReactNode {
+1 -1
View File
@@ -34,7 +34,7 @@ import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
export const pluginCollector = createCollector(
new Set<BackstagePlugin>(),
() => new Set<BackstagePlugin>(),
(acc, node) => {
const plugin = getComponentData<BackstagePlugin>(node, 'core.plugin');
if (plugin) {
+18 -54
View File
@@ -14,44 +14,10 @@
* limitations under the License.
*/
import {
ConcreteRoute,
routeReference,
ReferencedRoute,
resolveRoute,
RouteRefConfig,
} from './types';
import { generatePath } from 'react-router-dom';
import { RouteRefConfig, RouteRef } from './types';
type SubRouteConfig = {
path: string;
};
export class SubRouteRef<T extends { [name in string]: string } | never = never>
implements ReferencedRoute {
constructor(
private readonly parent: ConcreteRoute,
private readonly config: SubRouteConfig,
) {}
get [routeReference]() {
return this;
}
link<Args extends T extends never ? [] : [T]>(...args: Args): ConcreteRoute {
return {
[routeReference]: this,
[resolveRoute]: (path: string) => {
const ownPart = generatePath(this.config.path, args[0] ?? {});
const parentPart = this.parent[resolveRoute](path);
return parentPart + ownPart;
},
};
}
}
export class AbsoluteRouteRef implements ConcreteRoute {
constructor(private readonly config: RouteRefConfig) {}
export class AbsoluteRouteRef<Params extends { [param in string]: string }> {
constructor(private readonly config: RouteRefConfig<Params>) {}
get icon() {
return this.config.icon;
@@ -66,26 +32,24 @@ export class AbsoluteRouteRef implements ConcreteRoute {
return this.config.title;
}
createSubRoute<T extends { [name in string]: string } | never = never>(
config: SubRouteConfig,
) {
return new SubRouteRef<T>(this, config);
/**
* This function should not be used, create a separate RouteRef instead
* @deprecated
*/
createSubRoute(): any {
throw new Error(
'This method should not be called, create a separate RouteRef instead',
);
}
get [routeReference]() {
return this;
}
[resolveRoute](path: string) {
return path;
toString() {
return `routeRef{path=${this.path}}`;
}
}
export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef {
return new AbsoluteRouteRef(config);
export function createRouteRef<
ParamKeys extends string,
Params extends { [param in string]: string } = { [name in ParamKeys]: string }
>(config: RouteRefConfig<Params>): RouteRef<Params> {
return new AbsoluteRouteRef<Params>(config);
}
// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone
// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider
// a different model for how to create sub routes, just avoid this
export type MutableRouteRef = AbsoluteRouteRef;
@@ -1,105 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RouteRefRegistry } from './RouteRefRegistry';
import { createRouteRef } from './RouteRef';
const dummyConfig = { path: '/', icon: () => null, title: 'my-title' };
const ref1 = createRouteRef(dummyConfig);
const ref11 = createRouteRef(dummyConfig);
const ref12 = createRouteRef(dummyConfig);
const ref121 = createRouteRef(dummyConfig);
const ref2 = createRouteRef(dummyConfig);
const ref2a = ref2.createSubRoute({ path: '/a' });
const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' });
describe('RouteRefRegistry', () => {
it('should be constructed with a root route', () => {
const registry = new RouteRefRegistry();
expect(registry.resolveRoute([], [])).toBe('');
});
it('should register and resolve some absolute routes', () => {
const registry = new RouteRefRegistry();
expect(registry.registerRoute([ref1], '1')).toBe(true);
expect(registry.registerRoute([ref1, ref11], '11')).toBe(true);
expect(registry.registerRoute([ref1, ref12], '12')).toBe(true);
expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true);
expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe(
false,
);
expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false);
expect(registry.registerRoute([ref2], '2')).toBe(true);
expect(registry.registerRoute([ref2], 'duplicate')).toBe(false);
expect(registry.registerRoute([ref2], '2')).toBe(true);
expect(registry.resolveRoute([], [ref1])).toBe('/1');
expect(registry.resolveRoute([], [ref11])).toBe(undefined);
expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11');
expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11');
expect(registry.resolveRoute([ref1], [ref2])).toBe('/2');
expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121');
expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe(
'/1/12/121',
);
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe(
'/1/12/121',
);
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12');
expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1');
});
it('should register and resolve with sub routes', () => {
const registry = new RouteRefRegistry();
expect(registry.registerRoute([ref1], '1')).toBe(true);
expect(registry.registerRoute([ref2], '2')).toBe(true);
expect(registry.registerRoute([ref2a], '2')).toBe(true);
expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true);
expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true);
expect(registry.registerRoute([ref2b], '2')).toBe(true);
expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true);
expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true);
expect(registry.resolveRoute([], [ref1])).toBe('/1');
expect(registry.resolveRoute([], [ref2])).toBe('/2');
expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1');
expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2');
expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2');
expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2');
expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe(
'/2/b/abc/1',
);
expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe(
'/2/b/xyz/2',
);
expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe(
'/2/b/abc/2',
);
expect(
registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]),
).toBe('/2/b/abc/2');
});
it('should throw when registering routes incorrectly', () => {
const registry = new RouteRefRegistry();
expect(() => {
registry.registerRoute([ref1, ref11], '11');
}).toThrow('Could not find parent for new routing node');
expect(() => {
registry.registerRoute([], '11');
}).toThrow('Must provide at least 1 route to add routing node');
});
});
@@ -1,155 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ConcreteRoute,
routeReference,
resolveRoute,
ReferencedRoute,
} from './types';
const rootRoute: ConcreteRoute = {
get [routeReference]() {
return this;
},
[resolveRoute]: () => '',
};
export type RouteRefResolver = {
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string;
};
class Node {
readonly children = new Map<unknown, Node>();
constructor(readonly path: string, readonly parent: Node | undefined) {}
/**
* Look up a node in the tree given a path.
*/
findNode(routes: ReferencedRoute[]): Node | undefined {
let node = this as Node | undefined;
for (let i = 0; i < routes.length; i++) {
node = node?.children.get(routes[i][routeReference]);
}
return node;
}
/**
* Assigns a path to a leaf node in the routing tree. All ancestor
* nodes of the new leaf node must already exist, or an error will be thrown.
*
* Returns true if the node was added, or false if the node already existed.
*/
addNode(routes: ReferencedRoute[], path: string): boolean {
if (routes.length === 0) {
throw new Error('Must provide at least 1 route to add routing node');
}
const parentNode = this.findNode(routes.slice(0, -1));
if (!parentNode) {
throw new Error('Could not find parent for new routing node');
}
const lastRoute = routes[routes.length - 1];
const lastRouteRef = lastRoute[routeReference];
const existingNode = parentNode.children.get(lastRouteRef);
if (existingNode) {
return existingNode.path === path;
}
parentNode.children.set(lastRouteRef, new Node(path, parentNode));
return true;
}
/**
* Resolve an absolute URL that represents this node in the routing tree, using
* using the supplied concrete routes and ancestors of this node.
*
* The length of the provided routes array must match the depth of
* the routing tree that this node is at, or an error will be thrown.
*/
resolve(routes: ConcreteRoute[]) {
const parts = Array(routes.length);
let node = this as Node | undefined;
for (let i = routes.length - 1; i >= 0; i--) {
if (!node) {
throw new Error('Route resolve missing required parent');
}
const route = routes[i];
parts[i] = route[resolveRoute](node.path);
node = node.parent;
}
if (node) {
throw new Error('Route resolve did not reach root');
}
return parts.join('/');
}
}
/**
* A registry for resolving route refs into concrete string routes.
*/
export class RouteRefRegistry {
private readonly root = new Node('', undefined);
/**
* Register a new leaf path for a sequence of routes. All ancestor
* routes must already exist.
*/
registerRoute(routes: ReferencedRoute[], path: string): boolean {
return this.root.addNode(routes, path);
}
/**
* Resolve an absolute path from a point in the routing tree.
*
* The route referenced by `from` must exist, and is the starting
* point for the search, walking up the tree until a subtree that
* matches the routes reference in `to` are found.
*
* If `from` is empty, the search starts and ends at the root node.
* If `to` is empty, the route referenced by `from` will always be returned.
*/
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined {
// Keep track of the `from` routes and pop the last ones as we traverse up
// the routing tree. The list of concrete routes that we're passing to
// `node.resolve()` should only include the ones in the resolve path.
const concreteStack = from.slice();
let fromNode = this.root.findNode(from);
while (fromNode) {
const resolvedNode = fromNode.findNode(to);
if (resolvedNode) {
return resolvedNode.resolve([rootRoute].concat(concreteStack, to));
}
// Search at this level of the tree failed, move up to parent
concreteStack.pop();
fromNode = fromNode.parent;
}
return undefined;
}
}
@@ -15,7 +15,7 @@
*/
import React, { PropsWithChildren } from 'react';
import { routeCollector, routeParentCollector } from './collectors';
import { routePathCollector, routeParentCollector } from './collectors';
import {
traverseElementTree,
@@ -96,7 +96,7 @@ describe('discovery', () => {
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
routes: routePathCollector,
routeParents: routeParentCollector,
},
});
@@ -147,7 +147,7 @@ describe('discovery', () => {
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
routes: routePathCollector,
routeParents: routeParentCollector,
},
});
@@ -184,7 +184,7 @@ describe('discovery', () => {
),
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
routes: routePathCollector,
routeParents: routeParentCollector,
},
}),
+51 -35
View File
@@ -14,69 +14,85 @@
* limitations under the License.
*/
import { isValidElement, ReactNode } from 'react';
import { RouteRef } from '../routing/types';
import { isValidElement, ReactElement, ReactNode } from 'react';
import { BackstageRouteObject, RouteRef } from '../routing/types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
export const routeCollector = createCollector(
new Map<RouteRef, string>(),
function getMountPoint(node: ReactElement): RouteRef | undefined {
const element: ReactNode = node.props?.element;
let routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
if (!routeRef && isValidElement(element)) {
routeRef = getComponentData<RouteRef>(element, 'core.mountPoint');
}
return routeRef;
}
export const routePathCollector = createCollector(
() => new Map<RouteRef, string>(),
(acc, node, parent) => {
if (parent.props.element === node) {
if (parent?.props.element === node) {
return;
}
const path: string | undefined = node.props?.path;
const element: ReactNode = node.props?.element;
const routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
const routeRef = getMountPoint(node);
if (routeRef) {
const path: string | undefined = node.props?.path;
if (!path) {
throw new Error('Mounted routable extension must have a path');
}
acc.set(routeRef, path);
} else if (isValidElement(element)) {
const elementRouteRef = getComponentData<RouteRef>(
element,
'core.mountPoint',
);
if (elementRouteRef) {
if (!path) {
throw new Error('Route element must have a path');
}
acc.set(elementRouteRef, path);
}
}
},
);
export const routeParentCollector = createCollector(
new Map<RouteRef, RouteRef | undefined>(),
() => new Map<RouteRef, RouteRef | undefined>(),
(acc, node, parent, parentRouteRef?: RouteRef) => {
if (parent.props.element === node) {
if (parent?.props.element === node) {
return parentRouteRef;
}
const element: ReactNode = node.props?.element;
let nextParent = parentRouteRef;
const routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
const routeRef = getMountPoint(node);
if (routeRef) {
acc.set(routeRef, parentRouteRef);
nextParent = routeRef;
} else if (isValidElement(element)) {
const elementRouteRef = getComponentData<RouteRef>(
element,
'core.mountPoint',
);
if (elementRouteRef) {
acc.set(elementRouteRef, parentRouteRef);
nextParent = elementRouteRef;
}
}
return nextParent;
},
);
export const routeObjectCollector = createCollector(
() => Array<BackstageRouteObject>(),
(acc, node, parent, parentChildArr: BackstageRouteObject[] = acc) => {
if (parent?.props.element === node) {
return parentChildArr;
}
const path: string | undefined = node.props?.path;
const caseSensitive: boolean = Boolean(node.props?.caseSensitive);
const routeRef = getMountPoint(node);
if (routeRef) {
const children: BackstageRouteObject[] = [];
if (!path) {
throw new Error(`No path found for mount point ${routeRef}`);
}
parentChildArr.push({
caseSensitive,
path,
element: null,
routeRef,
children,
});
return children;
}
return parentChildArr;
},
);
@@ -0,0 +1,247 @@
/*
* 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 { render } from '@testing-library/react';
import React, { PropsWithChildren, ReactElement } from 'react';
import { MemoryRouter, Routes } from 'react-router-dom';
import { createRoutableExtension } from '../extensions';
import {
childDiscoverer,
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { createPlugin } from '../plugin';
import {
routePathCollector,
routeParentCollector,
routeObjectCollector,
} from './collectors';
import {
useRouteRef,
RoutingProvider,
validateRoutes,
RouteFunc,
} from './hooks';
import { createRouteRef } from './RouteRef';
import { RouteRef, RouteRefConfig } from './types';
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
path: '/unused',
title: 'Unused',
...extra,
});
const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
const plugin = createPlugin({ id: 'my-plugin' });
const ref1 = createRouteRef(mockConfig({ path: '/wat1' }));
const ref2 = createRouteRef(mockConfig({ path: '/wat2' }));
const ref3 = createRouteRef(mockConfig({ path: '/wat3' }));
const ref4 = createRouteRef(mockConfig({ path: '/wat4' }));
const ref5 = createRouteRef(mockConfig({ path: '/wat5' }));
const MockRouteSource = <T extends { [name in string]: string }>(props: {
name: string;
routeRef: RouteRef<T>;
params?: T;
}) => {
try {
const routeFunc = useRouteRef(props.routeRef) as RouteFunc<any>;
return (
<div>
Path at {props.name}: {routeFunc(props.params)}
</div>
);
} catch (ex) {
return (
<div>
Error at {props.name}: {ex.message}
</div>
);
}
};
const Extension1 = plugin.provide(
createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
);
const Extension2 = plugin.provide(
createRoutableExtension({ component: MockRouteSource, mountPoint: ref2 }),
);
const Extension3 = plugin.provide(
createRoutableExtension({ component: MockComponent, mountPoint: ref3 }),
);
const Extension4 = plugin.provide(
createRoutableExtension({ component: MockRouteSource, mountPoint: ref4 }),
);
const Extension5 = plugin.provide(
createRoutableExtension({ component: MockComponent, mountPoint: ref5 }),
);
function withRoutingProvider(root: ReactElement) {
const { routePaths, routeParents, routeObjects } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
},
});
return (
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
>
{root}
</RoutingProvider>
);
}
describe('discovery', () => {
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => {
const root = (
<MemoryRouter initialEntries={['/foo/bar']}>
<Routes>
<Extension1 path="/foo">
<Extension2 path="/bar" name="inside" routeRef={ref2} />
</Extension1>
<Extension3 path="/baz" />
</Routes>
<MockRouteSource name="outside" routeRef={ref2} />
</MemoryRouter>
);
const rendered = render(withRoutingProvider(root));
expect(rendered.getByText('Path at inside: /foo/bar')).toBeInTheDocument();
expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument();
});
it('should handle routeRefs with parameters', () => {
const root = (
<MemoryRouter initialEntries={['/foo/bar/wat']}>
<Routes>
<Extension1 path="/foo">
<Extension4
path="/bar/:id"
name="inside"
routeRef={ref4}
params={{ id: 'bleb' }}
/>
</Extension1>
</Routes>
<MockRouteSource
name="outside"
routeRef={ref4}
params={{ id: 'blob' }}
/>
</MemoryRouter>
);
const rendered = render(withRoutingProvider(root));
expect(
rendered.getByText('Path at inside: /foo/bar/bleb'),
).toBeInTheDocument();
expect(
rendered.getByText('Path at outside: /foo/bar/blob'),
).toBeInTheDocument();
});
it('should handle relative routing within parameterized routePaths', () => {
const root = (
<MemoryRouter initialEntries={['/foo/blob/baz']}>
<Routes>
<Extension5 path="/foo/:id">
<Extension2 path="/bar" name="inside" routeRef={ref3} />
<Extension3 path="/baz" />
</Extension5>
</Routes>
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
<MockRouteSource
name="outsideWithParams"
routeRef={ref3}
params={{ id: 'blob' }}
/>
</MemoryRouter>
);
const rendered = render(withRoutingProvider(root));
expect(
rendered.getByText('Path at inside: /foo/blob/baz'),
).toBeInTheDocument();
});
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
const root = (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Extension5 path="/foo/:id">
<Extension2 path="/bar" name="inside" routeRef={ref3} />
<Extension3 path="/baz" />
</Extension5>
</Routes>
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
<MockRouteSource
name="outsideWithParams"
routeRef={ref3}
params={{ id: 'blob' }}
/>
</MemoryRouter>
);
const rendered = render(withRoutingProvider(root));
expect(
rendered.getByText(
`Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`,
),
).toBeInTheDocument();
expect(
rendered.getByText(
`Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`,
),
).toBeInTheDocument();
});
it('should handle relative routing of parameterized routePaths with duplicate param names', () => {
const root = (
<MemoryRouter>
<Routes>
<Extension5 path="/foo/:id">
<Extension4 path="/bar/:id" name="borked" routeRef={ref4} />
</Extension5>
</Routes>
</MemoryRouter>
);
const { routePaths, routeParents } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
},
});
expect(() => validateRoutes(routePaths, routeParents)).toThrow(
'Parameter :id is duplicated in path /foo/:id/bar/:id',
);
});
});
+183
View File
@@ -0,0 +1,183 @@
/*
* 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, ReactNode, useContext, useMemo } from 'react';
import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types';
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
// The extra TS magic here is to require a single params argument if the RouteRef
// had at least one param defined, but require 0 arguments if there are no params defined.
// Without this we'd have to pass in empty object to all parameter-less RouteRefs
// just to make TypeScript happy, or we would have to make the argument optional in
// which case you might forget to pass it in when it is actually required.
export type RouteFunc<Params extends { [param in string]: string }> = (
...[params]: Params[keyof Params] extends never
? readonly []
: readonly [Params]
) => string;
class RouteResolver {
constructor(
private readonly routePaths: Map<AnyRouteRef, string>,
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
private readonly routeObjects: BackstageRouteObject[],
) {}
resolve<Params extends { [param in string]: string }>(
routeRef: RouteRef<Params>,
sourceLocation: ReturnType<typeof useLocation>,
): RouteFunc<Params> {
const match = matchRoutes(this.routeObjects, sourceLocation) ?? [];
const lastPath = this.routePaths.get(routeRef);
if (!lastPath) {
throw new Error(`No path for ${routeRef}`);
}
const targetRefStack = Array<AnyRouteRef>();
let matchIndex = -1;
for (
let currentRouteRef: AnyRouteRef | undefined = routeRef;
currentRouteRef;
currentRouteRef = this.routeParents.get(currentRouteRef)
) {
matchIndex = match.findIndex(
m => (m.route as BackstageRouteObject).routeRef === currentRouteRef,
);
if (matchIndex !== -1) {
break;
}
targetRefStack.unshift(currentRouteRef);
}
// If our target route is present in the initial match we need to construct the final path
// from the parent of the matched route segment. That's to allow the caller of the route
// function to supply their own params.
if (targetRefStack.length === 0) {
matchIndex -= 1;
}
// This is the part of the route tree that the target and source locations have in common.
// We re-use the existing pathname directly along with all params.
const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname;
// This constructs the mid section of the path using paths resolved from all route refs
// we need to traverse to reach our target except for the very last one. None of these
// paths are allowed to require any parameters, as the called would have no way of knowing
// what parameters those are.
const prefixPath = targetRefStack
.slice(0, -1)
.map(ref => {
const path = this.routePaths.get(ref);
if (!path) {
throw new Error(`No path for ${ref}`);
}
if (path.includes(':')) {
throw new Error(
`Cannot route to ${routeRef} with parent ${ref} as it has parameters`,
);
}
return path;
})
.join('/')
.replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s
const routeFunc: RouteFunc<Params> = (...[params]) => {
return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`;
};
return routeFunc;
}
}
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
export function useRouteRef<Params extends { [param in string]: string }>(
routeRef: RouteRef<Params>,
): RouteFunc<Params> {
const sourceLocation = useLocation();
const resolver = useContext(RoutingContext);
const routeFunc = useMemo(
() => resolver && resolver.resolve(routeRef, sourceLocation),
[resolver, routeRef, sourceLocation],
);
if (!routeFunc) {
throw new Error('No route resolver found in context');
}
return routeFunc;
}
type ProviderProps = {
routePaths: Map<AnyRouteRef, string>;
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>;
routeObjects: BackstageRouteObject[];
children: ReactNode;
};
export const RoutingProvider = ({
routePaths,
routeParents,
routeObjects,
children,
}: ProviderProps) => {
const resolver = new RouteResolver(routePaths, routeParents, routeObjects);
return (
<RoutingContext.Provider value={resolver}>
{children}
</RoutingContext.Provider>
);
};
export function validateRoutes(
routePaths: Map<AnyRouteRef, string>,
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
) {
const notLeafRoutes = new Set(routeParents.values());
notLeafRoutes.delete(undefined);
for (const route of routeParents.keys()) {
if (notLeafRoutes.has(route)) {
continue;
}
let currentRouteRef: AnyRouteRef | undefined = route;
let fullPath = '';
while (currentRouteRef) {
const path = routePaths.get(currentRouteRef);
if (!path) {
throw new Error(`No path for ${currentRouteRef}`);
}
fullPath = `${path}${fullPath}`;
currentRouteRef = routeParents.get(currentRouteRef);
}
const params = fullPath.match(/:(\w+)/g);
if (params) {
for (let j = 0; j < params.length; j++) {
for (let i = j + 1; i < params.length; i++) {
if (params[i] === params[j]) {
throw new Error(
`Parameter ${params[i]} is duplicated in path ${fullPath}`,
);
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More