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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
fix react-hooks/exhaustive-deps error
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Add the basics of cross-integration concerns
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Fix React warning of descendant paragraph tag
|
||||
+48
-18
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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} />;
|
||||
+ }
|
||||
+};
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Export the `defaultConfigLoader` implementation
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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 |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -250,3 +250,4 @@ auth:
|
||||
gitlabToken: g
|
||||
newRelicRestApiKey: r
|
||||
travisciAuthToken: fake-travis-ci-auth-token
|
||||
pagerdutyToken: h
|
||||
|
||||
+35
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) -
|
||||
|
||||
@@ -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
-1
@@ -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: Spotify’s 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 you’re 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 engineer’s solution to taming cloud costs
|
||||
author: Janisa Anandamohan
|
||||
author: Janisa Anandamohan, Spotify
|
||||
authorURL: https://twitter.com/janisa_a
|
||||
---
|
||||
|
||||
|
||||
@@ -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 ❤️ at Spotify</a>
|
||||
</p>
|
||||
<p className="copyright">{this.props.config.copyright}</p>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}}`;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||

|
||||
|
||||
- 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user