diff --git a/.changeset/chilly-owls-punch.md b/.changeset/chilly-owls-punch.md new file mode 100644 index 0000000000..35fb581a81 --- /dev/null +++ b/.changeset/chilly-owls-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Exports `CatalogLayout` and `CreateComponentButton` for catalog customization. diff --git a/.changeset/funny-toys-talk.md b/.changeset/funny-toys-talk.md new file mode 100644 index 0000000000..6e6a779ab8 --- /dev/null +++ b/.changeset/funny-toys-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/codemods': patch +--- + +Fix execution of `jscodeshift` on windows. diff --git a/.changeset/gorgeous-pumas-cover.md b/.changeset/gorgeous-pumas-cover.md deleted file mode 100644 index c4c9facac4..0000000000 --- a/.changeset/gorgeous-pumas-cover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-welcome': patch ---- - -Australian Greeting diff --git a/.changeset/moody-pigs-repeat.md b/.changeset/moody-pigs-repeat.md deleted file mode 100644 index 396fa80281..0000000000 --- a/.changeset/moody-pigs-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Exported SignInProviderConfig to strongly type SignInPage providers diff --git a/.changeset/purple-papayas-exist.md b/.changeset/purple-papayas-exist.md new file mode 100644 index 0000000000..4c77558476 --- /dev/null +++ b/.changeset/purple-papayas-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Fix a bug that prevented changing themes on the user settings page when the theme `id` didn't match exactly the theme `variant`. diff --git a/.changeset/tall-bears-taste.md b/.changeset/tall-bears-taste.md new file mode 100644 index 0000000000..050c47867c --- /dev/null +++ b/.changeset/tall-bears-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Add title prop in SupportButton component diff --git a/.changeset/techdocs-metal-clouds-work.md b/.changeset/techdocs-metal-clouds-work.md new file mode 100644 index 0000000000..64d18dcc02 --- /dev/null +++ b/.changeset/techdocs-metal-clouds-work.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Adding support for user owned document filter for TechDocs custom Homepage diff --git a/.changeset/techdocs-swift-mugs-invent.md b/.changeset/techdocs-swift-mugs-invent.md deleted file mode 100644 index 912089fb3c..0000000000 --- a/.changeset/techdocs-swift-mugs-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Add a test id to the shadow root element of the Reader to access it easily in e2e tests diff --git a/.changeset/tiny-ears-love.md b/.changeset/tiny-ears-love.md deleted file mode 100644 index 52218e2ecc..0000000000 --- a/.changeset/tiny-ears-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': minor ---- - -Added support for Datadog rum events diff --git a/.changeset/violet-birds-lay.md b/.changeset/violet-birds-lay.md new file mode 100644 index 0000000000..841603058f --- /dev/null +++ b/.changeset/violet-birds-lay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-proxy-backend': patch +--- + +Bump http-proxy-middleware from 0.19.2 to 2.0.0 diff --git a/.eslintrc.js b/.eslintrc.js index 3681612b35..4dfcf5317f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,32 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var path = require('path'); + module.exports = { root: true, - extends: ['@spotify/eslint-config-oss'], + plugins: ['notice'], + rules: { + 'notice/notice': [ + 'error', + { + // eslint-disable-next-line no-restricted-syntax + templateFile: path.resolve(__dirname, './scripts/copyright-header.txt'), + onNonMatchingHeader: 'replace', + }, + ], + }, }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4e21603c63..206c1a7883 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,12 +8,17 @@ /docs/features/techdocs @backstage/techdocs-core /docs/features/search @backstage/techdocs-core /docs/assets/search @backstage/techdocs-core +/plugins/code-coverage @alde @nissayeva +/plugins/code-coverage-backend @alde @nissayeva /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/plugins/ilert @yacut +/packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core diff --git a/.github/stale.yml b/.github/stale.yml index 1b3792ab5e..3cd0d53720 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -9,6 +9,7 @@ exemptLabels: - plugin - help wanted - good first issue + - rfc # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7d0f36d858..4ff766e5e2 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,119 +1,60 @@ -Apdex -Api -Autoscaling -Avro -Bigtable -Billett -Bitrise -Blackbox -Chai -Changesets -Chanwit -Cloudformation -Codecov -Codehilite -Config -Datadog -Debounce -Discoverability -Dockerfile -Dockerize -Docusaurus -Env -Expedia -Figma -Firekube -Fiverr -GitHub -GitLab -Grafana -GraphQL -Hackathons -Heroku -Hostname -Iain -JavaScript -Kaewkasi -Knex -Leasot -Lerna -Luxon -Minikube -Mkdocs -Monorepo -Namespaces -OAuth -Okta -Onboarding -Platformize -Podman -Preprarer -Protobuf -Proxying -Readme -readonly -rebase -Recharts -Redash -Repo -Rollbar -Rollup -Routable -Scaffolder -Serverless -Sinon -Snyk -Splunk -Spotifiers -Spotify -Superfences -Talkdesk -Telenor -Templater -Templaters -todo -Tolerations -WWW -Weaveworks -Webpack -Zalando -Zhou abc accessors +Apdex api +Api apis args asciidoc async +automations autoscaling +Autoscaling +Avro backrub +Bigtable +Billett +Bitrise +Blackbox bool boolean builtins +Chai changeset changesets +Changesets chanwit +Chanwit ci cisphobia cissexist classname cli cloudbuild +Cloudformation cncf +Cobertura codeblocks +Codecov codehilite +Codehilite +codemod +codemods codeowners composability composable config +Config configmaps configs const cookiecutter css +Datadog dataflow deadnaming debounce +Debounce declaratively deps destructured @@ -121,58 +62,99 @@ dev devops devs discoverability +Discoverability dls docgen +Dockerfile +dockerfiles +Dockerize dockerode +Docusaurus env +Env esbuild eslint etag +Expedia facto failover +Fargate +Figma +Firekube +Fiverr gitbeaker +GitHub +GitLab +Grafana graphql +GraphQL graphviz +Hackathons haproxy +Helidon +Heroku +hoc horizontalpodautoscalers +Hostname hotspots html http https +Iain img incentivised inlined inlinehilite interop +JaCoCo +JavaScript +Jira jq js json jsonnet jsx +Kaewkasi +Keyv +Knex kubectl kubernetes +kubernetes learnings +Leasot lerna +Lerna +LocalStack lockfile lunr +Luxon magiclink mailto maintainership makefile md +memcache +microservice +microservices microsite middleware minikube +Minikube +Minio misconfiguration misconfigured misgendering mkdocs +Mkdocs monorepo +Monorepo monorepos msw +mysql namespace namespaced namespaces +Namespaces namespacing neuro newrelic @@ -184,43 +166,68 @@ noop npm nvarchar nvm +OAuth octokit oidc +Okta onboarding +Onboarding pagerduty pageview parallelization plantuml +Platformize +Podman postgres postpack pre prebaked preconfigured prepack +Preprarer productional +Protobuf proxying +Proxying pygments pymdownx rankdir readme +Readme +readonly +rebase +Recharts +Redash replicasets repo +Repo repos rerender rollbar +Rollbar +Rollup routable +Routable rst rsync ruleset sam +sanitization scaffolded scaffolder +Scaffolder seb semlas semver +Serverless +Sinon +Snyk sourcemaps sparklines +Splunk +Spotifiers spotify +Spotify sqlite squidfunk src @@ -231,17 +238,25 @@ subkey subroutes subtree superfences +Superfences superset +supertype talkdesk +Talkdesk tasklist techdocs +Telenor templated templater +Templater templaters +Templaters theia thumbsup toc +todo tolerations +Tolerations toolchain toolsets tooltip @@ -249,6 +264,7 @@ tooltips touchpoints transpilation transpiled +truthy ui unmanaged unregister @@ -257,11 +273,18 @@ untracked upvote url utils +validator validators varchar +Weaveworks +Webpack winston www +WWW +XML xyz yaml +Zalando +Zhou zoomable zsh diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 4bd03b4d96..70afd15eee 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -20,3 +20,4 @@ jobs: version: yarn release env: GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + HUSKY: '0' diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index bb14e1c38b..89df848c76 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -48,5 +48,7 @@ jobs: - uses: chromaui/action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} + # projetToken intentionally shared to allow collaborators to run Chromatic on forks + # https://www.chromatic.com/docs/custom-ci-provider#run-chromatic-on-external-forks-of-open-source-projects projectToken: 9tzak77m9nj storybookBuildDir: 'packages/storybook/dist' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2337a70e2c..7f013d10f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,41 @@ jobs: matrix: node-version: [12.x, 14.x] + services: + postgres13: + image: postgres:13 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + postgres9: + image: postgres:9 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + mysql8: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306/tcp + env: CI: true NODE_OPTIONS: --max-old-space-size=4096 @@ -76,6 +111,9 @@ jobs: - name: prettier run: yarn prettier:check + - name: lock + run: yarn lock:check + - name: validate config run: yarn backstage-cli config:check --lax @@ -85,6 +123,9 @@ jobs: - name: type checking and declarations run: yarn tsc:full + - name: check api reports + run: yarn build:api-reports:only --ci + - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn lerna -- run build --since origin/master --include-dependencies @@ -99,12 +140,20 @@ jobs: - name: test changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn lerna -- run test --since origin/master -- --coverage + env: + BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }} + BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }} + BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored - name: test all packages (and upload coverage) if: ${{ steps.yarn-lock.outcome == 'failure' }} run: | yarn lerna -- run test -- --coverage bash <(curl -s https://codecov.io/bash) -N $(git rev-parse FETCH_HEAD) + env: + BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }} + BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }} + BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored - name: verify plugin template run: yarn lerna -- run diff -- --check diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index a9b5bfdf0e..c61ee4798b 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -53,8 +53,14 @@ jobs: - name: verify type dependencies run: yarn lint:type-deps + # The core packages need to be built for the codemods tests to work + - name: build core packages + run: lerna run --scope @backstage/core-* build + - name: test run: yarn lerna -- run test + env: + BACKSTAGE_TEST_DISABLE_DOCKER: 1 # credit: https://github.com/appleboy/discord-action/issues/3#issuecomment-731426861 - name: Discord notification diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 60db9ac16b..75e26d52d3 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -16,6 +16,41 @@ jobs: matrix: node-version: [12.x, 14.x] + services: + postgres13: + image: postgres:13 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + postgres9: + image: postgres:9 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + mysql8: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306/tcp + env: CI: true NODE_OPTIONS: --max-old-space-size=4096 @@ -86,6 +121,10 @@ jobs: # Upload code coverage for some specific flags. Also see .codecov.yml bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api + env: + BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }} + BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }} + BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored - name: Discord notification if: ${{ failure() }} diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 881a7ca5e4..68a6c08cd9 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -40,6 +40,10 @@ jobs: run: yarn prettier:check working-directory: microsite + - name: lock + run: yarn lock:check + working-directory: microsite + - name: build microsite run: yarn build working-directory: microsite diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000000..31354ec138 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..d2ae35e84b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/.prettierignore b/.prettierignore index c4e675a5d7..e60d54a466 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ microsite coverage *.hbs templates +api-report.md plugins/scaffolder-backend/sample-templates .vscode dist-types diff --git a/ADOPTERS.md b/ADOPTERS.md index 45842b1833..31f384e4e6 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,27 +1,32 @@ -| Organization | Contact | Description of Use | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | 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 | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | -| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | -| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | -| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | -| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | -| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | -| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | -| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| Organization | Contact | Description of Use | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | 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 | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | +| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | +| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | +| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | +| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | +| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9df533bb88..0000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,163 +0,0 @@ -# Backstage Changelog - -This changelog is no longer being updated and will be removed in the future, as each package now has its own changelog instead. It was a best-effort changelog where we manually collected breaking changes during the `v0.1.1-alpha.` releases. - -If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! - -## v0.1.1-alpha.26 - -### @backstage/cli - -- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. - -### @backstage/backend-common - -- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. - -## v0.1.1-alpha.25 - -### @backstage/cli - -- The recommended way to set the configuration environment is now to use `APP_ENV` instead of `NODE_ENV`. - -### Backend (example-backend, or backends created with @backstage/create-app) - -- A plugin database manager has been created, and plugins can now accept that interface as an argument during initialisation. Notably, the `auth` plugin has a [`createRouter` signature change](./plugins/auth-backend/src/service/router.ts). See [packages/backend/src/index.ts](./packages/backend/src/index.ts) on how to set it up. [#2697](https://github.com/backstage/backstage/pull/2697) - -## v0.1.1-alpha.24 - -### Backend (example-backend, or backends created with @backstage/create-app) - -- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/backstage/backstage/pull/2562) -- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/backstage/backstage/pull/2600) - -### @backstage/auth-backend - -- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/backstage/backstage/pull/2562) -- The auth backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. - -### @backstage/proxy-backend - -- The proxy backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. - -### @backstage/techdocs-backend - -- The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. - -### @backstage/plugin-identity-backend - -- This plugin was removed, remove it from your backend if it's there. [#2616](https://github.com/backstage/backstage/pull/2616) - -## v0.1.1-alpha.23 - -### @backstage/core - -- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/backstage/backstage/pull/2555). - -## v0.1.1-alpha.22 - -### @backstage/core - -- Introduced initial version of an inverted app/plugin relationship, where plugins export components for apps to use, instead registering themselves directly into the app. This enables more fine-grained control of plugin features, and also composition of plugins such as catalog pages with additional cards and tabs. This breaks the use of `RouteRef`s, and there will be more changes related to this in the future, but this change lays the initial foundation. See `packages/app` and followup PRs for how to update plugins for this change. [#2076](https://github.com/backstage/backstage/pull/2076) -- Switch to an automatic dependency injection mechanism for all Utility APIs, allowing plugins to ship default implementations of their APIs. See [https://backstage.io/docs/api/utility-apis](https://backstage.io/docs/api/utility-apis). [#2285](https://github.com/backstage/backstage/pull/2285) - -### @backstage/cli - -- Change `backstage-cli backend:build-image` to forward all args to `docker image build`, instead of just tag. Also add `--build` flag for building all dependent packages before packaging the workspace for the docker build. [#2299](https://github.com/backstage/backstage/pull/2299) - -### @backstage/create-app - -- Change root `tsc` output dir to `dist-types`, in order to allow for standalone plugin repos. [#2278](https://github.com/backstage/backstage/pull/2278) - -### @backstage/catalog-backend - -- We have simplified the way that GitHub ingestion works. The `catalog.processors.githubApi` key is deprecated, in favor of `catalog.processors.github`. At the same time, the location type `github/api` is likewise deprecated, in favor of `github`. This location type now serves both raw HTTP reads and APIv3 reads, depending on how you configure it. It also supports having several providers at once - for example, both public GitHub and an internal GitHub Enterprise, with different keys. If you still use the `catalog.processors.githubApi` config key, things will work but you will get a deprecation warning at startup. In a later release, support for the old key will go away entirely. See the [configuration section in the docs](https://backstage.io/docs/features/software-catalog/configuration) for more details. - -## v0.1.1-alpha.21 - -- Added many more frontend plugins to the template along with the sidebar. [#1942](https://github.com/backstage/backstage/pull/1942), [#2084](https://github.com/backstage/backstage/pull/2084) - -### @backstage/core - -- Material-UI: Bumped to 4.11.0, which is the version that create-app will - resolve to, because we wanted to get the renaming of ExpansionPanel to - Accordion into place. This gets rid of a lot of console deprecation warnings - in newly scaffolded apps. - -### @backstage/cli - -- Set `NODE_ENV` to `test` when running test. [#2214](https://github.com/backstage/backstage/pull/2214) - -- Fix for backend plugins names requiring to be prefixed with `@backstage` to build. [#2224](https://github.com/backstage/backstage/pull/2224) - -### @backstage/backend-common - -- The backend plugin - [service builder](https://github.com/backstage/backstage/blob/master/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts) - no longer adds `express.json()` automatically to all routes. While convenient - in a lot of cases, it also led to problems where for example the proxy - middleware could hang because the body had already been altered and could not - be streamed. Also, plugins that rather wanted to handle e.g. form encoded data - still had to cater to that manually. We therefore decided to let plugins add - `express.json()` themselves if they happen to deal with JSON data. - -### @backstage/catalog-backend - -- Add rules configuration for catalog location and entity kinds. The default rules should cover most use-cases, but you may need to allow specific entity kinds when using things like Template or Group entities. [#2118](https://github.com/backstage/backstage/pull/2118) - -## v0.1.1-alpha.20 - -### @backstage/cli - -- Use config files according to `NODE_ENV` when serving and building frontend packages. [#2077](https://github.com/backstage/backstage/pull/2077) - -- Pin `rollup-plugin-dts` to avoid a later broken version. [#2097](https://github.com/backstage/backstage/pull/2097) - -## v0.1.1-alpha.19 - -### @backstage/backend-common - -- Allow listen host and port to be configured separately, in order to support PORT environment variables. [#1950](https://github.com/backstage/backstage/pull/1950) - -### @backstage/core - -- Added new `DiscoveryApi` for discovering backend endpoint in the frontend, and use in most plugins. See [packages/app/src/apis.ts](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) for how to register in your app. [#2074](https://github.com/backstage/backstage/pull/2074) - -### @backstage/create-app - -- Added catalog and scaffolder frontend plugins to the template along with the sidebar. [#1942](https://github.com/backstage/backstage/pull/1942), [#2084](https://github.com/backstage/backstage/pull/2084) -- Many plugins have been added to the catalog and will for now be required to be added to separate apps as well. This will be solved as [#1536](https://github.com/backstage/backstage/issues/1536) gets sorted out, but for now you may need to install some plugins just to get pages to work. - -### @backstage/catalog-backend - -- Added the possibility to add static locations via `app-config.yaml`. This changed the signature of `new LocationReaders(logger)` inside `packages/backend/src/plugins/catalog.ts` to `new LocationReaders({config, logger})`. [#1890](https://github.com/backstage/backstage/pull/1890) - -### @backstage/theme - -- Changed the type signature of the palette, removing `sidebar: string` and adding `navigation: { background: string; indicator: string}`. [#1880](https://github.com/backstage/backstage/pull/1880) - -## v0.1.1-alpha.18 - -### @backstage/catalog-backend - -- Fixed an issue with duplicated location logs. Applying the database migrations from this fix will clear the existing migration logs. [#1836](https://github.com/backstage/backstage/pull/1836) - -### @backstage/auth-backend - -This version fixes a breakage in CSP policies set by the auth backend. If you're facing trouble with auth in alpha.17, upgrade to alpha.18. - -- OAuth redirect URLs no longer receive the `env` parameter, as it is now passed through state instead. This will likely require a reconfiguration of the OAuth app, where a redirect URL like `http://localhost:7000/auth/google/handler/frame?env=development` should now be configured as `http://localhost:7000/auth/google/handler/frame`. [#1812](https://github.com/backstage/backstage/pull/1812) - -### @backstage/core - -- `SignInPage` props have been changed to receive a list of provider objects instead of simple string identifiers for all but the `'guest'` and `'custom'` providers. This opens up for configuration of custom providers, but may break existing configurations. See [packages/app/src/App.tsx](https://github.com/backstage/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/App.tsx#L36) and [packages/app/src/identityProviders.ts](https://github.com/backstage/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/identityProviders.ts#L24) for how to bring back the existing providers. [#1816](https://github.com/backstage/backstage/pull/1816) - -## v0.1.1-alpha.17 - -### @backstage/techdocs-backend - -- The techdocs backend now requires more configuration to be supplied when creating the router. See [packages/backend/src/plugins/techdocs.ts](https://github.com/backstage/backstage/blob/0201fd9b4a52429519dd59e9184106ba69456deb/packages/backend/src/plugins/techdocs.ts#L42) for an example. [#1736](https://github.com/backstage/backstage/pull/1736) - -### @backstage/cli - -- The `create-app` command was moved out from the CLI to a standalone package. It's now invoked with `npx @backstage/create-app` instead. [#1745](https://github.com/backstage/backstage/pull/1745) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b7433888c..2caa8c9c9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ Which will look something like the following in the repo; ``` Awesome commit message -Signed-off-by: Jane Smith jane.smith@example.com +Signed-off-by: Jane Smith ``` - In case you forgot to add it to the most recent commit, use `git commit --amend --signoff` diff --git a/OWNERS.md b/OWNERS.md index a79328dc20..cdbc234183 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -27,5 +27,5 @@ People that have made significant contributions to the project and earned write - Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) - Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) - David Tuite - Roadie (GitHub: [dtuite](https://github.com/dtuite)) -- Adam Harvey - DXC Technology (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) +- Adam Harvey - Cisco (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) - Dominik Henneke - SDA SE (GitHub: [dhenneke](https://github.com/dhenneke)) diff --git a/app-config.yaml b/app-config.yaml index ac59874878..c951594677 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -2,9 +2,9 @@ app: title: Backstage Example App baseUrl: http://localhost:3000 googleAnalyticsTrackingId: # UA-000000-0 - datadogRum: - clientToken: '123456789' - applicationId: qwerty + #datadogRum: + # clientToken: '123456789' + # applicationId: qwerty # site: # datadoghq.eu default = datadoghq.com # env: # optional @@ -29,12 +29,16 @@ backend: database: client: sqlite3 connection: ':memory:' + cache: + store: memory cors: origin: http://localhost:3000 methods: [GET, POST, PUT, DELETE] credentials: true csp: connect-src: ["'self'", 'http:', 'https:'] + # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference + # Default Helmet Content-Security-Policy values can be removed by setting the key to false reading: allow: - host: example.com @@ -81,6 +85,13 @@ proxy: headers: Authorization: ${SENTRY_TOKEN} + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} + organization: name: My Company diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 7d04a2cf1e..40bb2666bc 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -129,6 +129,16 @@ For the CA, create a `configMap` named `--postgres-ca` kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" ``` +or disable CA mount + +```yaml +backend: + postgresCertMountEnabled: false + +lighthouse: + postgresCertMountEnabled: false +``` + > Where the release name contains the chart name "backstage" then only the release name will be used. Now install the helm chart: diff --git a/contrib/chart/backstage/templates/_helpers.tpl b/contrib/chart/backstage/templates/_helpers.tpl index 5d49ba6975..123d4dba5b 100644 --- a/contrib/chart/backstage/templates/_helpers.tpl +++ b/contrib/chart/backstage/templates/_helpers.tpl @@ -214,7 +214,7 @@ Postgres port for the backend {{- .Values.postgresql.service.port }} {{- else if .Values.appConfig.backend.database.connection.port -}} {{- .Values.appConfig.backend.database.connection.port }} -{{ else }} +{{- else -}} 5432 {{- end -}} {{- end -}} diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 144fc93e62..0fa6b7fbe2 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -26,6 +26,13 @@ spec: {{- end}} containers: - name: {{ .Chart.Name }}-backend + command: ["node"] + args: + - "packages/backend" + - "--config" + - "app-config.yaml" + - "--config" + - {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) | quote }} image: {{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }} imagePullPolicy: {{ .Values.backend.image.pullPolicy }} ports: @@ -49,16 +56,20 @@ spec: name: {{ include "backend.postgresql.passwordSecret" .}} key: postgresql-password volumeMounts: + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca mountPath: {{ include "backstage.backend.postgresCaDir" . }} + {{- end }} - name: app-config mountPath: {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) }} subPath: {{ include "backstage.appConfigFilename" . }} volumes: + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} - name: app-config configMap: name: {{ include "backstage.fullname" . }}-app-config @@ -83,5 +94,5 @@ spec: app: backstage component: backend - type: ClusterIP + type: {{ .Values.backend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml index 9c674cc160..04983513a6 100644 --- a/contrib/chart/backstage/templates/frontend-deployment.yaml +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -1,3 +1,4 @@ +{{- if .Values.frontend.enabled }} apiVersion: apps/v1 kind: Deployment metadata: @@ -46,7 +47,6 @@ spec: {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} {{- end }} -{{- if .Values.frontend.enabled }} --- apiVersion: v1 kind: Service @@ -62,5 +62,5 @@ spec: app: backstage component: frontend - type: ClusterIP + type: {{ .Values.frontend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/lighthouse-config.yaml b/contrib/chart/backstage/templates/lighthouse-config.yaml index fdf4bd8b06..7b8cad42dc 100644 --- a/contrib/chart/backstage/templates/lighthouse-config.yaml +++ b/contrib/chart/backstage/templates/lighthouse-config.yaml @@ -1,3 +1,4 @@ +{{- if .Values.lighthouse.enabled }} apiVersion: v1 kind: ConfigMap metadata: @@ -7,4 +8,5 @@ data: PGUSER: {{ include "lighthouse.postgresql.user" . | quote }} PGPORT: {{ include "lighthouse.postgresql.port" . | quote }} PGHOST: {{ include "lighthouse.postgresql.host" . | quote }} - PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} \ No newline at end of file + PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} +{{- end }} \ No newline at end of file diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml index d6e1ce19dd..849c48d7a6 100644 --- a/contrib/chart/backstage/templates/lighthouse-deployment.yaml +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -51,14 +51,18 @@ spec: name: {{ include "lighthouse.postgresql.passwordSecret" . }} key: postgresql-password + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumeMounts: - name: postgres-ca mountPath: {{ include "backstage.lighthouse.postgresCaDir" . }} + {{- end }} + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumes: - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} @@ -78,5 +82,5 @@ spec: app: backstage component: lighthouse-audit-service - type: ClusterIP + type: {{ .Values.lighthouse.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml new file mode 100644 index 0000000000..a71cef7b21 --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml @@ -0,0 +1,15 @@ +{{- if not .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ include "backend.postgresql.passwordSecret" . }} + labels: + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install,pre-upgrade" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} +{{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-password-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml similarity index 51% rename from contrib/chart/backstage/templates/postgresql-password-secret.yaml rename to contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml index e53369adb9..e7374e70db 100644 --- a/contrib/chart/backstage/templates/postgresql-password-secret.yaml +++ b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml @@ -1,18 +1,4 @@ -{{- if not .Values.postgresql.enabled }} ---- -apiVersion: v1 -kind: Secret -type: Opaque -metadata: - name: {{ include "backend.postgresql.passwordSecret" . }} - labels: - release: {{ .Release.Name }} - annotations: - "helm.sh/hook": "pre-install,pre-upgrade" - "helm.sh/hook-delete-policy": "before-hook-creation" -data: - postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} -{{- end }} +{{- if .Values.lighthouse.enabled }} {{- if not .Values.postgresql.enabled }} --- apiVersion: v1 @@ -28,3 +14,4 @@ metadata: data: postgresql-password: {{ .Values.lighthouse.database.connection.password | b64enc }} {{- end }} +{{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index bd80bc22b6..9f70dac6dc 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -3,13 +3,14 @@ # Declare variables to be passed into your templates. frontend: - enabled: true + enabled: false replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-frontend tag: test1 pullPolicy: IfNotPresent containerPort: 80 + serviceType: ClusterIP resources: requests: memory: 128Mi @@ -23,9 +24,11 @@ backend: replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-backend - tag: test1 + tag: 20210423T1550 pullPolicy: IfNotPresent containerPort: 7000 + serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 512Mi @@ -40,6 +43,8 @@ lighthouse: tag: latest pullPolicy: IfNotPresent containerPort: 3003 + serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 128Mi diff --git a/contrib/docker/frontend-with-nginx/Dockerfile b/contrib/docker/frontend-with-nginx/Dockerfile deleted file mode 100644 index a444c9de83..0000000000 --- a/contrib/docker/frontend-with-nginx/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM nginx:mainline - -# The purpose of this image is to serve the frontend app content separately. -# By default the Backstage backend uses the app-backend plugin to serve the -# app from the backend itself, but it may be desirable to move the frontend -# content serving to a separate deployment, in which case this image can be used. - -# This dockerfile requires the app to be built on the host first, as it -# simply copies in the build output into the image. - -RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* - -COPY packages/app/dist /usr/share/nginx/html -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh -CMD run.sh - -ENV PORT 80 diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild new file mode 100644 index 0000000000..41f931544f --- /dev/null +++ b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild @@ -0,0 +1,58 @@ +# The purpose of this image is to serve the frontend app content separately. +# By default the Backstage backend uses the app-backend plugin to serve the +# app from the backend itself, but it may be desirable to move the frontend +# content serving to a separate deployment, in which case this image can be +# used. + +# This dockerfile also performs the build first inside docker. This may come +# with a build time impact, but is sometimes desirable. If you want to run the +# build on the host instead, use the file simply named Dockerfile in this folder +# instead. + +# USAGE: +# +# - Copy this file and the "docker" folder from this directory to your project +# root +# +# - Update your .dockerignore, make sure that the source folders are not +# excluded, but do exclude node_modules and build artifacts: +# +# .git +# node_modules +# packages/*/dist +# packages/*/node_modules +# plugins/*/dist +# plugins/*/node_modules +# +# - Update the copy of this file to add configuration arguments to the "build" +# command, for example: +# +# RUN yarn workspace app build --config --config ... +# +# - In your project root, run: +# +# docker build -t backstage-frontend -f Dockerfile.dockerbuild . + + + +FROM node:14-buster AS build + +RUN mkdir /app +COPY . /app +WORKDIR /app + +RUN yarn install +RUN yarn workspace app build + + + +FROM nginx:mainline + +RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/packages/app/dist /usr/share/nginx/html +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template + +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh + +ENV PORT 80 diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild new file mode 100644 index 0000000000..1e0134017f --- /dev/null +++ b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild @@ -0,0 +1,41 @@ +# The purpose of this image is to serve the frontend app content separately. +# By default the Backstage backend uses the app-backend plugin to serve the +# app from the backend itself, but it may be desirable to move the frontend +# content serving to a separate deployment, in which case this image can be +# used. + +# This dockerfile requires the app to be built on the host first, as it +# simply copies in the build output into the image. If you want to also perform +# the build itself inside docker, use Dockerfile.build in this folder instead. + + +# USAGE: +# +# - Copy this file and the "docker" folder from this directory to your project +# root +# +# - Add the following line to your .dockerignore to make sure that the built +# frontend actually can be transferred into the docker image: +# +# !packages/app/dist +# +# - In your project root, run: +# +# yarn install +# yarn tsc +# yarn build --config --config ... +# docker build -t backstage-frontend -f Dockerfile.hostbuild . + + + +FROM nginx:mainline + +RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* + +COPY packages/app/dist /usr/share/nginx/html +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template + +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh + +ENV PORT 80 + diff --git a/contrib/docker/frontend-with-nginx/README.md b/contrib/docker/frontend-with-nginx/README.md new file mode 100644 index 0000000000..eae70b8bba --- /dev/null +++ b/contrib/docker/frontend-with-nginx/README.md @@ -0,0 +1,16 @@ +# Frontend with NGINX + +This folder contains Docker images that let you run the Backstage frontend as +a separate image, rather than having it served through the `app-backend` plugin +from the backend. + +Note that when running the frontend like this, the app configuration becomes +embedded into the actual static JavaScript files at build time. This means that +you will have to supply the list of configuration files as part of the command +line at build. + +## Usage + +There are two variants: one that builds inside Docker, and one that builds on +the host. See the comments at the top of the individual dockerfiles for usage +instructions. diff --git a/contrib/docker/frontend-with-nginx/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/inject-config.sh similarity index 80% rename from contrib/docker/frontend-with-nginx/docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/inject-config.sh index fdb1742a07..a0cea96ff3 100755 --- a/contrib/docker/frontend-with-nginx/docker/run.sh +++ b/contrib/docker/frontend-with-nginx/docker/inject-config.sh @@ -2,13 +2,6 @@ set -Eeuo pipefail -# Run nginx as root -sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf - -# Write selected env vars to nginx config -envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Inject runtime config into the client function inject_config() { # Read runtime config from env in the same way as the @backstage/config-loader package local config @@ -41,4 +34,3 @@ function inject_config() { inject_config -exec nginx -g 'daemon off;' diff --git a/contrib/docker/multi-stage-frontend/Dockerfile b/contrib/docker/multi-stage-frontend/Dockerfile deleted file mode 100644 index 3190687c52..0000000000 --- a/contrib/docker/multi-stage-frontend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:12-buster AS build - -RUN mkdir /app -COPY . /app -WORKDIR /app - -RUN yarn install -RUN yarn workspace example-app build - -# Contruct backstage-frontend image -FROM nginx:mainline - -RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* - -# Copy from build stage -COPY --from=build /app/packages/app/dist /usr/share/nginx/html - -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh - -CMD run.sh - -ENV PORT 80 diff --git a/contrib/docker/multi-stage-frontend/README.md b/contrib/docker/multi-stage-frontend/README.md deleted file mode 100644 index b6ae4fdc6c..0000000000 --- a/contrib/docker/multi-stage-frontend/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Standalone Dockerfile for frontend - -This directory contains the resources which will help you build backstage without any requirements -other than docker itself. It uses a multi-stage Dockerfile to build and ship backstage. - -## Usage - -You can simply run the following command to build backstage. - -``` -# Make sure you are in the root directory of backstage then run -docker build -t backstage-frontend -f ./contrib/docker/multi-stage-frontend/Dockerfile . -``` - -After a successful build, You can simply run backstage frontend with the following command. - -``` -docker run -it --rm -p 3080:80 backstage-frontend -``` diff --git a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md index ba0aef89ea..0567a6bbb6 100644 --- a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md +++ b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md @@ -167,4 +167,4 @@ Once it's deployed, after going through the AAD authentication flow, Backstage s -[monorepo-app-setup-with-auth-ms]: https://backstage.io/docs/tutorials/quickstart-app-auth#the-auth-configuration +[monorepo-app-setup-with-auth-ms]: https://backstage.io/docs/auth/microsoft/provider diff --git a/contrib/docs/tutorials/aws-deployment.md b/contrib/docs/tutorials/aws-deployment.md index 5f94672b82..47170658a3 100644 --- a/contrib/docs/tutorials/aws-deployment.md +++ b/contrib/docs/tutorials/aws-deployment.md @@ -41,12 +41,9 @@ documentation to build a new Backstage Docker image: ```shell $ yarn build -$ docker image build . -f packages/backend/Dockerfile --tag backstage +$ yarn build-image --tag backstage ``` -This command builds a backend-only image, but you can similarly build a frontend -or combined Docker image. - Next, configure the [AWS CLI](https://aws.amazon.com/cli/) to use the `ecr-publisher` user you created: @@ -90,65 +87,37 @@ document, but it can be as easy as `eksctl create cluster` documented in the guide](https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html), which uses a Cloudformation template to create the necessary resources. -To deploy the Docker image to EKS, create a `kubernetes` folder in your -Backstage source folder and add a Kubernetes `deployment.yaml`: +To deploy the Docker image to EKS, follow the [Kubernetes +guide](https://backstage.io/docs/deployment/k8s#creating-the-backstage-instance) +but set the Backstage deployment `image` to the ECR repository URL: ```yaml apiVersion: apps/v1 kind: Deployment metadata: - name: backstage-backend - labels: - app: backstage-backend - namespace: default + name: backstage + namespace: backstage spec: - replicas: 1 - selector: - matchLabels: - app: backstage-backend - strategy: - rollingUpdate: - maxSurge: 25% - maxUnavailable: 25% - type: RollingUpdate + ... template: metadata: labels: - app: backstage-backend + app: backstage spec: containers: - image: /backstage:1.0.0 imagePullPolicy: Always - name: backstage-backend - ports: - - containerPort: 7000 - protocol: TCP + ... ``` -Note the `image` key in the container spec referencing the ECR repository. - -Now create a simple `service.yaml` to map the container ports: - -```yaml -apiVersion: v1 -kind: Service -metadata: - name: backstage-backend -spec: - selector: - app: backstage-backend - ports: - - protocol: TCP - port: 80 - targetPort: 7000 -``` - -Apply these Kubernetes definitions to the EKS cluster to complete the Backstage -deployment: +Create the [Service +descriptor](https://backstage.io/docs/deployment/k8s#creating-a-backstage-service) +as well, and apply these Kubernetes definitions to the EKS cluster to complete +the Backstage deployment: ```shell -$ kubectl apply -f deployment.yaml -$ kubectl apply -f service.yaml +$ kubectl apply -f kubernetes/backstage.yaml +$ kubectl apply -f kubernetes/backstage-service.yaml ``` Now you can see your Backstage workload running from the [EKS @@ -158,14 +127,15 @@ console](https://console.aws.amazon.com/eks/home). ### Exposing Backstage with a load balancer -Backstage users need to query the backend, which means we need to expose -the workload with a load balancer. Follow the [Application load balancing on +To make the service useful, we need to expose the workload with a load balancer. +Follow the [Application load balancing on EKS](https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html) guide to set up a Load Balancer controller and Kubernetes ingress to your application. This is ultimately a `kubectl apply` with an ingress definition: ```yaml +# kubernetes/backstage-ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -187,9 +157,3 @@ spec: port: number: 80 ``` - -### Updating the deployment - -To update the Kubernetes deployment to a newly published version of your -Backstage Docker image, update the image tag reference in `deployment.yaml` and -then apply the changes to EKS with `kubectl apply -f deployment.yaml`. diff --git a/contrib/docs/tutorials/aws-fargate-deployment.md b/contrib/docs/tutorials/aws-fargate-deployment.md new file mode 100644 index 0000000000..bc530441c6 --- /dev/null +++ b/contrib/docs/tutorials/aws-fargate-deployment.md @@ -0,0 +1,16 @@ +# Deploying Backstage with AWS Fargate and Aurora + +There are any number of ways to deploy backstage containers on AWS. One of the +simplest from a management and ops perspective is to leverage AWS Fargate and +Aurora PostgreSQL, where both the container orchestration and database clusters +are AWS managed services. + +However, to use them properly there are a large number of supporting resources +(VPCs, Security Groups, Load Balancers, Certificate, etc) required. One approach +is to use [AWS Cloud Development Kit (CDK)](https://aws.amazon.com/cdk/) to +simplify and automate the creation of the entire infrastructure stack along with +automating the build and deploy of the container. + +Check out the [Backstage On AWS](https://github.com/rbogle/backstage-on-aws) +repository, for a ready to use CDK application for deploying your custom +Backstage app onto AWS ECS Fargate and Aurora. diff --git a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md index bee0a84fe6..841e13dd8b 100644 --- a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md +++ b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md @@ -80,6 +80,4 @@ backend: origin: https://your-public-url.com:3000 ``` -If the protocol is `http`, you will need to set `backend.csp.upgrade-insecure-requests` to `false` as well. - The app port must proxy web socket connections in order to make hot reloading work. diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml index 77a113ae5c..e74bfe0b8d 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml @@ -16,7 +16,7 @@ spec: app.kubernetes.io/name: {{ include "backstage.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} -{{- if .Values.app.enabled }} +{{- if .Values.backend.enabled }} --- apiVersion: v1 kind: Service diff --git a/contrib/kubernetes/plain_single_backend_deployment/README.md b/contrib/kubernetes/plain_single_backend_deployment/README.md deleted file mode 100644 index e4709615ce..0000000000 --- a/contrib/kubernetes/plain_single_backend_deployment/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Plain Kubernetes Deployment - -This directory contains an example of a simple Kubernetes deployment of Backstage. It is not intended to serve as a complete production deployment, but as a starting point for setting one up. - -## Usage - -You can try the deployment out as is. The easiest way is to use [Docker Desktop](https://www.docker.com/products/docker-desktop) with [Kubernetes](https://docs.docker.com/get-started/kube-deploy/). - -You can now follow the documentation here to build the Backend Container [Docker Build](https://backstage.io/docs/getting-started/deployment-docker) - -From a fresh clone of this repo, run the following in the root: - -```bash -yarn install - -yarn docker-build - -kubectl apply -f contrib/kubernetes/plain_single_backend_deployment/deployment.yaml -``` - -You can use the following commands to monitor the deployment: - -```bash -# List all resources in the backstage namespace -kubectl -n backstage get all - -# Inspect the status of the deployment resource -kubectl -n backstage describe deployment backstage-backend - -# Inspect the status of the pod running the backstage backend -kubectl -n backstage describe pod -l app=backstage,component=backend -``` - -Once the deployment is up and running, you can use the following to set up a proxy to reach the backend locally: - -```bash -kubectl proxy -``` - -With the proxy up and running, you should be able to navigate to [http://localhost:8001/api/v1/namespaces/backstage/services/backstage-backend:http/proxy](http://localhost:8001/api/v1/namespaces/backstage/services/backstage-backend:http/proxy) and see Backstage. Note that you'll end up on a 404 page, but hitting the home icon in the sidebar should take you to the catalog page where you can see a few example services. - -## Caveats - -This deployment is for demonstration purposes only, for a production deployment you will need to set up at least a persistent database and some form of ingress. If your organization doesn't already have established patterns for these, you could look at options of managed PostgreSQL instances from cloud providers, or something like Zalando's [postgres-operator](https://github.com/zalando/postgres-operator). For ingress there are also [plenty of options](https://ramitsurana.gitbook.io/awesome-kubernetes/docs/projects/projects#load-balancing), where `nginx` is a popular choice to get started. diff --git a/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml b/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml deleted file mode 100644 index 822f5e42dc..0000000000 --- a/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml +++ /dev/null @@ -1,107 +0,0 @@ ---- -apiVersion: v1 -kind: Namespace -metadata: - name: backstage ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: backstage-backend - namespace: backstage -spec: - replicas: 1 - selector: - matchLabels: - app: backstage - component: backend - template: - metadata: - labels: - app: backstage - component: backend - spec: - containers: - - name: backend - # This image is built with `yarn docker-build` in the repo root. - # Replace this with your own image to deploy your own Backstage app. - image: example-backend:latest - imagePullPolicy: Never - - command: [node, packages/backend] - args: [--config, app-config.yaml, --config, k8s-config.yaml] - - env: - # We set this to development to make the backend start with incomplete configuration. In a production - # deployment you will want to make sure that you have a full configuration, and remove any plugins that - # you are not using. - - name: NODE_ENV - value: development - - # This makes it possible for the app to reach the backend when serving through `kubectl proxy` - # If you expose the service using for example an ingress controller, you should - # switch this out or remove it. - # - # Note that we're not setting app.baseUrl here, as setting the base path is not working at the moment. - # Further work is needed around the routing in the frontend or react-router before we can support that. - - name: APP_CONFIG_backend_baseUrl - value: http://localhost:8001/api/v1/namespaces/backstage/services/backstage-backend:http/proxy - - ports: - - name: http - containerPort: 7000 - - volumeMounts: - - name: config-volume - mountPath: /app/k8s-config.yaml - subPath: k8s-config.yaml - - resources: - limits: - cpu: 1 - memory: 0.5Gi - - readinessProbe: - httpGet: - port: 7000 - path: /healthcheck - livenessProbe: - httpGet: - port: 7000 - path: /healthcheck - - volumes: - - name: config-volume - configMap: - name: backstage-config - items: - - key: app-config - path: k8s-config.yaml ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: backstage-config - namespace: backstage -data: - # Note that the config here is only applied to the backend. The frontend config is applied at build time. - # To override frontend config in this deployment, use `APP_CONFIG_` env vars. - app-config: | - app: - baseUrl: http://localhost:8001/api/v1/namespaces/backstage/services/backstage-backend:http/proxy - backend: - baseUrl: http://localhost:8001/api/v1/namespaces/backstage/services/backstage-backend:http/proxy ---- -apiVersion: v1 -kind: Service -metadata: - name: backstage-backend - namespace: backstage -spec: - selector: - app: backstage - component: backend - ports: - - name: http - port: 80 - targetPort: http diff --git a/contrib/terraform/techdocs-s3-storage/readme.md b/contrib/terraform/techdocs-s3-storage/readme.md new file mode 100644 index 0000000000..9615cc8d13 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/readme.md @@ -0,0 +1 @@ +This terraform file should create a S3 bucket and setup IAM with a user with an inline policy which gives the user access to the bucket. After you have created the bucket, user and policy you should go to the user in the AWS console and create an access key. This access key should be used as the env variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs). diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf new file mode 100644 index 0000000000..f256663f43 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -0,0 +1,81 @@ +#========================== +# Variables +#========================== + +variable "backstage_bucket" { + default = "backstage_bucket_for_my_corp" +} + +variable "backstage_iam" { + default = "backstage" +} + +variable "shared_managed_tag" { + default = "terraform_for_my_corp" +} + +#========================== +# Bucket +#========================== + +resource "aws_s3_bucket" "backstage" { + bucket = var.backstage_bucket + acl = "private" + provider = aws + + lifecycle { + prevent_destroy = true + } + + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } + + tags = { + Name = var.backstage_bucket + "Managed By Terraform" = var.shared_managed_tag + } +} + +resource "aws_s3_bucket_public_access_block" "backstage" { + bucket = aws_s3_bucket.backstage.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + + +#========================== +# IAM +#========================== + +resource "aws_iam_user" "backstage" { + name = var.backstage_iam +} + +resource "aws_iam_user_policy" "backstage" { + name = var.backstage_iam + user = aws_iam_user.backstage.name + policy = data.aws_iam_policy_document.backstage_policy.json +} + +data "aws_iam_policy_document" "backstage_policy" { + statement { + actions = [ + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket" + ] + effect = "Allow" + resources = [ + "${aws_s3_bucket.backstage.arn}", + "${aws_s3_bucket.backstage.arn}/*", + ] + } +} diff --git a/cypress/package.json b/cypress/package.json index 8549386f7e..6a231326c4 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -5,7 +5,7 @@ "license": "MIT", "private": true, "dependencies": { - "cypress": "^6.4.0", + "cypress": "^7.3.0", "typescript": "^4.1.3" } } diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts new file mode 100644 index 0000000000..5ee077502a --- /dev/null +++ b/cypress/src/integration/integrations.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Integrations', () => { + describe('ReadTree', () => { + it('should work for github', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://github.com/backstage-verification/test-repo/blob/main/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'github-repo'); + cy.get('table').should('contain', 'github-repo-nested'); + }); + + // it('should work for azure', () => { + // cy.loginAsGuest(); + + // cy.request('POST', '/api/catalog/locations', { + // target: + // 'https://dev.azure.com/backstage-verification/_git/test-repo?path=*', + // type: 'url', + // }); + // }); + + it('should work for gitlab', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://gitlab.com/backstage-verification/test-repo/-/tree/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'gitlab-repo'); + cy.get('table').should('contain', 'gitlab-repo-nested'); + }); + + it('should work for bitbucket', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://bitbucket.org/backstage-verification/test-repo/src/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'bitbucket-repo'); + cy.get('table').should('contain', 'bitbucket-repo-nested'); + }); + }); +}); diff --git a/cypress/yarn.lock b/cypress/yarn.lock index 3c737f6ebc..baf0d92715 100644 --- a/cypress/yarn.lock +++ b/cypress/yarn.lock @@ -53,7 +53,12 @@ dependencies: any-observable "^0.3.0" -"@types/sinonjs__fake-timers@^6.0.1": +"@types/node@^14.14.31": + version "14.17.0" + resolved "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz#3ba770047723b3eeb8dc9fca02cce8a7fb6378da" + integrity sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA== + +"@types/sinonjs__fake-timers@^6.0.2": version "6.0.2" resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== @@ -117,7 +122,7 @@ any-observable@^0.3.0: resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== -arch@^2.1.2: +arch@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== @@ -171,7 +176,7 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -blob-util@2.0.2: +blob-util@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== @@ -242,10 +247,10 @@ check-more-types@^2.24.0: resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-info@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz#9a32fcefdf7bcdb6f0a7e1c0f8098ec57897b80a" + integrity sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ== cli-cursor@^1.0.2: version "1.0.2" @@ -359,18 +364,19 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -cypress@^6.4.0: - version "6.4.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2" - integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg== +cypress@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-7.3.0.tgz#17345b8d18681c120f033e7d8fd0f0271e9d0d51" + integrity sha512-aseRCH1tRVCrM6oEfja6fR/bo5l6e4SkHRRSATh27UeN4f/ANC8U7tGIulmrISJVy9xuOkOdbYKbUb2MNM+nrw== dependencies: "@cypress/listr-verbose-renderer" "^0.4.1" "@cypress/request" "^2.88.5" "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "^6.0.1" + "@types/node" "^14.14.31" + "@types/sinonjs__fake-timers" "^6.0.2" "@types/sizzle" "^2.3.2" - arch "^2.1.2" - blob-util "2.0.2" + arch "^2.2.0" + blob-util "^2.0.2" bluebird "^3.7.2" cachedir "^2.3.0" chalk "^4.1.0" @@ -378,27 +384,26 @@ cypress@^6.4.0: cli-table3 "~0.6.0" commander "^5.1.0" common-tags "^1.8.0" - dayjs "^1.9.3" - debug "^4.1.1" - eventemitter2 "^6.4.2" - execa "^4.0.2" + dayjs "^1.10.4" + debug "4.3.2" + eventemitter2 "^6.4.3" + execa "4.1.0" executable "^4.1.1" extract-zip "^1.7.0" - fs-extra "^9.0.1" + fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^2.0.0" - is-installed-globally "^0.3.2" + is-ci "^3.0.0" + is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr "^0.14.3" - lodash "^4.17.19" + lodash "^4.17.21" log-symbols "^4.0.0" minimist "^1.2.5" - moment "^2.29.1" ospath "^1.2.2" - pretty-bytes "^5.4.1" - ramda "~0.26.1" + pretty-bytes "^5.6.0" + ramda "~0.27.1" request-progress "^3.0.0" - supports-color "^7.2.0" + supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" url "^0.11.0" @@ -416,11 +421,18 @@ date-fns@^1.27.2: resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -dayjs@^1.9.3: +dayjs@^1.10.4: version "1.10.4" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== +debug@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + debug@^2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -435,13 +447,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.1.1: - version "4.3.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - dependencies: - ms "2.1.2" - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -477,12 +482,12 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -eventemitter2@^6.4.2: - version "6.4.3" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" - integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== +eventemitter2@^6.4.3: + version "6.4.4" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== -execa@^4.0.2: +execa@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== @@ -580,7 +585,7 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -fs-extra@^9.0.1: +fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -628,12 +633,12 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^2.0.1: - version "2.1.0" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" - integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== +global-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" + integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== dependencies: - ini "1.3.7" + ini "2.0.0" graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.5" @@ -702,17 +707,17 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@1.3.7: - version "1.3.7" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" - integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== - -is-ci@^2.0.0: +ini@2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + +is-ci@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz#c7e7be3c9d8eef7d0fa144390bd1e4b88dc4c994" + integrity sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ== dependencies: - ci-info "^2.0.0" + ci-info "^3.1.1" is-fullwidth-code-point@^1.0.0: version "1.0.0" @@ -731,13 +736,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-installed-globally@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" - integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== +is-installed-globally@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== dependencies: - global-dirs "^2.0.1" - is-path-inside "^3.0.1" + global-dirs "^3.0.0" + is-path-inside "^3.0.2" is-observable@^1.1.0: version "1.1.0" @@ -746,10 +751,10 @@ is-observable@^1.1.0: dependencies: symbol-observable "^1.1.0" -is-path-inside@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" - integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== +is-path-inside@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== is-promise@^2.1.0: version "2.2.2" @@ -879,10 +884,10 @@ lodash.once@^4.1.1: resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@^4.17.19: - version "4.17.20" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^1.0.2: version "1.0.2" @@ -953,11 +958,6 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.5" -moment@^2.29.1: - version "2.29.1" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -1056,10 +1056,10 @@ pify@^2.2.0: resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= -pretty-bytes@^5.4.1: - version "5.5.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" - integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== +pretty-bytes@^5.6.0: + version "5.6.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== process-nextick-args@~2.0.0: version "2.0.1" @@ -1099,10 +1099,10 @@ querystring@0.2.0: resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@~0.26.1: - version "0.26.1" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@~0.27.1: + version "0.27.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== readable-stream@^2.2.2: version "2.3.7" @@ -1277,13 +1277,20 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0, supports-color@^7.2.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + symbol-observable@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 8523b4a111..013e2cbf39 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -125,8 +125,8 @@ entity, and for machines and other components to reference the entity (e.g. in URLs or from other entity specification files). Names must be unique per kind, within a given namespace (if specified), at any -point in time. Names may be reused at a later time, after an entity is deleted -from the registry. +point in time. This uniqueness constraint is also case insensitive. Names may be +reused at a later time, after an entity is deleted from the registry. Names are required to follow a certain format. Entities that do not follow those rules will not be accepted for registration in the catalog. The ruleset is @@ -139,18 +139,6 @@ follows. Example: `visits-tracking-service`, `CircleciBuildsDs_avro_gcs` -In addition to this, names are passed through a normalization function and then -compared to the same normalized form of other entity names and made sure to not -collide. This rule of uniqueness exists to avoid situations where e.g. both -`my-component` and `MyComponent` are registered side by side, which leads to -confusion and risk. The normalization function is also configurable, but the -default behavior is as follows. - -- Strip out all characters outside of the set `[a-zA-Z0-9]` -- Convert to lowercase - -Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs` - ### `namespace` The `name` of a namespace that the entity belongs to. This field is optional, diff --git a/docs/assets/integrations/github/email.png b/docs/assets/integrations/github/email.png new file mode 100644 index 0000000000..763cfbcd58 Binary files /dev/null and b/docs/assets/integrations/github/email.png differ diff --git a/docs/assets/integrations/github/permissions.png b/docs/assets/integrations/github/permissions.png new file mode 100644 index 0000000000..4928b184ac Binary files /dev/null and b/docs/assets/integrations/github/permissions.png differ diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index 8e9c499dc4..c7ac2cedc9 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -31,8 +31,8 @@ auth: providers: gitlab: development: - clientId: ${AUTH_GITLAB_APPLICATION_ID} - clientSecret: ${AUTH_GITLAB_SECRET} + clientId: ${AUTH_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} ## uncomment if using self-hosted GitLab # audience: https://gitlab.company.com ``` diff --git a/docs/auth/index.md b/docs/auth/index.md index f3783c79f3..000c57a50c 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -77,7 +77,6 @@ built-in providers: + const app = createApp({ apis, - plugins: Object.values(plugins), + components: { + SignInPage: props => ( + ( + Only include the schema that applies to the given package + -h, --help display help for command +``` + ## config:print Scope: `root` @@ -466,6 +487,7 @@ Usage: backstage-cli config:print [options] Options: --package <name> Only load config schema that applies to the given package + --lax Do not require environment variables to be set --frontend Print only the frontend configuration --with-secrets Include secrets in the printed configuration --format <format> Format to print the configuration in, either json or yaml [yaml] @@ -486,6 +508,7 @@ Usage: backstage-cli config:check [options] Options: --package <name> Only load config schema that applies to the given package + --lax Do not require environment variables to be set --config <path> Config files to load instead of app-config.yaml (default: []) -h, --help display help for command ``` diff --git a/docs/conf/writing.md b/docs/conf/writing.md index c7b3bfe296..4288d23b17 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -6,8 +6,9 @@ description: Documentation on Writing Backstage Configuration Files ## File Format -Configuration is stored in YAML format in `app-config.yaml` files, looking -something like this: +Configuration is stored in YAML format in `app-config.yaml` files. This +configuration is shared between the frontend and backend and it looks something +like this: ```yaml app: @@ -32,6 +33,15 @@ proxy: Configuration files are typically checked in and stored in the repo that houses the rest of the Backstage application. +The particular configuration that is available to each Backstage app depends on +what plugins and packages are installed. To view the configuration reference for +your own project, including what configuration keys available and whether they +are needed by the frontend, use the following command: + +```sh +yarn backstage-cli config:docs +``` + ## Environment Variable Overrides Individual configuration values can be overridden using environment variables diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index ef178b2977..095a4989d8 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -19,9 +19,9 @@ their state, rather than using SQLite. By default, in an app created with `@backstage/create-app`, the frontend is bundled and served from the backend. This is done using the `@backstage/plugin-app-backend` plugin, which also injects the frontend -configuration into the app. This means you that you only need to build and -deploy a single container in a minimal setup of Backstage. If you wish to -separate the serving of the frontend out from the backend, see the +configuration into the app. This means that you only need to build and deploy a +single container in a minimal setup of Backstage. If you wish to separate the +serving of the frontend out from the backend, see the [separate frontend](#separate-frontend) topic below. ## Host Build @@ -232,7 +232,7 @@ package, which is done as follows: Once the `app-backend` is removed from the backend, you can use your favorite static file serving method for serving the frontend. An example of how to set up an NGINX image is available in the -[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx/Dockerfile) +[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx) Note that if you're building a separate docker build of the frontend you probably need to adjust `.dockerignore` appropriately. Most likely by making diff --git a/docs/deployment/helm.md b/docs/deployment/helm.md index fd36aeb81f..cbfcb1bc57 100644 --- a/docs/deployment/helm.md +++ b/docs/deployment/helm.md @@ -1,12 +1,10 @@ --- id: helm -title: Deploying Backstage with Helm +title: Deploying with Helm description: How to deploy Backstage with Helm and Kubernetes sidebar_label: Helm --- -# Helm charts - An example Backstage app can be deployed in Kubernetes using the [Backstage Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage). diff --git a/docs/deployment/index.md b/docs/deployment/index.md index db4ceca6f1..86ad03d1ae 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -30,7 +30,9 @@ There is also an example of deploying on [Heroku](heroku.md), which only requires the first two steps. An example of deploying Backstage with a [Helm chart](helm.md), a common pattern -in AWS, is also available. +in AWS, is also available. There is also a contrib guide to deploying Backstage +with +[AWS Fargate and Aurora PostgreSQL](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-fargate-deployment.md) Please consider contributing other deployment guides if you get Backstage set up on common infrastructure, it would be a great benefit to the community. diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 9dbf447740..2c52e0e308 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -357,6 +357,16 @@ spec: name: postgres-secrets - secretRef: name: backstage-secrets +# Uncomment if health checks are enabled in your app: +# https://backstage.io/docs/plugins/observability#health-checks +# readinessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck +# livenessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck ``` For production deployments, the `image` reference will usually be a full URL to diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 2ecd7d42be..45ef28ea2f 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -25,6 +25,7 @@ kubernetes: - url: http://127.0.0.1:9999 name: minikube authProvider: 'serviceAccount' + skipTLSVerify: false serviceAccountToken: ${K8S_MINIKUBE_TOKEN} - url: http://127.0.0.2:9999 name: aws-cluster-1 @@ -32,6 +33,7 @@ kubernetes: - type: 'gke' projectId: 'gke-clusters' region: 'europe-west1' + skipTLSVerify: true ``` ### `serviceLocatorMethod` @@ -77,6 +79,12 @@ cluster. Valid values are: | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | | `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | +| `aws` | This will use AWS credentials to access resources in EKS clusters | + +##### `clusters.\*.skipTLSVerify` + +This determines whether or not the Kubernetes client verifies the TLS +certificate presented by the API server. Defaults to `false`. ##### `clusters.\*.serviceAccountToken` (optional) @@ -122,6 +130,11 @@ The Google Cloud project to look for Kubernetes clusters in. The Google Cloud region to look for Kubernetes clusters in. Defaults to all regions. +##### `skipTLSVerify` + +This determines whether or not the Kubernetes client verifies the TLS +certificate presented by the API server. Defaults to `false`. + ### `customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index fed24c58d6..36bb724be6 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -8,52 +8,36 @@ The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when viewing entities in the software catalog. If you haven't setup Backstage already, read the -[Getting Started](../../getting-started/index.md). +[Getting Started](../../getting-started/index.md) guide. ## Adding the Kubernetes frontend plugin -The first step is to add the frontend Kubernetes plugin to your Backstage -application. Navigate to your new Backstage application directory. And then to +The first step is to add the Kubernetes frontend plugin to your Backstage +application. Navigate to your new Backstage application directory, and then to your `packages/app` directory, and install the `@backstage/plugin-kubernetes` package. ```bash -cd my-backstage-app/ +# From your Backstage root directory cd packages/app yarn add @backstage/plugin-kubernetes ``` -Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -`plugins.ts`: - -```typescript -export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; -``` - -Now, add the "Kubernetes" tab to the catalog entity page. In -`packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get -to the tab, and add the tab itself. - -`EntityPage.tsx`: +Once the package has been installed, you need to import the plugin in your app +by adding the "Kubernetes" tab to the respective catalog pages. ```tsx -import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; +// In packages/app/src/components/catalog/EntityPage.tsx +import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; -// ... - -const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( - - // ... - } - /> - // ... - -); +// You can add the tab to any number of pages, the service page is shown as an +// example here +const serviceEntityPage = ( + + {/* other tabs... */} + + + ``` That's it! But now, we need the Kubernetes Backend plugin for the frontend to @@ -65,17 +49,16 @@ Navigate to `packages/backend` of your Backstage app, and install the `@backstage/plugin-kubernetes-backend` package. ```bash -cd my-backstage-app/ +# From your Backstage root directory cd packages/backend yarn add @backstage/plugin-kubernetes-backend ``` Create a file called `kubernetes.ts` inside `packages/backend/src/plugins/` and -add the following - -`kubernetes.ts`: +add the following: ```typescript +// In packages/backend/src/plugins/kubernetes.ts import { createRouter } from '@backstage/plugin-kubernetes-backend'; import { PluginEnvironment } from '../types'; @@ -91,18 +74,15 @@ And import the plugin to `packages/backend/src/index.ts`. There are three lines of code you'll need to add, and they should be added near similar code in your existing Backstage backend. -`index.ts`: - ```typescript +// In packages/backend/src/index.ts import kubernetes from './plugins/kubernetes'; - // ... - -const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); - -// ... - -apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); +async function main() { + // ... + const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + // ... + apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); ``` That's it! The Kubernetes frontend and backend have now been added to your diff --git a/docs/features/search/README.md b/docs/features/search/README.md index bc0c9b3908..9f5a5d7009 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -24,22 +24,22 @@ Backstage ecosystem. ## Project roadmap -| Version | Description | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Backstage Search v0 ✅ | Search Frontend letting you search through the entities of the software catalog. [See v0 Use Cases.](#backstage-search-v0) | -| [Backstage Search V0.5 ✅ ][v0.5] | Foundations for the architecture. | -| [Backstage Search v1 ⌛][v1] | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See v1 Use Cases.](#backstage-search-v1) | -| [Backstage Search v2 ⌛][v2] | Search Backend responsible for the indexing process of entities, and their metadata, registered to the Software Catalog. [See v2 Use Cases.](#backstage-search-v2) | -| [Backstage Search v3 ⌛][v3] | Standardized Search API lets you index other plugins data to the search engine of choice. [See v3 Use Cases.](#backstage-search-v3) | +| Version | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Backstage Search Pre-Alpha ✅ | Search Frontend letting you search through the entities of the software catalog. [See Pre-Alpha Use Cases.](#backstage-search-pre-alpha) | +| Backstage Search Alpha ✅ | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See Alpha Use Cases](#backstage-search-alpha). | +| [Backstage Search Beta ⌛][beta] | At least one production-ready search engine that supports the same use-cases as in the alpha. [See Beta Use Cases](#backstage-search-beta). | +| [Backstage Search GA ⌛][ga] | A stable Search API for plugin developers to add search to their plugins, and app integrators to expose that to their users. [See GA Use Cases](#backstage-search-ga). | -[v0.5]: https://github.com/backstage/backstage/milestone/25 -[v1]: https://github.com/backstage/backstage/milestone/26 -[v2]: https://github.com/backstage/backstage/milestone/27 -[v3]: https://github.com/backstage/backstage/milestone/28 +[beta]: https://github.com/backstage/backstage/milestone/27 +[ga]: https://github.com/backstage/backstage/milestone/28 ## Use Cases -#### Backstage Search V.0 +#### Backstage Search Pre-Alpha + +The pre-alpha is intended to solve for the following user stories, but will get +there by means of a front-end only, non-extensible MVP. - As a software engineer I should be able to navigate to a search page and search for entities registered in the Software Catalog. @@ -52,28 +52,48 @@ Backstage ecosystem. - As a software engineer I should be able to hide the filters if I don’t need to use them. -#### Backstage Search V.1 +#### Backstage Search Alpha -- As a software engineer I should be able to get a match of a search on all - entity metadata (e.g. owner, name, description, kind). -- As an integrator I should not have to plug in any search engine, instead I can - use the out of the box in-memory indexing process to index entities and their - metadata registered in the Software Catalog. +We will consider Backstage Search to be in alpha when the above use-cases are +met, but built on top of a flexible, extensible platform. -#### Backstage Search V.2 +- As an integrator, I should be able to provide all of the pre-alpha experiences + to my users if I choose, but also be able to customize the experience using a + composable set of components. +- As a plugin developer, I should have a standard way to expose my plugin's data + to Backstage Search. +- As an integrator, I should still be able to expose everything in the Software + Catalog in search, but it should be possible to customize what is searchable. +- As an integrator, although I should be able to customize all of the above, it + should be possible to have the pre-alpha user experiences covered without + having to set up and configure a search engine. -- As an integrator I should be able to spin up an instance of ElasticSearch. -- As an integrator I should be able to define a ElasticSearch cluster in my - app_config.yaml where my data gets indexed to. +#### Backstage Search Beta -more to come... +We will consider Backstage Search to be in a beta phase when the above use-cases +are met, and can be deployed using a production-ready search engine. -#### Backstage Search V.3 +- As an integrator, I should be able to power my Backstage Search experience + (including querying and indexing) using a production-ready search engine like + ElasticSearch. +- As an integrator, I should be able to configure the connection to my search + engine in `app_config.yaml`. +- As an integrator, I should be able to tune the queries sent to my chosen + search engine according to my organization's needs, but a sensible default + query should be in place so that I am not required to do so. -- As a contributor I should be able to integrate plugin data to the indexing - process of Backstage Search by using the standardized API. -- As a software engineer I should be able to search for all content (for - example, entities, metadata, documentation) in Backstage search. +#### Backstage Search GA + +We will consider Backstage Search to be generally available (GA) when the above +use-cases are met, and an ecosystem of search-enabled plugins are available and +stable. + +- As a plugin developer, there should be at least one example of a Backstage + plugin that integrates with search that I can use as inspiration for my own + plugin's search capabilities (for example, the TechDocs plugin). +- As an app integrator, there should be plenty of examples and documentation on + how to customize and extend search in my Backstage instance to meet my + organization's needs. more to come... @@ -84,12 +104,19 @@ the search engines are used. | Search Engine | Support Status | | ------------- | -------------- | -| Basic (lunr) | Not yet ❌ | +| Basic (lunr) | ✅ | | ElasticSearch | Not yet ❌ | [Reach out to us](#feedback) if you want to chat about support for more search engines. +## Plugins Integrated with Search + +| Plugin | Support Status | +| -------- | -------------- | +| Catalog | ✅ | +| TechDocs | Not yet ❌ | + ## Tech Stack | Stack | Location | diff --git a/docs/features/search/concepts.md b/docs/features/search/concepts.md new file mode 100644 index 0000000000..5da7c9c58f --- /dev/null +++ b/docs/features/search/concepts.md @@ -0,0 +1,112 @@ +--- +id: concepts +title: Search Concepts +description: Documentation on Backstage Search Concepts +--- + +# Search Concepts + +Backstage Search lets you find the right information you are looking for in the +Backstage ecosystem. + +To get started, you should get familiar with these core concepts: + +- [Search Engines](#search-engines) +- [Query Translators](#query-translators) +- [Documents and Indices](#documents-and-indices) +- [Collators](#collators) +- [Decorators](#decorators) +- [The Scheduler](#the-scheduler) +- [The Search Page](#the-search-page) +- [Search Context and Components](#search-context-and-components) + +### Search Engines + +Backstage Search isn't a search engine itself, rather, it provides an interface +between your Backstage instance and a Search Engine of your choice. More +concretely, a `SearchEngine` is an interface whose concrete implementations +facilitate communication with different search engines (like ElasticSearch, +Lunr, Solr, etc). This abstraction exists in order to support your +organization's needs. + +Out of the box, Backstage Search comes pre-packaged with an in-memory search +engine implementation built on top of Lunr. + +### Query Translators + +Because you can bring your own search engine, and because search engines have +very unique and robust query languages themselves, there needs to be a +translation layer between an abstract search query (containing search terms, +filters, and document types) into a concrete search query that is specific to a +search engine. + +Search Engines come pre-packaged with simple translators that do rudimentary +transformations of search terms and filters, but you may want to provide your +own to help tune search results in the context of your organization. + +### Documents and Indices + +"Document" is an abstract concept representing something that can be found by +searching for it. A document can represent a software entity, a TechDocs page, +etc. Documents are made up of metadata fields, at a minimum including a title, +text, and location (as in a URL). + +An index is a collection of such documents of a given type. + +### Collators + +You need to be able to search something! Collators are the way to define what +can be searched. Specifically, they're classes which return documents conforming +to a minimum set of fields (including a document title, location, and text), but +which can contain any other fields as defined by the collator itself. One +collator is responsible for defining and collecting documents of a type. + +Some plugins, like the Catalog Backend, provide so-called "default" collators +which you can use out-of-the-box to start searching across Backstage quickly. + +### Decorators + +Sometimes you want to add extra information to a set of documents in your search +index that the collator may not be aware of. For example, the Software Catalog +knows about software entities, but it may not know about their usage or quality. + +Decorators are classes which can add extra fields to pre-collated documents. +This extra metadata could then be used to bias search results or otherwise +improve the search experience in your Backstage instance. + +### The Scheduler + +There are many ways a search index could be built and maintained, but Backstage +Search chooses to completely rebuild indices on a schedule. Different collators +can be configured to refresh at different intervals, depending on how often the +source information is updated. + +### The Search Page + +Search pages are very custom things. Not every Backstage instance will want the +same interface! In order to allow you to customize your search experience to +your heart's content, the Search Plugin takes care of state management and other +search logic for you, but most of the layout of a search page lives in a search +page component defined in your Backstage App. + +For an example of a simple search page, check +[getting started](./getting-started.md#adding-search-to-the-frontend) + +### Search Context and Components + +A search experience, like a page, is composed of any number of search +components, which are all wired up using a search context. + +Each search experience's context consists of details like a search term, +filters, types, results, and a page cursor for handling pagination. Different +components use this context in different ways. For example, the `` +can set the search term, `` components can set filters, and +search results can be displayed using the `` component. + +The `` and `` components are special, in that +they themselves are extensible. For an example of how to extend these +components, check +[getting started](./getting-started.md#adding-search-to-the-frontend). + +If you need even more customization, you can use the search context like any +other React context to create custom search components of your own. diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md new file mode 100644 index 0000000000..7408d69193 --- /dev/null +++ b/docs/features/search/getting-started.md @@ -0,0 +1,297 @@ +--- +id: getting-started +title: Getting Started with Search +description: How to set up and install Backstage Search +--- + +# Getting Started + +Search functions as a plugin to Backstage, so you will need to use Backstage to +use Search. + +If you haven't setup Backstage already, start +[here](../../getting-started/index.md). + +> If you used `npx @backstage/create-app`, and you have a search page defined in +> `packages/app/src/components/search`, skip to +> [`Customizing Search`](#customizing-search) below. + +## Adding Search to the Frontend + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-search +``` + +Create a new `packages/app/src/components/search/SearchPage.tsx` file in your +Backstage app with the following contents: + +```tsx +import React from 'react'; +import { Content, Header, Page } from '@backstage/core'; +import { Grid, List, Card, CardContent } from '@material-ui/core'; +import { + SearchBar, + SearchResult, + DefaultResultListItem, + SearchFilter, +} from '@backstage/plugin-search'; +import { CatalogResultListItem } from '@backstage/plugin-catalog'; + +export const searchPage = ( + +
+ + + + + + + + + + + + + + + + + + {({ results }) => ( + + {results.map(result => { + switch (result.type) { + case 'software-catalog': + return ( + + ); + default: + return ( + + ); + } + })} + + )} + + + + + +); +``` + +Bind the above Search Page to the `/search` route in your +`packages/app/src/App.tsx` file, like this: + +```tsx +import { SearchPage } from '@backstage/plugin-search'; +import { searchPage } from './components/search/SearchPage'; + +const routes = ( + + }> + {searchPage} + + +); +``` + +## Adding Search to the Backend + +Add the following plugins into your backend app: + +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-search-backend @backstage/plugin-search-backend-node +``` + +Create a `packages/backend/src/plugins/search.ts` file containing the following +code: + +```typescript +import { useHotCleanup } from '@backstage/backend-common'; +import { createRouter } from '@backstage/plugin-search-backend'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; +import { PluginEnvironment } from '../types'; +import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; + +export default async function createPlugin({ + logger, + discovery, +}: PluginEnvironment) { + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); + + indexBuilder.addCollator({ + type: 'software-catalog', + defaultRefreshIntervalSeconds: 600, + collator: new DefaultCatalogCollator({ discovery }), + }); + + const { scheduler } = await indexBuilder.build(); + + scheduler.start(); + useHotCleanup(module, () => scheduler.stop()); + + return await createRouter({ + engine: indexBuilder.getSearchEngine(), + logger, + }); +} +``` + +Make the following modifications to your `packages/backend/src/index.ts` file: + +Import the `plugins/search` file you created above: + +```typescript +import search from './plugins/search'; +``` + +Set up an environment for search: + +```typescript +const searchEnv = useHotMemoize(module, () => createEnv('search')); +``` + +Register the search service with the router: + +```typescript +apiRouter.use('/search', await search(searchEnv)); +``` + +## Customizing Search + +### Frontend + +The Search Plugin exposes several default filter types as static properties, +including `` and ``. These allow +you to provide values relevant to your Backstage instance that, when selected, +get passed to the backend. + +```tsx {2-5,8-11} + + + + + + +``` + +If you have advanced filter needs, you can specify your own filter component +like this (although new core filter contributions are welcome): + +```tsx +import { useSearch, SearchFilter } from '@backstage/plugin-search'; + +const MyCustomFilter = () => { + // Note: filters contain filter data from other filter components. Be sure + // not to clobber other filters' data! + const { filters, setFilters } = useSearch(); + + return (/* ... */); +}; + +// Which could be rendered like this: + +``` + +It's good practice for search results to highlight information that was used to +return it in the first place! The code below highlights how you might specify a +custom result item component, using the `` component as +an example: + +```tsx {7-13} + + {({ results }) => ( + + {results.map(result => { + // result.type is the index type defined by the collator. + switch (result.type) { + case 'software-catalog': + return ( + + ); + // ... + } + })} + + )} + +``` + +### Backend + +Backstage Search isn't a search engine itself, rather, it provides an interface +between your Backstage instance and a +[Search Engine](./concepts.md#search-engines) of your choice. Currently, we only +support one, an in-memory search Engine called Lunr. It can be instantiated like +this: + +```typescript +const searchEngine = new LunrSearchEngine({ logger }); +const indexBuilder = new IndexBuilder({ logger, searchEngine }); +``` + +Backstage Search can be used to power search of anything! Plugins like the +Catalog offer default [collators](./concepts.md#collators) which are responsible +for providing documents [to be indexed](./concepts.md#documents-and-indices). +You can register any number of collators with the `IndexBuilder` like this: + +```typescript +const indexBuilder = new IndexBuilder({ logger, searchEngine }); + +indexBuilder.addCollator({ + type: 'software-catalog', + defaultRefreshIntervalSeconds: 600, + collator: new DefaultCatalogCollator({ discovery }), +}); + +indexBuilder.addCollator({ + type: 'my-custom-stuff', + defaultRefreshIntervalSeconds: 3600, + collator: new MyCustomCollator(), +}); +``` + +Backstage Search builds and maintains its index +[on a schedule](./concepts.md#the-scheduler). You can change how often the +indexes are rebuilt for a given type of document. You may want to do this if +your documents are updated more or less frequently. You can do so by modifying +its `defaultRefreshIntervalSeconds` value, like this: + +```typescript {3} +indexBuilder.addCollator({ + type: 'software-catalog', + defaultRefreshIntervalSeconds: 600, + collator: new DefaultCatalogCollator({ discovery }), +}); +``` diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md new file mode 100644 index 0000000000..e0228bf7d0 --- /dev/null +++ b/docs/features/software-catalog/catalog-customization.md @@ -0,0 +1,183 @@ +--- +id: catalog-customization +title: Catalog Customization +# prettier-ignore +description: How to add custom filters or interface elements to the Backstage software catalog +--- + +The Backstage software catalog comes with a default `CatalogIndexPage` to filter +and find catalog entities. This is already set up by default by +`@backstage/create-app`. + +If you want to change the default index page - such as to add a custom filter to +the catalog - you can replace the routing in `App.tsx` to point to your own +`CatalogIndexPage`. + +> Note: The catalog index page is designed to have a minimal code footprint to +> support easy customization, but creating a copy does introduce a possibility +> of drifting out of date over time. Be sure to check the catalog +> [CHANGELOG](https://github.com/backstage/backstage/blob/master/plugins/catalog/CHANGELOG.md) +> periodically. + +For example, suppose that I want to allow filtering by a custom annotation added +to entities, `company.com/security-tier`. To start, I'll copy the code for the +default catalog page and create a component in a +[new plugin](../../plugins/create-a-plugin.md): + +```tsx +// imports, etc omitted for brevity. for full source see: +// https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +export const CustomCatalogPage = () => { + return ( + + + + + All your software catalog entities + +
+ +
+
+ +
+
+
+
+ ); +}; +``` + +The `EntityListProvider` shown here provides a list of entities from the +`catalog-backend`, and a way to hook in filters. + +Now we're ready to create a new filter that implements the `EntityFilter` +interface: + +```ts +import { EntityFilter } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; + +class EntitySecurityTierFilter implements EntityFilter { + constructor(readonly values: string[]) {} + filterEntity(entity: Entity): boolean { + const tier = entity.metadata.annotations?.['company.com/security-tier']; + return tier !== undefined && this.values.includes(tier); + } +} +``` + +The `EntityFilter` interface permits backend filters, which are passed along to +the `catalog-backend` - or frontend filters, which are applied after entities +are loaded from the backend. + +We'll use this filter to extend the default filters in a type-safe way. Let's +create the custom filter shape extending the default somewhere alongside this +filter: + +```ts +export type CustomFilters = DefaultEntityFilters & { + securityTiers?: EntitySecurityTierFilter; +}; +``` + +To control this filter, we can create a React component that shows checkboxes +for the security tiers. This component will make use of the +`useEntityListProvider` hook, which accepts this extended filter type as a +[generic](https://www.typescriptlang.org/docs/handbook/2/generics.html) +parameter: + +```tsx +export const EntitySecurityTierPicker = () => { + // The securityTiers key is recognized due to the CustomFilter generic + const { + filters: { securityTiers }, + updateFilters, + } = useEntityListProvider(); + + // Toggles the value, depending on whether it's already selected + function onChange(value: string) { + const newTiers = securityTiers?.values.includes(value) + ? securityTiers.values.filter(tier => tier !== value) + : [...(securityTiers?.values ?? []), value]; + updateFilters({ + securityTiers: newTiers.length + ? new EntitySecurityTierFilter(newTiers) + : undefined, + }); + } + + const tierOptions = ['1', '2', '3']; + return ( + + Security Tier + + {tierOptions.map(tier => ( + onChange(tier)} + /> + } + label={`Tier ${tier}`} + /> + ))} + + + ); +}; +``` + +Now we can add the component to `CustomCatalogPage`: + +```diff +export const CustomCatalogPage = () => { + return ( + ... + +
+
+ +
+ ... +}; +``` + +This page itself can be exported as a routable extension in the plugin: + +```ts +export const CustomCatalogIndexPage = myPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CustomCatalogPage').then(m => m.CustomCatalogPage), + mountPoint: catalogRouteRef, + }), +); +``` + +Finally, we can replace the catalog route in the Backstage application with our +new `CustomCatalogIndexPage`. + +```diff +# packages/app/src/App.tsx +const routes = ( + + +- } /> ++ } /> +``` + +The same method can be used to customize the _default_ filters with a different +interface - for such usage, the generic argument isn't needed since the filter +shape remains the same as the default. diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index 1afdff418a..320b82d0ad 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -37,6 +37,13 @@ The locations added through static configuration cannot be removed through the catalog locations API. To remove these locations, you must remove them from the configuration. +Syntax errors or other types of errors present in `catalog-info.yaml` files will +be logged for investigation. Errors do not cause processing to abort. + +When multiple `catalog-info.yaml` files with the same `metadata.name` property +are discovered, one will be processed and all others will be skipped. This +action is logged for further investigation. + ### Integration Processors Integrations may simply provide a mechanism to handle `url` location type for an @@ -111,8 +118,7 @@ catalog: > deleting entities will not work in this mode.** A common use case for this configuration is when organizations have a remote -source that should be mirrored into backstage. If we want backstage to be a -mirror of this remote source we cannot allow users to also register entities -with e.g. +source that should be mirrored into Backstage. To make Backstage a mirror of +this remote source, users cannot also register new entities with e.g. the [catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) plugin. diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index f4e1f86dd8..988af89e47 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -23,6 +23,7 @@ we recommend that you name them `catalog-info.yaml`. - [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope) - [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata) - [Common to All Kinds: Relations](#common-to-all-kinds-relations) +- [Common to All Kinds: Status](#common-to-all-kinds-status) - [Kind: Component](#kind-component) - [Kind: Template](#kind-template) - [Kind: API](#kind-api) @@ -44,7 +45,7 @@ metadata: name: artist-web description: The place to be, for great artists labels: - system: public-websites + example.com/custom: custom_label_value annotations: example.com/service-discovery: artistweb circleci.com/project-slug: github/example-org/artist-website @@ -58,6 +59,7 @@ spec: type: website lifecycle: production owner: artist-relations-team + system: public-websites ``` This is the same entity as returned in JSON from the software catalog API: @@ -76,7 +78,7 @@ This is the same entity as returned in JSON from the software catalog API: "etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk", "generation": 1, "labels": { - "system": "public-websites" + "example.com/custom": "custom_label_value" }, "links": [{ "url": "https://admin.example-org.com", @@ -90,7 +92,8 @@ This is the same entity as returned in JSON from the software catalog API: "spec": { "lifecycle": "production", "owner": "artist-relations-team", - "type": "website" + "type": "website", + "system": "public-websites" } } ``` @@ -394,6 +397,68 @@ with it (such as the default kind being `Group` if not specified). See the [well-known relations section](well-known-relations.md) for a list of well-known / common relations and their semantics. +## Common to All Kinds: Status + +The `status` root object is a read-only set of statuses, pertaining to the +current state or health of the entity, described in the +[well-known statuses section](well-known-statuses.md). + +Currently, the only defined field is the `items` array. Each of its items +contains a specific data structure that describes some aspect of the state of +the entity, as seen from the point of view of some specific system. Different +systems may contribute to this array, under their own respective `type` keys. + +The current main use case for this field is for the ingestion processes of the +catalog itself to convey information about errors and warnings back to the user. + +A status field as part of a single entity that's read out of the API may look as +follows. + +```js +{ + // ... + "status": { + "items": [ + { + "type": "backstage.io/catalog-processing", + "level": "error", + "message": "NotFoundError: File not found", + "error": { + "name": "NotFoundError", + "message": "File not found", + "stack": "..." + } + } + ] + }, + "spec": { + // ... + } +} +``` + +The fields of a status item are: + +| Field | Type | Description | +| --------- | ------ | ------------------------------------------------------------------------------------------------ | +| `type` | String | The type of status as a unique key per source. Each type may appear more than once in the array. | +| `level` | String | The level / severity of the status item: 'info', 'warning, or 'error'. | +| `message` | String | A brief message describing the status, intended for human consumption. | +| `error` | Object | An optional serialized error object related to the status. | + +The `type` is an arbitrary string, but we recommend that types that are not +strictly private within the organization be namespaced to avoid collisions. +Types emitted by Backstage core processes will for example be prefixed with +`backstage.io/` as in the example above. + +Entity descriptor YAML files are not supposed to contain a `status` root key. +Instead, catalog processors analyze the entity descriptor data and its +surroundings, and deduce status entries that are then attached onto the entity +as read from the catalog. + +See the [well-known statuses section](well-known-statuses.md) for a list of +well-known / common status types. + ## Kind: Component Describes the following entity kind: @@ -639,22 +704,11 @@ A list of strings that can be associated with the template, e.g. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. -### `spec.type` [optional] +### `spec.type` [required] -The type of component as a string, e.g. `website`. This field is optional but -recommended. - -The software catalog accepts any type value, but an organization should take -great care to establish a proper taxonomy for these. Tools including Backstage -itself may read this field and behave differently depending on its value. For -example, a website type component may present tooling in the Backstage interface -that is specific to just websites. - -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 +The type of component created by the template, e.g. `website`. This is used for +filtering templates, and should ideally match the Component +[spec.type](#spectype-required) created by the template. ### `spec.parameters` [required] @@ -666,6 +720,25 @@ You can find out more about the `parameters` key You can find out more about the `steps` key [here](../software-templates/writing-templates.md) +### `spec.owner` [optional] + +An [entity reference](#string-references) to the owner of the template, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a Template is the singular entity (commonly a team) +that bears ultimate responsibility for the Template, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this Template belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +Template, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + ## Kind: API Describes the following entity kind: diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index d4dca0c059..aa5aed0540 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -1,6 +1,7 @@ --- id: extending-the-model title: Extending the model +# prettier-ignore description: Documentation on extending the catalog model --- @@ -301,9 +302,9 @@ Example intents: > "We have this concept of service maintainership, separate from ownership, that > we would like to make relations to individual users for." -> We feel that we want to explicitly model the team-to-global-department mapping -> as a relation, because it is core to our org setup and we frequently query for -> it. +> "We feel that we want to explicitly model the team-to-global-department +> mapping as a relation, because it is core to our org setup and we frequently +> query for it." Any processor can emit relations for entities as they are being processed, and new processors can be added when building the backend catalog using the @@ -349,3 +350,46 @@ If you want to extend the use of an established relation type in a way that has an effect outside of your organization, reach out to the Backstage maintainers or a support partner to discuss risk/impact. It may even be that one end of the relation could be considered for addition to the core. + +## Adding a New Status field + +Example intent: + +> "We would like to convey entity statuses through the catalog in a generic way, +> as an integration layer. Our monitoring and alerting system has a plugin with +> Backstage, and it would be useful if the entity's status field contained the +> current alert state close to the actual entity data for anyone to consume. We +> find the `status.items` semantics a poor fit, so we would prefer to make our +> own custom field under `status` for these purposes." + +We have not yet ventured to define any generic semantics for the `status` +object. We recommend sticking with the `status.items` mechanism where possible +(see below), since third party consumers will not be able to consume your status +information otherwise. Please reach out to the maintainers on Discord or by +making a GitHub issue describing your use case if you are interested in this +topic. + +## Adding a New Status Item Type + +Example intent: + +> "The semantics of the entity `status.items` field are fine for our needs, but +> we want to contribute our own type of status into that array instead of the +> catalog specific one." + +This is a simple, low risk way of adding your own status information to +entities. Consumers will be able to easily track and display the status together +with other types / sources. + +We recommend that any status type that are not strictly private within the +organization be namespaced to avoid collisions. Statuses emitted by Backstage +core processes will for example be prefixed with `backstage.io/`, your +organization may prefix with `my-org.net/`, and `pagerduty.com/active-alerts` +could be a sensible complete status item type for that particular external +system. + +The mechanics for how to emit custom statuses is not in place yet, so if this is +of interest to you, you might consider contacting the maintainers on Discord or +my making a GitHub issue describing your use case. +[This issue](https://github.com/backstage/backstage/issues/2292) also contains +more context. diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index ff8dfbda68..0b622fb093 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -18,20 +18,15 @@ The catalog frontend plugin should be installed in your `app` package, which is created as a part of `@backstage/create-app`. To install the package, run: ```bash +# From your Backstage root directory cd packages/app yarn add @backstage/plugin-catalog ``` ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { catalogPlugin } from '@backstage/plugin-catalog'; -``` - -Next we need to install the two pages that the catalog plugin provides. You can -choose any name for these routes, but we recommend the following: +Add the two pages that the catalog plugin provides to your app. You can choose +any name for these routes, but we recommend the following: ```tsx // packages/app/src/App.tsx @@ -109,6 +104,7 @@ The catalog backend should be installed in your `backend` package, which is created as a part of `@backstage/create-app`. To install the package, run: ```bash +# From your Backstage root directory cd packages/backend yarn add @backstage/plugin-catalog-backend ``` diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 0053c14b79..67b92bd941 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -273,6 +273,20 @@ project within your organization. Specifying this annotation may enable SonarQube related features in Backstage for that entity. +### backstage.io/code-coverage + +```yaml +# Example: +metadata: + annotations: + backstage.io/code-coverage: scm-only +``` + +The value of this annotation controls the code-coverage backstage plugin. If set +to `scm-only`, the plugin will only take into account files stored in source +control (e.g. ignoring generated code). If set to `enabled`, all files covered +by a coverage report will be taken into account. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in diff --git a/docs/features/software-catalog/well-known-statuses.md b/docs/features/software-catalog/well-known-statuses.md new file mode 100644 index 0000000000..d293ae5062 --- /dev/null +++ b/docs/features/software-catalog/well-known-statuses.md @@ -0,0 +1,55 @@ +--- +id: well-known-statuses +title: Well-known Status fields of Catalog Entities +sidebar_label: Well-known Statuses +# prettier-ignore +description: Lists a number of well known entity statuses, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. +--- + +This section lists well known +[entity statuses](descriptor-format.md#common-to-all-kinds-status), that have +defined semantics. They can be attached to catalog entities and consumed by +plugins as needed. + +If you are looking to extend the statuses, see +[Extending the model](extending-the-model.md). + +## Common Fields + +The `status` object of an entity is currently left unrestricted, except for the +`items` field. Its structure is defined in the +[descriptor format](descriptor-format.md#common-to-all-kinds-status) section. + +We reserve the right to extend this model in the future. This status is in +active development and its format will change unexpectedly. Do not consume it in +your own code until such a time that this documentation has been updated. + +## Status Item Types + +This is a (non-exhaustive) list of `status.items.[].type` values that are known +to be in active use. + +### `backstage.io/catalog-processing` + +Expresses an aspect of the current status of the catalog's ingestion of this +entity. Errors that may appear here include inability to read from the remote +SCM provider, syntax errors in the YAML file, and similar. + +Note that the entity data itself may be of an older version, when errors are +present. The ingestion system keeps the old valid entity data untouched when +possible, so the errors described in this state may not seem to align with the +rest of the entity, because they pertain to a remote that could not be +successfully ingested. This is normal. + +```yaml +# Example: +status: + items: + - type: backstage.io/catalog-processing + level: error + message: 'NotFoundError: File not found' + error: + name: NotFoundError + message: File not found + stack: ... +``` diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 105b7e5f96..d5643ca955 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -20,20 +20,15 @@ The scaffolder frontend plugin should be installed in your `app` package, which is created as a part of `@backstage/create-app`. To install the package, run: ```bash +# From your Backstage root directory cd packages/app yarn add @backstage/plugin-scaffolder ``` ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -``` - -Next we need to install the root page that the Scaffolder plugin provides. You -can choose any path for the route, but we recommend the following: +Add the root page that the Scaffolder plugin provides to your app. You can +choose any path for the route, but we recommend the following: ```tsx import { ScaffolderPage } from '@backstage/plugin-scaffolder'; @@ -63,6 +58,7 @@ The scaffolder backend should be installed in your `backend` package, which is created as a part of `@backstage/create-app`. To install the package, run: ```bash +# From your Backstage root directory cd packages/backend yarn add @backstage/plugin-scaffolder-backend ``` @@ -74,6 +70,10 @@ creating a file called `packages/backend/src/plugins/scaffolder.ts` with the following contents to get you up and running quickly. ```ts +import { + DockerContainerRunner, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { CookieCutter, createRouter, @@ -82,7 +82,6 @@ import { CreateReactAppTemplater, Templaters, } from '@backstage/plugin-scaffolder-backend'; -import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; import { CatalogClient } from '@backstage/catalog-client'; @@ -93,8 +92,11 @@ export default async function createPlugin({ database, reader, }: PluginEnvironment) { - const cookiecutterTemplater = new CookieCutter(); - const craTemplater = new CreateReactAppTemplater(); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + const craTemplater = new CreateReactAppTemplater({ containerRunner }); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); @@ -103,8 +105,6 @@ export default async function createPlugin({ const preparers = await Preparers.fromConfig(config, { logger }); const publishers = await Publishers.fromConfig(config, { logger }); - const dockerClient = new Docker(); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -114,7 +114,6 @@ export default async function createPlugin({ publishers, logger, config, - dockerClient, database, catalogClient, reader, @@ -265,9 +264,17 @@ the templates at [localhost:3000/create](http://localhost:3000/create) now! Software Templates use [Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating library. By default it will use the -[spotify/backstage-cookiecutter](<[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)>) +[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile) docker image. -If you are running backstage from a Docker container and you want to avoid +If you are running Backstage from a Docker container and you want to avoid calling a container inside a container, you can set up Cookiecutter in your own image, this will use the local installation instead. + +You can do so by including the following lines in the last step of your +`Dockerfile`: + +```Dockerfile +RUN apt-get update && apt-get install -y python3 python3-pip +RUN pip3 install cookiecutter +``` diff --git a/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md new file mode 100644 index 0000000000..25ff2a2f3a --- /dev/null +++ b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md @@ -0,0 +1,334 @@ +--- +id: migrating-from-v1alpha1-to-v1beta2 +title: Migrating to v1beta2 templates +# prettier-ignore +description: + How to move your old templates from v1alpha1 to the more declarative v1beta2 +--- + +# What's new? + +Previously, the scaffolder was very restricted in what you could do when +creating new software components from templates. There were three scaffolding +steps which was pretty hard to extend and add new functionality to, difficult to +re-use logic between templates. There used to be a fixed pipeline of +`preparers`, `templaters`, and `publishers`, which were defined by the backend +and needed to be run for each template. This is now changed, to give the +template total control over what should be executed as part of the templating +run. This makes templates a little more declarative as you can now register +different `actions` or `functions` with the `scaffolder-backend` which you then +can decide how, and in what order, to run using the template definition YAML +file. + +We've also made some improvements, and added some helpers to work with +cookiecutter. The skeleton for a template can now be stored in a different place +to where your entity definition is: previously you needed to have your +`template.yaml` next to the skeleton source (`{{cookiecutter.component_id}}` +directory), but now that's not the case. Part of the changes with the `v1beta2` +syntax is that you can grab your template source from any repository, and re-use +them between templates. + +We've also renamed the `schema` property to `parameters` as this makes more +sense when using them as parameters to the actions or steps that you've setup +for your templates. There's the added benefit that you can now assign an array +to the `parameters` property, which will then give you multiple steps in the UI, +so you can split apart your input parameters and group them as needed rather +than having one long list of input fields. + +## The `parameters` property + +The `schema` key has now been renamed to `parameters` with a few more features. +You can pass an array now to break apart the input form into different steps in +the UI. You can also specify `ui:schema` fields that are passed along to +[`react-jsonschema-form`](https://rjsf-team.github.io/react-jsonschema-form/) +inline with the JSON schema. + +```yaml +spec: + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 +``` + +## The `steps` property + +`v1beta2` template syntax introduces the new `steps` property, which is an array +of `actions` that the scaffolder will run in combination with the user input +that is declared in the `schema`. Actions look like the following: + +```yaml +spec: + steps: + - id: publish # a unique id for the step, can be anything you like + name: Publish # a user friendly name for the step, this is what is shown in the frontend + action: publish:github # the action ID that has been registered with the scaffolder-backend + input: # parameters that are passed as input to the action handler function + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' # handlebars templating is supported with the values from the parameters section in the same file. + repoUrl: '{{ parameters.repoUrl }}' +``` + +# Migrating a `v1alpha1` template + +## The template definition (.yaml) + +### `parameters` + +Because of the changes to invert the control to the `template.yaml` definition +for running the workflow, we need to adjust the `schema` property and we also +now need to define what the template is actually going to do as part of the +template run. + +A simple migration would move the following yaml: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + templater: cookiecutter + type: website + path: '.' + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. +``` + +To something that looks like the following: + +```yaml +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + type: website + parameters: + - title: Add some input + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +There are a few things to note here. On the `alpha` version, the second step of +the template flow in the frontend was provided by Backstage for free, so we used +to collect the user input for the `owner` field and the `repositoryUrl` that you +were going to publish to. Now because `actions` can have any workflow they like, +it doesn't make sense to still provide these fields for every scaffolding +workflow, as you might not need these anymore. That's why we now manually add +those fields back into the template parameters that are shown to the user: + +```yaml + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +Maybe you also don't need to publish to `github.com`, you should replace this +with your VCS provider URL that is listed in your `integrations` config instead. + +### `steps` + +So now we should have all the required information that we need from the user in +a much more extensible way. We now need to tell the scaffolder what to do with +these parameters and what to do with the user input. + +We've made templating using `cookiecutter` a little simpler. You don't need to +store the `cookiecutter` skeleton in the same directory as the `template.yaml` +definition, it can live wherever you like - maybe a shared repository somewhere +so you can re-use the skeletons but apply different actions for different +templates depending on your use case. + +We also no longer need to have a directory called +`{{cookiecutter.component_id}}`. This is because now we can't ensure that +`component_id` will be a parameter that is provided from the frontend, this +could break `cookiecutter`. If your directory structure used to look like this: + +``` +my-awesome-template + -> {{cookiecutter.component_id}} + -> file.txt + -> some_more_files.ts + -> hooks + -> post_gen_project.sh + -> template.yaml +``` + +We now recommend that you move to the following structure: + +``` +my-awesome-template + -> skeleton + -> file.txt + -> some_more_files.ts + -> template.yaml +``` + +This migration renames the skeleton folder to something more semantic, and also +drops support for `cookiecutter` hooks. We've dropped support for `cookiecutter` +hooks for now, as hopefully everything that is stored in these hooks can be +moved to `actions` instead, and for security reasons, it's more secure to run +trusted code that you ship with Backstage as an action rather than some script +that can be pulled in from anywhere which doesn't get vetted first. It's a +pretty big security risk that those scripts will be run on Backstage instances +inside your infrastructure, especially `.sh` files. + +If you really need hooks and can't find a suitable solution by using actions +please reach out to us through a ticket and we'll see what we can do to assist +:) + +You'll notice that we removed the `templater` property from the `spec` +definition in the template `yaml`, so there's no way to define that this is a +`cookiecutter` `templater`. + +We've created a built-in action that you can use which will when run, go grab a +directory from anywhere and run `cookiecutter` on top of it, and then extract +the contents into the working directory for the scaffolder. + +Adding the `steps` for a simple template should look something like the +following: + +```yaml +spec: + steps: + # this action will go use cookiecutter to template some files into the working directory + - id: template # an ID for the templating step + name: Create skeleton # A user friendly name for the action + action: fetch:cookiecutter + input: + url: ./skeleton # this is the directory for your skeleton files. + # If it's located next to the `template.yaml` then you can use a relative path, + # otherwise you can use absolute URLs that point at the VCS: https://github.com/backstage/backstage/tree/master/some_folder_somewhere + values: + # for each value that you need to pass to cookiecutter, they should be listed here and set in this values object. + # You can use the handlebars templating syntax to pull them from the input parameters listed in the same file + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + destination: '{{ parseRepoUrl parameters.repoUrl }}' + + # this action is for publishing the working directory to the VCS + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + # this action will then register the created component in Backstage + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' +``` + +### `output` + +Steps can output values, and so can the template itself. This is good for +returning values to the frontend, so we can make the buttons like +`Go to catalog` and `Go to repo` work correctly. You can add the following to +your `template.yaml` to make sure you return the right values from the steps: + +```yaml +spec: + output: + remoteUrl: '{{ steps.publish.output.remoteUrl }}' + entityRef: '{{ steps.register.output.entityRef }}' +``` + +Or you can return a `links` array with text and a URL explicitly: + +```yaml +spec: + output: + links: + - url: '{{steps.publish.output.remoteUrl}}' + text: 'Go to Repo' +``` + +## Questions? + +If you have any questions or feedback, please reach out to us on GitHub or +Discord and we will do our best to help! diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index ff145ee5af..90dd603c8f 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -106,7 +106,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, @@ -124,7 +123,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, @@ -136,11 +134,9 @@ return await createRouter({ want to have those as well as your new one, you'll need to do the following: ```ts - -import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend`; +import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend'; const builtInActions = createBuiltinActions({ - dockerClient, integrations, catalogClient, templaters, @@ -155,7 +151,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 4f9323c6e9..8481e64eac 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -142,6 +142,12 @@ this: "type": "string", "title": "Last name" }, + "nicknames":{ + "type": "array", + "items": { + "type": "string" + } + }, "telephone": { "type": "string", "title": "Telephone", @@ -161,6 +167,11 @@ this: "ui:emptyValue": "", "ui:autocomplete": "given-name" }, + "nicknames": { + "ui:options":{ + "orderable": false + } + }, "telephone": { "ui:options": { "inputType": "tel" @@ -202,6 +213,12 @@ spec: title: Last name ui:emptyValue: '' ui:autocomplete: given-name + nicknames: + type: array + items: + type: string + ui:options: + orderable: false telephone: type: string title: Telephone @@ -222,17 +239,17 @@ You can see it in the above full example which is a separate step and it looks a little like this: ```yaml - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com +- title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com ``` The `allowedHosts` part should be set to where you wish to enable this template @@ -271,6 +288,7 @@ template. These follow the same standard format: ```yaml - id: fetch-base # A unique id for the step name: Fetch Base # A title displayed in the frontend + if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy action: fetch:cookiecutter # an action to call input: # input that is passed as arguments to the action handler url: ./template @@ -304,7 +322,10 @@ You might have noticed in the examples that there are `{{ }}`, and these are a `yaml` together. All the form inputs from the `parameters` section, when passed to the steps will be available by using the template syntax `{{ parameters.something }}`. This is great for passing the values from the form -into different steps and reusing these input variables. +into different steps and reusing these input variables. To pass arrays or +objects use the syntax `{{ json paramaters.something }}` where +`paramaters.something` is of type `object` or `array` in the `jsonSchema`, such +as the `nicknames` parameter in the previous example. As you can see above in the `Outputs` section, `actions` and `steps` can also output things. So you can grab that output by using diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 5ce71ebfad..117471f749 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -45,3 +45,15 @@ metadata annotation is used in the build process of TechDocs. But when annotation should still be present in entity descriptor file (e.g. `catalog-info.yaml`) for Backstage to know that TechDocs is enabled for the entity. + +#### Is it possible for users to suggest changes or provide feedback on a TechDocs page? + +This is supported for TechDocs sites whose source code is hosted in either +GitHub or GitLab. In order to add "edit this page" and "leave feedback" buttons +on a TechDocs page, be sure that you have `repo_url` and `edit_uri` values in +your `mkdocs.yml` files per +[MkDocs instructions](https://www.mkdocs.org/user-guide/configuration). + +If the host name of your source code hosting URL does not include `github` or +`gitlab`, an `integrations` entry in your `app-config.yaml` pointed at your +source code provider is also needed (only the `host` key is necessary). diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index c7876f3874..aa38dd42b1 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -78,6 +78,11 @@ techdocs: # https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property endpoint: ${AWS_ENDPOINT} + # (Optional) Whether to use path style URLs when communicating with S3. + # Defaults to false. + # This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. + s3ForcePathStyle: false + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. azureBlobStorage: diff --git a/docs/features/techdocs/configuring-ci-cd.md b/docs/features/techdocs/configuring-ci-cd.md index a9844ef528..0dec7ec24e 100644 --- a/docs/features/techdocs/configuring-ci-cd.md +++ b/docs/features/techdocs/configuring-ci-cd.md @@ -148,6 +148,23 @@ jobs: - uses: actions/setup-node@v2 - uses: actions/setup-python@v2 + # the 2 steps below can be removed if you aren't using plantuml in your documentation + - name: setup java + uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '11' + - name: download, validate, install plantuml and its dependencies + run: | + curl -o plantuml.jar -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2021.4.jar/download + echo "be498123d20eaea95a94b174d770ef94adfdca18 plantuml.jar" | sha1sum -c - + mv plantuml.jar /opt/plantuml.jar + mkdir -p "$HOME/.local/bin" + echo $'#!/bin/sh\n\njava -jar '/opt/plantuml.jar' ${@}' >> "$HOME/.local/bin/plantuml" + chmod +x "$HOME/.local/bin/plantuml" + echo "$HOME/.local/bin" >> $GITHUB_PATH + sudo apt-get install -y graphviz + - name: Install techdocs-cli run: sudo npm install -g @techdocs/cli diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index a96e881849..cf0bbb0de1 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -22,21 +22,15 @@ Navigate to your new Backstage application directory. And then to your `packages/app` directory, and install the `@backstage/plugin-techdocs` package. ```bash -cd my-backstage-app/ +# From your Backstage root directory cd packages/app yarn add @backstage/plugin-techdocs ``` Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -```typescript -export { plugin as TechDocs } from '@backstage/plugin-techdocs'; -``` - -Now we can add a route for the TechDocs page. In `packages/app/src/App.tsx`, -import TechDocsPage and add the following to `FlatRoutes`: +In `packages/app/src/App.tsx`, import `TechDocsPage` and add the following to +`FlatRoutes`: ```tsx import { TechDocsPage } from '@backstage/plugin-techdocs'; @@ -60,7 +54,7 @@ Navigate to `packages/backend` of your Backstage app, and install the `@backstage/plugin-techdocs-backend` package. ```bash -cd my-backstage-app/ +# From your Backstage root directory cd packages/backend yarn add @backstage/plugin-techdocs-backend ``` @@ -69,6 +63,7 @@ Create a file called `techdocs.ts` inside `packages/backend/src/plugins/` and add the following ```typescript +import { DockerContainerRunner } from '@backstage/backend-common'; import { createRouter, Generators, @@ -90,9 +85,14 @@ export default async function createPlugin({ reader, }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + // Generators are used for generating documentation sites. const generators = await Generators.fromConfig(config, { logger, + containerRunner, }); // Publisher is used for @@ -103,14 +103,13 @@ export default async function createPlugin({ discovery, }); - // Docker client (conditionally) used by the generators, based on techdocs.generators config. - const dockerClient = new Docker(); + // checks if the publisher is working and logs the result + await publisher.getReadiness(); return await createRouter({ preparers, generators, publisher, - dockerClient, logger, config, discovery, @@ -214,10 +213,19 @@ environment is compatible with techdocs. You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g. -apt). See our +apt). + +You can do so by including the following lines in the last step of your +`Dockerfile`: + +```Dockerfile +RUN apt-get update && apt-get install -y python3 python3-pip +RUN pip3 install mkdocs-techdocs-core==0.0.16 +``` + +Please be aware that the version requirement could change, you need to check our [`Dockerfile`](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) -for the latest requirements. You should be trying to match your `Dockerfile` -with this one. +and make sure to match with it. Note: We recommend Python version 3.7 or higher. diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 2cb769b0e3..6c64e83a76 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -82,3 +82,84 @@ Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 minutes which means they will not be re-built if new changes are made within 30 minutes. This cache invalidation will be replaced by commit timestamp based implementation very soon. + +## How to use a custom TechDocs home page? + +### 1st way: TechDocsCustomHome with a custom configuration + +As an example, in your main App.tsx: + +```tsx +import { + TechDocsCustomHome, + PanelType, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { Entity } from '@backstage/catalog-model'; + +const tabsConfig = [ + { + label: 'Custom Tab', + panels: [ + { + title: 'Custom Documents Cards 1', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsCardGrid' as PanelType, + // optional, is applied to a container of the panel (excludes header of panel) + panelCSS: { maxHeight: '400px', overflow:'auto' }, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; + }, + { + title: 'Custom Documents Cards 2', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsCardGrid' as PanelType, + panelCSS: { maxHeight: '400px', overflow:'auto' }, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; + }, + ], + }, + { + label: 'Overview', + panels: [ + { + title: 'Overview', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsTable' as PanelType, + filterPredicate: () => true, + }, + ], + }, +]; + +const routes = ( + + } + /> + } + /> + +``` + +An example of tabsConfig that corresponds to the default documentation home page +can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. + +Currently `panelType` has DocsCardGrid and DocsTable available. We currently +recommend that DocsCardGrid can be optionally vertically stacked by setting a +maxHeight using `panelCSS`, and DocsTable to be in a tab by itself. + +### 2nd way: Custom home page plugin + +A custom home page plugin can be built that uses the components extensions +DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a +array of documentation entities ( i.e.have a 'backstage.io/techdocs-ref' +annotation ) as an 'entities' attribute. + +For a reference to the React structure of the default home page, please refer to +`plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index d5a3a68425..62ee4867c8 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -119,6 +119,7 @@ techdocs: Create a dedicated AWS S3 bucket for the storage of TechDocs sites. [Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). +[Terraform example](https://github.com/backstage/backstage/blob/master/contrib/terraform/techdocs-s3-storage/terraform.tf). TechDocs will publish documentation to this bucket and will fetch files from here to serve documentation in Backstage. Note that the bucket names are @@ -135,13 +136,29 @@ techdocs: bucketName: 'name-of-techdocs-storage-bucket' ``` -**3a. (Recommended) Setup authentication the AWS way, using environment +**3. Create minimal AWS IAM policies to manage TechDocs** + +To _write_ TechDocs into the S3 bucket the IAM policy needs to have at a minimum +permissions to: + +- `s3:ListBucket` to retrieve bucket metadata +- `s3:PutObject` to upload files to the bucket + +To _read_ TechDocs from the S3 bucket the IAM policy needs to have at a minimum +permissions to: + +- `s3:ListBucket` - To retrieve bucket metadata +- `s3:GetObject` - To retrieve files from the bucket + +**4a. (Recommended) Setup authentication the AWS way, using environment variables** You should follow the [AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). -TechDocs needs access to read files and metadata of the S3 bucket. +TechDocs needs access to read files and metadata of the S3 bucket. So if you are +creating a policy for a user you want to make sure it is granted access to +ListBucket, GetObject and PutObject. If the environment variables @@ -166,7 +183,7 @@ more in The AWS Region of the bucket is optional since TechDocs uses AWS SDK V2 and not V3. -**3b. Authentication using app-config.yaml** +**4b. Authentication using app-config.yaml** AWS credentials and region can be provided to the AWS SDK via `app-config.yaml`. If the configs below are present, they will be used over existing `AWS_*` @@ -187,7 +204,7 @@ techdocs: Refer to the [official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html). -**3c. Authentication using an assumed role** Users with multiple AWS accounts +**4c. Authentication using an assumed role** Users with multiple AWS accounts may want to use a role for S3 storage that is in a different AWS account. Using the `roleArn` parameter as seen below, you can instruct the TechDocs publisher to assume a role before accessing S3. @@ -207,7 +224,7 @@ Note: Assuming a role requires that primary credentials are already configured at `AWS.config.credentials`. Read more about [assuming roles in AWS](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). -**4. That's it!** +**5. That's it!** Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read the static generated documentation files. When you start the backend of the app, diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 181f541929..4dbc6bfa48 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -135,3 +135,31 @@ const themeOptions = createThemeOptions({ }, }); ``` + +## Custom Logo + +In addition to a custom theme, you can also customize the logo displayed at the +far top left of the site. + +In your frontend app, locate `src/components/Root/` folder. You'll find two +components: + +- `LogoFull.tsx` - A larger logo used when the Sidebar navigation is opened. +- `LogoIcon.tsx` - A smaller logo used when the sidebar navigation is closed. + +To replace the images, you can simply replace the relevant code in those +components with raw SVG definitions. + +You can also use another web image format such as PNG by importing it. To do +this, place your new image into a new subdirectory such as +`src/components/Root/logo/my-company-logo.png`, and then add this code: + +```jsx +import MyCustomLogoFull from './logo/my-company-logo.png'; + +//... + +const LogoFull = () => { + return ; +}; +``` diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index cdef932127..746b4bd940 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -27,30 +27,30 @@ package.json. Backstage Apps are set up as monorepos with [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). Since CircleCI is a frontend UI plugin, it goes in `app` rather than `backend`. -2. Add the plugin itself to the App: +2. Add the `EntityCircleCIContent` extension to the entity pages in the app: -```js -// packages/app/src/plugins.ts -export { plugin as Circleci } from '@backstage/plugin-circleci'; +```diff + // packages/app/src/components/catalog/EntityPage.tsx ++import { ++ EntityCircleCIContent, ++ isCircleCIAvailable, ++} from '@backstage/plugin-circleci'; + +... + const cicdContent = ( + + ... ++ ++ ++ ; + + ); ``` -3. Register the plugin router: - -```jsx -// packages/app/src/components/catalog/EntityPage.tsx - -import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; - -// Then somewhere inside -} -/>; -``` - -Note that stand-alone plugins that are not "attached" to the Software Catalog -would be added outside the `EntityPage`. +This is just one example, but each Backstage instance may integrate content or +cards to suit their needs on different pages, tabs, etc. Note that stand-alone +plugins that are not "attached" to the Software Catalog would be added outside +the `EntityPage`. 4. [Optional] Add proxy config: diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index fefb20b095..2e568664d9 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -23,7 +23,8 @@ Backstage provides a utility for creating new apps. It guides you through the initial setup of selecting the name of the app and a database for the backend. The database options are either SQLite or PostgreSQL, where the latter requires you to set up a separate database instance. If in doubt, choose SQLite, but -don't worry about the choice, it's easy to change later! +don't worry about the choice, it's easy to change later! Here is a +[tutorial](../tutorials/switching-sqlite-postgres.md) for it. The easiest way to run the create app package is with `npx`: diff --git a/docs/integrations/bitbucket/discovery.md b/docs/integrations/bitbucket/discovery.md index b24734abde..fadb7c9f06 100644 --- a/docs/integrations/bitbucket/discovery.md +++ b/docs/integrations/bitbucket/discovery.md @@ -39,3 +39,29 @@ The target is composed of four parts: - The path within each repository to find the catalog YAML file. This will usually be `/catalog-info.yaml` or a similar variation for catalog files stored in the root directory of each repository. + +## Custom repository processing + +The Bitbucket Discovery Processor will by default emit a location for each +matching repository for further processing by other processors. However, it is +possible to override this functionality and take full control of how each +matching repository is processed. + +`BitbucketDiscoveryProcessor.fromConfig` takes an optional parameter +`options.parser` where you can set your own parser to be used for each matched +repository. + +```typescript +const customRepositoryParser: BitbucketRepositoryParser = async function* customRepositoryParser({ + client, + repository, +}) { + // Custom logic for interpret the matching repository. + // See defaultRepositoryParser for an example +}; + +const processor = BitbucketDiscoveryProcessor.fromConfig(env.config, { + parser: customRepositoryParser, + logger: env.logger, +}); +``` diff --git a/docs/integrations/datadog-rum/installation.md b/docs/integrations/datadog-rum/installation.md index 50ebe722be..f96298a33b 100644 --- a/docs/integrations/datadog-rum/installation.md +++ b/docs/integrations/datadog-rum/installation.md @@ -3,28 +3,32 @@ id: installation title: Datadog RUM Installation sidebar_label: Installation # prettier-ignore -description: Adding Datadog RUM to Your App +description: Adding Datadog Real User Monitoring (RUM) to Your App --- -There is a basic [Datadog](https://docs.datadoghq.com/real_user_monitoring/) +Datadog Real User Monitoring (RUM) allows you to visualize and analyze the +real-time performance and user journeys of your application's individual users. +This is an option to profile and monitor the user experience of your Backstage +installation. + +There is a basic [Datadog RUM](https://docs.datadoghq.com/real_user_monitoring/) integration built into Backstage. You can enable it by adding the following to -your app configuration: +your `app-config.yaml`: ```yaml app: datadogRum: clientToken: '123456789' applicationId: qwerty - # site: # datadoghq.eu default = datadoghq.com - # env: # optional + # site: datadoghq.eu + # env: 'staging' ``` -Replace the clientToken and applicationId with the ones generated for you -Datadog. +The `clientToken` and `applicationId` are generated from the Datadog RUM page +following +[these instructions](https://docs.datadoghq.com/real_user_monitoring/browser/). -optional arguments: +There are two optional arguments: -``` -site: datadoghq.eu # default equals datadoghq.com -env: dev # allow to specify the environment -``` +- `site`: The Datadog site of your organization; defaults to `datadoghq.com` +- `env`: The application environment for Datadog events (no default) diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md index 4a1bf88932..7140ffa505 100644 --- a/docs/integrations/github/org.md +++ b/docs/integrations/github/org.md @@ -22,8 +22,8 @@ entities that mirror your org setup. The processor that performs the import, `GithubOrgReaderProcessor`, comes installed with the default setup of Backstage. -If you replace the set of processors in your installation using that facility of -the catalog builder class, you can import and add it as follows. +If you have _replaced_ the set of processors in your installation using +`replaceProcessors`, you can import and add it as follows: ```ts // Typically in packages/backend/src/plugins/catalog.ts @@ -45,30 +45,23 @@ catalog: locations: - type: github-org target: https://github.com/my-org-name - processors: - githubOrg: - providers: - - target: https://github.com - apiBaseUrl: https://api.github.com - token: ${GITHUB_TOKEN} ``` +If Backstage is configured to use GitHub Apps authentication you must grant +`Read-Only` access for `Members` under `Organization` in order to ingest users +correctly. You can modify the app's permissions under the organization settings, +`https://github.com/organizations/{ORG}/settings/apps/{APP_NAME}/permissions`. +![permissions](../../assets/integrations/github/permissions.png) + +**Please note that when you change permissions, the app owner will get an email +that must be approved first before the changes are applied.** +![email](../../assets/integrations/github/email.png) + Locations point out the specific org(s) you want to import. The `type` of these locations must be `github-org`, and the `target` must point to the exact URL of -some organization. You can have several such location entries if you want, but -typically you will have just one. +some organization. You can have several such location entries if needed. -The processor itself is configured in the other block, under -`catalog.processors.githubOrg`. There may be many providers, each targeting a -specific `target` which is supposed to be the address of the home page of GitHub -or your GitHub Enterprise installation. - -The example above assumes that the backend is started with an environment -variable called `GITHUB_TOKEN` that contains a Personal Access Token. The token -needs to have at least the scopes `read:org`, `read:user`, and `user:email` in -the given `target`. - -If you want to address your own GitHub Enterprise instance, replace occurrences -of `https://github.com` in the configuration above with the address of your -GitHub Enterprise home page, and the `apiBaseUrl` to where your API endpoint -lives - commonly on the form `https:///api/v3`. +The authorization for loading org information comes from a configured +[GitHub integration](locations.md#configuration). When using a personal access +token, the token needs to have at least the scopes `read:org`, `read:user`, and +`user:email` in the given `target`. diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index f2f2bc72c8..00d83b43ed 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -43,13 +43,13 @@ this. The UI is a thin, client-side wrapper around a set of plugins. It provides some core UI components and libraries for shared activities such as config -management. [[live demo](https://backstage-demo.roadie.io/)] +management. [[live demo](https://demo.backstage.io/catalog)] ![UI with different components highlighted](../assets/architecture-overview/core-vs-plugin-components-highlighted.png) Each plugin typically makes itself available in the UI on a dedicated URL. For example, the Lighthouse plugin is registered with the UI on `/lighthouse`. -[[live demo](https://backstage-demo.roadie.io/lighthouse)] +[[learn more](https://backstage.io/blog/2020/04/06/lighthouse-plugin)] ![The lighthouse plugin UI](../assets/architecture-overview/lighthouse-plugin.png) @@ -67,33 +67,41 @@ is available at ### Installing plugins -Plugins are typically loaded by the UI in your Backstage applications -`plugins.ts` file. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) -is that file in the Backstage sample app. +Plugins are typically installed as React components in your Backstage +application. For example, +[here](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) +is a file that imports many full-page plugins in the Backstage sample app. -Plugins can be enabled, and passed configuration in `apis.ts`. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) -is that file in the Backstage sample app. - -This is how the Lighthouse plugin would be enabled in a typical Backstage -application: +An example of one of these plugin components is the `CatalogIndexPage`, which is +a full-page view that allows you to browse entities in the Backstage catalog. It +is installed in the app by importing it and adding it as an element like this: ```tsx -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; +import { CatalogIndexPage } from '@backstage/plugin-catalog'; -const builder = ApiRegistry.builder(); +... -export const lighthouseApi = new LighthouseRestApi(/* URL of the lighthouse microservice! */); -builder.add(lighthouseApiRef, lighthouseApi); - -export default builder.build() as ApiHolder; +const routes = ( + + ... + } /> + ... + +); ``` +Note that we use `"/catalog"` as our path to this plugin page, but we can choose +any route we want for the page, as long as it doesn't collide with the routes +that we choose for the other plugins in the app. + +These components that are exported from plugins are referred to as "Plugin +Extension Components", or "Extension Components". They are regular React +components, but in addition to being able to be rendered by React, they also +contain various pieces of metadata that is used to wire together the entire app. +Extension components are created using `create*Extension` methods, which you can +read more about in the +[composability documentation](../plugins/composability.md). + As of this moment, there is no config based install procedure for plugins. Some code changes are required. @@ -108,9 +116,9 @@ Architecturally, plugins can take three forms: #### Standalone plugins Standalone plugins run entirely in the browser. -[The Tech Radar plugin](https://backstage-demo.roadie.io/tech-radar), for -example, simply renders hard-coded information. It doesn't make any API requests -to other services. +[The Tech Radar plugin](https://demo.backstage.io/tech-radar), for example, +simply renders hard-coded information. It doesn't make any API requests to other +services. ![tech radar plugin ui](../assets/architecture-overview/tech-radar-plugin.png) @@ -174,6 +182,21 @@ work but [aren't tested as fully](https://github.com/backstage/backstage/issues/2460) yet. +## Cache + +The Backstage backend and its builtin plugins are also able to leverage cache +stores as a means of improving performance or reliability. Similar to how +databases are supported, plugins receive logically separated cache connections, +which are powered by [Keyv](https://github.com/lukechilds/keyv) under the hood. + +At this time of writing, Backstage can be configured to use one of two cache +stores: memory, which is mainly used for local testing, and memcache, which is a +cache store better suited for production deployment. The right cache store for +your Backstage instance will depend on your own run-time constraints and those +required of the plugins you're running. + +Contributions supporting other cache stores are welcome! + ## Containerization The example Backstage architecture shown above would Dockerize into three diff --git a/docs/overview/logos.md b/docs/overview/logos.md index 4c0c345f36..9421b7f31e 100644 --- a/docs/overview/logos.md +++ b/docs/overview/logos.md @@ -6,37 +6,37 @@ description: Guidelines for how to use the Backstage logos and icons --- Guidelines for how to use the Backstage logo and icon can be found -[here](/logo_assets/Backstage_Identity_Assets_Overview.pdf). The assets below -are all in `.svg` format. Other formats are available in the +[here](https://backstage.io/logo_assets/Backstage_Identity_Assets_Overview.pdf). +The assets below are all in `.svg` format. Other formats are available in the [repository](https://github.com/backstage/backstage/tree/master/microsite/static/logo_assets). ## Backstage logo - - + + - - + + - - + + ## Backstage icon diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 4172e4d1b7..29b9be14b6 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -108,50 +108,40 @@ Stability: `1`. Mainly intended for internal use. ### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/) -#### Section: React Components +The `@backstage/core` and `@backstage/core-api` packages are being phased out +and replaced by other `@backstage/core-*` packages. They are still in use but +will not receive any breaking changes. -All of the React components exported from `src/components/` and `src/layout/` +### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) + +Stability: See `@backstage/core` above + +### `core-app-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-app-api/) + +The APIs used exclusively in the app, such as `createApp` and the system icons. + +Stability: `2`. + +### `core-components` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-components/) + +A collection of React components for use in Backstage plugins and apps. +Previously exported by `@backstage/core`. Stability: `1`. These components have not received a proper review of the API, but we also want to ensure stability. -#### Section: Plugin API +### `core-plugin-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-plugin-api/) -The parts of the core API that are used by plugins, and the way plugins expose -functionality to apps and other plugins. Includes for example `createPlugin`, -`createRouteRef`, `createApiRef`. +The core API used to build Backstage plugins and apps. -Stability: `2`. There are planned breaking changes around the way that plugins -expose features and do routing. We still commit to keeping a short deprecation -period so that plugins outside of the main repo have time to migrate. +Stability: `2`. -#### Section: App API +### `cost-insights` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) -The APIs used exclusively in the app, such as `createApp` and the system icons. +A frontend plugin that allows users to visualize, understand and optimize your +team's cloud costs. -Stability: `2` - -#### Section: Utility API Definitions - -The type declarations of the core Utility APIs. - -Stability: `2`. Changes to the Utility API type declarations need time to -propagate. - -#### Section: Utility API Implementations - -The interfaces and default implementations for various Utility APIs, such as -ErrorApi, IdentityApi, the auth APIs, etc. - -Stability: `1`. Most changes to the core utility APIs will not lead to -widespread breaking changes since most apps rely on the default implementations. - -### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) - -The non-visual parts of @backstage/core. Everything in this packages is -re-exported from @backstage/core, and this package should not be used directly. - -Stability: See @backstage/core +Stability: `1` ### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) @@ -359,7 +349,7 @@ https://github.com/backstage/backstage/issues/2771. ### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) -Visualize the your company's official guidelines of different areas of software +Visualize your company's official guidelines of different areas of software development. Stability: `0` diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 516ba4b111..7a57cefa9c 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -1,7 +1,152 @@ --- id: backend-plugin -title: Backend plugin -description: Documentation on Backend plugin +title: Backend plugins +description: Creating and Developing Backend plugins --- -## TODO +This page describes the process of creating and managing backend plugins in your +Backstage repository. + +## Creating a Backend Plugin + +A new, bare-bones backend plugin package can be created by issuing the following +command in your Backstage repository root: + +```sh +yarn create-plugin --backend +``` + +Please also see the `--help` flag for the `create-plugin` command for some +further options that are available, notably the `--scope` and `--no-private` +flags that control naming and publishing of the newly created package. Your repo +root `package.json` will probably also have some default values already set up +for these. + +You will be asked to supply a name for the plugin. This is an identifier that +will be part of the NPM package name, so make it short and containing only +lowercase characters separated by dashes, for example `carmen`, if it's a +package that adds an integration with a system named Carmen, for example. The +full NPM package name would then be something like +`@internal/plugin-carmen-backend`, depending on the other flags passed to the +`create-plugin` command, and your settings for the `create-plugin` command in +your root `package.json`. + +Creating the plugin will take a little while, so be patient. It will helpfully +run the initial installation and build commands, so that your package is ready +to be hacked on! It will be located in a new folder in your `plugins` directory, +in this example `plugins/carmen-backend`. + +For simple development purposes, a backend plugin can actually be started in a +standalone mode. You can do a first-light test of your service: + +```sh +cd plugins/carmen-backend +yarn start +``` + +This will think for a bit, and then say `Listening on :7000`. In a different +terminal window, now run + +```sh +curl localhost:7000/carmen/health +``` + +This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it +again. + +## Developing your Backend Plugin + +A freshly created backend plugin does basically nothing, in terms of the overall +app. It has a small set of basic dependencies and exposes an Express router in +`src/service/router.ts`. This is where you will start adding routes and +connecting those to actual underlying functionality. But nothing in your +Backstage application / backend exposes it. + +To actually attach and run the plugin router, you will make some modifications +to your backend. + +```sh +# From the Backstage root directory +cd packages/backend +yarn add @internal/plugin-carmen-backend@^0.1.1 # Change this to match the plugin's package.json +``` + +Create a new file named `packages/backend/src/plugins/carmen.ts`, and add the +following to it + +```ts +import { createRouter } from '@internal/plugin-carmen-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment) { + // Here is where you will add all of the required initialization code that + // your backend plugin needs to be able to start! + + // The env contains a lot of goodies, but our router currently only + // needs a logger + return await createRouter({ + logger: env.logger, + }); +} +``` + +And finally, wire this into the overall backend router. Edit +`packages/backend/src/index.ts`: + +```ts +import carmen from './plugins/carmen'; +// ... +async function main() { + // ... + const carmenEnv = useHotMemoize(module, () => createEnv('carmen')); + apiRouter.use('/carmen', await carmen(badgesEnv)); +``` + +After you start the backend (e.g. using `yarn start-backend` from the repo +root), you should be able to fetch data from it. + +```sh +# Note the extra /api here +curl localhost:7000/api/carmen/health +``` + +This should return `{"status":"ok"}` like before. Success! + +## Making Use of a Database + +The Backstage backend comes with a builtin facility for SQL database access. +Most plugins that have persistence needs will choose to make use of this +facility, so that Backstage operators can manage database needs uniformly. + +As part of the environment object that is passed to your `createPlugin` +function, there is a `database` field. You can use that to get a +[Knex](http://knexjs.org/) connection object. + +```ts +// in packages/backend/src/plugins/carmen.ts +export default async function createPlugin(env: PluginEnvironment) { + const db: Knex = await env.database.getClient(); + + // You will then pass this client into your actual plugin implementation + // code, maybe similar to the following: + const model = new CarmenDatabaseModel(db); + return await createRouter({ + model: model, + logger: env.logger, + }); +} +``` + +You may note that the `getClient` call has no parameters. This is because all +plugin database needs are configured under the `backend.database` config key of +your `app-config.yaml`. The framework may even make sure behind the scenes that +the logical database is created automatically if it doesn't exist, based on +rules that the Backstage operator decides on. + +The framework does not handle database schema migrations for you, however. The +builtin plugins in the main repo have chosen to use the Knex library to manage +schema migrations as well, but you can do so in any manner that you see fit. + +See the [Knex library documentation](http://knexjs.org/) for examples and +details on how to write schema migrations and perform SQL queries against your +database.. diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md index 01de779635..047b6f14e6 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -1,7 +1,7 @@ --- id: integrating-plugin-into-service-catalog title: Integrate into the Service Catalog -description: Documentation on How to integrate plugin into service catalog +description: How to integrate a plugin into service catalog --- > This is an advanced use case and currently is an experimental feature. Expect diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 50c56af7a6..3e20e37cad 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -36,7 +36,7 @@ Example: ```yaml # in app-config.yaml proxy: - '/simple-example': http://simple.example.com:8080 + simple-example: http://simple.example.com:8080 '/larger-example/v1': target: http://larger.example.com:8080/svc.v1 headers: @@ -46,10 +46,11 @@ proxy: ``` Each key under the proxy configuration entry is a route to match, below the -prefix that the proxy plugin is mounted on. It must start with a slash. For -example, if the backend mounts the proxy plugin as `/proxy`, the above -configuration will lead to the proxy acting on backend requests to -`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`. +prefix that the proxy plugin is mounted on. If it does not start with a slash, +one will be prefixed automatically. For example, if the backend mounts the proxy +plugin as `/proxy`, the above configuration will lead to the proxy acting on +backend requests to `/api/proxy/simple-example/...` and +`/api/proxy/larger-example/v1/...`. The value inside each route is either a simple URL string, or an object on the format accepted by diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 2aacd02cf2..f81e3794e5 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -100,8 +100,7 @@ There are three things needed for a Backstage app to start making use of a plugin. 1. Add plugin as dependency in `app/package.json` -2. `import` plugin in `app/src/plugins.ts` -3. Import and use one or more plugin extensions, for example in +2. Import and use one or more plugin extensions, for example in `app/src/App.tsx`. Luckily these three steps happen automatically when you create a plugin with the diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md deleted file mode 100644 index 510b697221..0000000000 --- a/docs/tutorials/quickstart-app-auth.md +++ /dev/null @@ -1,371 +0,0 @@ ---- -id: quickstart-app-auth -title: Monorepo App Setup With Authentication ---- - -###### January 8th 2021 - @backstage/create-app - v0.4.5 - -
- -> This document takes you through setting up a Backstage app that runs in your -> own environment. It starts with a skeleton install and verifying of the -> monorepo's functionality. Next, authentication is added and tested. -> -> This document assumes you have Node.js 12 or 14 active along with Yarn and -> Python. Please note, that at the time of this writing, the current version is -> v0.4.5. This guide can still be used with future versions, just, verify as you -> go. - -# The Skeleton Application - -From the terminal: - -1. Create a (monorepo) application: `npx @backstage/create-app` -1. Enter an `id` for your new app like `mybiz-backstage` I went with - `simple-backstage-app` -1. Choose `SQLite` as your database. This is the quickest way to get started as - PostgreSQL requires additional setup not covered here. -1. Start your backend: `yarn --cwd packages/backend start` - -```zsh -# You should see positive verbiage in your terminal output -2020-09-11T22:20:26.712Z backstage info Listening on :7000 -``` - -5. Finally, start the frontend. Open a new terminal window and from the root of - your project, run: `yarn start` - -```zsh -# You should see positive verbiage in your terminal output -ℹ 「wds」: Project is running at http://localhost:3000/ -``` - -Once the app compiles, a browser window should have popped with your stand-alone -application loaded at `localhost:3000`. This could take a couple minutes. - -```zsh -# You should see positive verbiage in your terminal output -ℹℹ 「wdm」: Compiled successfully. -``` - -Since there is no auth currently configured, you are automatically entered as a -guest. Let's fix that now and add auth. - -# The Auth Configuration - -A default Backstage installation includes multiple authentication providers out -of the box. The steps to enable new authentication providers in Backstage are -very similar to each other, the biggest difference is usually configuring the -external authentication provider. Please see a subset of possible providers and -instructions to integrate them below. Steps 1 & 2 are described separately for -each provider and steps beyond that are common for all. - -
GitHub -

- -### 1. Open `app-config.yaml` and change it as follows - -_from:_ - -```yaml -auth: - providers: {} -``` - -_to:_ - -```yaml -auth: - providers: - github: - development: - clientId: ${AUTH_GITHUB_CLIENT_ID} - clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} - ## uncomment the following line if using enterprise - # enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} -``` - -### 2. Generate a GitHub client ID and secret - -- Log into http://github.com -- Navigate to (Settings > Developer Settings > OAuth Apps > New OAuth - App)[https://github.com/settings/applications/new] -- Set Homepage URL = `http://localhost:3000` -- Set Callback URL = `http://localhost:7000/api/auth/github` -- Click [Register application] -- On the next page, copy and paste your new Client ID and Client Secret to - environment variables defined in the `app-config.yaml` file, - `AUTH_GITHUB_CLIENT_ID` & `AUTH_GITHUB_CLIENT_SECRET` - -

-
- -
GitLab -

- -### 1. Open `app-config.yaml` and change it as follows - -_from:_ - -```yaml -auth: - providers: {} -``` - -_to:_ - -```yaml -auth: - providers: - gitlab: - development: - clientId: ${AUTH_GITLAB_CLIENT_ID} - clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} - audience: https://gitlab.com # Or your self-hosted GitLab instance URL -``` - -### 2. Generate a GitLab Application client ID and secret - -- Log into GitLab -- Navigate to (Profile > Settings > - Applications)[https://gitlab.com/-/profile/applications] -- Name your application -- Set Callback URL = `http://localhost:7000/api/auth/gitlab/handler/frame` -- Select the following values: - - `read_user` (Read the authenticated user's personal information) - - `read_repository` (Allows read-only access to the repository) - - `write_repository` (Allows read-write access to the repository) - - `openid` (Authenticate using OpenID Connect) - - `profile` (Allows read-only access to the user's personal information using - OpenID Connect) - - `email` (Allows read-only access to the user's primary email address using - OpenID Connect) -- Click [Save application] -- On the next page, copy and paste your new Application ID and Secret to - environment variables defined in the `app-config.yaml` file, - `AUTH_GITLAB_CLIENT_ID` & `AUTH_GITLAB_CLIENT_SECRET` - -

-
- -
Google -

- -### 1. Open `app-config.yaml` and change it as follows - -_from:_ - -```yaml -auth: - providers: {} -``` - -_to:_ - -```yaml -auth: - providers: - google: - development: - clientId: ${AUTH_GOOGLE_CLIENT_ID} - clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} -``` - -### 2. Generate Google Credentials in Google Cloud console - -- Log into https://console.cloud.google.com -- Select or create a new project from the dropdown on the top bar -- Navigate to (APIs & Services > - Credentials)[https://console.cloud.google.com/apis/credentials] -- Click Create Credentials and select [OAuth client ID] -- Select Web Application as the application type -- Add new Authorised JavaScript origin = `http://localhost:3000` -- Add new Authorised redirect URI = - `http://localhost:7000/api/auth/google/handler/frame` -- Click [Save application] -- Google should display a modal with your Client ID and Secret. Copy and paste - those to environment variables defined in the `app-config.yaml` file, - `AUTH_GOOGLE_CLIENT_ID` & `AUTH_GOOGLE_CLIENT_SECRET` - -

-
- -
Microsoft -

- -### 1. Open `app-config.yaml` and change it as follows - -_from:_ - -```yaml -auth: - providers: {} -``` - -_to:_ - -```yaml -auth: - providers: - microsoft: - development: - clientId: ${AUTH_MICROSOFT_CLIENT_ID} - clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET} - tenantId: ${AUTH_MICROSOFT_TENANT_ID} -``` - -### 2. Create a Microsoft App Registration in Microsoft Portal - -- Log into https://portal.azure.com -- Navigate to (Azure Active Directory > App - Registrations)[https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps] -- Create a New Registration -- Add new Redirect URI = `http://localhost:3000` -- Add new Authorised redirect URI = - `http://localhost:7000/api/auth/microsoft/handler/frame` -- Click [Save application] -- Set environment variable `AUTH_MICROSOFT_CLIENT_ID` from - `Application (client) Id` displayed on the directory page -- Set environment variable `AUTH_MICROSOFT_TENANT_ID` from - `Directory (tenant) ID` displayed on the directory page -- Navigate to Certificates & Secrets section and click [Create a new secret] -- Set environment variable `AUTH_MICROSOFT_CLIENT_SECRET` from the `value` field - created. - -

-
- -
Auth0 -

- -### 1. Open `app-config.yaml` and change it as follows - -_from:_ - -```yaml -auth: - providers: {} -``` - -_to:_ - -```yaml -auth: - providers: - auth0: - development: - clientId: ${AUTH_AUTH0_CLIENT_ID} - clientSecret: ${AUTH_AUTH0_CLIENT_SECRET} - domain: ${AUTH_AUTH0_DOMAIN_ID} -``` - -### 2. Create an Auth0 application in the Auth0 management console - -- Log into https://manage.auth0.com/dashboard/ -- Navigate to Applications -- Create a New Application - - Select Single Page Web Application -- Go to Settings tab -- Add new line to Allowed Callback URLs = - `http://localhost:7000/api/auth/auth0/handler/frame` -- Click [Save Changes] -- Set environment variables displayed on the Basic Information page - - `AUTH_AUTH0_CLIENT_ID` from `Client ID` displayed on Auth0 application page - - `AUTH_AUTH0_CLIENT_SECRET` from `Client Secret` displayed on Auth0 - application page - - `AUTH_AUTH0_DOMAIN_ID` from `Domain` displayed on Auth0 application page - -

-
- -### 3. Set environment variables in whatever fashion is easiest for you. I chose to - -add mine to my `.zshrc` profile. - -```zsh -# For macOS Catalina & Z Shell -# ------ simple-backstage-app GitHub -# -# (Change the name of the environment variables based on your auth setup above) -export AUTH_GITHUB_CLIENT_ID=xxx -export AUTH_GITHUB_CLIENT_SECRET=xxx -# export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://github.{MY_BIZ}.com -``` - -### 4. And of course I need to source that file. - -```zsh -# Loading the new variables -% source ~/.zshrc - -# Any other currently opened terminals need to be restarted to pick up the new values -# verify your setup by running env -% env -# should output something like -> ... -> AUTH_GITHUB_CLIENT_ID=xxx -> AUTH_GITHUB_CLIENT_SECRET=xxx -> ... -``` - -### 5. Open and change _root > packages > app > src >_ `App.tsx` to use correct - -authentication provider reference - -```tsx -import { githubAuthApiRef, SignInPage } from '@backstage/core'; -``` - -Modify the imported reference based on the authentication method you selected -above: - -| Auth Provider | Import Name | -| ------------- | ------------------- | -| GitHub | githubAuthApiRef | -| GitLab | gitlabAuthApiRef | -| Google | googleAuthApiRef | -| Microsoft | microsoftAuthApiRef | -| Auth0 | auth0AuthApiRef | - -### 6. In the same file, modify createApp - -Remember to modify the provider information based on the table above. - -```tsx -const app = createApp({ - apis, - plugins: Object.values(plugins), - components: { - SignInPage: props => ( - - ), - }, -}); -``` - -After finishing setting up one (or multiple) authentication providers defined -above you can start the backend and frontend as before - -When the browser loads, you should be presented with a login page for GitHub. -Login as usual with your GitHub account. If this is your first time, you will be -asked to authorize and then are redirected to the catalog page if all is well. - -For more information you can clone -[the backstage-auth-example repository](https://github.com/RoadieHQ/backstage-auth-example). -Each authentication setting is set up there on a branch named after the -authentication provider. - -# Where to go from here - -> You're probably eager to write your first custom plugin. Follow this next -> tutorial for an in-depth look at a custom GitHub repository browser plugin. -> [Adding Custom Plugin to Existing Monorepo App](quickstart-app-plugin.md). diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index f1efcf0745..7dca90c14b 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -35,8 +35,8 @@ title: Adding Custom Plugin to Existing Monorepo App 1. When the process finishes, let's start the backend: `yarn --cwd packages/backend start` 1. If you see errors starting, refer to - [Auth Configuration](https://backstage.io/docs/tutorials/quickstart-app-auth#the-auth-configuration) - for more information on environment variables. + [Auth Configuration](https://backstage.io/docs/auth/) for more information on + environment variables. 1. And now the frontend, from a new terminal window and the root of your project: `yarn start` 1. As usual, a browser window should popup loading the App. diff --git a/docs/tutorials/switching-sqlite-postgres.md b/docs/tutorials/switching-sqlite-postgres.md index a759b7dc49..fafd2ad586 100644 --- a/docs/tutorials/switching-sqlite-postgres.md +++ b/docs/tutorials/switching-sqlite-postgres.md @@ -17,11 +17,11 @@ switch between database backends. ## Install PostgreSQL -First, swap out SQLite for PostgreSQL in your `backend` package: +First, add PostgreSQL to your `backend` package: ```shell +# From your Backstage root directory cd packages/backend -yarn remove sqlite3 yarn add pg ``` @@ -46,7 +46,6 @@ backend: + #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + #ca: # if you have a CA file and want to verify it you can uncomment this section + #$file: /ca/server.crt - ``` If you have an `app-config.local.yaml` for local development, a similar update diff --git a/microsite/.npmrc b/microsite/.npmrc new file mode 100644 index 0000000000..c3c66347fd --- /dev/null +++ b/microsite/.npmrc @@ -0,0 +1,2 @@ +registry=https://registry.npmjs.org/ +engine-strict=true diff --git a/microsite/README.md b/microsite/README.md index 9589def3e3..285622f1f8 100644 --- a/microsite/README.md +++ b/microsite/README.md @@ -1,3 +1,9 @@ +# Backstage Documentation + +This folder holds the service and configuration that runs Backstage's documentation hosted at https://backstage.io. + +It pulls content in from the [root `/docs`](../docs/) folder and builds it into the resulting HTML web site. + This website was created with [Docusaurus](https://docusaurus.io/). # What's In This Document @@ -10,6 +16,8 @@ This website was created with [Docusaurus](https://docusaurus.io/). # Getting Started +Testing the web site locally is a great way to see what final website will look like after publishing, and is ideal for testing more complex changes, large updates to navigation, or complex page designs that may include special alignment, which may not otherwise be validated through continuous integration. + ## Installation ``` @@ -22,7 +30,7 @@ $ yarn install $ yarn start ``` -This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. +This command starts a local development server and opens up a browser window. Most content changes made to the `docs/` root folder are reflected live without having to restart the server. ## Build @@ -57,9 +65,9 @@ my-docusaurus/ siteConfig.js ``` -# Editing Content +## Editing Content -## Editing an existing docs page +### Editing an existing docs page Edit docs by navigating to `docs/` and editing the corresponding document: @@ -76,7 +84,7 @@ Edit me... For more information about docs, click [here](https://docusaurus.io/docs/en/navigation) -## Editing an existing blog post +### Editing an existing blog post Edit blog posts by navigating to `website/blog` and editing the corresponding post: @@ -93,9 +101,9 @@ Edit me... For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) -# Adding Content +## Adding Content -## Adding a new docs page to an existing sidebar +### Adding a new docs page to an existing sidebar 1. Create the doc as a new markdown file in `/docs`, example `docs/newly-created-doc.md`: @@ -126,7 +134,7 @@ My new content here.. For more information about adding new docs, click [here](https://docusaurus.io/docs/en/navigation) -## Adding a new blog post +### Adding a new blog post 1. Make sure there is a header link to your blog in `website/siteConfig.js`: @@ -157,7 +165,7 @@ Lorem Ipsum... For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) -## Adding items to your site's top navigation bar +### Adding items to your site's top navigation bar 1. Add links to docs, custom pages or external links by editing the headerLinks field of `website/siteConfig.js`: @@ -181,7 +189,7 @@ For more information about blog posts, click [here](https://docusaurus.io/docs/e For more information about the navigation bar, click [here](https://docusaurus.io/docs/en/navigation) -## Adding custom pages +### Adding custom pages 1. Docusaurus uses React components to build pages. The components are saved as .js files in `website/pages/en`: 1. If you want your page to show up in your navigation header, you will need to update `website/siteConfig.js` to add to the `headerLinks` element: @@ -199,11 +207,11 @@ For more information about the navigation bar, click [here](https://docusaurus.i } ``` -For more information about custom pages, click [here](https://docusaurus.io/docs/en/custom-pages). +Learn more about [Docusaurus custom pages](https://docusaurus.io/docs/en/custom-pages). -# Full Documentation +## Full Documentation -Full documentation can be found on the [website](https://docusaurus.io/). +Full documentation can be found on the [Docusaurus website](https://docusaurus.io/). ## Additional notes diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index 65102c919f..7289878bc1 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -45,7 +45,7 @@ The only thing you need to do is to start the app: ```bash cd my-app -yarn start +yarn dev ``` And you are good to go! 👍 @@ -101,18 +101,12 @@ We provide a collection of public Backstage plugins (look for packages with the Install in your app’s package folder (`/packages/app`) with: ```bash +# From your Backstage root directory +cd packages/app yarn add @backstage/plugin- ``` -Then add it to your app's `plugin.ts` file to import and register it: - -`/packages/app/src/plugin.ts`: - -```js -export { plugin as PluginName } from '@backstage/plugin-'; -``` - -A plugin registers its own `route` in the app — read the documentation for the specific plugin you are installing for more information on that. +After that, you inject the plugin into the application where you want it to be exposed. Please read the documentation for the specific plugin you are installing for more information. ### Creating an internal plugin diff --git a/microsite/blog/2021-05-20-adopting-backstage.md b/microsite/blog/2021-05-20-adopting-backstage.md new file mode 100644 index 0000000000..191422004b --- /dev/null +++ b/microsite/blog/2021-05-20-adopting-backstage.md @@ -0,0 +1,135 @@ +--- +title: Where do you start when adopting Backstage? +author: Austin Lamon, Spotify +authorURL: https://www.linkedin.com/in/austinlamon +--- + +![Create, Manage, Explore](assets/21-05-20/create-manage-explore.gif) + +One of the greatest strengths of Backstage also presents a never-ending challenge: Backstage is highly customizable and allows you to easily build a unique developer portal suited to your organization’s needs. The downside of this flexibility is that it can be hard to know where to start. Backstage can do so many things — integrating every part of your tech infrastructure and developer experience — but if you set off building a developer portal without a plan, it’s easy to get overwhelmed by all the possibilities. To help you form your plan, this post will detail how Spotify came to design our internal portal and recommend potential models for you to use when designing and building your own. + + + +## Infrastructure as tech culture + +Before providing recommendations on getting started with Backstage, it’s helpful to have a bit of context as to why Spotify made the design decisions we did. No two companies are identical — and thus, no two Backstage implementations are the same. + +Rolling back the clock just a few years, [Spotify was challenged](https://engineering.atspotify.com/2021/05/18/a-product-story-the-lessons-of-backstage-and-spotifys-autonomous-culture/) to continue to scale our engineering team (and the number of features and components built) but retain the speed of product development. Some user research with Spotify developers highlighted a clear problem: there was simply too much non-documented institutional knowledge needed to get things done. No one could find anything and everyone was interrupting everyone else trying to figure things out. + +Spotify’s developers were facing three big challenges on a daily basis: + +- They not only needed to build software quickly, they also needed to pass along knowledge to new joiners about how best to create new components. +- They needed to somehow maintain a mental model of the systems their squad owned. (Or, if they were lucky, they found a hopefully-up-to-date spreadsheet tracking this information.) +- They needed to keep an eye on what squads around them might be building to ensure they could reuse systems when they needed to solve similar problems in the future. + +In short, Spotify developers needed to continue building industry leading features at breakneck speed, while simultaneously maintaining a mental model for all the software at Spotify (oh, and help every new joiner develop that mental model as well!). +
+ +## Three jobs: Create, manage, explore + +Around the same time, the [jobs to be done](https://hbr.org/2016/09/know-your-customers-jobs-to-be-done) framework was becoming popular and luckily, a few Spotifiers helped guide the vision for a _sense-making tool for developers_ toward using it. After user research and many failed attempts, we landed on three jobs Spotify developers needed to do consistently: +
+ +- **Create**: Spotify developers want to delight their customers with incredible features. They create new software to do that. +- **Manage**: Spotify developers are proud of their work and treat their software and data like products. That requires maintaining all the software they own on an ongoing basis. +- **Explore**: Spotify developers want to solve new, yet unsolved problems. They try to build on existing systems to help them do that. + +So: make software, maintain the software you own throughout its lifecycle, and integrate with other people’s software. + +Within today’s complex development environments, there are barriers both big and small that get in the way of those three jobs. Backstage provides the building blocks for removing those barriers, streamlining your development cycle, and letting developers do what they really want to do: build great features. Let’s take a closer look at each of these jobs. + +![Backstage Software Templates rocket](assets/21-05-20/create.gif) + +### Create + +**Job:** You’re an engineer, ready to start building a new microservice. Do you just pick whatever framework you want? How do you reserve capacity to run your service in production? What about managing CI/CD? + +**Tool:** At Spotify, we use **Backstage Software Templates** to simplify all of this, reducing time–to–Hello World to just a few clicks. Instead of researching Spring Boot versus Helidon, opening a Jira ticket, rummaging through docs, and configuring CI automations, you just choose a template and your project is automatically set up in the repo for you, with the CI already running your first build. + +**Result:** By making it easier to start new projects, your engineers get to the good part of coding features faster. And your organization’s best practices are built into the templates, encouraging standards and reducing complexity in your tech ecosystem. + +![Backstage Service Catalog system](assets/21-05-20/manage.gif) + +### Manage + +**Job:** You’re on a small team that owns a dozen services. Whenever you update and deploy those services, you’re switching between your CI, the AWS console, a security dashboard, and a CLI so you can try to figure out which Kubernetes cluster your service ended up on. In other words, you have a lot of open windows and tabs, and each step means switching to a new interface. + +**Tool:** All of your team’s software components are organized together on one page in the **Backstage Service Catalog**. Go to any service’s page from there and its CI/CD status, [Kubernetes deployment status](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners), documentation, security checks — and everything else related to that service — is grouped together in one seamless interface that shows you only the info you want. + +**Result**: One page in Backstage has everything you need to manage the software you own. No more context-switching. No more digging through your cloud provider’s obscure admin features. Outside the repo and your IDE, everything you need to manage your service is right inside Backstage. + +![Backstage explorer](assets/21-05-20/explore.gif) + +### Explore + +**Job:** You're building a new mobile feature that needs to ensure a user is paying for the premium version of your product — but someone must’ve already built a library that handles that, right? A company-wide email and a few calls for help on Slack yield no response, so you resign yourself to building the capability yourself. Turns out someone did build the library you needed. They were just on vacation so they didn’t see your messages. How do you enable better discovery and collaboration across your org? + +**Tools:** At Spotify, anyone can find everyone else’s software — because everything is centralized inside Backstage, organized by the **Backstage Service Catalog** and accessible by **search**. Go to any library or services page, and you’ll find the owners and documentation, even its API and how to extend it if need be. + +**Result:** One place for everything, one place to search. Developers can more easily share components, build on top of each other’s work, and discover tools, libraries, frameworks, documentation, system designs, org charts, and more. + +## Sounds great. Where do I start? + +After talking with companies who have already adopted Backstage, we’ve seen a few common strategies for getting started. The different strategies are based on the size of your engineering org (which often also corresponds with how fast you’re growing). + +![200 engineers](assets/21-05-20/200-engineers_v.2.png) +
+ +### My org has ~200 engineers (and is growing fast) + +You’re big enough to start feeling the pain, and only getting bigger. Onboarding and discoverability are your biggest challenges. + +**Pain points:** + +- This size seems to be the tipping point — where complexity is taking hold, collaboration starts breaking down, and ad hoc solutions stop working. +- Often this is also when you’re growing so fast (doubling in size every 6–12 months) that there are now more new engineers than old engineers. +- New engineers can’t find anything, so they ask around, which pulls all your other engineers off-task with interruptions. +- Logjams form. One company told us how it was taking 3–4 days for pull requests to get merged. + +**Recommendation — Explore, then create:** + +- New engineers need an easy way to find out how to do things, so you’re not just adding bodies, you’re adding happy, productive contributors. +- To streamline onboarding, start with centralizing your documentation and making your tools and software components discoverable in Backstage. +- At this size, you might not have a dedicated platform/infra team in place yet, but Backstage can provide the framework for centralizing and sharing knowledge — from managing compliance requirements to finding the right API documentation. +- This allows both new and old engineers to collaborate more efficiently, easily discover best practices, and cuts down on duplicated work (e.g., a new team doesn’t end up rebuilding a database that already exists but nobody can find). + +![1,000 engineers](assets/21-05-20/1000-engineers_v.2.png) + +### My org has ~1,000 engineers + +You’re officially big. Lots and lots of teams are managing lots and lots of software — and the frustration of switching between all the various tools to do that is growing exponentially. + +**Pain points:** + +- Fragmentation and entropy are real threats to productivity. From security requirements to cluster management to a thousand data endpoints, there’s too much to keep track of, leading to cognitive overload. +- A death by a thousand cuts: constantly logging into new interfaces — from your cloud provider’s console to some brand new security tool then back to your CI/CD — is bogging your engineers down with too much context-switching and a lot of noise. +- Every day, it’s getting more and more difficult for one team to manage their own microservices, data, and documentation, let alone share their knowledge with other teams. + +**Recommendation — Manage, then explore, then create:** + +- Backstage will allow your teams to get a handle on the software they own, since everything they need to manage it is in one place. +- From CI/CD status to documentation to deciphering a monorepo, Backstage makes it easy to manage a service (or other software component) on a day-to-day basis. +- The service catalog also helps your developers visualize your existing software ecosystem, beyond the software they own. And with Backstage Software Templates, every new software component is already added to the catalog. +- Even at this scale, a small platform team should be all it takes to build and maintain your own version of Backstage. At Spotify, we have a 4-person team responsible for our internal version of Backstage, which is used by all of Spotify’s 1,600+ developers. + +![More than 1,000 engineers](assets/21-05-20/1000-plus-engineers_v.2.png) + +### My org has 1,000+ engineers + +Integrating infrastructure of this size and complexity can seem overwhelming. It’s an even bigger challenge to bring this level of change to a well-established culture with ingrained processes. + +**Pain points:** + +- When you’re this large, you’ve incorporated a lot of technology and processes, as well as a lot of partners, each with their own technologies and processes. +- You’re focussed on “replacing cruft” and bringing discoverability and order to your vast ecosystem of components and tools. +- Getting your tools adopted by your engineers while modernizing your tech stack and coordinating with your infra teams to improve your engineering practices across the whole company… it’s a lot. + +**Recommendation — Create, then manage, then explore:** + +- The fastest way to bring change to your ecosystem is to start at the beginning of the chain with Backstage Software Templates. +- With every new component created with your templates, you’re establishing best practices and rewarding your developers with a streamlined experience focused on their needs — all the while building up your new service catalog. +- The more the templates ease the process of starting up a project, the more your engineers will adopt them, and the faster the other benefits of Backstage will build up, transforming productivity across your organization. ([That’s how we did it at Spotify](https://open.spotify.com/episode/7iuQ3ew1Wwpuiq6LbBKzCl).) + +## More questions about adopting Backstage? + +[Contact the Backstage team at Spotify.](https://calendly.com/spotify-backstage) We’ll share more about what we’ve learned from our experience here at Spotify — and from other companies who are already using Backstage to transform their developer experience. diff --git a/microsite/blog/assets/21-05-20/1000-engineers.png b/microsite/blog/assets/21-05-20/1000-engineers.png new file mode 100644 index 0000000000..b7c9413e99 Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/1000-engineers_v.2.png b/microsite/blog/assets/21-05-20/1000-engineers_v.2.png new file mode 100644 index 0000000000..86013c61c5 Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers_v.2.png differ diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers.png b/microsite/blog/assets/21-05-20/1000-plus-engineers.png new file mode 100644 index 0000000000..63d49dd63e Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png b/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png new file mode 100644 index 0000000000..1120a28c39 Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers_v.2.png differ diff --git a/microsite/blog/assets/21-05-20/200-engineers.png b/microsite/blog/assets/21-05-20/200-engineers.png new file mode 100644 index 0000000000..d0f9bd73be Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/200-engineers_v.2.png b/microsite/blog/assets/21-05-20/200-engineers_v.2.png new file mode 100644 index 0000000000..23c6c507a7 Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers_v.2.png differ diff --git a/microsite/blog/assets/21-05-20/create-manage-explore.gif b/microsite/blog/assets/21-05-20/create-manage-explore.gif new file mode 100644 index 0000000000..104523a886 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create-manage-explore.gif differ diff --git a/microsite/blog/assets/21-05-20/create.gif b/microsite/blog/assets/21-05-20/create.gif new file mode 100644 index 0000000000..ce2c04f384 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create.gif differ diff --git a/microsite/blog/assets/21-05-20/explore.gif b/microsite/blog/assets/21-05-20/explore.gif new file mode 100644 index 0000000000..76b527af44 Binary files /dev/null and b/microsite/blog/assets/21-05-20/explore.gif differ diff --git a/microsite/blog/assets/21-05-20/manage.gif b/microsite/blog/assets/21-05-20/manage.gif new file mode 100644 index 0000000000..f4cf00d9f5 Binary files /dev/null and b/microsite/blog/assets/21-05-20/manage.gif differ diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 1471d7b4d1..ec08216367 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -85,7 +85,7 @@ class Footer extends React.Component {

- Made with ❤️  at Spotify + Made with ❤️ at Spotify

{this.props.config.copyright}

diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml index ca753f682a..121c833cb4 100644 --- a/microsite/data/plugins/argo-cd.yaml +++ b/microsite/data/plugins/argo-cd.yaml @@ -1,10 +1,10 @@ --- title: Argo CD author: roadie.io -authorUrl: https://roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=argo-cd category: CI/CD description: View Argo CD status for your projects in Backstage. -documentation: https://roadie.io/backstage/plugins/argo-cd +documentation: https://roadie.io/backstage/plugins/argo-cd/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=argo-cd iconUrl: https://roadie.io/images/logos/argo.png npmPackageName: '@roadiehq/backstage-plugin-argo-cd' tags: diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml index cc3d59d188..f20cc0d393 100644 --- a/microsite/data/plugins/aws-lambda.yaml +++ b/microsite/data/plugins/aws-lambda.yaml @@ -1,9 +1,9 @@ --- title: AWS Lambda author: roadie.io -authorUrl: https://roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=aws-lambda category: Infrastructure description: View AWS Lambda functions for your components in Backstage. -documentation: https://roadie.io/backstage/plugins/aws-lambda +documentation: https://roadie.io/backstage/plugins/aws-lambda/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=aws-lambda iconUrl: https://roadie.io/images/logos/lambda.png npmPackageName: '@roadiehq/backstage-plugin-aws-lambda' diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml index 16c6960898..4e9b1594d3 100644 --- a/microsite/data/plugins/buildkite.yaml +++ b/microsite/data/plugins/buildkite.yaml @@ -1,10 +1,10 @@ --- title: Buildkite author: roadie.io -authorUrl: https://roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=buildkite category: CI/CD description: View Buildkite CI builds for your service in Backstage. -documentation: https://roadie.io/backstage/plugins/buildkite +documentation: https://roadie.io/backstage/plugins/buildkite/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=buildkite iconUrl: https://roadie.io/images/logos/buildkite.png npmPackageName: '@roadiehq/backstage-plugin-buildkite' tags: diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 0acad832fa..cffbedca37 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci -iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png +iconUrl: img/circleci.png npmPackageName: '@backstage/plugin-circleci' tags: - ci diff --git a/microsite/data/plugins/cortex.yaml b/microsite/data/plugins/cortex.yaml new file mode 100644 index 0000000000..1f1c57adfd --- /dev/null +++ b/microsite/data/plugins/cortex.yaml @@ -0,0 +1,13 @@ +--- +title: Cortex Service Quality Scorecards +author: Cortex +authorUrl: https://www.getcortexapp.com +category: Monitoring +description: Grade the quality of your Backstage services using Scorecards. Automate production readiness, migrations, security audits, and more with CQL (Cortex Query Language). +documentation: https://github.com/cortexapps/backstage-plugin +iconUrl: img/cortex.png +npmPackageName: '@cortexapps/backstage-plugin' +tags: + - web + - monitoring + - sre diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml index daa4f55a02..ec2d162b9d 100644 --- a/microsite/data/plugins/cost-insights.yaml +++ b/microsite/data/plugins/cost-insights.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: Discovery description: Visualize, understand and optimize your team's cloud costs. documentation: https://github.com/backstage/backstage/tree/master/plugins/cost-insights -iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png +iconUrl: img/cost-insights.png npmPackageName: '@backstage/plugin-cost-insights' tags: - web diff --git a/microsite/data/plugins/datadog.yaml b/microsite/data/plugins/datadog.yaml new file mode 100644 index 0000000000..266627009c --- /dev/null +++ b/microsite/data/plugins/datadog.yaml @@ -0,0 +1,9 @@ +--- +title: Datadog +author: roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=datadog +category: Monitoring +description: Embed Datadog graphs and dashboards in Backstage. +documentation: https://roadie.io/backstage/plugins/datadog/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=datadog +iconUrl: https://roadie.io/images/logos/datadog-white-background.png +npmPackageName: '@roadiehq/backstage-plugin-datadog' diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml index 9a1777754a..0b98948f88 100644 --- a/microsite/data/plugins/firebase-functions.yaml +++ b/microsite/data/plugins/firebase-functions.yaml @@ -1,9 +1,9 @@ --- title: Firebase Functions author: roadie.io -authorUrl: https://roadie.io/ +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=firebase-functions category: Infrastructure description: View Firebase Functions details for your service in Backstage. -documentation: https://roadie.io/backstage/plugins/firebase-functions +documentation: https://roadie.io/backstage/plugins/firebase-functions/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=firebase-functions iconUrl: https://roadie.io/images/logos/firebase.png npmPackageName: '@roadiehq/backstage-plugin-firebase-functions' diff --git a/microsite/data/plugins/git-release-manager.yaml b/microsite/data/plugins/git-release-manager.yaml new file mode 100644 index 0000000000..7eec6040ac --- /dev/null +++ b/microsite/data/plugins/git-release-manager.yaml @@ -0,0 +1,9 @@ +--- +title: GitHub Release Manager +author: '@Spotify' +authorUrl: https://github.com/spotify +category: Release management +description: Manage releases without having to juggle git commands +documentation: https://github.com/backstage/backstage/tree/master/plugins/git-release-manager +iconUrl: img/git-release-manager-logo.svg +npmPackageName: '@backstage/plugin-git-release-manager' diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml index f127f88a3c..3515065916 100644 --- a/microsite/data/plugins/github-insights.yaml +++ b/microsite/data/plugins/github-insights.yaml @@ -1,9 +1,9 @@ --- title: GitHub Insights author: roadie.io -authorUrl: https://roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=github-insights category: Source Control Mgmt description: View GitHub Insights for your components in Backstage. -documentation: https://roadie.io/backstage/plugins/github-insights +documentation: https://roadie.io/backstage/plugins/github-insights/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=github-insights iconUrl: https://roadie.io/images/logos/insights.png npmPackageName: '@roadiehq/backstage-plugin-github-insights' diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 4172cdff6b..8fc0dd5a6e 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -1,9 +1,9 @@ --- title: GitHub Pull Requests author: roadie.io -authorUrl: https://roadie.io/ +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=github-pull-requests category: Source Control Mgmt description: View GitHub pull requests for your service in Backstage. -documentation: https://roadie.io/backstage/plugins/github-pull-requests +documentation: https://roadie.io/backstage/plugins/github-pull-requests/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=github-pull-requests iconUrl: https://roadie.io/images/logos/github.png npmPackageName: '@roadiehq/backstage-plugin-github-pull-requests' diff --git a/microsite/data/plugins/ilert.yaml b/microsite/data/plugins/ilert.yaml new file mode 100644 index 0000000000..29cc4f6aa5 --- /dev/null +++ b/microsite/data/plugins/ilert.yaml @@ -0,0 +1,15 @@ +--- +title: iLert +author: iLert +authorUrl: https://www.ilert.com +category: Monitoring +description: iLert is a platform for alerting, on-call management and uptime monitoring targeted at DevOps and IT teams. +documentation: https://github.com/backstage/backstage/tree/master/plugins/ilert +iconUrl: https://avatars.githubusercontent.com/u/13230510?s=200&v=4 +npmPackageName: '@backstage/plugin-ilert' +tags: + - monitoring + - errors + - alerting + - uptime + - on-call diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml index c32455c7d5..3342a2594c 100644 --- a/microsite/data/plugins/jira.yaml +++ b/microsite/data/plugins/jira.yaml @@ -1,9 +1,9 @@ --- title: Jira author: roadie.io -authorUrl: https://roadie.io +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=jira category: Agile Planning description: View Jira summary for your projects in Backstage. -documentation: https://roadie.io/backstage/plugins/jira +documentation: https://roadie.io/backstage/plugins/jira/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=jira iconUrl: https://roadie.io/images/logos/jira.png npmPackageName: '@roadiehq/backstage-plugin-jira' diff --git a/microsite/data/plugins/security-insights.yaml b/microsite/data/plugins/security-insights.yaml index 4f34f79027..1761bdf30a 100644 --- a/microsite/data/plugins/security-insights.yaml +++ b/microsite/data/plugins/security-insights.yaml @@ -1,9 +1,9 @@ --- title: Security Insights author: roadie.io -authorUrl: https://roadie.io/ +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=security-insights category: Security description: View Security Insights for your components in Backstage. -documentation: https://roadie.io/backstage/plugins/security-insights +documentation: https://roadie.io/backstage/plugins/security-insights/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=security-insights iconUrl: https://roadie.io/images/logos/github.png npmPackageName: '@roadiehq/backstage-plugin-security-insights' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index a6b8c6ebd7..7cb108a5d8 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -1,9 +1,9 @@ --- title: Travis CI author: roadie.io -authorUrl: https://roadie.io/ +authorUrl: https://roadie.io/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=travis-ci category: CI/CD description: View Travis CI builds for your service in Backstage. -documentation: https://roadie.io/backstage/plugins/travis-ci +documentation: https://roadie.io/backstage/plugins/travis-ci/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=travis-ci iconUrl: https://roadie.io/images/logos/travis.png npmPackageName: '@roadiehq/backstage-plugin-travis-ci' diff --git a/microsite/package.json b/microsite/package.json index 07899fa703..3112bbfe17 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -12,13 +12,15 @@ "write-translations": "docusaurus-write-translations", "version": "docusaurus-version", "rename-version": "docusaurus-rename-version", - "verify:sidebars": "node ./scripts/verify-sidebars" + "verify:sidebars": "node ./scripts/verify-sidebars", + "lock:check": "yarn-lock-check" }, "devDependencies": { "@spotify/prettier-config": "^10.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.2.1" + "prettier": "^2.3.1", + "yarn-lock-check": "^1.0.4" }, "prettier": "@spotify/prettier-config" } diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index 86cb6c786d..e3943d2905 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -18,12 +18,6 @@ const Background = props => { See us in action - - To explore the UI and basic features of Backstage firsthand, go - to: demo.backstage.io. - (Tip: click “All” to view all the example components in the - service catalog.) - Watch the videos below to get an introduction to Backstage and to see how we use different plugins to customize{' '} @@ -32,13 +26,31 @@ const Background = props => { . + + To see how other companies have already started using Backstage, + watch these presentations from{' '} + Expedia,{' '} + Zalando, and{' '} + TELUS. For more, + join our{' '} + + Community Sessions + + . + + + To explore the UI and basic features of Backstage firsthand, go + to: demo.backstage.io. + (Tip: click “All” to view all the example components in the + service catalog.) + diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index a761d6b6e2..45d87daa1d 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -431,7 +431,7 @@ class Index extends React.Component { Pick a cloud, any cloud Since Backstage uses the Kubernetes API, it's cloud agnostic — - so it works no matter which cloud provide or managed Kubernetes + so it works no matter which cloud provider or managed Kubernetes service you use, and even works in multi-cloud orgs diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 86e94d9fbd..f2ce425307 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -40,8 +40,10 @@ "features/software-catalog/references", "features/software-catalog/well-known-annotations", "features/software-catalog/well-known-relations", + "features/software-catalog/well-known-statuses", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", + "features/software-catalog/catalog-customization", "features/software-catalog/software-catalog-api" ] }, @@ -65,7 +67,8 @@ "features/software-templates/writing-templates", "features/software-templates/builtin-actions", "features/software-templates/writing-custom-actions", - "features/software-templates/template-legacy" + "features/software-templates/template-legacy", + "features/software-templates/migrating-from-v1alpha1-to-v1beta2" ] }, { @@ -73,6 +76,8 @@ "label": "Backstage Search", "ids": [ "features/search/search-overview", + "features/search/getting-started", + "features/search/concepts", "features/search/architecture" ] }, @@ -109,6 +114,11 @@ "integrations/bitbucket/discovery" ] }, + { + "type": "subcategory", + "label": "Datadog", + "ids": ["integrations/datadog-rum/installation"] + }, { "type": "subcategory", "label": "GitHub", @@ -130,13 +140,13 @@ }, { "type": "subcategory", - "label": "LDAP", - "ids": ["integrations/ldap/org"] + "label": "Google GCS", + "ids": ["integrations/google-cloud-storage/locations"] }, { "type": "subcategory", - "label": "Google GCS", - "ids": ["integrations/google-cloud-storage/locations"] + "label": "LDAP", + "ids": ["integrations/ldap/org"] } ], "Plugins": [ @@ -203,6 +213,7 @@ ], "Deployment": [ "deployment/index", + "deployment/docker", "deployment/k8s", "deployment/helm", "deployment/heroku" @@ -231,7 +242,6 @@ ], "Tutorials": [ "tutorials/journey", - "tutorials/quickstart-app-auth", "tutorials/quickstart-app-plugin", "tutorials/switching-sqlite-postgres" ], diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 5d61e55bc1..ea78fb462a 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -92,10 +92,8 @@ const siteConfig = { cleanUrl: true, // Open Graph and Twitter card images. - ogImage: - 'logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png', - twitterImage: - 'logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png', + ogImage: 'img/sharing-opengraph.png', + twitterImage: 'img/twitter-summary.png', // For sites with a sizable amount of content, set collapsible to true. // Expand/collapse the links and subcategories under categories. diff --git a/microsite/static/img/circleci.png b/microsite/static/img/circleci.png new file mode 100644 index 0000000000..85f262e4b3 Binary files /dev/null and b/microsite/static/img/circleci.png differ diff --git a/microsite/static/img/cortex.png b/microsite/static/img/cortex.png new file mode 100644 index 0000000000..6a45d0ca06 Binary files /dev/null and b/microsite/static/img/cortex.png differ diff --git a/microsite/static/img/cost-insights.png b/microsite/static/img/cost-insights.png new file mode 100644 index 0000000000..7296dd3524 Binary files /dev/null and b/microsite/static/img/cost-insights.png differ diff --git a/microsite/static/img/demo-screen.png b/microsite/static/img/demo-screen.png new file mode 100644 index 0000000000..c3aa8a6a3b Binary files /dev/null and b/microsite/static/img/demo-screen.png differ diff --git a/microsite/static/img/git-release-manager-logo.svg b/microsite/static/img/git-release-manager-logo.svg new file mode 100644 index 0000000000..b8e9cfe3ac --- /dev/null +++ b/microsite/static/img/git-release-manager-logo.svg @@ -0,0 +1 @@ +Export \ No newline at end of file diff --git a/microsite/static/img/sharing-opengraph.png b/microsite/static/img/sharing-opengraph.png new file mode 100644 index 0000000000..7d304f81ed Binary files /dev/null and b/microsite/static/img/sharing-opengraph.png differ diff --git a/microsite/static/img/twitter-summary.png b/microsite/static/img/twitter-summary.png new file mode 100644 index 0000000000..4a31eec062 Binary files /dev/null and b/microsite/static/img/twitter-summary.png differ diff --git a/microsite/yarn.lock b/microsite/yarn.lock index bed7897abc..6f71394265 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -9,6 +9,13 @@ dependencies: "@babel/highlight" "^7.0.0" +"@babel/code-frame@^7.0.0": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + dependencies: + "@babel/highlight" "^7.12.13" + "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": version "7.12.11" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -220,6 +227,11 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== +"@babel/helper-validator-identifier@^7.14.0": + version "7.14.0" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288" + integrity sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A== + "@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": version "7.12.11" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" @@ -253,6 +265,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.12.13": + version "7.14.0" + resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz#3197e375711ef6bf834e67d0daec88e4f46113cf" + integrity sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg== + dependencies: + "@babel/helper-validator-identifier" "^7.14.0" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7": version "7.12.11" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" @@ -911,7 +932,7 @@ "@spotify/prettier-config@^10.0.0": version "10.0.0" - resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-10.0.0.tgz#fa076d98d2e7e6c53dd3d86a696307a7010bd056" + resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-10.0.0.tgz#fa076d98d2e7e6c53dd3d86a696307a7010bd056" integrity sha512-VYOdo8P7lIScAkl02nB9KpUAuOYMManryBIBuKJkAw5D3aVtLobfmdIKvdV6MqEmGMEQPbn7w/UpnjJYhUH+IA== "@types/cheerio@^0.22.8": @@ -921,16 +942,39 @@ dependencies: "@types/node" "*" +"@types/glob@^7.1.3": + version "7.1.3" + resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" + integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/minimatch@*": + version "3.0.4" + resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21" + integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA== + "@types/node@*": version "14.14.20" resolved "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340" integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A== +"@types/node@^15.6.1": + version "15.9.0" + resolved "https://registry.npmjs.org/@types/node/-/node-15.9.0.tgz#0b7f6c33ca5618fe329a9d832b478b4964d325a8" + integrity sha512-AR1Vq1Ei1GaA5FjKL5PBqblTZsL5M+monvGSZwe6sSIdGiuu7Xr/pNwWJY+0ZQuN8AapD/XMB5IzBAyYRFbocA== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + accepts@~1.3.7: version "1.3.7" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -1039,7 +1083,7 @@ argparse@^1.0.10, argparse@^1.0.7: argparse@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== arr-diff@^4.0.0: @@ -1359,15 +1403,15 @@ browserslist@4.7.0: node-releases "^1.1.29" browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0: - version "4.16.0" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" - integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== + version "4.16.6" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== dependencies: - caniuse-lite "^1.0.30001165" - colorette "^1.2.1" - electron-to-chromium "^1.3.621" + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" escalade "^3.1.1" - node-releases "^1.1.67" + node-releases "^1.1.71" buffer-alloc-unsafe@^1.1.0: version "1.1.0" @@ -1405,6 +1449,11 @@ buffer@^5.2.1: base64-js "^1.3.1" ieee754 "^1.1.13" +builtin-modules@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= + bytes@1: version "1.0.0" resolved "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" @@ -1498,10 +1547,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: - version "1.0.30001173" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz#3c47bbe3cd6d7a9eda7f50ac016d158005569f56" - integrity sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219: + version "1.0.30001235" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001235.tgz#ad5ca75bc5a1f7b12df79ad806d715a43a5ac4ed" + integrity sha512-zWEwIVqnzPkSAXOUlQnPW2oKoYb2aLQ4Q5ejdjBcnH63rfypaW34CxaeBn1VMya2XaEU3P/R2qHpWyj+l0BT1A== caseless@~0.12.0: version "0.12.0" @@ -1518,7 +1567,7 @@ caw@^2.0.0, caw@^2.0.1: tunnel-agent "^0.6.0" url-to-options "^1.0.1" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1697,10 +1746,10 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.4" -colorette@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" - integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== +colorette@^1.2.1, colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" @@ -1709,7 +1758,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@^2.8.1: +commander@^2.12.1, commander@^2.8.1: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -2181,6 +2230,11 @@ diacritics-map@^0.1.0: resolved "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz#6dfc0ff9d01000a2edf2865371cac316e94977af" integrity sha1-bfwP+dAQAKLt8oZTccrDFulJd68= +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" @@ -2355,10 +2409,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.621: - version "1.3.634" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz#82ea400f520f739c4f6ff00c1f7524827a917d25" - integrity sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw== +electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.723: + version "1.3.749" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.749.tgz#0ecebc529ceb49dd2a7c838ae425236644c3439a" + integrity sha512-F+v2zxZgw/fMwPz/VUGIggG4ZndDsYy0vlpthi3tjmDZlcfbhN5mYW0evXUsBr2sUtuDANFtle410A9u/sd/4A== "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" @@ -3049,6 +3103,18 @@ glob@^7.0.0, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6, glob@~7.1.1: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.1, glob@^7.1.7: + version "7.1.7" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-modules@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" @@ -3460,6 +3526,11 @@ ini@^1.3.4, ini@^1.3.5: resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + inquirer@6.5.0: version "6.5.0" resolved "https://registry.npmjs.org/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42" @@ -3567,6 +3638,13 @@ is-core-module@^2.1.0: dependencies: has "^1.0.3" +is-core-module@^2.2.0: + version "2.4.0" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1" + integrity sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -3864,7 +3942,7 @@ js-yaml@^3.13.1, js-yaml@^3.8.1: js-yaml@^4.1.0: version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" @@ -4142,9 +4220,9 @@ lodash.uniq@^4.5.0: integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20, lodash@~4.17.10: - version "4.17.20" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== logalot@^2.0.0: version "2.1.0" @@ -4379,7 +4457,7 @@ mixin-deep@^1.1.3, mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp@^0.5.1, mkdirp@^0.5.5, mkdirp@~0.5.1: +mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -4448,10 +4526,10 @@ node-modules-regexp@^1.0.0: resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= -node-releases@^1.1.29, node-releases@^1.1.67: - version "1.1.69" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz#3149dbde53b781610cd8b486d62d86e26c3725f6" - integrity sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA== +node-releases@^1.1.29, node-releases@^1.1.71: + version "1.1.72" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" + integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.5.0" @@ -5197,10 +5275,10 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== +prettier@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6" + integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA== prismjs@^1.22.0: version "1.23.0" @@ -5603,6 +5681,14 @@ resolve@^1.1.6, resolve@^1.10.0: is-core-module "^2.1.0" path-parse "^1.0.6" +resolve@^1.3.2: + version "1.20.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + responselike@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -6351,11 +6437,37 @@ truncate-html@^1.0.3: "@types/cheerio" "^0.22.8" cheerio "0.22.0" -tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslint@^6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz#5c23b2eccc32487d5523bd3a470e9aa31789d904" + integrity sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg== + dependencies: + "@babel/code-frame" "^7.0.0" + builtin-modules "^1.1.1" + chalk "^2.3.0" + commander "^2.12.1" + diff "^4.0.1" + glob "^7.1.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + mkdirp "^0.5.3" + resolve "^1.3.2" + semver "^5.3.0" + tslib "^1.13.0" + tsutils "^2.29.0" + +tsutils@^2.29.0: + version "2.29.0" + resolved "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" + integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== + dependencies: + tslib "^1.8.1" + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -6381,6 +6493,11 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript@^4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805" + integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw== + unbzip2-stream@^1.0.9: version "1.4.3" resolved "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" @@ -6650,6 +6767,19 @@ yargs@^2.3.0: dependencies: wordwrap "0.0.2" +yarn-lock-check@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/yarn-lock-check/-/yarn-lock-check-1.0.4.tgz#a0373de051be0c8442d8933070df7a45595263b4" + integrity sha512-Gj0wRN85c4OPZUlE7WsQ0a1COv38uyeWWR0YAvJr2Vxw1f32bwK19xySjUZlxt9o4QopJkd8g6x6CLv81OHwYg== + dependencies: + "@types/glob" "^7.1.3" + "@types/node" "^15.6.1" + "@yarnpkg/lockfile" "^1.1.0" + glob "^7.1.7" + ini "^2.0.0" + tslint "^6.1.3" + typescript "^4.3.2" + yauzl@^2.4.2: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" diff --git a/mkdocs.yml b/mkdocs.yml index 031fb56059..67b97b6ffb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,114 +7,158 @@ plugins: nav: - Overview: - What is Backstage?: 'overview/what-is-backstage.md' - - Backstage architecture: 'overview/architecture-overview.md' - - Roadmap: 'overview/roadmap.md' + - Architecture overview: 'overview/architecture-overview.md' + - Project Roadmap: 'overview/roadmap.md' - Vision: 'overview/vision.md' - - The Spotify story: 'overview/background.md' + - The Spotify Story: 'overview/background.md' - Strategies for adopting: 'overview/adopting.md' + - Stability Index: 'overview/stability-index.md' - Logo assets: 'overview/logos.md' - - Getting started: + - Getting Started: - Getting Started: 'getting-started/index.md' + - Create an App: 'getting-started/create-an-app.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' + - App configuration: + - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' + - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' + - Keeping Backstage Updated: 'getting-started/keeping-backstage-updated.md' + - Key Concepts: 'getting-started/concepts.md' - Contributors: 'getting-started/contributors.md' - - Demo deployment: https://backstage-demo.roadie.io - - Production deployments: - - Create an App: 'getting-started/create-an-app.md' - - App configuration: - - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' - - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - - Deployment scenarios: - - Docker: 'getting-started/deployment-docker.md' - - Kubernetes: 'getting-started/deployment-k8s.md' - - Kubernetes and Helm: 'getting-started/deployment-helm.md' - - Other: 'getting-started/deployment-other.md' - - Features: + - CLI: + - Overview: 'cli/index.md' + - Commands: 'cli/commands.md' + - Core Features: - Software Catalog: - Overview: 'features/software-catalog/index.md' - - Installation: 'features/software-catalog/installation.md' - - Configuration: 'features/software-catalog/configuration.md' - - System model: 'features/software-catalog/system-model.md' + - Installing in your Backstage App: 'features/software-catalog/installation.md' + - Catalog Configuration: 'features/software-catalog/configuration.md' + - System Model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' + - Well-known Relations: 'features/software-catalog/well-known-relations.md' + - Well-known Statuses: 'features/software-catalog/well-known-statuses.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' + - Catalog Customization: 'features/software-catalog/catalog-customization.md' - API: 'features/software-catalog/api.md' - - Software creation templates: + - Kubernetes: + - Overview: 'features/kubernetes/index.md' + - Installation: 'features/kubernetes/installation.md' + - Configuration: 'features/kubernetes/configuration.md' + - Troubleshooting: 'features/kubernetes/troubleshooting.md' + - Software Templates: - Overview: 'features/software-templates/index.md' - - Installation: 'features/software-templates/installation.md' - - Adding templates: 'features/software-templates/adding-templates.md' - - Extending the Scaffolder: - - Overview: 'features/software-templates/extending/index.md' - - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Installing in your Backstage App: 'features/software-templates/installation.md' + - Adding your own Templates: 'features/software-templates/adding-templates.md' + - Writing Templates: 'features/software-templates/writing-templates.md' + - Builtin Actions: 'features/software-templates/builtin-actions.md' + - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' + - Writing Templates (Legacy): 'features/software-templates/legacy.md' + - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' + - Backstage Search: - Overview: 'features/search/README.md' - - Architecture: 'features/search/architecture.md' + - Getting Started: 'features/search/getting-started.md' + - Concepts: 'features/search/concepts.md' + - Search Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - - Configuration: 'features/techdocs/configuration.md' + - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' - - Kubernetes: - - Overview: 'features/kubernetes/index.md' - Integrations: + - Overview: 'integrations/index.md' + - Azure DevOps: + - Locations: 'integrations/azure/locations.md' + - Bitbucket: + - Locations: 'integrations/bitbucket/locations.md' + - Discovery: 'integrations/bitbucket/discovery.md' + - Datadog: + - Installation: 'integrations/datadog-rum/installation.md' - GitHub: + - Locations: 'integrations/github/locations.md' + - Discovery: 'integrations/github/discovery.md' - Org Data: 'integrations/github/org.md' - - LDAP: - - Org Data: 'integrations/ldap/org.md' + - GitLab: + - Locations: 'integrations/gitlab/locations.md' - Google Analytics: - Installation: 'integrations/google-analytics/installation.md' + - Google GCS: + - Locations: 'integrations/google-cloud-storage/locations.md' + - LDAP: + - Org Data: 'integrations/ldap/org.md' - Plugins: - - Overview: 'plugins/index.md' + - Intro to plugins: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - - Creating a new plugin: 'plugins/create-a-plugin.md' - - Developing a plugin: 'plugins/plugin-development.md' + - Create a Backstage Plugin: 'plugins/create-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' + - Integrate into the Service Catalog: 'plugins/integrating-plugin-into-service-catalog.md' - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - - Backstage backend plugin: 'plugins/backend-plugin.md' + - Backend plugin: 'plugins/backend-plugin.md' - Call existing API: 'plugins/call-existing-api.md' + - GitHub Apps for Backend Authentication: 'plugins/github-apps.md' - Testing: - - Overview: 'plugins/testing.md' + - Testing with Jest: 'plugins/testing.md' - Publishing: - - Open source and npm: 'plugins/publishing.md' - - Private/internal (non-open source): 'plugins/publish-private.md' + - Publishing: 'plugins/publishing.md' + - Publish private: 'plugins/publish-private.md' + - Add to Marketplace: 'plugins/add-to-marketplace.md' - Observability: 'plugins/observability.md' - Configuration: - - Overview: 'conf/index.md' - - Reading Configuration: 'conf/reading.md' - - Writing Configuration: 'conf/writing.md' - - Defining Configuration: 'conf/defining.md' + - Static Configuration in Backstage: 'conf/index.md' + - Reading Backstage Configuration: 'conf/reading.md' + - Writing Backstage Configuration: 'conf/writing.md' + - Defining Configuration for your Plugin: 'conf/defining.md' - Authentication and identity: - - Overview: 'auth/index.md' - - Add auth provider: 'auth/add-auth-provider.md' + - Adding Authentication: 'auth/index.md' + - Included providers: + - Auth0: 'auth/auth0/provider.md' + - Azure: 'auth/microsoft/provider.md' + - GitHub: 'auth/github/provider.md' + - GitLab: 'auth/gitlab/provider.md' + - Google: 'auth/google/provider.md' + - Okta: 'auth/okta/provider.md' + - OneLogin: 'auth/onelogin/provider.md' + - Adding authentication providers: 'auth/add-auth-provider.md' + - Using authentication and identity: 'auth/using-auth.md' - Auth backend: 'auth/auth-backend.md' - - Auth backend class structure: 'auth/auth-backend-classes.md' - - OAuth: 'auth/oauth.md' + - OAuth and OpenID Connect: 'auth/oauth.md' + - Auth backend classes: 'auth/auth-backend-classes.md' - Glossary: 'auth/glossary.md' + - Deployment: + - Deploying Backstage: 'deployment/index.md' + - Docker: 'deployment/docker.md' + - Kubernetes: 'deployment/k8s.md' + - Helm: 'deployment/helm.md' + - Heroku: 'deployment/heroku.md' - Designing for Backstage: - - Backstage Design Language System (DLS): 'dls/design.md' - - Storybook -- reusable UI components: 'http://backstage.io/storybook' + - Design: 'dls/design.md' - Contributing to Storybook: 'dls/contributing-to-storybook.md' - - Figma resources: 'dls/figma.md' + - Figma: 'dls/figma.md' - API references: - - TypeScript APIs: - - Utilities: 'api/utility-apis.md' + - TypeScript API: + - Utility APIs: 'api/utility-apis.md' + - reference/utility-apis/README: 'reference/utility-apis/README.md' - createPlugin: 'reference/createPlugin.md' - - createPlugin-feature-flags: 'reference/createPlugin-feature-flags.md' + - createPlugin -feature flags: 'reference/createPlugin-feature-flags.md' - Backend APIs: - Backend: 'api/backend.md' - Tutorials: - - Overview: 'tutorials/index.md' + - Future developer journey: 'tutorials/journey.md' + - Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md' + - Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md' - Architecture Decision Records (ADRs): - Overview: 'architecture-decisions/index.md' - ADR001 - Architecture Decision Record (ADR) log: 'architecture-decisions/adr001-add-adr-log.md' @@ -129,7 +173,7 @@ nav: - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md' - Support: - - 'support/support.md' - - 'support/project-structure.md' + - Support and community: 'support/support.md' + - Backstage Project Structure: 'support/project-structure.md' - Glossary: glossary.md - FAQ: FAQ.md diff --git a/package.json b/package.json index d87b98fbdb..c0887d7f05 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "start": "yarn workspace example-app start", "start-backend": "yarn workspace example-backend start", "build": "lerna run build", + "build:api-reports": "tsc && yarn build:api-reports:only", + "build:api-reports:only": "ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", + "build:api-docs": "yarn build:api-reports --docs", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", @@ -27,7 +30,9 @@ "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", - "build-storybook": "yarn workspace storybook build-storybook" + "build-storybook": "yarn workspace storybook build-storybook", + "prepare": "husky install", + "lock:check": "yarn-lock-check" }, "workspaces": { "packages": [ @@ -39,29 +44,31 @@ "**/@roadiehq/**/@backstage/core": "*", "**/@roadiehq/**/@backstage/plugin-catalog": "*", "**/@roadiehq/**/@backstage/catalog-model": "*", + "**/@microsoft/api-extractor/typescript": "^4.0.3", "graphql-language-service-interface": "2.8.2", "graphql-language-service-parser": "1.9.0" }, "version": "1.0.0", + "dependencies": { + "@microsoft/api-documenter": "^7.12.16", + "@microsoft/api-extractor": "7.13.2-pr1916.0", + "@microsoft/api-extractor-model": "^7.12.5" + }, "devDependencies": { "@changesets/cli": "^2.14.0", "@octokit/openapi-types": "^2.2.0", - "@spotify/eslint-config-oss": "^1.0.1", - "@spotify/prettier-config": "^9.0.0", + "@spotify/prettier-config": "^10.0.0", "command-exists": "^1.2.9", "concurrently": "^6.0.0", + "eslint-plugin-notice": "^0.9.10", "fs-extra": "^9.0.0", - "husky": "^4.2.3", + "husky": "^6.0.0", "lerna": "^4.0.0", "lint-staged": "^10.1.0", "prettier": "^2.2.1", "recursive-readdir": "^2.2.2", - "shx": "^0.3.2" - }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } + "shx": "^0.3.2", + "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config", "lint-staged": { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 81a7f66f4c..c200104775 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,297 @@ # example-app +## 0.2.32 + +### Patch Changes + +- Updated dependencies [9cd3c533c] +- Updated dependencies [db1c8f93b] +- Updated dependencies [9d906c7a1] +- Updated dependencies [9bdd2cca8] +- Updated dependencies [27a9b503a] +- Updated dependencies [f4e3ac5ce] +- Updated dependencies [9b4010965] +- Updated dependencies [7f7443308] +- Updated dependencies [7028ee1ca] +- Updated dependencies [70bc30c5b] +- Updated dependencies [db1c8f93b] +- Updated dependencies [5aff84759] +- Updated dependencies [21e8ebef5] +- Updated dependencies [4fbb00707] +- Updated dependencies [d5ad47bbb] + - @backstage/cli@0.7.0 + - @backstage/plugin-catalog@0.6.2 + - @backstage/plugin-cost-insights@0.10.2 + - @backstage/plugin-github-actions@0.4.9 + - @backstage/catalog-model@0.8.2 + - @backstage/plugin-scaffolder@0.9.8 + - @backstage/integration-react@0.1.3 + - @backstage/plugin-catalog-react@0.2.2 + - @backstage/plugin-catalog-import@0.5.9 + - @backstage/plugin-search@0.4.0 + +## 0.2.31 + +### Patch Changes + +- Updated dependencies [497f4ce18] +- Updated dependencies [ee4eb5b40] +- Updated dependencies [84160313e] +- Updated dependencies [3772de8ba] +- Updated dependencies [7e7c71417] +- Updated dependencies [f430b6c6f] +- Updated dependencies [2a942cc9e] +- Updated dependencies [e7c5e4b30] +- Updated dependencies [ebe802bc4] +- Updated dependencies [1cf1d351f] +- Updated dependencies [90a505a77] +- Updated dependencies [76f99a1a0] +- Updated dependencies [deaba2e13] +- Updated dependencies [1157fa307] +- Updated dependencies [8e919a6f8] +- Updated dependencies [2305ab8fc] +- Updated dependencies [054bcd029] +- Updated dependencies [aad98c544] +- Updated dependencies [f46a9e82d] + - @backstage/plugin-scaffolder@0.9.7 + - @backstage/cli@0.6.14 + - @backstage/plugin-catalog@0.6.1 + - @backstage/theme@0.2.8 + - @backstage/catalog-model@0.8.1 + - @backstage/core@0.7.12 + - @backstage/plugin-tech-radar@0.4.0 + - @backstage/plugin-catalog-react@0.2.1 + - @backstage/plugin-techdocs@0.9.5 + +## 0.2.30 + +### Patch Changes + +- Updated dependencies [41c3ec421] +- Updated dependencies [add62a455] +- Updated dependencies [cc592248b] +- Updated dependencies [17c497b81] +- Updated dependencies [1cd0cacd9] +- Updated dependencies [4ea9df9d3] +- Updated dependencies [7a7da5146] +- Updated dependencies [5baf2ff0f] +- Updated dependencies [bf805b467] +- Updated dependencies [203ce6f6f] +- Updated dependencies [7ab5bfe68] +- Updated dependencies [704875e26] +- Updated dependencies [3a181cff1] + - @backstage/plugin-cost-insights@0.10.1 + - @backstage/catalog-model@0.8.0 + - @backstage/core@0.7.11 + - @backstage/plugin-catalog@0.6.0 + - @backstage/plugin-catalog-react@0.2.0 + - @backstage/cli@0.6.13 + - @backstage/plugin-techdocs@0.9.4 + - @backstage/plugin-jenkins@0.4.4 + - @backstage/plugin-api-docs@0.4.15 + - @backstage/plugin-badges@0.2.2 + - @backstage/plugin-catalog-import@0.5.8 + - @backstage/plugin-circleci@0.2.15 + - @backstage/plugin-cloudbuild@0.2.16 + - @backstage/plugin-code-coverage@0.1.4 + - @backstage/plugin-explore@0.3.6 + - @backstage/plugin-github-actions@0.4.8 + - @backstage/plugin-kafka@0.2.8 + - @backstage/plugin-kubernetes@0.4.5 + - @backstage/plugin-lighthouse@0.2.17 + - @backstage/plugin-org@0.3.14 + - @backstage/plugin-pagerduty@0.3.5 + - @backstage/plugin-rollbar@0.3.6 + - @backstage/plugin-scaffolder@0.9.6 + - @backstage/plugin-search@0.3.7 + - @backstage/plugin-sentry@0.3.11 + - @backstage/plugin-todo@0.1.2 + +## 0.2.29 + +### Patch Changes + +- Updated dependencies [7cbfcae48] +- Updated dependencies [2bfec55a6] +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] +- Updated dependencies [ca6e0ab69] +- Updated dependencies [5914a76d5] +- Updated dependencies [81d7b9c6f] +- Updated dependencies [a62cfe068] +- Updated dependencies [35e091604] +- Updated dependencies [a53f3d603] +- Updated dependencies [b203699e9] + - @backstage/plugin-cost-insights@0.10.0 + - @backstage/cli@0.6.12 + - @backstage/catalog-model@0.7.10 + - @backstage/plugin-scaffolder@0.9.5 + - @backstage/core@0.7.10 + - @backstage/plugin-api-docs@0.4.14 + - @backstage/plugin-cloudbuild@0.2.15 + - @backstage/plugin-github-actions@0.4.7 + - @backstage/plugin-techdocs@0.9.3 + - @backstage/plugin-catalog-import@0.5.7 + - @backstage/plugin-catalog@0.5.8 + +## 0.2.28 + +### Patch Changes + +- Updated dependencies [062bbf90f] +- Updated dependencies [2cd70e164] +- Updated dependencies [0b033d07b] +- Updated dependencies [3be844496] +- Updated dependencies [5542de095] +- Updated dependencies [10c008a3a] +- Updated dependencies [81ef1d57b] +- Updated dependencies [ea21d46f0] +- Updated dependencies [e3fc89df6] +- Updated dependencies [f59a945b7] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-api-docs@0.4.13 + - @backstage/plugin-badges@0.2.1 + - @backstage/plugin-catalog@0.5.7 + - @backstage/plugin-catalog-import@0.5.6 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/plugin-circleci@0.2.14 + - @backstage/plugin-cloudbuild@0.2.14 + - @backstage/plugin-code-coverage@0.1.3 + - @backstage/plugin-cost-insights@0.9.1 + - @backstage/plugin-explore@0.3.5 + - @backstage/plugin-gcp-projects@0.2.6 + - @backstage/plugin-github-actions@0.4.6 + - @backstage/plugin-graphiql@0.2.11 + - @backstage/plugin-jenkins@0.4.3 + - @backstage/plugin-kafka@0.2.7 + - @backstage/plugin-kubernetes@0.4.4 + - @backstage/plugin-lighthouse@0.2.16 + - @backstage/plugin-newrelic@0.2.7 + - @backstage/plugin-org@0.3.13 + - @backstage/plugin-pagerduty@0.3.4 + - @backstage/plugin-rollbar@0.3.5 + - @backstage/plugin-scaffolder@0.9.4 + - @backstage/plugin-search@0.3.6 + - @backstage/plugin-sentry@0.3.10 + - @backstage/plugin-shortcuts@0.1.2 + - @backstage/plugin-tech-radar@0.3.11 + - @backstage/plugin-techdocs@0.9.2 + - @backstage/plugin-todo@0.1.1 + - @backstage/plugin-user-settings@0.2.10 + - @backstage/cli@0.6.11 + - @backstage/catalog-model@0.7.9 + +## 0.2.27 + +### Patch Changes + +- Updated dependencies [6f1b82b14] +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [8aedbb4af] +- Updated dependencies [fc79a6dd3] +- Updated dependencies [f53fba29f] +- Updated dependencies [b2e2ec753] +- Updated dependencies [9314a8592] +- Updated dependencies [2e05277e0] +- Updated dependencies [4075c6367] +- Updated dependencies [d8b81fd28] + - @backstage/plugin-cost-insights@0.9.0 + - @backstage/plugin-catalog-import@0.5.5 + - @backstage/plugin-github-actions@0.4.5 + - @backstage/cli@0.6.10 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/plugin-kubernetes@0.4.3 + - @backstage/plugin-tech-radar@0.3.10 + - @backstage/plugin-scaffolder@0.9.3 + - @backstage/plugin-techdocs@0.9.1 + - @backstage/catalog-model@0.7.8 + +## 0.2.26 + +### Patch Changes + +- Updated dependencies [1ce80ff02] +- Updated dependencies [b98de52ae] +- Updated dependencies [4c42ecca2] +- Updated dependencies [c614ede9a] +- Updated dependencies [9afcac5af] +- Updated dependencies [07a7806c3] +- Updated dependencies [f6efa71ee] +- Updated dependencies [19a4dd710] +- Updated dependencies [dcd54c7cd] +- Updated dependencies [da546ce00] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6fbd7beca] +- Updated dependencies [15cbe6815] +- Updated dependencies [39bdaa004] +- Updated dependencies [cb8c848a3] +- Updated dependencies [21fddf452] +- Updated dependencies [17915e29b] +- Updated dependencies [6eaecbd81] +- Updated dependencies [23769512a] +- Updated dependencies [1a142ae8a] + - @backstage/plugin-api-docs@0.4.12 + - @backstage/plugin-cost-insights@0.8.5 + - @backstage/plugin-github-actions@0.4.4 + - @backstage/plugin-catalog-import@0.5.4 + - @backstage/plugin-circleci@0.2.13 + - @backstage/plugin-explore@0.3.4 + - @backstage/plugin-graphiql@0.2.10 + - @backstage/plugin-jenkins@0.4.2 + - @backstage/plugin-lighthouse@0.2.15 + - @backstage/plugin-pagerduty@0.3.3 + - @backstage/plugin-rollbar@0.3.4 + - @backstage/plugin-sentry@0.3.9 + - @backstage/core@0.7.7 + - @backstage/plugin-scaffolder@0.9.2 + - @backstage/plugin-catalog@0.5.6 + - @backstage/plugin-search@0.3.5 + - @backstage/plugin-techdocs@0.9.0 + +## 0.2.25 + +### Patch Changes + +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [4e5c94249] +- Updated dependencies [99fbef232] +- Updated dependencies [cb0206b2b] +- Updated dependencies [1373f4f12] +- Updated dependencies [29a7e4be8] +- Updated dependencies [ab07d77f6] +- Updated dependencies [96728a2af] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [87c4f59de] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] +- Updated dependencies [ac6025f63] +- Updated dependencies [e292e393f] +- Updated dependencies [479b29124] + - @backstage/core@0.7.6 + - @backstage/cli@0.6.9 + - @backstage/plugin-scaffolder@0.9.1 + - @backstage/plugin-catalog-import@0.5.3 + - @backstage/plugin-catalog@0.5.5 + - @backstage/theme@0.2.6 + - @backstage/plugin-code-coverage@0.1.2 + - @backstage/plugin-techdocs@0.8.0 + ## 0.2.24 ### Patch Changes diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js new file mode 100644 index 0000000000..4e13fc8a0f --- /dev/null +++ b/packages/app/cypress/integration/components/search/SearchPage.js @@ -0,0 +1,121 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const API_ENDPOINT = 'http://localhost:7000/api/search/query'; + +describe('SearchPage', () => { + describe('Given a search context with a term, results, and filter values', () => { + it('The results are rendered as expected', () => { + const results = [ + { + type: 'software-catalog', + document: { + title: 'backstage', + text: 'Backstage system documentation', + location: '/result/location/path', + }, + }, + ]; + + cy.enterAsGuest(); + cy.visit('/search-next', { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs(`${API_ENDPOINT}?term=&pageCursor=`) + .resolves({ + ok: true, + json: () => ({ results }), + }); + }, + }); + cy.contains('Search'); + + cy.contains(results[0].document.title); + cy.contains(results[0].document.text); + cy.get(`a[href="${results[0].document.location}"]`).should('be.visible'); + }); + + it('The filters are rendered as expected', () => { + cy.enterAsGuest(); + cy.visit( + '/search-next?filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B%5D=experimental', + { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs( + `${API_ENDPOINT}?term=&filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B0%5D=experimental&pageCursor=`, + ) + .resolves({ + ok: true, + json: () => ({ results: [] }), + }); + }, + }, + ); + cy.contains('Search'); + + // lifecycle + cy.contains('lifecycle'); + + cy.contains('experimental'); + cy.get( + '[data-testid="search-checkboxfilter-next"] input[value="experimental"]', + ).should('have.attr', 'checked'); + + cy.contains('production'); + cy.get( + '[data-testid="search-checkboxfilter-next"] input[value="production"]', + ).should('not.have.attr', 'checked'); + + // kind + cy.contains('kind'); + cy.get( + '[data-testid="search-selectfilter-next"] [role="button"][aria-haspopup="listbox"]', + ).click(); + + cy.contains('All'); + cy.contains('Template'); + cy.contains('Component'); + + cy.get('[role="option"][data-value="Component"]').should( + 'have.attr', + 'aria-selected', + 'true', + ); + }); + + it('The search bar is rendered as expected', () => { + cy.enterAsGuest(); + cy.visit('/search-next?query=backstage', { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs(`${API_ENDPOINT}?term=backstage&pageCursor=`) + .resolves({ + ok: true, + json: () => ({ results: [] }), + }); + }, + }); + cy.contains('Search'); + + cy.get('[data-testid="search-bar-next"] input').should( + 'have.attr', + 'value', + 'backstage', + ); + }); + }); +}); diff --git a/packages/app/cypress/support/commands.js b/packages/app/cypress/support/commands.js new file mode 100644 index 0000000000..dd2b26634d --- /dev/null +++ b/packages/app/cypress/support/commands.js @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +Cypress.Commands.add('enterAsGuest', () => { + cy.visit('/'); + cy.get('button').contains('Enter').click(); +}); diff --git a/packages/app/cypress/support/index.js b/packages/app/cypress/support/index.js index 8fc8ca91f1..c1f930027a 100644 --- a/packages/app/cypress/support/index.js +++ b/packages/app/cypress/support/index.js @@ -14,3 +14,4 @@ * limitations under the License. */ import '@testing-library/cypress/add-commands'; +import './commands'; diff --git a/packages/app/package.json b/packages/app/package.json index 6f58bdb82a..1b0b3aa22c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,50 +1,49 @@ { "name": "example-app", - "version": "0.2.24", + "version": "0.2.32", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/cli": "^0.6.8", - "@backstage/core": "^0.7.5", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-api-docs": "^0.4.11", - "@backstage/plugin-badges": "^0.2.0", - "@backstage/plugin-catalog": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.5.2", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/plugin-circleci": "^0.2.12", - "@backstage/plugin-cloudbuild": "^0.2.13", - "@backstage/plugin-cost-insights": "^0.8.4", - "@backstage/plugin-explore": "^0.3.2", - "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.2", - "@backstage/plugin-github-deployments": "^0.1.2", - "@backstage/plugin-gitops-profiles": "^0.2.6", - "@backstage/plugin-graphiql": "^0.2.9", - "@backstage/plugin-jenkins": "^0.4.1", - "@backstage/plugin-kafka": "^0.2.6", - "@backstage/plugin-kubernetes": "^0.4.2", - "@backstage/plugin-lighthouse": "^0.2.14", - "@backstage/plugin-newrelic": "^0.2.6", - "@backstage/plugin-org": "^0.3.12", - "@backstage/plugin-pagerduty": "0.3.2", - "@backstage/plugin-register-component": "^0.2.12", - "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.9.0", - "@backstage/plugin-search": "^0.3.4", - "@backstage/plugin-sentry": "^0.3.8", - "@backstage/plugin-tech-radar": "^0.3.9", - "@backstage/plugin-techdocs": "^0.7.2", - "@backstage/plugin-todo": "^0.1.0", - "@backstage/plugin-user-settings": "^0.2.8", - "@backstage/theme": "^0.2.5", + "@backstage/catalog-model": "^0.8.2", + "@backstage/cli": "^0.7.0", + "@backstage/core": "^0.7.12", + "@backstage/integration-react": "^0.1.3", + "@backstage/plugin-api-docs": "^0.4.15", + "@backstage/plugin-badges": "^0.2.2", + "@backstage/plugin-catalog": "^0.6.2", + "@backstage/plugin-catalog-import": "^0.5.9", + "@backstage/plugin-catalog-react": "^0.2.2", + "@backstage/plugin-circleci": "^0.2.15", + "@backstage/plugin-cloudbuild": "^0.2.16", + "@backstage/plugin-code-coverage": "^0.1.4", + "@backstage/plugin-cost-insights": "^0.10.2", + "@backstage/plugin-explore": "^0.3.6", + "@backstage/plugin-gcp-projects": "^0.2.6", + "@backstage/plugin-github-actions": "^0.4.9", + "@backstage/plugin-graphiql": "^0.2.11", + "@backstage/plugin-jenkins": "^0.4.4", + "@backstage/plugin-kafka": "^0.2.8", + "@backstage/plugin-kubernetes": "^0.4.5", + "@backstage/plugin-lighthouse": "^0.2.17", + "@backstage/plugin-newrelic": "^0.2.7", + "@backstage/plugin-org": "^0.3.14", + "@backstage/plugin-pagerduty": "0.3.5", + "@backstage/plugin-rollbar": "^0.3.6", + "@backstage/plugin-scaffolder": "^0.9.8", + "@backstage/plugin-search": "^0.4.0", + "@backstage/plugin-sentry": "^0.3.11", + "@backstage/plugin-shortcuts": "^0.1.2", + "@backstage/plugin-tech-radar": "^0.4.0", + "@backstage/plugin-techdocs": "^0.9.5", + "@backstage/plugin-todo": "^0.1.2", + "@backstage/plugin-user-settings": "^0.2.10", + "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", - "@octokit/rest": "^18.0.12", - "@roadiehq/backstage-plugin-buildkite": "^1.0.0", - "@roadiehq/backstage-plugin-github-insights": "^1.0.0", - "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.0", + "@octokit/rest": "^18.5.3", + "@roadiehq/backstage-plugin-buildkite": "^1.0.3", + "@roadiehq/backstage-plugin-github-insights": "^1.1.11", + "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.5", "@roadiehq/backstage-plugin-travis-ci": "^1.0.0", "history": "^5.0.0", "prop-types": "^15.7.2", @@ -53,22 +52,22 @@ "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.13", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/jquery": "^3.3.34", "@types/node": "^14.14.32", "@types/react-dom": "^16.9.8", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", - "cypress": "^4.2.0", + "cypress": "^7.3.0", "eslint-plugin-cypress": "^2.10.3", "start-server-and-test": "^1.10.11" }, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c5e5033973..b14f9a3163 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -43,7 +43,7 @@ import { GraphiQLPage } from '@backstage/plugin-graphiql'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; +import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; @@ -54,6 +54,7 @@ import { Navigate, Route } from 'react-router'; import { apis } from './apis'; import { Root } from './components/Root'; import { entityPage } from './components/catalog/EntityPage'; +import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -118,11 +119,9 @@ const routes = ( } /> } /> } /> - } /> - } - /> + }> + {searchPage} + } /> ) => ( + + diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 9860792ea0..c33adf6c4a 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -44,6 +44,7 @@ describe('EntityPage Test', () => { getWorkflow: jest.fn(), getWorkflowRun: jest.fn(), reRunWorkflow: jest.fn(), + listJobsForWorkflowRun: jest.fn(), downloadJobLogsForWorkflowRun: jest.fn(), } as jest.Mocked; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d6f91957a9..f646992c97 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -28,6 +28,8 @@ import { import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntityHasComponentsCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, @@ -37,6 +39,9 @@ import { EntitySwitch, isComponentType, isKind, + EntityHasResourcesCard, + EntityOrphanWarning, + isOrphan, } from '@backstage/plugin-catalog'; import { EntityCircleCIContent, @@ -102,6 +107,7 @@ import { EntityTravisCIOverviewCard, isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; +import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -210,7 +216,15 @@ const errorsContent = ( const overviewContent = ( - + + + + + + + + + @@ -222,7 +236,7 @@ const overviewContent = ( - + @@ -256,7 +270,7 @@ const overviewContent = ( - + @@ -287,6 +301,17 @@ const serviceEntityPage = ( + + + + + + + + + + + @@ -303,6 +328,10 @@ const serviceEntityPage = ( + + + + @@ -331,6 +360,17 @@ const websiteEntityPage = ( {errorsContent} + + + + + + + + + + + @@ -347,6 +387,10 @@ const websiteEntityPage = ( + + + + @@ -441,10 +485,6 @@ const groupPage = ( - - - - ); @@ -461,8 +501,14 @@ const systemPage = ( + + + + + + ); diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx new file mode 100644 index 0000000000..7b94e876e6 --- /dev/null +++ b/packages/app/src/components/search/SearchPage.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; + +import { Content, Header, Lifecycle, Page } from '@backstage/core'; +import { CatalogResultListItem } from '@backstage/plugin-catalog'; +import { + SearchBar, + SearchFilter, + SearchResult, + DefaultResultListItem, +} from '@backstage/plugin-search'; + +const useStyles = makeStyles((theme: Theme) => ({ + bar: { + padding: theme.spacing(1, 0), + }, + filters: { + padding: theme.spacing(2), + }, + filter: { + '& + &': { + marginTop: theme.spacing(2.5), + }, + }, +})); + +const SearchPage = () => { + const classes = useStyles(); + + return ( + +
} /> + + + + + + + + + + + + + + + + {({ results }) => ( + + {results.map(({ type, document }) => { + switch (type) { + case 'software-catalog': + return ( + + ); + default: + return ( + + ); + } + })} + + )} + + + + + + ); +}; + +export const searchPage = ; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index e08bfd6abe..e02e30e5d9 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -13,36 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export { catalogPlugin } from '@backstage/plugin-catalog'; -export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; -export { explorePlugin } from '@backstage/plugin-explore'; -export { plugin as Circleci } from '@backstage/plugin-circleci'; -export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; -export { plugin as Sentry } from '@backstage/plugin-sentry'; -export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; -export { plugin as TechDocs } from '@backstage/plugin-techdocs'; -export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; -export { plugin as GithubActions } from '@backstage/plugin-github-actions'; -export { plugin as Rollbar } from '@backstage/plugin-rollbar'; -export { plugin as Newrelic } from '@backstage/plugin-newrelic'; -export { travisciPlugin } from '@roadiehq/backstage-plugin-travis-ci'; -export { plugin as Jenkins } from '@backstage/plugin-jenkins'; -export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; -export { githubPullRequestsPlugin } from '@roadiehq/backstage-plugin-github-pull-requests'; -export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects'; -export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; -export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild'; -export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; -export { githubInsightsPlugin } from '@roadiehq/backstage-plugin-github-insights'; -export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; -export { plugin as UserSettings } from '@backstage/plugin-user-settings'; -export { plugin as PagerDuty } from '@backstage/plugin-pagerduty'; -export { buildkitePlugin } from '@roadiehq/backstage-plugin-buildkite'; -export { plugin as Search } from '@backstage/plugin-search'; -export { plugin as Org } from '@backstage/plugin-org'; -export { plugin as Kafka } from '@backstage/plugin-kafka'; -export { todoPlugin } from '@backstage/plugin-todo'; + +// TODO(Rugvip): This plugin is currently not part of the app element tree, +// ideally we have an API for the context menu that permits that. export { badgesPlugin } from '@backstage/plugin-badges'; -export { githubDeploymentsPlugin } from '@backstage/plugin-github-deployments'; +export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index c194f7e666..6256697036 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,191 @@ # @backstage/backend-common +## 0.8.2 + +### Patch Changes + +- 92963779b: Omits the `upgrade-insecure-requests` Content-Security-Policy directive by default, to prevent automatic HTTPS request upgrading for HTTP-deployed Backstage sites. + + If you previously disabled this using `false` in your `app-config.yaml`, this line is no longer necessary: + + ```diff + backend: + csp: + - upgrade-insecure-requests: false + ``` + + To keep the existing behavior of `upgrade-insecure-requests` Content-Security-Policy being _enabled_, add the key with an empty array as the value in your `app-config.yaml`: + + ```diff + backend: + + csp: + + upgrade-insecure-requests: [] + ``` + + Read more on [upgrade-insecure-requests here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/upgrade-insecure-requests). + +- eda9dbd5f: Download archives as compressed tar files for Bitbucket to keep executable permissions. +- Updated dependencies [eda9dbd5f] + - @backstage/integration@0.5.6 + +## 0.8.1 + +### Patch Changes + +- c7dad9218: All cache-related connection errors are now handled and logged by the cache manager. App Integrators may provide an optional error handler when instantiating the cache manager if custom error handling is needed. + + ```typescript + // Providing an error handler + const cacheManager = CacheManager.fromConfig(config, { + onError: e => { + if (isSomehowUnrecoverable(e)) { + gracefullyShutThingsDown(); + process.exit(1); + } + }, + }); + ``` + +- 65e6c4541: Remove circular dependencies +- 5001de908: Change GitlabUrlReader to SHA timestamp compare using only commits that modify given file path, if file path given +- Updated dependencies [65e6c4541] +- Updated dependencies [290405276] + - @backstage/integration@0.5.3 + - @backstage/config-loader@0.6.2 + +## 0.8.0 + +### Minor Changes + +- 22fd8ce2a: Introducing: a standard API for App Integrators to configure cache stores and Plugin Developers to interact with them. + + Two cache stores are currently supported. + + - `memory`, which is a very simple in-memory key/value store, intended for local development. + - `memcache`, which can be used to connect to a memcache host. + + Configuring and working with cache stores is very similar to the process for database connections. + + ```yaml + backend: + cache: + store: memcache + connection: user:pass@cache.example.com:11211 + ``` + + ```typescript + import { CacheManager } from '@backstage/backend-common'; + + // Instantiating a cache client for a plugin. + const cacheManager = CacheManager.fromConfig(config); + const somePluginCache = cacheManager.forPlugin('somePlugin'); + const cacheClient = somePluginCache.getClient(); + + // Using the cache client: + const cachedValue = await cacheClient.get('someKey'); + if (cachedValue) { + return cachedValue; + } else { + const someValue = await someExpensiveProcess(); + await cacheClient.set('someKey', someValue); + } + await cacheClient.delete('someKey'); + ``` + + Cache clients deal with TTLs in milliseconds. A TTL can be provided as a defaultTtl when getting a client, or may be passed when setting specific objects. If no TTL is provided, data will be persisted indefinitely. + + ```typescript + // Getting a client with a default TTL + const cacheClient = somePluginCache.getClient({ + defaultTtl: 3600000, + }); + + // Setting a TTL on a per-object basis. + cacheClient.set('someKey', data, { ttl: 3600000 }); + ``` + + Configuring a cache store is optional. Even when no cache store is configured, the cache manager will dutifully pass plugins a manager that resolves a cache client that does not actually write or read any data. + +### Patch Changes + +- f9fb4a205: Prep work for mysql support in backend-common + +## 0.7.0 + +### Minor Changes + +- e0bfd3d44: Refactor the `runDockerContainer(…)` function to an interface-based api. + This gives the option to replace the docker runtime in the future. + + Packages and plugins that previously used the `dockerode` as argument should be migrated to use the new `ContainerRunner` interface instead. + + ```diff + import { + - runDockerContainer, + + ContainerRunner, + PluginEndpointDiscovery, + } from '@backstage/backend-common'; + - import Docker from 'dockerode'; + + type RouterOptions = { + // ... + - dockerClient: Docker, + + containerRunner: ContainerRunner; + }; + + export async function createRouter({ + // ... + - dockerClient, + + containerRunner, + }: RouterOptions): Promise { + // ... + + + await containerRunner.runContainer({ + - await runDockerContainer({ + image: 'docker', + // ... + - dockerClient, + }); + + // ... + } + ``` + + To keep the `dockerode` based runtime, use the `DockerContainerRunner` implementation: + + ```diff + + import { + + ContainerRunner, + + DockerContainerRunner + + } from '@backstage/backend-common'; + - import { runDockerContainer } from '@backstage/backend-common'; + + + const containerRunner: ContainerRunner = new DockerContainerRunner({dockerClient}); + + await containerRunner.runContainer({ + - await runDockerContainer({ + image: 'docker', + // ... + - dockerClient, + }); + ``` + +### Patch Changes + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/integration@0.5.2 + - @backstage/config-loader@0.6.1 + - @backstage/config@0.1.5 + +## 0.6.3 + +### Patch Changes + +- d367f63b5: remove use of deprecated type HelmetOptions +- b42531cfe: Support configuration of file storage for SQLite databases. Every plugin has its + own database file at the specified path. + ## 0.6.2 ### Patch Changes diff --git a/packages/backend-common/README.md b/packages/backend-common/README.md index dc08d89416..dc69790bc3 100644 --- a/packages/backend-common/README.md +++ b/packages/backend-common/README.md @@ -8,6 +8,8 @@ error handling and similar. Add the library to your backend package: ```sh +# From your Backstage root directory +cd packages/backend yarn add @backstage/backend-common ``` diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md new file mode 100644 index 0000000000..068220cad3 --- /dev/null +++ b/packages/backend-common/api-report.md @@ -0,0 +1,379 @@ +## API Report File for "@backstage/backend-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegration } from '@backstage/integration'; +import { BitbucketIntegration } from '@backstage/integration'; +import { Config } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; +import cors from 'cors'; +import Docker from 'dockerode'; +import { ErrorRequestHandler } from 'express'; +import express from 'express'; +import { GithubCredentialsProvider } from '@backstage/integration'; +import { GitHubIntegration } from '@backstage/integration'; +import { GitLabIntegration } from '@backstage/integration'; +import * as http from 'http'; +import { JsonValue } from '@backstage/config'; +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { MergeResult } from 'isomorphic-git'; +import { PushResult } from 'isomorphic-git'; +import { Readable } from 'stream'; +import { ReadCommitResult } from 'isomorphic-git'; +import { RequestHandler } from 'express'; +import { Router } from 'express'; +import { Server } from 'http'; +import * as winston from 'winston'; +import { Writable } from 'stream'; + +// @public (undocumented) +export class AzureUrlReader implements UrlReader { + constructor(integration: AzureIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export class BitbucketUrlReader implements UrlReader { + constructor(integration: BitbucketIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export interface CacheClient { + delete(key: string): Promise; + get(key: string): Promise; + set(key: string, value: JsonValue, options?: CacheSetOptions): Promise; +} + +// @public +export class CacheManager { + forPlugin(pluginId: string): PluginCacheManager; + static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager; + } + +// @public (undocumented) +export const coloredFormat: winston.Logform.Format; + +// @public (undocumented) +export interface ContainerRunner { + // (undocumented) + runContainer(opts: RunContainerOptions): Promise; +} + +// @public @deprecated +export const createDatabase: typeof createDatabaseClient; + +// @public +export function createDatabaseClient(dbConfig: Config, overrides?: Partial): Knex; + +// @public (undocumented) +export function createRootLogger(options?: winston.LoggerOptions, env?: NodeJS.ProcessEnv): winston.Logger; + +// @public +export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl; + +// @public (undocumented) +export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise; + +// @public (undocumented) +export class DockerContainerRunner implements ContainerRunner { + constructor({ dockerClient }: { + dockerClient: Docker; + }); + // (undocumented) + runContainer({ imageName, command, args, logStream, mountDirs, workingDir, envVars, }: RunContainerOptions): Promise; +} + +// @public +export function ensureDatabaseExists(dbConfig: Config, ...databases: Array): Promise; + +// @public +export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler; + +// @public (undocumented) +export type ErrorHandlerOptions = { + showStackTraces?: boolean; + logger?: Logger; + logClientErrors?: boolean; +}; + +// @public (undocumented) +export function getRootLogger(): winston.Logger; + +// @public +export function getVoidLogger(): winston.Logger; + +// @public (undocumented) +export class Git { + // (undocumented) + add({ dir, filepath, }: { + dir: string; + filepath: string; + }): Promise; + // (undocumented) + addRemote({ dir, url, remote, }: { + dir: string; + remote: string; + url: string; + }): Promise; + // (undocumented) + clone({ url, dir, ref, }: { + url: string; + dir: string; + ref?: string; + }): Promise; + // (undocumented) + commit({ dir, message, author, committer, }: { + dir: string; + message: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + currentBranch({ dir, fullName, }: { + dir: string; + fullName?: boolean; + }): Promise; + // (undocumented) + fetch({ dir, remote, }: { + dir: string; + remote?: string; + }): Promise; + // (undocumented) + static fromAuth: ({ username, password, logger, }: { + username?: string | undefined; + password?: string | undefined; + logger?: Logger | undefined; + }) => Git; + // (undocumented) + init({ dir }: { + dir: string; + }): Promise; + // (undocumented) + merge({ dir, theirs, ours, author, committer, }: { + dir: string; + theirs: string; + ours?: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + push({ dir, remote }: { + dir: string; + remote: string; + }): Promise; + // (undocumented) + readCommit({ dir, sha, }: { + dir: string; + sha: string; + }): Promise; + // (undocumented) + resolveRef({ dir, ref, }: { + dir: string; + ref: string; + }): Promise; +} + +// @public +export class GithubUrlReader implements UrlReader { + constructor(integration: GitHubIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export class GitlabUrlReader implements UrlReader { + constructor(integration: GitLabIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export function loadBackendConfig(options: Options): Promise; + +// @public +export function notFoundHandler(): RequestHandler; + +// @public +export type PluginCacheManager = { + getClient: (options?: ClientOptions) => CacheClient; +}; + +// @public +export interface PluginDatabaseManager { + getClient(): Promise; +} + +// @public +export type PluginEndpointDiscovery = { + getBaseUrl(pluginId: string): Promise; + getExternalBaseUrl(pluginId: string): Promise; +}; + +// @public (undocumented) +export type ReadTreeResponse = { + files(): Promise; + archive(): Promise; + dir(options?: ReadTreeResponseDirOptions): Promise; + etag: string; +}; + +// @public +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +// @public +export function requestLoggingHandler(logger?: Logger): RequestHandler; + +// @public +export function resolvePackagePath(name: string, ...paths: string[]): string; + +// @public (undocumented) +export type RunContainerOptions = { + imageName: string; + command?: string | string[]; + args: string[]; + logStream?: Writable; + mountDirs?: Record; + workingDir?: string; + envVars?: Record; +}; + +// @public +export type SearchResponse = { + files: SearchResponseFile[]; + etag: string; +}; + +// @public +export type SearchResponseFile = { + url: string; + content(): Promise; +}; + +// @public (undocumented) +export type ServiceBuilder = { + loadConfig(config: ConfigReader): ServiceBuilder; + setPort(port: number): ServiceBuilder; + setHost(host: string): ServiceBuilder; + setLogger(logger: Logger): ServiceBuilder; + enableCors(options: cors.CorsOptions): ServiceBuilder; + setHttpsSettings(settings: HttpsSettings): ServiceBuilder; + addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; + start(): Promise; +}; + +// @public (undocumented) +export function setRootLogger(newLogger: winston.Logger): void; + +// @public +export class SingleConnectionDatabaseManager { + forPlugin(pluginId: string): PluginDatabaseManager; + static fromConfig(config: Config): SingleConnectionDatabaseManager; + } + +// @public +export class SingleHostDiscovery implements PluginEndpointDiscovery { + static fromConfig(config: Config, options?: { + basePath?: string; + }): SingleHostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; + } + +// @public (undocumented) +export type StatusCheck = () => Promise; + +// @public +export function statusCheckHandler(options?: StatusCheckHandlerOptions): Promise; + +// @public (undocumented) +export interface StatusCheckHandlerOptions { + statusCheck?: StatusCheck; +} + +// @public +export type UrlReader = { + read(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; + search(url: string, options?: SearchOptions): Promise; +}; + +// @public +export class UrlReaders { + static create({ logger, config, factories }: CreateOptions): UrlReader; + static default({ logger, config, factories }: CreateOptions): UrlReader; +} + +// @public +export function useHotCleanup(_module: NodeModule, cancelEffect: () => void): void; + +// @public +export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 74c199e737..875660a594 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -57,7 +57,7 @@ export interface Config { database: | { client: 'sqlite3'; - connection: ':memory:' | string; + connection: ':memory:' | string | { filename: string }; } | { client: 'pg'; @@ -68,6 +68,20 @@ export interface Config { connection: string | object; }; + /** Cache connection configuration, select cache type using the `store` field */ + cache?: + | { + store: 'memory'; + } + | { + store: 'memcache'; + /** + * A memcache connection string in the form `user:pass@host:port`. + * @secret + */ + connection: string; + }; + cors?: { origin?: string | string[]; methods?: string | string[]; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 41fa657818..fc57ee59a8 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.6.2", + "version": "0.8.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,12 +30,12 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.6.0", + "@backstage/config": "^0.1.5", + "@backstage/config-loader": "^0.6.2", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.6", "@google-cloud/storage": "^5.8.0", - "@octokit/rest": "^18.0.12", + "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", @@ -51,6 +51,8 @@ "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "keyv": "^4.0.3", + "keyv-memcache": "^1.2.5", "knex": "^0.95.1", "lodash": "^4.17.15", "logform": "^2.1.1", @@ -62,7 +64,8 @@ "stoppable": "^1.1.0", "tar": "^6.0.5", "unzipper": "^0.10.11", - "winston": "^3.2.1" + "winston": "^3.2.1", + "yn": "^4.0.0" }, "peerDependencies": { "pg-connection-string": "^2.3.0" @@ -73,8 +76,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.7", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.7.0", + "@backstage/test-utils": "^0.1.12", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", @@ -94,6 +97,7 @@ "jest": "^26.0.1", "mock-fs": "^4.13.0", "msw": "^0.21.2", + "mysql2": "^2.2.5", "recursive-readdir": "^2.2.2", "supertest": "^6.1.3" }, diff --git a/packages/backend-common/src/cache/CacheClient.test.ts b/packages/backend-common/src/cache/CacheClient.test.ts new file mode 100644 index 0000000000..81e8351b58 --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DefaultCacheClient } from './CacheClient'; +import Keyv from 'keyv'; + +describe('CacheClient', () => { + let client: Keyv; + const b64 = (k: string) => Buffer.from(k).toString('base64'); + + beforeEach(() => { + client = new Keyv(); + client.get = jest.fn(); + client.set = jest.fn(); + client.delete = jest.fn(); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheClient.get', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.get(key); + + expect(client.get).toHaveBeenCalledWith(b64(key)); + }); + + it('calls client with normalized key (very long key)', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'x'.repeat(251); + + await sut.get(key); + + const spy = client.get as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).not.toEqual(b64(key)); + expect(actualKey.length).toBeLessThan(250); + }); + + it('rejects on underlying error', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.get = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.get('someKey')).rejects.toEqual(expectedError); + }); + + it('resolves what underlying client resolves', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedValue = 'some value'; + client.get = jest.fn().mockResolvedValue(expectedValue); + + const actualValue = await sut.get('someKey'); + + return expect(actualValue).toEqual(expectedValue); + }); + }); + + describe('CacheClient.set', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.set(key, {}); + + const spy = client.set as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(key)); + }); + + it('passes ttl to client when given', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedTtl = 3600; + + await sut.set('someKey', {}, { ttl: expectedTtl }); + + const spy = client.set as jest.Mock; + const actualTtl = spy.mock.calls[0][2]; + expect(actualTtl).toEqual(expectedTtl); + }); + + it('rejects on underlying error if configured', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.set = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.set('someKey', {})).rejects.toEqual(expectedError); + }); + }); + + describe('CacheClient.delete', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.delete(key); + + const spy = client.delete as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(key)); + }); + + it('rejects on underlying error if configured', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.delete = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.delete('someKey')).rejects.toEqual(expectedError); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts new file mode 100644 index 0000000000..3b654e248b --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonValue } from '@backstage/config'; +import { createHash } from 'crypto'; +import Keyv from 'keyv'; + +type CacheClientArgs = { + client: Keyv; +}; + +type CacheSetOptions = { + /** + * Optional TTL in milliseconds. Defaults to the TTL provided when the client + * was set up (or no TTL if none are provided). + */ + ttl?: number; +}; + +/** + * A pre-configured, storage agnostic cache client suitable for use by + * Backstage plugins. + */ +export interface CacheClient { + /** + * Reads data from a cache store for the given key. If no data was found, + * returns undefined. + */ + get(key: string): Promise; + + /** + * Writes the given data to a cache store, associated with the given key. An + * optional TTL may also be provided, otherwise it defaults to the TTL that + * was provided when the client was instantiated. + */ + set(key: string, value: JsonValue, options?: CacheSetOptions): Promise; + + /** + * Removes the given key from the cache store. + */ + delete(key: string): Promise; +} + +/** + * A basic, concrete implementation of the CacheClient, suitable for almost + * all uses in Backstage. + */ +export class DefaultCacheClient implements CacheClient { + private readonly client: Keyv; + + constructor({ client }: CacheClientArgs) { + this.client = client; + } + + async get(key: string): Promise { + const k = this.getNormalizedKey(key); + return await this.client.get(k); + } + + async set( + key: string, + value: JsonValue, + opts: CacheSetOptions = {}, + ): Promise { + const k = this.getNormalizedKey(key); + await this.client.set(k, value, opts.ttl); + } + + async delete(key: string): Promise { + const k = this.getNormalizedKey(key); + await this.client.delete(k); + } + + /** + * Ensures keys are well-formed for any/all cache stores. + */ + private getNormalizedKey(candidateKey: string): string { + // Remove potentially invalid characters. + const wellFormedKey = Buffer.from(candidateKey).toString('base64'); + + // Memcache in particular doesn't do well with keys > 250 bytes. + // Padded because a plugin ID is also prepended to the key. + if (wellFormedKey.length < 200) { + return wellFormedKey; + } + + return createHash('md5').update(candidateKey).digest('base64'); + } +} diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts new file mode 100644 index 0000000000..97c1714c53 --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -0,0 +1,225 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import Keyv from 'keyv'; +/* @ts-expect-error */ +import KeyvMemcache from 'keyv-memcache'; +import { DefaultCacheClient } from './CacheClient'; +import { CacheManager } from './CacheManager'; +import { NoStore } from './NoStore'; + +jest.createMockFromModule('keyv'); +jest.mock('keyv'); +jest.createMockFromModule('keyv-memcache'); +jest.mock('keyv-memcache'); +jest.mock('./CacheClient', () => { + return { + DefaultCacheClient: jest.fn(), + }; +}); + +describe('CacheManager', () => { + const defaultConfigOptions = { + backend: { + cache: { + store: 'memory', + }, + }, + }; + const defaultConfig = () => new ConfigReader(defaultConfigOptions); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheManager.fromConfig', () => { + it('accesses the backend.cache key', () => { + const getOptionalString = jest.fn(); + const config = defaultConfig(); + config.getOptionalString = getOptionalString; + + CacheManager.fromConfig(config); + + expect(getOptionalString.mock.calls[0][0]).toEqual('backend.cache.store'); + expect(getOptionalString.mock.calls[1][0]).toEqual( + 'backend.cache.connection', + ); + }); + + it('does not require the backend.cache key', () => { + const config = new ConfigReader({ backend: {} }); + expect(() => { + CacheManager.fromConfig(config); + }).not.toThrowError(); + }); + + it('throws on unknown cache store', () => { + const config = new ConfigReader({ + backend: { cache: { store: 'notreal' } }, + }); + expect(() => { + CacheManager.fromConfig(config); + }).toThrowError(); + }); + }); + + describe('CacheManager.forPlugin', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + + it('connects to a cache store scoped to the plugin', async () => { + const pluginId = 'test1'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + expect(client).toHaveBeenCalledTimes(1); + }); + + it('attaches error handler to client', () => { + const pluginId = 'error-test'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('provides different plugins different cache clients', async () => { + const plugin1Id = 'test1'; + const plugin2Id = 'test2'; + const expectedTtl = 3600; + manager.forPlugin(plugin1Id).getClient({ defaultTtl: expectedTtl }); + manager.forPlugin(plugin2Id).getClient({ defaultTtl: expectedTtl }); + + const client = DefaultCacheClient as jest.Mock; + const cache = (Keyv as unknown) as jest.Mock; + expect(cache).toHaveBeenCalledTimes(2); + expect(client).toHaveBeenCalledTimes(2); + + const plugin1CallArgs = cache.mock.calls[0]; + const plugin2CallArgs = cache.mock.calls[1]; + expect(plugin1CallArgs[0].namespace).not.toEqual( + plugin2CallArgs[0].namespace, + ); + }); + }); + + describe('CacheManager.forPlugin stores', () => { + it('returns none client when no cache is configured', () => { + const manager = CacheManager.fromConfig( + new ConfigReader({ backend: {} }), + ); + const expectedNamespace = 'test-plugin'; + manager.forPlugin(expectedNamespace).getClient(); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0].store).toBeInstanceOf(NoStore); + }); + + it('returns memory client when explicitly configured', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + const expectedTtl = 3600; + const expectedNamespace = 'test-plugin'; + manager + .forPlugin(expectedNamespace) + .getClient({ defaultTtl: expectedTtl }); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + ttl: expectedTtl, + namespace: expectedNamespace, + }); + }); + + it('returns a memcache client when configured', () => { + const expectedHost = '127.0.0.1:11211'; + const manager = CacheManager.fromConfig( + new ConfigReader({ + backend: { + cache: { + store: 'memcache', + connection: expectedHost, + }, + }, + }), + ); + const expectedTtl = 3600; + manager.forPlugin('test').getClient({ defaultTtl: expectedTtl }); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCacheCalls = cache.mock.calls.splice(-1); + expect(mockCacheCalls[0][0]).toMatchObject({ + ttl: expectedTtl, + }); + expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache); + const memcache = KeyvMemcache as jest.Mock; + const mockMemcacheCalls = memcache.mock.calls.splice(-1); + expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); + }); + }); + + describe('connection errors', () => { + it('uses provided logger', () => { + // Set up and inject mock logger. + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + const manager = CacheManager.fromConfig(defaultConfig(), { + logger: mockLogger as any, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-logger-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockLogger.error).toHaveBeenCalledWith(expectedError); + }); + + it('calls provided handler', () => { + // Set up and inject mock logger. + const mockHandler = jest.fn(); + const manager = CacheManager.fromConfig(defaultConfig(), { + onError: mockHandler, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-handler-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockHandler).toHaveBeenCalledWith(expectedError); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts new file mode 100644 index 0000000000..e6699e628e --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import Keyv from 'keyv'; +// @ts-expect-error +import KeyvMemcache from 'keyv-memcache'; +import { Logger } from 'winston'; +import { getRootLogger } from '../logging'; +import { DefaultCacheClient, CacheClient } from './CacheClient'; +import { NoStore } from './NoStore'; +import { + CacheManagerOptions, + OptionalOnError, + PluginCacheManager, +} from './types'; + +/** + * Implements a Cache Manager which will automatically create new cache clients + * for plugins when requested. All requested cache clients are created with the + * connection details provided. + */ +export class CacheManager { + /** + * Keys represent supported `backend.cache.store` values, mapped to factories + * that return Keyv instances appropriate to the store. + */ + private readonly storeFactories = { + memcache: this.getMemcacheClient, + memory: this.getMemoryClient, + none: this.getNoneClient, + }; + + private readonly logger: Logger; + private readonly store: keyof CacheManager['storeFactories']; + private readonly connection: string; + private readonly errorHandler: OptionalOnError; + + /** + * Creates a new CacheManager instance by reading from the `backend` config + * section, specifically the `.cache` key. + * + * @param config The loaded application configuration. + */ + static fromConfig( + config: Config, + options: CacheManagerOptions = {}, + ): CacheManager { + // If no `backend.cache` config is provided, instantiate the CacheManager + // with a "NoStore" cache client. + const store = config.getOptionalString('backend.cache.store') || 'none'; + const connectionString = + config.getOptionalString('backend.cache.connection') || ''; + const logger = (options.logger || getRootLogger()).child({ + type: 'cacheManager', + }); + return new CacheManager(store, connectionString, logger, options.onError); + } + + private constructor( + store: string, + connectionString: string, + logger: Logger, + errorHandler: OptionalOnError, + ) { + if (!this.storeFactories.hasOwnProperty(store)) { + throw new Error(`Unknown cache store: ${store}`); + } + this.logger = logger; + this.store = store as keyof CacheManager['storeFactories']; + this.connection = connectionString; + this.errorHandler = errorHandler; + } + + /** + * Generates a PluginCacheManager for consumption by plugins. + * + * @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique. + */ + forPlugin(pluginId: string): PluginCacheManager { + return { + getClient: (opts = {}): CacheClient => { + const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl); + + // Always provide an error handler to avoid killing the process. + concreteClient.on('error', (err: Error) => { + // In all cases, just log the error. + this.logger.error(err); + + // Invoke any custom error handler if provided. + if (typeof this.errorHandler === 'function') { + this.errorHandler(err); + } + }); + + return new DefaultCacheClient({ + client: concreteClient, + }); + }, + }; + } + + private getClientWithTtl(pluginId: string, ttl: number | undefined): Keyv { + return this.storeFactories[this.store].call(this, pluginId, ttl); + } + + private getMemcacheClient( + pluginId: string, + defaultTtl: number | undefined, + ): Keyv { + return new Keyv({ + namespace: pluginId, + ttl: defaultTtl, + store: new KeyvMemcache(this.connection), + }); + } + + private getMemoryClient( + pluginId: string, + defaultTtl: number | undefined, + ): Keyv { + return new Keyv({ + namespace: pluginId, + ttl: defaultTtl, + }); + } + + private getNoneClient(pluginId: string): Keyv { + return new Keyv({ + namespace: pluginId, + store: new NoStore(), + }); + } +} diff --git a/packages/backend-common/src/cache/NoStore.ts b/packages/backend-common/src/cache/NoStore.ts new file mode 100644 index 0000000000..c89e814c93 --- /dev/null +++ b/packages/backend-common/src/cache/NoStore.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Storage class compatible with Keyv which always results in a no-op. This is + * used when no cache store is configured in a Backstage backend instance. + */ +export class NoStore extends Map { + clear(): void { + return; + } + + delete(_key: string): boolean { + return false; + } + + get(_key: string) { + return; + } + + has(_key: string): boolean { + return false; + } + + set(_key: string, _value: any): this { + return this; + } +} diff --git a/packages/backend-common/src/cache/index.ts b/packages/backend-common/src/cache/index.ts new file mode 100644 index 0000000000..0cc8178b1a --- /dev/null +++ b/packages/backend-common/src/cache/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { CacheClient } from './CacheClient'; +export { CacheManager } from './CacheManager'; +export type { PluginCacheManager } from './types'; diff --git a/packages/backend-common/src/cache/types.ts b/packages/backend-common/src/cache/types.ts new file mode 100644 index 0000000000..30db53d420 --- /dev/null +++ b/packages/backend-common/src/cache/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Logger } from 'winston'; +import { CacheClient } from './CacheClient'; + +type ClientOptions = { + /** + * An optional default TTL (in milliseconds) to be set when getting a client + * instance. If not provided, data will persist indefinitely by default (or + * can be configured per entry at set-time). + */ + defaultTtl?: number; +}; + +export type OptionalOnError = ((err: Error) => void) | undefined; + +export type CacheManagerOptions = { + /** + * An optional logger for use by the PluginCacheManager. + */ + logger?: Logger; + + /** + * An optional handler for connection errors emitted from the underlying data + * store. + */ + onError?: OptionalOnError; +}; + +/** + * The PluginCacheManager manages access to cache stores that Plugins get. + */ +export type PluginCacheManager = { + /** + * getClient provides backend plugins cache connections for itself. + * + * The purpose of this method is to allow plugins to get isolated data + * stores so that plugins are discouraged from cache-level integration + * and/or cache key collisions. + */ + getClient: (options?: ClientOptions) => CacheClient; +}; diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 9810ff9bef..6ab163ff7f 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -46,11 +46,11 @@ describe('database connection', () => { ).toBeTruthy(); }); - it('tries to create a mysql connection as a passthrough', () => { + it('returns a mysql connection', () => { expect(() => createDatabaseClient( new ConfigReader({ - client: 'mysql', + client: 'mysql2', connection: { host: '127.0.0.1', user: 'foo', @@ -59,7 +59,7 @@ describe('database connection', () => { }, }), ), - ).toThrowError(/Cannot find module 'mysql'/); + ).toBeTruthy(); }); it('accepts overrides', () => { diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 17ef2c461d..fb16915cf4 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import knexFactory, { Knex } from 'knex'; import { Config } from '@backstage/config'; +import knexFactory, { Knex } from 'knex'; import { mergeDatabaseConfig } from './config'; +import { createMysqlDatabaseClient, ensureMysqlDatabaseExists } from './mysql'; import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres'; import { createSqliteDatabaseClient } from './sqlite3'; @@ -36,8 +37,10 @@ export function createDatabaseClient( if (client === 'pg') { return createPgDatabaseClient(dbConfig, overrides); + } else if (client === 'mysql' || client === 'mysql2') { + return createMysqlDatabaseClient(dbConfig, overrides); } else if (client === 'sqlite3') { - return createSqliteDatabaseClient(dbConfig); + return createSqliteDatabaseClient(dbConfig, overrides); } return knexFactory(mergeDatabaseConfig(dbConfig.get(), overrides)); @@ -60,6 +63,8 @@ export async function ensureDatabaseExists( if (client === 'pg') { return ensurePgDatabaseExists(dbConfig, ...databases); + } else if (client === 'mysql' || client === 'mysql2') { + return ensureMysqlDatabaseExists(dbConfig, ...databases); } return undefined; diff --git a/packages/backend-common/src/database/mysql.test.ts b/packages/backend-common/src/database/mysql.test.ts new file mode 100644 index 0000000000..9e23585d5b --- /dev/null +++ b/packages/backend-common/src/database/mysql.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { Config, ConfigReader } from '@backstage/config'; +import { + buildMysqlDatabaseConfig, + createMysqlDatabaseClient, + getMysqlConnectionConfig, + parseMysqlConnectionString, +} from './mysql'; + +describe('mysql', () => { + const createMockConnection = () => ({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }); + + const createMockConnectionString = () => 'mysql://foo:bar@acme:3306/foodb'; + + const createConfig = (connection: any): Config => + new ConfigReader({ client: 'mysql2', connection }); + + describe('buildMysqlDatabaseConfig', () => { + it('builds a mysql config', () => { + const mockConnection = createMockConnection(); + + expect(buildMysqlDatabaseConfig(createConfig(mockConnection))).toEqual({ + client: 'mysql2', + connection: mockConnection, + useNullAsDefault: true, + }); + }); + + it('builds a connection string config', () => { + const mockConnectionString = createMockConnectionString(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnectionString)), + ).toEqual({ + client: 'mysql2', + connection: mockConnectionString, + useNullAsDefault: true, + }); + }); + + it('overrides the database name', () => { + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + + it('adds additional config settings', () => { + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + pool: { min: 0, max: 7 }, + debug: true, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + pool: { min: 0, max: 7 }, + debug: true, + }); + }); + + it('overrides the database from connection string', () => { + const mockConnectionString = createMockConnectionString(); + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnectionString), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + port: 3306, + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + }); + + describe('getMysqlConnectionConfig', () => { + it('returns the connection object back', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getMysqlConnectionConfig(config)).toEqual(mockConnection); + }); + + it('does not parse the connection string', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getMysqlConnectionConfig(config, true)).toEqual(mockConnection); + }); + + it('automatically parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getMysqlConnectionConfig(config)).toEqual({ + ...mockConnection, + port: 3306, + }); + }); + + it('parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getMysqlConnectionConfig(config, true)).toEqual({ + ...mockConnection, + port: 3306, + }); + }); + }); + + describe('createMysqlDatabaseClient', () => { + it('creates a mysql knex instance', () => { + expect( + createMysqlDatabaseClient( + createConfig({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }), + ), + ).toBeTruthy(); + }); + }); + + describe('parseMysqlConnectionString', () => { + it('parses a connection string uri', () => { + expect( + parseMysqlConnectionString( + 'mysql://u:pass@foobar:3307/dbname?ssl=require', + ), + ).toEqual({ + host: 'foobar', + user: 'u', + password: 'pass', + port: 3307, + database: 'dbname', + ssl: 'require', + }); + }); + }); +}); diff --git a/packages/backend-common/src/database/mysql.ts b/packages/backend-common/src/database/mysql.ts new file mode 100644 index 0000000000..be4632baf6 --- /dev/null +++ b/packages/backend-common/src/database/mysql.ts @@ -0,0 +1,161 @@ +/* + * 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 { Config } from '@backstage/config'; +import { InputError } from '@backstage/errors'; +import knexFactory, { Knex } from 'knex'; +import { mergeDatabaseConfig } from './config'; +import yn from 'yn'; + +/** + * Creates a knex mysql database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function createMysqlDatabaseClient( + dbConfig: Config, + overrides?: Knex.Config, +) { + const knexConfig = buildMysqlDatabaseConfig(dbConfig, overrides); + const database = knexFactory(knexConfig); + return database; +} + +/** + * Builds a knex mysql database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function buildMysqlDatabaseConfig( + dbConfig: Config, + overrides?: Knex.Config, +) { + return mergeDatabaseConfig( + dbConfig.get(), + { + connection: getMysqlConnectionConfig(dbConfig, !!overrides), + useNullAsDefault: true, + }, + overrides, + ); +} + +/** + * Gets the mysql connection config + * + * @param dbConfig The database config + * @param parseConnectionString Flag to explicitly control connection string parsing + */ +export function getMysqlConnectionConfig( + dbConfig: Config, + parseConnectionString?: boolean, +): Knex.MySqlConnectionConfig | string { + const connection = dbConfig.get('connection') as any; + const isConnectionString = + typeof connection === 'string' || connection instanceof String; + const autoParse = typeof parseConnectionString !== 'boolean'; + + const shouldParseConnectionString = autoParse + ? isConnectionString + : parseConnectionString && isConnectionString; + + return shouldParseConnectionString + ? parseMysqlConnectionString(connection as string) + : connection; +} + +/** + * Parses a mysql connection string. + * + * e.g. mysql://examplename:somepassword@examplehost:3306/dbname + * @param connectionString The mysql connection string + */ +export function parseMysqlConnectionString( + connectionString: string, +): Knex.MySqlConnectionConfig { + try { + const { + protocol, + username, + password, + port, + hostname, + pathname, + searchParams, + } = new URL(connectionString); + + if (protocol !== 'mysql:') { + throw new Error(`Unknown protocol ${protocol}`); + } else if (!username || !password) { + throw new Error(`Missing username/password`); + } else if (!pathname.match(/^\/[^/]+$/)) { + throw new Error(`Expected single path segment`); + } + + const result: Knex.MySqlConnectionConfig = { + user: username, + password, + host: hostname, + port: Number(port || 3306), + database: decodeURIComponent(pathname.substr(1)), + }; + + const ssl = searchParams.get('ssl'); + if (ssl) { + result.ssl = ssl; + } + + const debug = searchParams.get('debug'); + if (debug) { + result.debug = yn(debug); + } + + return result; + } catch (e) { + throw new InputError( + `Error while parsing MySQL connection string, ${e}`, + e, + ); + } +} + +/** + * Creates the missing mysql database if it does not exist + * + * @param dbConfig The database config + * @param databases The names of the databases to create + */ +export async function ensureMysqlDatabaseExists( + dbConfig: Config, + ...databases: Array +) { + const admin = createMysqlDatabaseClient(dbConfig, { + connection: { + database: (null as unknown) as string, + }, + }); + + try { + const ensureDatabase = async (database: string) => { + await admin.raw(`CREATE DATABASE IF NOT EXISTS ??`, [database]); + }; + await Promise.all(databases.map(ensureDatabase)); + } finally { + await admin.destroy(); + } +} diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts index a6b8e5d84d..86f3a6968b 100644 --- a/packages/backend-common/src/database/sqlite3.test.ts +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -15,6 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; +import path from 'path'; import { buildSqliteDatabaseConfig, createSqliteDatabaseClient, @@ -25,25 +26,70 @@ describe('sqlite3', () => { new ConfigReader({ client: 'sqlite3', connection }); describe('buildSqliteDatabaseConfig', () => { - it('buidls a string connection', () => { + it('builds an in-memory connection', () => { expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({ client: 'sqlite3', - connection: ':memory:', + connection: { filename: ':memory:' }, useNullAsDefault: true, }); }); - it('builds a filename connection', () => { + it('builds an in-memory connection by override with filename', () => { + expect( + buildSqliteDatabaseConfig( + createConfig(path.join('path', 'to', 'foo')), + { connection: ':memory:' }, + ), + ).toEqual({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + }); + + it('builds a persistent connection, normalize config with filename', () => { + expect( + buildSqliteDatabaseConfig(createConfig(path.join('path', 'to', 'foo'))), + ).toEqual({ + client: 'sqlite3', + connection: { filename: path.join('path', 'to', 'foo') }, + useNullAsDefault: true, + }); + }); + + it('builds a persistent connection', () => { expect( buildSqliteDatabaseConfig( createConfig({ - filename: '/path/to/foo', + filename: path.join('path', 'to', 'foo'), }), ), ).toEqual({ client: 'sqlite3', connection: { - filename: '/path/to/foo', + filename: path.join('path', 'to', 'foo'), + }, + useNullAsDefault: true, + }); + }); + + it('builds a persistent connection per database', () => { + expect( + buildSqliteDatabaseConfig( + createConfig({ + filename: path.join('path', 'to', 'foo'), + }), + { + connection: { + database: 'my-database', + }, + }, + ), + ).toEqual({ + client: 'sqlite3', + connection: { + filename: path.join('path', 'to', 'foo', 'my-database.sqlite'), + database: 'my-database', }, useNullAsDefault: true, }); @@ -52,12 +98,12 @@ describe('sqlite3', () => { it('replaces the connection with an override', () => { expect( buildSqliteDatabaseConfig(createConfig(':memory:'), { - connection: { filename: '/path/to/foo' }, + connection: { filename: path.join('path', 'to', 'foo') }, }), ).toEqual({ client: 'sqlite3', connection: { - filename: '/path/to/foo', + filename: path.join('path', 'to', 'foo'), }, useNullAsDefault: true, }); diff --git a/packages/backend-common/src/database/sqlite3.ts b/packages/backend-common/src/database/sqlite3.ts index f5742c68a0..d4169e3899 100644 --- a/packages/backend-common/src/database/sqlite3.ts +++ b/packages/backend-common/src/database/sqlite3.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import knexFactory, { Knex } from 'knex'; import { Config } from '@backstage/config'; +import { ensureDirSync } from 'fs-extra'; +import knexFactory, { Knex } from 'knex'; +import path from 'path'; import { mergeDatabaseConfig } from './config'; /** @@ -29,6 +31,19 @@ export function createSqliteDatabaseClient( overrides?: Knex.Config, ) { const knexConfig = buildSqliteDatabaseConfig(dbConfig, overrides); + + // If storage on disk is used, ensure that the directory exists + if ( + (knexConfig.connection as Knex.Sqlite3ConnectionConfig).filename && + (knexConfig.connection as Knex.Sqlite3ConnectionConfig).filename !== + ':memory:' + ) { + const { filename } = knexConfig.connection as Knex.Sqlite3ConnectionConfig; + const directory = path.dirname(filename); + + ensureDirSync(directory); + } + const database = knexFactory(knexConfig); database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { @@ -47,12 +62,40 @@ export function createSqliteDatabaseClient( export function buildSqliteDatabaseConfig( dbConfig: Config, overrides?: Knex.Config, -) { - return mergeDatabaseConfig( - dbConfig.get(), +): Knex.Config { + const baseConfig = dbConfig.get(); + + // Normalize config to always contain a connection object + if (typeof baseConfig.connection === 'string') { + baseConfig.connection = { filename: baseConfig.connection }; + } + if (overrides && typeof overrides.connection === 'string') { + overrides.connection = { filename: overrides.connection }; + } + + const config: Knex.Config = mergeDatabaseConfig( + { + connection: {}, + }, + baseConfig, { useNullAsDefault: true, }, overrides, ); + + // If we don't create an in-memory database, interpret the connection string + // as a directory that contains multiple sqlite files based on the database + // name. + const database = (config.connection as Knex.ConnectionConfig).database; + const sqliteConnection = config.connection as Knex.Sqlite3ConnectionConfig; + + if (database && sqliteConnection.filename !== ':memory:') { + sqliteConnection.filename = path.join( + sqliteConnection.filename, + `${database}.sqlite`, + ); + } + + return config; } diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index 29b1062750..dab981a824 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './cache'; export * from './config'; export * from './database'; export * from './discovery'; diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index bb76181287..c4a0fe0466 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -29,11 +29,11 @@ import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 16e7628ec8..e2b230a05f 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index aa78bf27d5..24216d45b5 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -28,9 +28,9 @@ import os from 'os'; import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); @@ -90,7 +90,7 @@ describe('BitbucketUrlReader', () => { 'src', 'reading', '__fixtures__', - 'bitbucket-repo-with-commit-hash.zip', + 'bitbucket-repo-with-commit-hash.tar.gz', ), ); @@ -99,7 +99,7 @@ describe('BitbucketUrlReader', () => { 'src', 'reading', '__fixtures__', - 'bitbucket-server-repo.zip', + 'bitbucket-server-repo.tar.gz', ), ); @@ -119,14 +119,14 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://bitbucket.org/backstage/mock/get/master.zip', + 'https://bitbucket.org/backstage/mock/get/master.tgz', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + 'attachment; filename=backstage-mock-12ab34cd56ef.tgz', ), ctx.body(repoBuffer), ), @@ -142,14 +142,14 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock.zip', + 'attachment; filename=backstage-mock.tgz', ), ctx.body(privateBitbucketRepoBuffer), ), @@ -177,8 +177,8 @@ describe('BitbucketUrlReader', () => { const files = await response.files(); expect(files.length).toBe(2); - const indexMarkdownFile = await files[0].content(); - const mkDocsFile = await files[1].content(); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); expect(indexMarkdownFile.toString()).toBe('# Test\n'); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); @@ -189,7 +189,7 @@ describe('BitbucketUrlReader', () => { 'https://bitbucket.org/backstage/mock', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), @@ -234,7 +234,7 @@ describe('BitbucketUrlReader', () => { 'https://bitbucket.org/backstage/mock/src/master/docs', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'index.md'), 'utf8'), @@ -284,7 +284,7 @@ describe('BitbucketUrlReader', () => { 'src', 'reading', '__fixtures__', - 'bitbucket-repo-with-commit-hash.zip', + 'bitbucket-repo-with-commit-hash.tar.gz', ), ); @@ -304,14 +304,14 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://bitbucket.org/backstage/mock/get/master.zip', + 'https://bitbucket.org/backstage/mock/get/master.tgz', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + 'attachment; filename=backstage-mock-12ab34cd56ef.tgz', ), ctx.body(repoBuffer), ), @@ -359,21 +359,21 @@ describe('BitbucketUrlReader', () => { 'src', 'reading', '__fixtures__', - 'bitbucket-server-repo.zip', + 'bitbucket-server-repo.tar.gz', ), ); beforeEach(() => { worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock.zip', + 'attachment; filename=backstage-mock.tgz', ), ctx.body(privateBitbucketRepoBuffer), ), diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index f102be7283..009b81bc28 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, @@ -126,7 +126,7 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - return await this.deps.treeResponseFactory.fromZipArchive({ + return await this.deps.treeResponseFactory.fromTarArchive({ stream: (archiveBitbucketResponse.body as unknown) as Readable, subpath: filepath, etag: lastCommitShortHash, diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 8dc4aba29b..2363a16d1a 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -19,7 +19,7 @@ import { msw } from '@backstage/test-utils'; import { setupServer } from 'msw/node'; import { getVoidLogger } from '../logging'; import { FetchUrlReader } from './FetchUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; describe('FetchUrlReader', () => { const worker = setupServer(); @@ -45,7 +45,7 @@ describe('FetchUrlReader', () => { }, }), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 67e7f27069..cbe61f2e66 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -35,9 +35,9 @@ import { GhTreeResponse, GithubUrlReader, } from './GithubUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 77e33cbd54..2b1247fe77 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -26,8 +26,8 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 9dcc92a4a6..b5592c09f5 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -24,7 +24,7 @@ import os from 'os'; import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { NotModifiedError, NotFoundError } from '@backstage/errors'; import { GitLabIntegration, @@ -33,7 +33,7 @@ import { const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); @@ -190,11 +190,17 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; + + const specificPathCommitsGitlabApiResponse = [ + { + id: 'sha456def', + }, + ]; beforeEach(() => { worker.use( @@ -221,17 +227,29 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), - ), - rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', - (_, res, ctx) => res(ctx.status(404)), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + if (refName === 'branchDoesNotExist') { + return res(ctx.status(404)); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', @@ -243,13 +261,26 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', @@ -351,7 +382,7 @@ describe('GitlabUrlReader', () => { ).resolves.toBe('# Test\n'); }); - it('throws a NotModifiedError when given a etag in options', async () => { + it('throws a NotModifiedError when given a etag in options matching last commit', async () => { const fnGitlab = async () => { await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', { etag: 'sha123abc', @@ -371,6 +402,29 @@ describe('GitlabUrlReader', () => { await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); }); + it('throws a NotModifiedError when given a etag in options matching last commit affecting specified filepath', async () => { + const fnGitlab = async () => { + await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + const fnHostedGitlab = async () => { + await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + await expect(fnGitlab).rejects.toThrow(NotModifiedError); + await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); + }); + it('should not throw error when given an outdated etag in options', async () => { const response = await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/main', @@ -389,12 +443,12 @@ describe('GitlabUrlReader', () => { }); it('should throw error on missing branch', async () => { - const fnGithub = async () => { + const fnGitlab = async () => { await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/branchDoesNotExist', ); }; - await expect(fnGithub).rejects.toThrow(NotFoundError); + await expect(fnGitlab).rejects.toThrow(NotFoundError); }); }); @@ -408,11 +462,11 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; beforeEach(() => { worker.use( @@ -439,13 +493,18 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), ); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index c93103b2da..635565c8a0 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -25,9 +25,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, @@ -106,25 +106,30 @@ export class GitlabUrlReader implements UrlReader { // ref is an empty string if no branch is set in provided url to readTree. const branch = ref || projectGitlabResponseJson.default_branch; - // Fetch the latest commit in the provided or default branch to compare against - // the provided sha. - const branchGitlabResponse = await fetch( + // Fetch the latest commit that modifies the the filepath in the provided or default branch + // to compare against the provided sha. + const commitsReqParams = new URLSearchParams(); + commitsReqParams.set('ref_name', branch); + if (!!filepath) { + commitsReqParams.set('path', filepath); + } + const commitsGitlabResponse = await fetch( new URL( `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, - )}/repository/branches/${branch}`, + )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), getGitLabRequestOptions(this.integration.config), ); - if (!branchGitlabResponse.ok) { - const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; - if (branchGitlabResponse.status === 404) { + if (!commitsGitlabResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; + if (commitsGitlabResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - const commitSha = (await branchGitlabResponse.json()).commit.id; + const commitSha = (await commitsGitlabResponse.json())[0].id; if (options?.etag && options.etag === commitSha) { throw new NotModifiedError(); diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts index e201361a91..5d4b01da44 100644 --- a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts @@ -16,7 +16,7 @@ import { ConfigReader, JsonObject } from '@backstage/config'; import { getVoidLogger } from '../logging'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; import { UrlReaderPredicateTuple } from './types'; @@ -25,7 +25,7 @@ describe('GcsUrlReader', () => { return GoogleGcsUrlReader.factory({ config: new ConfigReader(config), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 06c6fdcdcf..8f27d058df 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -22,7 +22,7 @@ import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { FetchUrlReader } from './FetchUrlReader'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; @@ -44,7 +44,9 @@ export class UrlReaders { */ static create({ logger, config, factories }: CreateOptions): UrlReader { const mux = new UrlReaderPredicateMux(); - const treeResponseFactory = ReadTreeResponseFactory.create({ config }); + const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config, + }); for (const factory of factories ?? []) { const tuples = factory({ config, logger: logger, treeResponseFactory }); diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.tar.gz b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.tar.gz new file mode 100644 index 0000000000..24175a5e16 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip deleted file mode 100644 index 135860afd3..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip and /dev/null differ diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.tar.gz new file mode 100644 index 0000000000..fba107dd51 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip deleted file mode 100644 index be6b20d127..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip and /dev/null differ diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 05ecdb0fc3..1ce7555a30 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -15,27 +15,18 @@ */ import os from 'os'; -import { Readable } from 'stream'; import { Config } from '@backstage/config'; -import { ReadTreeResponse } from '../types'; +import { + ReadTreeResponse, + FromArchiveOptions, + ReadTreeResponseFactory, +} from '../types'; import { TarArchiveResponse } from './TarArchiveResponse'; import { ZipArchiveResponse } from './ZipArchiveResponse'; -type FromArchiveOptions = { - // A binary stream of a tar archive. - stream: Readable; - // If unset, the files at the root of the tree will be read. - // subpath must not contain the name of the top level directory. - subpath?: string; - // etag of the blob - etag: string; - // Filter passed on from the ReadTreeOptions - filter?: (path: string) => boolean; -}; - -export class ReadTreeResponseFactory { - static create(options: { config: Config }): ReadTreeResponseFactory { - return new ReadTreeResponseFactory( +export class DefaultReadTreeResponseFactory implements ReadTreeResponseFactory { + static create(options: { config: Config }): DefaultReadTreeResponseFactory { + return new DefaultReadTreeResponseFactory( options.config.getOptionalString('backend.workingDirectory') ?? os.tmpdir(), ); diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts index 858cf15877..3126907c1e 100644 --- a/packages/backend-common/src/reading/tree/index.ts +++ b/packages/backend-common/src/reading/tree/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ReadTreeResponseFactory } from './ReadTreeResponseFactory'; +export { DefaultReadTreeResponseFactory } from './ReadTreeResponseFactory'; diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 4caa76841a..7ba806197d 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { ReadTreeResponseFactory } from './tree'; /** * A generic interface for fetching plain data from URLs. @@ -105,6 +105,23 @@ export type ReadTreeResponseFile = { content(): Promise; }; +export type FromArchiveOptions = { + // A binary stream of a tar archive. + stream: Readable; + // If unset, the files at the root of the tree will be read. + // subpath must not contain the name of the top level directory. + subpath?: string; + // etag of the blob + etag: string; + // Filter passed on from the ReadTreeOptions + filter?: (path: string) => boolean; +}; + +export interface ReadTreeResponseFactory { + fromTarArchive(options: FromArchiveOptions): Promise; + fromZipArchive(options: FromArchiveOptions): Promise; +} + /** * An options object for search operations. */ diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 07da975184..45eba03157 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -18,7 +18,7 @@ import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; -import helmet, { HelmetOptions } from 'helmet'; +import helmet from 'helmet'; import * as http from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; @@ -55,7 +55,6 @@ const DEFAULT_CSP = { 'script-src': ["'self'", "'unsafe-eval'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], - 'upgrade-insecure-requests': [] as string[], }; export class ServiceBuilderImpl implements ServiceBuilder { @@ -195,14 +194,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { }); } - private getOptions(): { - port: number; - host: string; - logger: Logger; - corsOptions?: cors.CorsOptions; - httpsSettings?: HttpsSettings; - helmetOptions: HelmetOptions; - } { + private getOptions() { return { port: this.port ?? DEFAULT_PORT, host: this.host ?? DEFAULT_HOST, diff --git a/packages/backend-common/src/util/ContainerRunner.ts b/packages/backend-common/src/util/ContainerRunner.ts new file mode 100644 index 0000000000..7740e384e4 --- /dev/null +++ b/packages/backend-common/src/util/ContainerRunner.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Writable } from 'stream'; + +export type RunContainerOptions = { + imageName: string; + command?: string | string[]; + args: string[]; + logStream?: Writable; + mountDirs?: Record; + workingDir?: string; + envVars?: Record; +}; + +export interface ContainerRunner { + runContainer(opts: RunContainerOptions): Promise; +} diff --git a/packages/backend-common/src/util/docker.test.ts b/packages/backend-common/src/util/DockerContainerRunner.test.ts similarity index 89% rename from packages/backend-common/src/util/docker.test.ts rename to packages/backend-common/src/util/DockerContainerRunner.test.ts index 2ec14494da..942264af84 100644 --- a/packages/backend-common/src/util/docker.test.ts +++ b/packages/backend-common/src/util/DockerContainerRunner.test.ts @@ -13,17 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import Docker from 'dockerode'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; import Stream, { PassThrough } from 'stream'; -import { runDockerContainer, UserOptions } from './docker'; +import { ContainerRunner } from './ContainerRunner'; +import { DockerContainerRunner, UserOptions } from './DockerContainerRunner'; const mockDocker = new Docker() as jest.Mocked; const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; -describe('runDockerContainer', () => { +describe('DockerContainerRunner', () => { + let containerTaskApi: ContainerRunner; + beforeEach(() => { mockFs({ [rootDir]: { @@ -49,6 +53,8 @@ describe('runDockerContainer', () => { jest .spyOn(mockDocker, 'ping') .mockResolvedValue(Buffer.from('OK', 'utf-8')); + + containerTaskApi = new DockerContainerRunner({ dockerClient: mockDocker }); }); afterEach(() => { @@ -66,10 +72,9 @@ describe('runDockerContainer', () => { const envVarsArray = ['HOME=/tmp', 'LOG_LEVEL=debug']; it('should pull the docker container', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); expect(mockDocker.pull).toHaveBeenCalledWith( @@ -82,13 +87,12 @@ describe('runDockerContainer', () => { }); it('should call the dockerClient run command with the correct arguments passed through', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, mountDirs, envVars, workingDir, - dockerClient: mockDocker, }); expect(mockDocker.run).toHaveBeenCalledWith( @@ -113,20 +117,18 @@ describe('runDockerContainer', () => { }); it('should ping docker to test availability', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); expect(mockDocker.ping).toHaveBeenCalled(); }); it('should pass through the user and group id from the host machine and set the home dir', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); const userOptions: UserOptions = {}; @@ -153,10 +155,9 @@ describe('runDockerContainer', () => { ]); await expect( - runDockerContainer({ + containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }), ).rejects.toThrow(/Something went wrong with docker/); }); @@ -172,10 +173,9 @@ describe('runDockerContainer', () => { it('should throw with a descriptive error message including the docker error message', async () => { await expect( - runDockerContainer({ + containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); }); @@ -183,11 +183,10 @@ describe('runDockerContainer', () => { it('should pass through the log stream to the docker client', async () => { const logStream = new PassThrough(); - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, logStream, - dockerClient: mockDocker, }); expect(mockDocker.run).toHaveBeenCalledWith( diff --git a/packages/backend-common/src/util/DockerContainerRunner.ts b/packages/backend-common/src/util/DockerContainerRunner.ts new file mode 100644 index 0000000000..a96ca1351b --- /dev/null +++ b/packages/backend-common/src/util/DockerContainerRunner.ts @@ -0,0 +1,117 @@ +/* + * 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 Docker from 'dockerode'; +import fs from 'fs-extra'; +import { PassThrough } from 'stream'; +import { ContainerRunner, RunContainerOptions } from './ContainerRunner'; + +export type UserOptions = { + User?: string; +}; + +export class DockerContainerRunner implements ContainerRunner { + private readonly dockerClient: Docker; + + constructor({ dockerClient }: { dockerClient: Docker }) { + this.dockerClient = dockerClient; + } + + async runContainer({ + imageName, + command, + args, + logStream = new PassThrough(), + mountDirs = {}, + workingDir, + envVars = {}, + }: RunContainerOptions) { + // Show a better error message when Docker is unavailable. + try { + await this.dockerClient.ping(); + } catch (e) { + throw new Error( + `This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`, + ); + } + + await new Promise((resolve, reject) => { + this.dockerClient.pull(imageName, {}, (err, stream) => { + if (err) return reject(err); + stream.pipe(logStream, { end: false }); + stream.on('end', () => resolve()); + stream.on('error', (error: Error) => reject(error)); + return undefined; + }); + }); + + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + + // Initialize volumes to mount based on mountDirs map + const Volumes: { [T: string]: object } = {}; + for (const containerDir of Object.values(mountDirs)) { + Volumes[containerDir] = {}; + } + + // Create bind volumes + const Binds: string[] = []; + for (const [hostDir, containerDir] of Object.entries(mountDirs)) { + // Need to use realpath here as Docker mounting does not like + // symlinks for binding volumes + const realHostDir = await fs.realpath(hostDir); + Binds.push(`${realHostDir}:${containerDir}`); + } + + // Create docker environment variables array + const Env = []; + for (const [key, value] of Object.entries(envVars)) { + Env.push(`${key}=${value}`); + } + + const [ + { Error: error, StatusCode: statusCode }, + ] = await this.dockerClient.run(imageName, args, logStream, { + Volumes, + HostConfig: { + Binds, + }, + ...(workingDir ? { WorkingDir: workingDir } : {}), + Entrypoint: command, + Env, + ...userOptions, + } as Docker.ContainerCreateOptions); + + if (error) { + throw new Error( + `Docker failed to run with the following error message: ${error}`, + ); + } + + if (statusCode !== 0) { + throw new Error( + `Docker container returned a non-zero exit code (${statusCode})`, + ); + } + } +} diff --git a/packages/backend-common/src/util/docker.ts b/packages/backend-common/src/util/docker.ts deleted file mode 100644 index 356d645465..0000000000 --- a/packages/backend-common/src/util/docker.ts +++ /dev/null @@ -1,137 +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 Docker from 'dockerode'; -import fs from 'fs-extra'; -import { PassThrough, Writable } from 'stream'; - -export type UserOptions = { - User?: string; -}; - -export type RunDockerContainerOptions = { - imageName: string; - args: string[]; - logStream?: Writable; - dockerClient: Docker; - mountDirs?: Record; - workingDir?: string; - envVars?: Record; - createOptions?: Docker.ContainerCreateOptions; -}; - -/** - * - * @param options the options object - * @param options.imageName the image to run - * @param options.args the arguments to pass the container - * @param options.logStream the log streamer to capture log messages - * @param options.dockerClient the dockerClient to use - * @param options.mountDirs A map of host directories to mount on the container. - * Object Key: Path on host machine, Value: Path on Docker container - * @param options.workingDir Working dir in the container - * @param options.envVars Environment variables to set in the container. e.g. {'HOME': '/tmp'} - */ -export const runDockerContainer = async ({ - imageName, - args, - logStream = new PassThrough(), - dockerClient, - mountDirs = {}, - workingDir, - envVars = {}, - createOptions = {}, -}: RunDockerContainerOptions) => { - // Show a better error message when Docker is unavailable. - try { - await dockerClient.ping(); - } catch (e) { - throw new Error( - `This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`, - ); - } - - await new Promise((resolve, reject) => { - dockerClient.pull(imageName, {}, (err, stream) => { - if (err) return reject(err); - stream.pipe(logStream, { end: false }); - stream.on('end', () => resolve()); - stream.on('error', (error: Error) => reject(error)); - return undefined; - }); - }); - - const userOptions: UserOptions = {}; - if (process.getuid && process.getgid) { - // Files that are created inside the Docker container will be owned by - // root on the host system on non Mac systems, because of reasons. Mainly the fact that - // volume sharing is done using NFS on Mac and actual mounts in Linux world. - // So we set the user in the container as the same user and group id as the host. - // On Windows we don't have process.getuid nor process.getgid - userOptions.User = `${process.getuid()}:${process.getgid()}`; - } - - // Initialize volumes to mount based on mountDirs map - const Volumes: { [T: string]: object } = {}; - for (const containerDir of Object.values(mountDirs)) { - Volumes[containerDir] = {}; - } - - // Create bind volumes - const Binds: string[] = []; - for (const [hostDir, containerDir] of Object.entries(mountDirs)) { - // Need to use realpath here as Docker mounting does not like - // symlinks for binding volumes - const realHostDir = await fs.realpath(hostDir); - Binds.push(`${realHostDir}:${containerDir}`); - } - - // Create docker environment variables array - const Env = []; - for (const [key, value] of Object.entries(envVars)) { - Env.push(`${key}=${value}`); - } - - const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( - imageName, - args, - logStream, - { - Volumes, - HostConfig: { - Binds, - }, - ...(workingDir ? { WorkingDir: workingDir } : {}), - Env, - ...userOptions, - ...createOptions, - }, - ); - - if (error) { - throw new Error( - `Docker failed to run with the following error message: ${error}`, - ); - } - - if (statusCode !== 0) { - throw new Error( - `Docker container returned a non-zero exit code (${statusCode})`, - ); - } - - return { error, statusCode }; -}; diff --git a/packages/backend-common/src/util/index.ts b/packages/backend-common/src/util/index.ts index 04715824b4..85c5436a2e 100644 --- a/packages/backend-common/src/util/index.ts +++ b/packages/backend-common/src/util/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { runDockerContainer } from './docker'; +export type { ContainerRunner, RunContainerOptions } from './ContainerRunner'; +export { DockerContainerRunner } from './DockerContainerRunner'; diff --git a/packages/backend-test-utils/.eslintrc.js b/packages/backend-test-utils/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/backend-test-utils/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md new file mode 100644 index 0000000000..c2078fd0bc --- /dev/null +++ b/packages/backend-test-utils/CHANGELOG.md @@ -0,0 +1,14 @@ +# @backstage/backend-test-utils + +## 0.1.2 + +### Patch Changes + +- 0711954a9: Skip running docker tests unless in CI +- Updated dependencies [9cd3c533c] +- Updated dependencies [92963779b] +- Updated dependencies [7f7443308] +- Updated dependencies [21e8ebef5] +- Updated dependencies [eda9dbd5f] + - @backstage/cli@0.7.0 + - @backstage/backend-common@0.8.2 diff --git a/packages/backend-test-utils/README.md b/packages/backend-test-utils/README.md new file mode 100644 index 0000000000..38756fa5d7 --- /dev/null +++ b/packages/backend-test-utils/README.md @@ -0,0 +1,18 @@ +# @backstage/backend-test-utils + +Test helpers library for Backstage backends. + +## Usage + +Add the library as a `devDependency` to your backend package: + +```sh +# From the Backstage root directory, go to your backend package, or to a backend plugin +cd plugins/my-plugin-backend +yarn add --dev @backstage/backend-test-utils +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md new file mode 100644 index 0000000000..28d4a00463 --- /dev/null +++ b/packages/backend-test-utils/api-report.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/backend-test-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Knex } from 'knex'; + +// @public (undocumented) +export function isDockerDisabledForTests(): boolean; + +// @public +export type TestDatabaseId = 'POSTGRES_13' | 'POSTGRES_9' | 'MYSQL_8' | 'SQLITE_3'; + +// @public +export class TestDatabases { + static create(options?: { + ids?: TestDatabaseId[]; + disableDocker?: boolean; + }): TestDatabases; + // (undocumented) + eachSupportedId(): [TestDatabaseId][]; + init(id: TestDatabaseId): Promise; + // (undocumented) + supports(id: TestDatabaseId): boolean; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json new file mode 100644 index 0000000000..c4ff5603f3 --- /dev/null +++ b/packages/backend-test-utils/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/backend-test-utils", + "description": "Test helpers library for Backstage backends", + "version": "0.1.2", + "main": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-test-utils" + }, + "keywords": [ + "backstage", + "test" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.8.2", + "@backstage/cli": "^0.7.0", + "@backstage/config": "^0.1.5", + "knex": "^0.95.1", + "mysql2": "^2.2.5", + "pg": "^8.3.0", + "sqlite3": "^5.0.0", + "testcontainers": "^7.10.0", + "uuid": "^8.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.0", + "jest": "^26.0.1" + }, + "files": [ + "dist" + ] +} diff --git a/packages/backend-test-utils/src/database/TestDatabases.test.ts b/packages/backend-test-utils/src/database/TestDatabases.test.ts new file mode 100644 index 0000000000..7c1e6e1bdd --- /dev/null +++ b/packages/backend-test-utils/src/database/TestDatabases.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knexFactory from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { startMysqlContainer } from './startMysqlContainer'; +import { startPostgresContainer } from './startPostgresContainer'; +import { TestDatabases } from './TestDatabases'; + +const itIfDocker = isDockerDisabledForTests() ? it.skip : it; + +describe('TestDatabases', () => { + const OLD_ENV = process.env; + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + afterAll(() => { + process.env = OLD_ENV; + }); + + describe('each', () => { + const dbs = TestDatabases.create(); + + it.each(dbs.eachSupportedId())( + 'creates distinct %p databases', + async databaseId => { + if (!dbs.supports(databaseId)) { + return; + } + const db1 = await dbs.init(databaseId); + const db2 = await dbs.init(databaseId); + await db1.schema.createTable('a', table => table.string('x').primary()); + await db2.schema.createTable('a', table => table.string('y').primary()); + await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([ + { a: 1 }, + ]); + }, + 60_000, + ); + }); + + itIfDocker( + 'obeys a provided connection string for postgres 13', + async () => { + const dbs = TestDatabases.create(); + const { host, port, user, password, stop } = await startPostgresContainer( + 'postgres:13', + ); + + try { + // Leave a mark + process.env.BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; + const input = await dbs.init('POSTGRES_13'); + await input.schema.createTable('a', table => + table.string('x').primary(), + ); + await input.insert({ x: 'y' }).into('a'); + + // Look for the mark + const database = 'backstage_plugin_0'; + const output = knexFactory({ + client: 'pg', + connection: { host, port, user, password, database }, + }); + // eslint-disable-next-line jest/no-standalone-expect + await expect(output.select('x').from('a')).resolves.toEqual([ + { x: 'y' }, + ]); + } finally { + await stop(); + } + }, + 60_000, + ); + + itIfDocker( + 'obeys a provided connection string for postgres 9', + async () => { + const dbs = TestDatabases.create(); + const { host, port, user, password, stop } = await startPostgresContainer( + 'postgres:9', + ); + + try { + // Leave a mark + process.env.BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; + const input = await dbs.init('POSTGRES_9'); + await input.schema.createTable('a', table => + table.string('x').primary(), + ); + await input.insert({ x: 'y' }).into('a'); + + // Look for the mark + const database = 'backstage_plugin_0'; + const output = knexFactory({ + client: 'pg', + connection: { host, port, user, password, database }, + }); + // eslint-disable-next-line jest/no-standalone-expect + await expect(output.select('x').from('a')).resolves.toEqual([ + { x: 'y' }, + ]); + } finally { + await stop(); + } + }, + 60_000, + ); + + itIfDocker( + 'obeys a provided connection string for mysql 8', + async () => { + const dbs = TestDatabases.create(); + const { host, port, user, password, stop } = await startMysqlContainer( + 'mysql:8', + ); + + try { + // Leave a mark + process.env.BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING = `mysql://${user}:${password}@${host}:${port}/ignored`; + const input = await dbs.init('MYSQL_8'); + await input.schema.createTable('a', table => + table.string('x').primary(), + ); + await input.insert({ x: 'y' }).into('a'); + + // Look for the mark + const database = 'backstage_plugin_0'; + const output = knexFactory({ + client: 'mysql2', + connection: { host, port, user, password, database }, + }); + // eslint-disable-next-line jest/no-standalone-expect + await expect(output.select('x').from('a')).resolves.toEqual([ + { x: 'y' }, + ]); + } finally { + await stop(); + } + }, + 60_000, + ); +}); diff --git a/packages/backend-test-utils/src/database/TestDatabases.ts b/packages/backend-test-utils/src/database/TestDatabases.ts new file mode 100644 index 0000000000..ab5846d84d --- /dev/null +++ b/packages/backend-test-utils/src/database/TestDatabases.ts @@ -0,0 +1,278 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SingleConnectionDatabaseManager } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { Knex } from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { startMysqlContainer } from './startMysqlContainer'; +import { startPostgresContainer } from './startPostgresContainer'; +import { + allDatabases, + Instance, + TestDatabaseId, + TestDatabaseProperties, +} from './types'; + +/** + * Encapsulates the creation of ephemeral test database instances for use + * inside unit or integration tests. + */ +export class TestDatabases { + private readonly instanceById: Map; + private readonly supportedIds: TestDatabaseId[]; + private lastDatabaseIndex: number; + + /** + * Creates an empty `TestDatabases` instance, and sets up Jest to clean up + * all of its acquired resources after all tests finish. + * + * You typically want to create just a single instance like this at the top + * of your test file or `describe` block, and then call `init` many times on + * that instance inside the individual tests. Spinning up a "physical" + * database instance takes a considerable amount of time, slowing down tests. + * But initializing a new logical database inside that instance using `init` + * is very fast. + */ + static create(options?: { + ids?: TestDatabaseId[]; + disableDocker?: boolean; + }): TestDatabases { + const defaultOptions = { + ids: Object.keys(allDatabases) as TestDatabaseId[], + disableDocker: isDockerDisabledForTests(), + }; + + const { ids, disableDocker } = Object.assign( + {}, + defaultOptions, + options ?? {}, + ); + + const supportedIds = ids.filter(id => { + const properties = allDatabases[id]; + if (!properties) { + return false; + } + // If the caller has set up the env with an explicit connection string, + // we'll assume that this database will work + if ( + properties.connectionStringEnvironmentVariableName && + process.env[properties.connectionStringEnvironmentVariableName] + ) { + return true; + } + // If the database doesn't require docker at all, there's nothing to worry + // about + if (!properties.dockerImageName) { + return true; + } + // If the database requires docker, but docker is disabled, we will fail. + if (disableDocker) { + return false; + } + return true; + }); + + const databases = new TestDatabases(supportedIds); + + afterAll(async () => { + await databases.shutdown(); + }); + + return databases; + } + + private constructor(supportedIds: TestDatabaseId[]) { + this.instanceById = new Map(); + this.supportedIds = supportedIds; + this.lastDatabaseIndex = 0; + } + + supports(id: TestDatabaseId): boolean { + return this.supportedIds.includes(id); + } + + eachSupportedId(): [TestDatabaseId][] { + return this.supportedIds.map(id => [id]); + } + + /** + * Returns a fresh, unique, empty logical database on an instance of the + * given database ID platform. + * + * @param id The ID of the database platform to use, e.g. 'POSTGRES_13' + * @returns A `Knex` connection object + */ + async init(id: TestDatabaseId): Promise { + const properties = allDatabases[id]; + if (!properties) { + const candidates = Object.keys(allDatabases).join(', '); + throw new Error( + `Unknown test database ${id}, possible values are ${candidates}`, + ); + } + if (!this.supportedIds.includes(id)) { + const candidates = this.supportedIds.join(', '); + throw new Error( + `Unsupported test database ${id} for this environment, possible values are ${candidates}`, + ); + } + + let instance: Instance | undefined = this.instanceById.get(id); + + // Ensure that a testcontainers instance is up for this ID + if (!instance) { + instance = await this.initAny(properties); + this.instanceById.set(id, instance); + } + + // Ensure that a unique logical database is created in the instance + const connection = await instance.databaseManager + .forPlugin(String(this.lastDatabaseIndex++)) + .getClient(); + + instance.connections.push(connection); + + return connection; + } + + private async initAny(properties: TestDatabaseProperties): Promise { + // Use the connection string if provided + if (properties.driver === 'pg' || properties.driver === 'mysql2') { + const envVarName = properties.connectionStringEnvironmentVariableName; + if (envVarName) { + const connectionString = process.env[envVarName]; + if (connectionString) { + const databaseManager = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: properties.driver, + connection: connectionString, + }, + }, + }), + ); + return { + databaseManager, + connections: [], + }; + } + } + } + + // Otherwise start a container for the purpose + switch (properties.driver) { + case 'pg': + return this.initPostgres(properties); + case 'mysql2': + return this.initMysql(properties); + case 'sqlite3': + return this.initSqlite(properties); + default: + throw new Error(`Unknown database driver ${properties.driver}`); + } + } + + private async initPostgres( + properties: TestDatabaseProperties, + ): Promise { + const { host, port, user, password, stop } = await startPostgresContainer( + properties.dockerImageName!, + ); + + const databaseManager = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { host, port, user, password }, + }, + }, + }), + ); + + return { + stopContainer: stop, + databaseManager, + connections: [], + }; + } + + private async initMysql( + properties: TestDatabaseProperties, + ): Promise { + const { host, port, user, password, stop } = await startMysqlContainer( + properties.dockerImageName!, + ); + + const databaseManager = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'mysql2', + connection: { host, port, user, password }, + }, + }, + }), + ); + + return { + stopContainer: stop, + databaseManager, + connections: [], + }; + } + + private async initSqlite( + _properties: TestDatabaseProperties, + ): Promise { + const databaseManager = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), + ); + + return { + databaseManager, + connections: [], + }; + } + + private async shutdown() { + const instances = [...this.instanceById.values()]; + await Promise.all( + instances.map(async ({ stopContainer, connections }) => { + try { + await Promise.all(connections.map(c => c.destroy())); + } catch { + // ignore + } + try { + await stopContainer?.(); + } catch { + // ignore + } + }), + ); + } +} diff --git a/packages/backend-test-utils/src/database/index.ts b/packages/backend-test-utils/src/database/index.ts new file mode 100644 index 0000000000..6988f0b80b --- /dev/null +++ b/packages/backend-test-utils/src/database/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +export { TestDatabases } from './TestDatabases'; +export type { TestDatabaseId } from './types'; diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.test.ts b/packages/backend-test-utils/src/database/startMysqlContainer.test.ts new file mode 100644 index 0000000000..e210ccb413 --- /dev/null +++ b/packages/backend-test-utils/src/database/startMysqlContainer.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createConnection from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { startMysqlContainer } from './startMysqlContainer'; + +const itIfDocker = isDockerDisabledForTests() ? it.skip : it; + +describe('startMysqlContainer', () => { + itIfDocker( + 'successfully launches the container', + async () => { + const { stop, ...connection } = await startMysqlContainer('mysql:8'); + const db = createConnection({ client: 'mysql2', connection }); + try { + const result = await db.select(db.raw('version() AS version')); + // eslint-disable-next-line jest/no-standalone-expect + expect(result[0]?.version).toContain('8.'); + } finally { + await db.destroy(); + await stop(); + } + }, + 60_000, + ); +}); diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.ts b/packages/backend-test-utils/src/database/startMysqlContainer.ts new file mode 100644 index 0000000000..739298dfe5 --- /dev/null +++ b/packages/backend-test-utils/src/database/startMysqlContainer.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createConnection, { Knex } from 'knex'; +import { GenericContainer } from 'testcontainers'; +import { v4 as uuid } from 'uuid'; + +async function waitForMysqlReady( + connection: Knex.MySqlConnectionConfig, +): Promise { + const startTime = Date.now(); + const db = createConnection({ client: 'mysql2', connection }); + + try { + for (;;) { + try { + const result = await db.select(db.raw('version() AS version')); + if (result[0]?.version) { + return; + } + } catch (e) { + if (Date.now() - startTime > 30_000) { + throw new Error( + `Timed out waiting for the database to be ready for connections, ${e}`, + ); + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + } finally { + db.destroy(); + } +} + +export async function startMysqlContainer(image: string) { + const user = 'root'; + const password = uuid(); + + const container = await new GenericContainer(image) + .withExposedPorts(3306) + .withEnv('MYSQL_ROOT_PASSWORD', password) + .withTmpFs({ '/var/lib/mysql': 'rw' }) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(3306); + const stop = async () => { + await container.stop({ timeout: 10_000 }); + }; + + await waitForMysqlReady({ host, port, user, password }); + + return { host, port, user, password, stop }; +} diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.test.ts b/packages/backend-test-utils/src/database/startPostgresContainer.test.ts new file mode 100644 index 0000000000..4e8acc8410 --- /dev/null +++ b/packages/backend-test-utils/src/database/startPostgresContainer.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createConnection from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { startPostgresContainer } from './startPostgresContainer'; + +const itIfDocker = isDockerDisabledForTests() ? it.skip : it; + +describe('startPostgresContainer', () => { + itIfDocker( + 'successfully launches the container', + async () => { + const { stop, ...connection } = await startPostgresContainer( + 'postgres:13', + ); + const db = createConnection({ client: 'pg', connection }); + try { + const result = await db.select(db.raw('version()')); + // eslint-disable-next-line jest/no-standalone-expect + expect(result[0]?.version).toContain('PostgreSQL'); + } finally { + await db.destroy(); + await stop(); + } + }, + 60_000, + ); +}); diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.ts b/packages/backend-test-utils/src/database/startPostgresContainer.ts new file mode 100644 index 0000000000..1c2ecdcb60 --- /dev/null +++ b/packages/backend-test-utils/src/database/startPostgresContainer.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createConnection, { Knex } from 'knex'; +import { GenericContainer } from 'testcontainers'; +import { v4 as uuid } from 'uuid'; + +async function waitForPostgresReady( + connection: Knex.PgConnectionConfig, +): Promise { + const startTime = Date.now(); + const db = createConnection({ client: 'pg', connection }); + + try { + for (;;) { + try { + const result = await db.select(db.raw('version()')); + if (Array.isArray(result) && result[0]?.version) { + return; + } + } catch (e) { + if (Date.now() - startTime > 30_000) { + throw new Error( + `Timed out waiting for the database to be ready for connections, ${e}`, + ); + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + } finally { + db.destroy(); + } +} + +export async function startPostgresContainer(image: string) { + const user = 'postgres'; + const password = uuid(); + + const container = await new GenericContainer(image) + .withExposedPorts(5432) + .withEnv('POSTGRES_PASSWORD', password) + .withTmpFs({ '/var/lib/postgresql/data': 'rw' }) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(5432); + const stop = async () => { + await container.stop({ timeout: 10_000 }); + }; + + await waitForPostgresReady({ host, port, user, password }); + + return { host, port, user, password, stop }; +} diff --git a/packages/backend-test-utils/src/database/types.ts b/packages/backend-test-utils/src/database/types.ts new file mode 100644 index 0000000000..791cf09b7b --- /dev/null +++ b/packages/backend-test-utils/src/database/types.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SingleConnectionDatabaseManager } from '@backstage/backend-common'; +import { Knex } from 'knex'; + +/** + * The possible databases to test against. + */ +export type TestDatabaseId = + | 'POSTGRES_13' + | 'POSTGRES_9' + | 'MYSQL_8' + | 'SQLITE_3'; + +export type TestDatabaseProperties = { + name: string; + driver: string; + dockerImageName?: string; + connectionStringEnvironmentVariableName?: string; +}; + +export type Instance = { + stopContainer?: () => Promise; + databaseManager: SingleConnectionDatabaseManager; + connections: Array; +}; + +export const allDatabases: Record< + TestDatabaseId, + TestDatabaseProperties +> = Object.freeze({ + POSTGRES_13: { + name: 'Postgres 13.x', + driver: 'pg', + dockerImageName: 'postgres:13', + connectionStringEnvironmentVariableName: + 'BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING', + }, + POSTGRES_9: { + name: 'Postgres 9.x', + driver: 'pg', + dockerImageName: 'postgres:9', + connectionStringEnvironmentVariableName: + 'BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING', + }, + MYSQL_8: { + name: 'MySQL 8.x', + driver: 'mysql2', + dockerImageName: 'mysql:8', + connectionStringEnvironmentVariableName: + 'BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING', + }, + SQLITE_3: { + name: 'SQLite 3.x', + driver: 'sqlite3', + }, +}); diff --git a/packages/backend-test-utils/src/index.ts b/packages/backend-test-utils/src/index.ts new file mode 100644 index 0000000000..ad3e422f44 --- /dev/null +++ b/packages/backend-test-utils/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './database'; +export * from './util'; diff --git a/packages/backend-test-utils/src/setupTests.ts b/packages/backend-test-utils/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/packages/backend-test-utils/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/packages/backend-test-utils/src/util/index.ts b/packages/backend-test-utils/src/util/index.ts new file mode 100644 index 0000000000..e4e0e97ac1 --- /dev/null +++ b/packages/backend-test-utils/src/util/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { isDockerDisabledForTests } from './isDockerDisabledForTests'; diff --git a/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts new file mode 100644 index 0000000000..4aeea59c50 --- /dev/null +++ b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isDockerDisabledForTests() { + // If we are not running in continuous integration, the default is to skip + // the (relatively heavy, long running) docker based tests. If you want to + // still run local tests for all databases, just pass either the CI=1 env + // parameter to your test runner, or individual connection strings per + // database. + return ( + Boolean(process.env.BACKSTAGE_TEST_DISABLE_DOCKER) || + !Boolean(process.env.CI) + ); +} diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 6585ea2723..65d28457d0 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,143 @@ # example-backend +## 0.2.32 + +### Patch Changes + +- Updated dependencies [9c63be545] +- Updated dependencies [92963779b] +- Updated dependencies [27a9b503a] +- Updated dependencies [66c6bfebd] +- Updated dependencies [55a253de2] +- Updated dependencies [70bc30c5b] +- Updated dependencies [db1c8f93b] +- Updated dependencies [5aff84759] +- Updated dependencies [f26e6008f] +- Updated dependencies [eda9dbd5f] +- Updated dependencies [4f8cf50fe] +- Updated dependencies [875809a59] + - @backstage/plugin-catalog-backend@0.10.2 + - @backstage/backend-common@0.8.2 + - @backstage/catalog-model@0.8.2 + - @backstage/plugin-scaffolder-backend@0.12.0 + - @backstage/catalog-client@0.3.13 + - @backstage/plugin-search-backend-node@0.2.0 + - @backstage/plugin-search-backend@0.2.0 + - @backstage/plugin-proxy-backend@0.2.9 + - example-app@0.2.32 + +## 0.2.30 + +### Patch Changes + +- Updated dependencies [0fd4ea443] +- Updated dependencies [add62a455] +- Updated dependencies [260aaa684] +- Updated dependencies [704875e26] + - @backstage/plugin-catalog-backend@0.10.0 + - @backstage/catalog-client@0.3.12 + - @backstage/catalog-model@0.8.0 + - @backstage/plugin-scaffolder-backend@0.11.4 + - example-app@0.2.30 + - @backstage/plugin-auth-backend@0.3.12 + - @backstage/plugin-badges-backend@0.1.6 + - @backstage/plugin-code-coverage-backend@0.1.6 + - @backstage/plugin-kafka-backend@0.2.6 + - @backstage/plugin-kubernetes-backend@0.3.8 + - @backstage/plugin-techdocs-backend@0.8.2 + - @backstage/plugin-todo-backend@0.1.6 + +## 0.2.28 + +### Patch Changes + +- Updated dependencies [062bbf90f] +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [82ca1ac22] +- Updated dependencies [f9fb4a205] +- Updated dependencies [9a207f052] +- Updated dependencies [16be1d093] +- Updated dependencies [fd39d4662] +- Updated dependencies [f9f9d633d] + - @backstage/plugin-scaffolder-backend@0.11.1 + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + - @backstage/plugin-catalog-backend@0.9.0 + - @backstage/plugin-kubernetes-backend@0.3.7 + - example-app@0.2.28 + - @backstage/plugin-app-backend@0.3.13 + - @backstage/plugin-auth-backend@0.3.10 + - @backstage/plugin-badges-backend@0.1.4 + - @backstage/plugin-code-coverage-backend@0.1.5 + - @backstage/plugin-graphql-backend@0.1.8 + - @backstage/plugin-kafka-backend@0.2.5 + - @backstage/plugin-proxy-backend@0.2.8 + - @backstage/plugin-rollbar-backend@0.1.11 + - @backstage/plugin-search-backend@0.1.5 + - @backstage/plugin-techdocs-backend@0.8.1 + - @backstage/plugin-todo-backend@0.1.5 + +## 0.2.27 + +### Patch Changes + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [b219821a0] +- Updated dependencies [69eefb5ae] +- Updated dependencies [f53fba29f] +- Updated dependencies [75c8cec39] +- Updated dependencies [227439a72] +- Updated dependencies [cdb3426e5] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/plugin-scaffolder-backend@0.11.0 + - @backstage/backend-common@0.7.0 + - @backstage/plugin-techdocs-backend@0.8.0 + - @backstage/plugin-catalog-backend@0.8.2 + - @backstage/plugin-kubernetes-backend@0.3.6 + - @backstage/plugin-proxy-backend@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + - example-app@0.2.27 + - @backstage/plugin-app-backend@0.3.12 + - @backstage/plugin-auth-backend@0.3.9 + - @backstage/plugin-badges-backend@0.1.3 + - @backstage/plugin-code-coverage-backend@0.1.4 + - @backstage/plugin-graphql-backend@0.1.7 + - @backstage/plugin-kafka-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.10 + - @backstage/plugin-search-backend@0.1.4 + - @backstage/plugin-todo-backend@0.1.4 + +## 0.2.25 + +### Patch Changes + +- Updated dependencies [b9b2b4b76] +- Updated dependencies [84c54474d] +- Updated dependencies [49574a8a3] +- Updated dependencies [d367f63b5] +- Updated dependencies [5fe62f124] +- Updated dependencies [09b5fcf2e] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [c42cd1daa] +- Updated dependencies [b42531cfe] +- Updated dependencies [c2306f898] + - @backstage/plugin-search-backend@0.1.3 + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/plugin-scaffolder-backend@0.10.0 + - @backstage/plugin-rollbar-backend@0.1.9 + - @backstage/backend-common@0.6.3 + - @backstage/plugin-catalog-backend@0.8.0 + - @backstage/plugin-code-coverage-backend@0.1.2 + - @backstage/plugin-kubernetes-backend@0.3.5 + - example-app@0.2.25 + ## 0.2.22 ### Patch Changes diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 02aab45609..31231a3a4a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -22,7 +22,7 @@ RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" # Then copy the rest of the backend bundle, along with any other files we might want. -ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ +COPY packages/backend/dist/bundle.tar.gz app-config.yaml ./ RUN tar xzf bundle.tar.gz && rm bundle.tar.gz CMD ["node", "packages/backend", "--config", "app-config.yaml"] diff --git a/packages/backend/package.json b/packages/backend/package.json index 40e5a1cd2f..f4f304d714 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.22", + "version": "0.2.32", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,29 +27,30 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", - "@backstage/config": "^0.1.4", - "@backstage/plugin-app-backend": "^0.3.10", - "@backstage/plugin-auth-backend": "^0.3.7", - "@backstage/plugin-badges-backend": "^0.1.1", - "@backstage/plugin-catalog-backend": "^0.7.0", - "@backstage/plugin-graphql-backend": "^0.1.6", - "@backstage/plugin-kubernetes-backend": "^0.3.3", - "@backstage/plugin-kafka-backend": "^0.2.3", - "@backstage/plugin-proxy-backend": "^0.2.6", - "@backstage/plugin-rollbar-backend": "^0.1.8", - "@backstage/plugin-scaffolder-backend": "^0.9.4", - "@backstage/plugin-search-backend": "^0.1.2", - "@backstage/plugin-search-backend-node": "^0.1.2", - "@backstage/plugin-techdocs-backend": "^0.7.0", - "@backstage/plugin-todo-backend": "^0.1.3", - "@gitbeaker/node": "^28.0.2", - "@octokit/rest": "^18.0.12", + "@backstage/backend-common": "^0.8.2", + "@backstage/catalog-client": "^0.3.13", + "@backstage/catalog-model": "^0.8.2", + "@backstage/config": "^0.1.5", + "@backstage/plugin-app-backend": "^0.3.13", + "@backstage/plugin-auth-backend": "^0.3.12", + "@backstage/plugin-badges-backend": "^0.1.6", + "@backstage/plugin-catalog-backend": "^0.10.2", + "@backstage/plugin-code-coverage-backend": "^0.1.6", + "@backstage/plugin-graphql-backend": "^0.1.8", + "@backstage/plugin-kubernetes-backend": "^0.3.8", + "@backstage/plugin-kafka-backend": "^0.2.6", + "@backstage/plugin-proxy-backend": "^0.2.9", + "@backstage/plugin-rollbar-backend": "^0.1.11", + "@backstage/plugin-scaffolder-backend": "^0.12.0", + "@backstage/plugin-search-backend": "^0.2.0", + "@backstage/plugin-search-backend-node": "^0.2.0", + "@backstage/plugin-techdocs-backend": "^0.8.2", + "@backstage/plugin-todo-backend": "^0.1.6", + "@gitbeaker/node": "^30.2.0", + "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.21", + "example-app": "^0.2.32", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^0.95.1", @@ -59,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.7.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index cbbdaecf5e..d43c8f0101 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,6 +24,7 @@ import Router from 'express-promise-router'; import { + CacheManager, createServiceBuilder, getRootLogger, loadBackendConfig, @@ -37,6 +38,7 @@ import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; +import codeCoverage from './plugins/codecoverage'; import kubernetes from './plugins/kubernetes'; import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; @@ -58,11 +60,13 @@ function makeCreateEnv(config: Config) { root.info(`Created UrlReader ${reader}`); const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); + const cacheManager = CacheManager.fromConfig(config); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); - return { logger, database, config, reader, discovery }; + const cache = cacheManager.forPlugin(plugin); + return { logger, cache, database, config, reader, discovery }; }; } @@ -75,6 +79,9 @@ async function main() { const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); + const codeCoverageEnv = useHotMemoize(module, () => + createEnv('code-coverage'), + ); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); @@ -90,6 +97,7 @@ async function main() { const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/code-coverage', await codeCoverage(codeCoverageEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 076840944f..63a3e53c81 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -18,7 +18,9 @@ import { useHotCleanup } from '@backstage/backend-common'; import { CatalogBuilder, createRouter, + NextCatalogBuilder, runPeriodically, + createNextRouter, } from '@backstage/plugin-catalog-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -26,6 +28,32 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + /* + * ** WARNING ** + * DO NOT enable the experimental catalog, it will brick your database migrations. + * This is solely for internal backstage development. + */ + if (process.env.EXPERIMENTAL_CATALOG === '1') { + const builder = new NextCatalogBuilder(env); + const { + entitiesCatalog, + locationAnalyzer, + processingEngine, + locationService, + } = await builder.build(); + + // TODO(jhaals): run and manage in background. + await processingEngine.start(); + + return await createNextRouter({ + entitiesCatalog, + locationAnalyzer, + locationService, + logger: env.logger, + config: env.config, + }); + } + const builder = new CatalogBuilder(env); const { entitiesCatalog, diff --git a/packages/backend/src/plugins/codecoverage.ts b/packages/backend/src/plugins/codecoverage.ts new file mode 100644 index 0000000000..c06e0e516f --- /dev/null +++ b/packages/backend/src/plugins/codecoverage.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-code-coverage-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment) { + return await createRouter({ + config: env.config, + discovery: env.discovery, + database: env.database, + urlReader: env.reader, + logger: env.logger, + }); +} diff --git a/packages/backend/src/plugins/kubernetes.ts b/packages/backend/src/plugins/kubernetes.ts index 131c5c8eee..7906765533 100644 --- a/packages/backend/src/plugins/kubernetes.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -15,12 +15,11 @@ */ import { createRouter } from '@backstage/plugin-kubernetes-backend'; -import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment): Promise { +}: PluginEnvironment) { return await createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index fca3f79270..9c7c3c12f3 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { SingleHostDiscovery } from '@backstage/backend-common'; +import { + DockerContainerRunner, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { CookieCutter, @@ -34,8 +37,11 @@ export default async function createPlugin({ database, reader, }: PluginEnvironment): Promise { - const cookiecutterTemplater = new CookieCutter(); - const craTemplater = new CreateReactAppTemplater(); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + const craTemplater = new CreateReactAppTemplater({ containerRunner }); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); @@ -44,8 +50,6 @@ export default async function createPlugin({ const preparers = await Preparers.fromConfig(config, { logger }); const publishers = await Publishers.fromConfig(config, { logger }); - const dockerClient = new Docker(); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -55,7 +59,6 @@ export default async function createPlugin({ publishers, logger, config, - dockerClient, database, catalogClient, reader, diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 7bd473756d..e587cdb606 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -15,7 +15,10 @@ */ import { useHotCleanup } from '@backstage/backend-common'; import { createRouter } from '@backstage/plugin-search-backend'; -import { IndexBuilder } from '@backstage/plugin-search-backend-node'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; @@ -23,21 +26,28 @@ export default async function createPlugin({ logger, discovery, }: PluginEnvironment) { - const indexBuilder = new IndexBuilder({ logger }); + // Initialize a connection to a search engine. + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); + // Collators are responsible for gathering documents known to plugins. This + // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ - type: 'software-catalog', defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator(discovery), + collator: new DefaultCatalogCollator({ discovery }), }); - // TODO: Move refresh loop logic into the builder. - const timerId = setInterval(() => { - indexBuilder.build(); - }, 60000); - useHotCleanup(module, () => clearInterval(timerId)); + // The scheduler controls when documents are gathered from collators and sent + // to the search engine for indexing. + const { scheduler } = await indexBuilder.build(); + + // A 3 second delay gives the backend server a chance to initialize before + // any collators are executed, which may attempt requests against the API. + setTimeout(() => scheduler.start(), 3000); + useHotCleanup(module, () => scheduler.stop()); return await createRouter({ + engine: indexBuilder.getSearchEngine(), logger, }); } diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index d0387cd15d..dbdc455e09 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DockerContainerRunner } from '@backstage/backend-common'; import { createRouter, Generators, @@ -35,9 +36,14 @@ export default async function createPlugin({ reader, }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + // Generators are used for generating documentation sites. const generators = await Generators.fromConfig(config, { logger, + containerRunner, }); // Publisher is used for @@ -51,14 +57,10 @@ export default async function createPlugin({ // checks if the publisher is working and logs the result await publisher.getReadiness(); - // Docker client (conditionally) used by the generators, based on techdocs.generators config. - const dockerClient = new Docker(); - return await createRouter({ preparers, generators, publisher, - dockerClient, logger, config, discovery, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index f63b9dd780..356dd08d5f 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,6 +17,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { + PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, UrlReader, @@ -24,6 +25,7 @@ import { export type PluginEnvironment = { logger: Logger; + cache: PluginCacheManager; database: PluginDatabaseManager; config: Config; reader: UrlReader; diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 0076202c64..847924b67b 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/catalog-client +## 0.3.13 + +### Patch Changes + +- 70bc30c5b: Display preview result final step. +- Updated dependencies [27a9b503a] + - @backstage/catalog-model@0.8.2 + +## 0.3.12 + +### Patch Changes + +- add62a455: Foundation for standard entity status values +- Updated dependencies [add62a455] +- Updated dependencies [704875e26] + - @backstage/catalog-model@0.8.0 + +## 0.3.11 + +### Patch Changes + +- d1b1306d9: Allow `filter` parameter to be specified multiple times +- Updated dependencies [d8b81fd28] + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.3.10 ### Patch Changes diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md new file mode 100644 index 0000000000..1f911c84e9 --- /dev/null +++ b/packages/catalog-client/api-report.md @@ -0,0 +1,85 @@ +## API Report File for "@backstage/catalog-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { Location as Location_2 } from '@backstage/catalog-model'; + +// @public (undocumented) +export type AddLocationRequest = { + type?: string; + target: string; + dryRun?: boolean; + presence?: 'optional' | 'required'; +}; + +// @public (undocumented) +export type AddLocationResponse = { + location: Location_2; + entities: Entity[]; +}; + +// @public (undocumented) +export interface CatalogApi { + // (undocumented) + addLocation(location: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(name: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; +} + +// @public (undocumented) +export class CatalogClient implements CatalogApi { + constructor(options: { + discoveryApi: DiscoveryApi; + }); + // (undocumented) + addLocation({ type, target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(compoundName: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; + } + +// @public (undocumented) +export type CatalogEntitiesRequest = { + filter?: Record[] | Record | undefined; + fields?: string[] | undefined; +}; + +// @public (undocumented) +export type CatalogListResponse = { + items: T[]; +}; + +// @public +export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = "backstage.io/catalog-processing"; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 91845e56ab..c366374bf5 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.10", + "version": "0.3.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,13 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/catalog-model": "^0.8.2", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.7.0", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 19da45e4ec..98b378f5b0 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -18,7 +18,8 @@ import { Entity } from '@backstage/catalog-model'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; -import { CatalogListResponse, DiscoveryApi } from './types'; +import { CatalogListResponse } from './types/api'; +import { DiscoveryApi } from './types/discovery'; const server = setupServer(); const token = 'fake-token'; @@ -76,7 +77,38 @@ describe('CatalogClient', () => { expect(response).toEqual(defaultResponse); }); - it('builds entity search filters properly', async () => { + it('builds multiple entity search filters properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?filter=a=1,b=2,b=3,%C3%B6=%3D&filter=a=2', + ); + return res(ctx.json([])); + }), + ); + + const response = await client.getEntities( + { + filter: [ + { + a: '1', + b: ['2', '3'], + ö: '=', + }, + { + a: '2', + }, + ], + }, + { token }, + ); + + expect(response.items).toEqual([]); + }); + + it('builds single entity search filter properly', async () => { expect.assertions(2); server.use( diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 3de25e9808..141929f3da 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -31,8 +31,8 @@ import { CatalogEntitiesRequest, CatalogListResponse, CatalogRequestOptions, - DiscoveryApi, -} from './types'; +} from './types/api'; +import { DiscoveryApi } from './types/discovery'; export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; @@ -56,18 +56,27 @@ export class CatalogClient implements CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise> { - const { filter = {}, fields = [] } = request ?? {}; + const { filter = [], fields = [] } = request ?? {}; + const filterItems = [filter].flat(); const params: string[] = []; - const filterParts: string[] = []; - for (const [key, value] of Object.entries(filter)) { - for (const v of [value].flat()) { - filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`); + // filter param can occur multiple times, for example + // /api/catalog/entities?filter=metadata.name=wayback-search,kind=component&filter=metadata.name=www-artist,kind=component' + // the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters + // the "inner array" defined within a `filter` param corresponds to "allOf" filters + for (const filterItem of filterItems) { + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filterItem)) { + for (const v of [value].flat()) { + filterParts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(v)}`, + ); + } } - } - if (filterParts.length) { - params.push(`filter=${filterParts.join(',')}`); + if (filterParts.length) { + params.push(`filter=${filterParts.join(',')}`); + } } if (fields.length) { @@ -125,11 +134,6 @@ export class CatalogClient implements CatalogApi { throw new Error(`Location wasn't added: ${target}`); } - if (entities.length === 0) { - throw new Error( - `Location was added but has no entities specified yet: ${target}`, - ); - } return { location, entities, diff --git a/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts index c5a626e25b..59c652f9fd 100644 --- a/packages/catalog-client/src/index.ts +++ b/packages/catalog-client/src/index.ts @@ -15,10 +15,4 @@ */ export { CatalogClient } from './CatalogClient'; -export type { - AddLocationRequest, - AddLocationResponse, - CatalogApi, - CatalogEntitiesRequest, - CatalogListResponse, -} from './types'; +export * from './types'; diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types/api.ts similarity index 89% rename from packages/catalog-client/src/types.ts rename to packages/catalog-client/src/types/api.ts index 0d25bf7483..f9ba2e9d8d 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types/api.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,10 @@ import { Entity, EntityName, Location } from '@backstage/catalog-model'; export type CatalogEntitiesRequest = { - filter?: Record | undefined; + filter?: + | Record[] + | Record + | undefined; fields?: string[] | undefined; }; @@ -78,10 +81,3 @@ export type AddLocationResponse = { location: Location; entities: Entity[]; }; - -/** - * This is a copy of the core DiscoveryApi, to avoid importing core. - */ -export type DiscoveryApi = { - getBaseUrl(pluginId: string): Promise; -}; diff --git a/packages/catalog-client/src/types/discovery.ts b/packages/catalog-client/src/types/discovery.ts new file mode 100644 index 0000000000..90eb748b2f --- /dev/null +++ b/packages/catalog-client/src/types/discovery.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This is a copy of the core DiscoveryApi, to avoid importing core. + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts new file mode 100644 index 0000000000..c1670659ec --- /dev/null +++ b/packages/catalog-client/src/types/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, +} from './api'; +export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/status.ts b/packages/catalog-client/src/types/status.ts new file mode 100644 index 0000000000..7990c4b121 --- /dev/null +++ b/packages/catalog-client/src/types/status.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The entity `status.items[].type` for the status of the processing engine in + * regards to an entity. + */ +export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = + 'backstage.io/catalog-processing'; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 445da3afab..df350ff288 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,77 @@ # @backstage/catalog-model +## 0.8.2 + +### Patch Changes + +- 27a9b503a: Introduce conditional steps in scaffolder templates. + + A step can now include an `if` property that only executes a step if the + condition is truthy. The condition can include handlebar templates. + + ```yaml + - id: register + if: '{{ not parameters.dryRun }}' + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' + ``` + + Also introduces a `not` helper in handlebar templates that allows to negate + boolean expressions. + +## 0.8.1 + +### Patch Changes + +- ebe802bc4: Remove the explicit connection from `EntityEnvelope` and `Entity`. + +## 0.8.0 + +### Minor Changes + +- 704875e26: Breaking changes: + + - The long-deprecated `schemaValidator` export is finally removed. + + Additions: + + - The `EntityEnvelope` type, which is a supertype of `Entity`. + - The `entityEnvelopeSchemaValidator` function, which returns a validator for an `EntityEnvelope` or its subtypes, based on a JSON schema. + - The `entitySchemaValidator` function, which returns a validator for an `Entity` or its subtypes, based on a JSON schema. + - The `entityKindSchemaValidator` function, which returns a specialized validator for custom `Entity` kinds, based on a JSON schema. + +### Patch Changes + +- add62a455: Foundation for standard entity status values + +## 0.7.10 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 68fdbf014: Add the `status` field to the Entity envelope + +## 0.7.9 + +### Patch Changes + +- 10c008a3a: Renamed parameters to input in template schema +- 16be1d093: Improve error messages when schema validation fails + +## 0.7.8 + +### Patch Changes + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config@0.1.5 + ## 0.7.7 ### Patch Changes diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md new file mode 100644 index 0000000000..0dab9670de --- /dev/null +++ b/packages/catalog-model/api-report.md @@ -0,0 +1,608 @@ +## API Report File for "@backstage/catalog-model" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; +import { JsonValue } from '@backstage/config'; +import { SerializedError } from '@backstage/errors'; +import * as yup from 'yup'; + +// @public (undocumented) +export const analyzeLocationSchema: yup.ObjectSchema<{ + location: LocationSpec; +}, object>; + +// @public (undocumented) +interface ApiEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'API'; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + definition: string; + system?: string; + }; +} + +export { ApiEntityV1alpha1 as ApiEntity } + +export { ApiEntityV1alpha1 } + +// @public (undocumented) +export const apiEntityV1alpha1Validator: KindValidator; + +// @public +export class CommonValidatorFunctions { + static isJsonSafe(value: unknown): boolean; + static isValidDnsLabel(value: unknown): boolean; + static isValidDnsSubdomain(value: unknown): boolean; + static isValidPrefixAndOrSuffix(value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean): boolean; + static isValidString(value: unknown): boolean; + static isValidUrl(value: unknown): boolean; +} + +// @public +export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName, context?: EntityRefContext): boolean; + +// @public (undocumented) +interface ComponentEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Component'; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + subcomponentOf?: string; + providesApis?: string[]; + consumesApis?: string[]; + dependsOn?: string[]; + system?: string; + }; +} + +export { ComponentEntityV1alpha1 as ComponentEntity } + +export { ComponentEntityV1alpha1 } + +// @public (undocumented) +export const componentEntityV1alpha1Validator: KindValidator; + +// @public +export class DefaultNamespaceEntityPolicy implements EntityPolicy { + constructor(namespace?: string); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public (undocumented) +interface DomainEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Domain'; + // (undocumented) + spec: { + owner: string; + }; +} + +export { DomainEntityV1alpha1 as DomainEntity } + +export { DomainEntityV1alpha1 } + +// @public (undocumented) +export const domainEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export const EDIT_URL_ANNOTATION = "backstage.io/edit-url"; + +// @public +export type Entity = { + apiVersion: string; + kind: string; + metadata: EntityMeta; + spec?: JsonObject; + relations?: EntityRelation[]; + status?: UNSTABLE_EntityStatus; +}; + +// @public +export const ENTITY_DEFAULT_NAMESPACE = "default"; + +// @public +export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"]; + +// @public +export type EntityEnvelope = { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace?: string; + }; +}; + +// @public +export function entityEnvelopeSchemaValidator(schema?: unknown): (data: unknown) => T; + +// @public +export function entityHasChanges(previous: Entity, next: Entity): boolean; + +// @public +export function entityKindSchemaValidator(schema: unknown): (data: unknown) => T | false; + +// @public +export type EntityLink = { + url: string; + title?: string; + icon?: string; +}; + +// @public +export type EntityMeta = JsonObject & { + uid?: string; + etag?: string; + generation?: number; + name: string; + namespace?: string; + description?: string; + labels?: Record; + annotations?: Record; + tags?: string[]; + links?: EntityLink[]; +}; + +// @public +export type EntityName = { + kind: string; + namespace: string; + name: string; +}; + +// @public (undocumented) +export const EntityPolicies: { + allOf(policies: EntityPolicy[]): AllEntityPolicies; + oneOf(policies: EntityPolicy[]): AnyEntityPolicy; +}; + +// @public +export type EntityPolicy = { + enforce(entity: Entity): Promise; +}; + +// @public +export type EntityRef = string | { + kind?: string; + namespace?: string; + name: string; +}; + +// @public +export type EntityRelation = { + type: string; + target: EntityName; +}; + +// @public +export type EntityRelationSpec = { + source: EntityName; + type: string; + target: EntityName; +}; + +// @public +export function entitySchemaValidator(schema?: unknown): (data: unknown) => T; + +// @public +export class FieldFormatEntityPolicy implements EntityPolicy { + constructor(validators?: Validators); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public +export function generateEntityEtag(): string; + +// @public +export function generateEntityUid(): string; + +// @public +export function generateUpdatedEntity(previous: Entity, next: Entity): Entity; + +// @public +export function getEntityName(entity: Entity): EntityName; + +// @public +export function getEntitySourceLocation(entity: Entity): { + type: string; + target: string; +}; + +// @public (undocumented) +interface GroupEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Group'; + // (undocumented) + spec: { + type: string; + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + parent?: string; + children: string[]; + members?: string[]; + }; +} + +export { GroupEntityV1alpha1 as GroupEntity } + +export { GroupEntityV1alpha1 } + +// @public (undocumented) +export const groupEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export type JSONSchema = JSONSchema7 & { + [key in string]?: JsonValue; +}; + +// @public +export type KindValidator = { + check(entity: Entity): Promise; +}; + +// @public +export class KubernetesValidatorFunctions { + // (undocumented) + static isValidAnnotationKey(value: unknown): boolean; + // (undocumented) + static isValidAnnotationValue(value: unknown): boolean; + // (undocumented) + static isValidApiVersion(value: unknown): boolean; + // (undocumented) + static isValidKind(value: unknown): boolean; + // (undocumented) + static isValidLabelKey(value: unknown): boolean; + // (undocumented) + static isValidLabelValue(value: unknown): boolean; + // (undocumented) + static isValidNamespace(value: unknown): boolean; + // (undocumented) + static isValidObjectName(value: unknown): boolean; +} + +// @public (undocumented) +type Location_2 = { + id: string; +} & LocationSpec; + +export { Location_2 as Location } + +// @public (undocumented) +export const LOCATION_ANNOTATION = "backstage.io/managed-by-location"; + +// @public (undocumented) +interface LocationEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Location'; + // (undocumented) + spec: { + type?: string; + target?: string; + targets?: string[]; + }; +} + +export { LocationEntityV1alpha1 as LocationEntity } + +export { LocationEntityV1alpha1 } + +// @public (undocumented) +export const locationEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export const locationSchema: yup.ObjectSchema; + +// @public (undocumented) +export type LocationSpec = { + type: string; + target: string; + presence?: 'optional' | 'required'; +}; + +// @public (undocumented) +export const locationSpecSchema: yup.ObjectSchema; + +// @public (undocumented) +export function makeValidator(overrides?: Partial): Validators; + +// @public +export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { + constructor(knownFields?: string[]); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public (undocumented) +export const ORIGIN_LOCATION_ANNOTATION = "backstage.io/managed-by-origin-location"; + +// @public +export function parseEntityName(ref: EntityRef, context?: EntityRefContext): EntityName; + +// @public +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; + defaultNamespace: string; +}): { + kind: string; + namespace: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; +}): { + kind: string; + namespace?: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultNamespace: string; +}): { + kind?: string; + namespace: string; + name: string; +}; + +// @public +export function parseLocationReference(ref: string): { + type: string; + target: string; +}; + +// @public (undocumented) +export const RELATION_API_CONSUMED_BY = "apiConsumedBy"; + +// @public (undocumented) +export const RELATION_API_PROVIDED_BY = "apiProvidedBy"; + +// @public (undocumented) +export const RELATION_CHILD_OF = "childOf"; + +// @public +export const RELATION_CONSUMES_API = "consumesApi"; + +// @public (undocumented) +export const RELATION_DEPENDENCY_OF = "dependencyOf"; + +// @public +export const RELATION_DEPENDS_ON = "dependsOn"; + +// @public (undocumented) +export const RELATION_HAS_MEMBER = "hasMember"; + +// @public (undocumented) +export const RELATION_HAS_PART = "hasPart"; + +// @public +export const RELATION_MEMBER_OF = "memberOf"; + +// @public +export const RELATION_OWNED_BY = "ownedBy"; + +// @public (undocumented) +export const RELATION_OWNER_OF = "ownerOf"; + +// @public +export const RELATION_PARENT_OF = "parentOf"; + +// @public +export const RELATION_PART_OF = "partOf"; + +// @public (undocumented) +export const RELATION_PROVIDES_API = "providesApi"; + +// @public (undocumented) +interface ResourceEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Resource'; + // (undocumented) + spec: { + type: string; + owner: string; + dependsOn?: string[]; + system?: string; + }; +} + +export { ResourceEntityV1alpha1 as ResourceEntity } + +export { ResourceEntityV1alpha1 } + +// @public (undocumented) +export const resourceEntityV1alpha1Validator: KindValidator; + +// @public +export class SchemaValidEntityPolicy implements EntityPolicy { + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public @deprecated +export function serializeEntityRef(ref: Entity | { + kind?: string; + namespace?: string; + name: string; +}): EntityRef; + +// @public (undocumented) +export const SOURCE_LOCATION_ANNOTATION = "backstage.io/source-location"; + +// @public +export function stringifyEntityRef(ref: Entity | { + kind: string; + namespace?: string; + name: string; +}): string; + +// @public +export function stringifyLocationReference(ref: { + type: string; + target: string; +}): string; + +// @public (undocumented) +interface SystemEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'System'; + // (undocumented) + spec: { + owner: string; + domain?: string; + }; +} + +export { SystemEntityV1alpha1 as SystemEntity } + +export { SystemEntityV1alpha1 } + +// @public (undocumented) +export const systemEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +interface TemplateEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'Template'; + // (undocumented) + spec: { + type: string; + templater: string; + path?: string; + schema: JSONSchema; + owner?: string; + }; +} + +export { TemplateEntityV1alpha1 as TemplateEntity } + +export { TemplateEntityV1alpha1 } + +// @public (undocumented) +export const templateEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export interface TemplateEntityV1beta2 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1beta2'; + // (undocumented) + kind: 'Template'; + // (undocumented) + metadata: EntityMeta & { + title?: string; + }; + // (undocumented) + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + input?: JsonObject; + if?: string | boolean; + }>; + output?: { + [name: string]: string; + }; + owner?: string; + }; +} + +// @public (undocumented) +export const templateEntityV1beta2Validator: KindValidator; + +// @alpha +export type UNSTABLE_EntityStatus = { + items?: UNSTABLE_EntityStatusItem[]; +}; + +// @alpha +export type UNSTABLE_EntityStatusItem = { + type: string; + level: UNSTABLE_EntityStatusLevel; + message: string; + error?: SerializedError; +}; + +// @alpha +export type UNSTABLE_EntityStatusLevel = 'info' | 'warning' | 'error'; + +// @public (undocumented) +interface UserEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'User'; + // (undocumented) + spec: { + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + memberOf: string[]; + }; +} + +export { UserEntityV1alpha1 as UserEntity } + +export { UserEntityV1alpha1 } + +// @public (undocumented) +export const userEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export type Validators = { + isValidApiVersion(value: unknown): boolean; + isValidKind(value: unknown): boolean; + isValidEntityName(value: unknown): boolean; + isValidNamespace(value: unknown): boolean; + isValidLabelKey(value: unknown): boolean; + isValidLabelValue(value: unknown): boolean; + isValidAnnotationKey(value: unknown): boolean; + isValidAnnotationValue(value: unknown): boolean; + isValidTag(value: unknown): boolean; +}; + +// @public +export const VIEW_URL_ANNOTATION = "backstage.io/view-url"; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index edd9b8fcf9..88fd33ac0d 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -33,3 +33,4 @@ spec: lifecycle: experimental owner: team-a system: artist-engagement-portal + dependsOn: ['resource:artists-db'] diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 64550b35d1..e2ea7fad92 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.7.7", + "version": "0.8.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,17 +29,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.5", + "@backstage/errors": "^0.1.1", "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", "ajv": "^7.0.3", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", + "typescript-json-schema": "^0.49.0", "lodash": "^4.17.15", "uuid": "^8.0.0", "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.7.0", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 7abe1f9c0c..07b3d939aa 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -16,9 +16,10 @@ import { JsonObject } from '@backstage/config'; import { EntityName } from '../types'; +import { UNSTABLE_EntityStatus } from './EntityStatus'; /** - * The format envelope that's common to all versions/kinds of entity. + * The parts of the format that's common to all versions/kinds of entity. * * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/ */ @@ -48,6 +49,14 @@ export type Entity = { * The relations that this entity has with other entities. */ relations?: EntityRelation[]; + + /** + * The current status of the entity, as claimed by various sources. + * + * The keys are implementation defined and the values can be any JSON object + * with semantics that match that implementation. + */ + status?: UNSTABLE_EntityStatus; }; /** diff --git a/packages/catalog-model/src/entity/EntityEnvelope.ts b/packages/catalog-model/src/entity/EntityEnvelope.ts new file mode 100644 index 0000000000..631a8873c7 --- /dev/null +++ b/packages/catalog-model/src/entity/EntityEnvelope.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The envelope skeleton parts of an entity - whatever is necessary to be able + * to give it a ref and pass to further validation / policy checking. + * + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/ + */ +export type EntityEnvelope = { + /** + * The version of specification format for this particular entity that + * this is written against. + */ + apiVersion: string; + + /** + * The high level entity type being described. + */ + kind: string; + + /** + * Metadata related to the entity. + */ + metadata: { + /** + * The name of the entity. + * + * Must be unique within the catalog at any given point in time, for any + * given namespace + kind pair. + */ + name: string; + + /** + * The namespace that the entity belongs to. + */ + namespace?: string; + }; +}; diff --git a/packages/catalog-model/src/entity/EntityStatus.ts b/packages/catalog-model/src/entity/EntityStatus.ts new file mode 100644 index 0000000000..67090f2bc2 --- /dev/null +++ b/packages/catalog-model/src/entity/EntityStatus.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SerializedError } from '@backstage/errors'; + +/** + * The current status of the entity, as claimed by various sources. + * @alpha + */ +export type UNSTABLE_EntityStatus = { + /** + * Specific status item on a well known format. + */ + items?: UNSTABLE_EntityStatusItem[]; +}; + +/** + * A specific status item on a well known format. + * @alpha + */ +export type UNSTABLE_EntityStatusItem = { + /** + * The type of status as a unique key per source. + */ + type: string; + /** + * The level / severity of the status item. If the level is "error", the + * processing of the entity may be entirely blocked. In this case the status + * entry may apply to a different, newer version of the data than what is + * being returned in the catalog response. + */ + level: UNSTABLE_EntityStatusLevel; + /** + * A brief message describing the status, intended for human consumption. + */ + message: string; + /** + * An optional serialized error object related to the status. + */ + error?: SerializedError; +}; + +/** + * Each entity status item has a level, describing its severity. + * @alpha + */ +export type UNSTABLE_EntityStatusLevel = + | 'info' // Only informative data + | 'warning' // Warnings were found + | 'error'; // Errors were found diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 572df63557..ae2c0bf503 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -15,10 +15,10 @@ */ export { + EDIT_URL_ANNOTATION, ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, VIEW_URL_ANNOTATION, - EDIT_URL_ANNOTATION, } from './constants'; export type { Entity, @@ -27,6 +27,12 @@ export type { EntityRelation, EntityRelationSpec, } from './Entity'; +export type { EntityEnvelope } from './EntityEnvelope'; +export type { + UNSTABLE_EntityStatus, + UNSTABLE_EntityStatusItem, + UNSTABLE_EntityStatusLevel, +} from './EntityStatus'; export * from './policies'; export { compareEntityToRef, diff --git a/packages/catalog-model/src/entity/util.ts b/packages/catalog-model/src/entity/util.ts index 2c63cc4c49..116913c85c 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -42,11 +42,9 @@ export function generateEntityEtag(): string { * the next version of this entity. * * Significance, in this case, means that we do not compare generated fields - * such as uid, etag and generation, and we only check that no new annotations - * are added or existing annotations were changed (since they are effectively - * merged when doing updates). + * such as uid, etag and generation. * - * Note that this comparison does NOT take state, relations or similar into + * Note that this comparison does NOT take status, relations or similar into * account. It only compares the actual input entity data, i.e. metadata and * spec. * @@ -86,7 +84,9 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { // Remove things that we explicitly do not compare delete e1.relations; + delete e1.status; delete e2.relations; + delete e2.status; return !lodash.isEqual(e1, e2); } diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index a4d7d904cd..249243bed3 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -168,4 +168,16 @@ components: (entity as any).spec.system = ''; await expect(validator.check(entity)).rejects.toThrow(/system/); }); + + it('rejects additional properties', async () => { + (entity as any).annotations = 'Test'; + await expect(validator.check(entity)).rejects.toThrow( + /additional properties/, + ); + }); + + it('rejects with useful error message', async () => { + (entity as any).annotations = 'Test'; + await expect(validator.check(entity)).rejects.toThrow(/annotations/); + }); }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 5432cafdeb..37d5a4fba0 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/API.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'API' as const; - export interface ApiEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'API'; spec: { type: string; lifecycle: string; @@ -37,8 +31,5 @@ export interface ApiEntityV1alpha1 extends Entity { } export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 489aa8b1bd..6ef45fda6e 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Component.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Component' as const; - export interface ComponentEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Component'; spec: { type: string; lifecycle: string; @@ -40,8 +34,5 @@ export interface ComponentEntityV1alpha1 extends Entity { } export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts index 7aab35e367..f23c330a87 100644 --- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -16,25 +16,16 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Domain' as const; - export interface DomainEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Domain'; spec: { owner: string; }; } export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 74ca4f221a..62a6edbc5e 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Group.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Group' as const; - export interface GroupEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Group'; spec: { type: string; profile?: { @@ -41,8 +35,5 @@ export interface GroupEntityV1alpha1 extends Entity { } export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index fb452b6ac7..dc79ff1921 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Location.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Location' as const; - export interface LocationEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Location'; spec: { type?: string; target?: string; @@ -35,8 +29,5 @@ export interface LocationEntityV1alpha1 extends Entity { } export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index fd71500f40..4c79209c9c 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Resource' as const; - export interface ResourceEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Resource'; spec: { type: string; owner: string; @@ -36,8 +30,5 @@ export interface ResourceEntityV1alpha1 extends Entity { } export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts index 1ee19466f0..41203083a5 100644 --- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/System.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'System' as const; - export interface SystemEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'System'; spec: { owner: string; domain?: string; @@ -34,8 +28,5 @@ export interface SystemEntityV1alpha1 extends Entity { } export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index e3d29aad4f..bfb27b4ed6 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => { }, }, }, + owner: 'team-a@example.com', }, }; }); @@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => { (entity as any).spec.templater = ''; await expect(validator.check(entity)).rejects.toThrow(/templater/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 72479c7efa..0600c58278 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -16,29 +16,21 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Template.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import type { JSONSchema } from '../types'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Template' as const; - export interface TemplateEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Template'; spec: { type: string; templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 60f3de9e59..b06a71c8da 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -51,14 +51,16 @@ describe('templateEntityV1beta2Validator', () => { id: 'fetch', name: 'Fetch', action: 'fetch:plan', - parameters: { + input: { url: './template', }, + if: '{{ parameters.owner }}', }, ], output: { fetchUrl: '{{ steps.fetch.output.targetUrl }}', }, + owner: 'team-b@example.com', }, }; }); @@ -121,4 +123,39 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps[0].action; await expect(validator.check(entity)).rejects.toThrow(/action/); }); + + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing if', async () => { + delete (entity as any).spec.steps[0].if; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts boolean in if', async () => { + (entity as any).spec.steps[0].if = true; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts empty if', async () => { + (entity as any).spec.steps[0].if = ''; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong type if', async () => { + (entity as any).spec.steps[0].if = 5; + await expect(validator.check(entity)).rejects.toThrow(/if/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index c78e7f85d5..6ee6fcb1f1 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -14,20 +14,14 @@ * limitations under the License. */ +import { JsonObject } from '@backstage/config'; import type { Entity, EntityMeta } from '../entity/Entity'; import schema from '../schema/kinds/Template.v1beta2.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -import { JsonObject } from '@backstage/config'; - -const API_VERSION = ['backstage.io/v1beta2'] as const; -const KIND = 'Template' as const; export interface TemplateEntityV1beta2 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1beta2'; + kind: 'Template'; metadata: EntityMeta & { title?: string; }; @@ -38,15 +32,14 @@ export interface TemplateEntityV1beta2 extends Entity { id?: string; name?: string; action: string; - parameters?: JsonObject; + input?: JsonObject; + if?: string | boolean; }>; output?: { [name: string]: string }; + owner?: string; }; } export const templateEntityV1beta2Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index a8700a496e..d73fa7aaf3 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/User.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'User' as const; - export interface UserEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'User'; spec: { profile?: { displayName?: string; @@ -38,8 +32,5 @@ export interface UserEntityV1alpha1 extends Entity { } export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 4ae2db483a..e36575f51d 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -export { schemaValidator } from './util'; -export type { KindValidator } from './types'; export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, @@ -59,6 +57,7 @@ export type { } from './TemplateEntityV1alpha1'; export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2'; export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2'; +export type { KindValidator } from './types'; export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index 4002f2806c..a907df7f8a 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -14,60 +14,18 @@ * limitations under the License. */ -import Ajv, { AnySchema } from 'ajv'; -import * as yup from 'yup'; +import { entityKindSchemaValidator } from '../validation'; import { KindValidator } from './types'; -/** - * @deprecated We no longer use yup for the catalog model. This utility method will be removed. - */ -export function schemaValidator( - kind: string, - apiVersion: readonly string[], - schema: yup.Schema, -): KindValidator { +// TODO(freben): Left here as a compatibility helper. It would be nicer to +// just export the inner validator directly. However, all of the already +// exported kind validators have the `KindValidator` signature which is +// different. So let's postpone that change until a later time. +export function ajvCompiledJsonSchemaValidator(schema: unknown): KindValidator { + const validator = entityKindSchemaValidator(schema); return { - async check(envelope) { - if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { - return false; - } - await schema.validate(envelope, { strict: true }); - return true; - }, - }; -} - -export function ajvCompiledJsonSchemaValidator( - kind: string, - apiVersion: readonly string[], - schema: AnySchema, - extraSchemas?: AnySchema[], -): KindValidator { - const ajv = new Ajv({ allowUnionTypes: true }); - if (extraSchemas) { - ajv.addSchema(extraSchemas, undefined, undefined, true); - } - const validate = ajv.compile(schema); - - return { - async check(envelope) { - if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { - return false; - } - - const result = validate(envelope); - if (result === true) { - return true; - } - - const [error] = validate.errors || []; - if (!error) { - throw new TypeError(`Malformed ${kind}, Unknown error`); - } - - throw new TypeError( - `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`, - ); + async check(data) { + return validator(data) === data; }, }; } diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json index 803e025796..90eb9dd5c6 100644 --- a/packages/catalog-model/src/schema/Entity.schema.json +++ b/packages/catalog-model/src/schema/Entity.schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", "$id": "Entity", - "description": "The format envelope that's common to all versions/kinds of entity.", + "description": "The parts of the format that's common to all versions/kinds of entity.", "examples": [ { "apiVersion": "backstage.io/v1alpha1", @@ -62,6 +62,9 @@ "items": { "$ref": "common#relation" } + }, + "status": { + "$ref": "common#status" } } } diff --git a/packages/catalog-model/src/schema/EntityEnvelope.schema.json b/packages/catalog-model/src/schema/EntityEnvelope.schema.json new file mode 100644 index 0000000000..7e64039c75 --- /dev/null +++ b/packages/catalog-model/src/schema/EntityEnvelope.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "EntityEnvelope", + "description": "The envelope skeleton parts of an entity - whatever is necessary to be able to give it a ref and pass to further validation / policy checking.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService" + } + } + ], + "type": "object", + "required": ["apiVersion", "kind", "metadata"], + "additionalProperties": true, + "properties": { + "apiVersion": { + "type": "string", + "description": "The version of specification format for this particular entity that this is written against.", + "minLength": 1, + "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"] + }, + "kind": { + "type": "string", + "description": "The high level entity type being described.", + "minLength": 1, + "examples": [ + "API", + "Component", + "Domain", + "Group", + "Location", + "Resource", + "System", + "Template", + "User" + ] + }, + "metadata": { + "type": "object", + "required": ["name"], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.", + "examples": ["metadata-proxy"], + "minLength": 1 + }, + "namespace": { + "type": "string", + "description": "The namespace that the entity belongs to.", + "default": "default", + "examples": ["default", "admin"], + "minLength": 1 + } + } + } + } +} diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 5bb83d116b..5a0c0efa73 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -66,7 +66,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, @@ -85,6 +85,11 @@ "schema": { "type": "object", "description": "The JSONSchema describing the inputs for the template." + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index ba3ccb9d1d..b14cdb8ee0 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -1,10 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "TemplateV1beta1", + "$id": "TemplateV1beta2", "description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.", "examples": [ { - "apiVersion": "backstage.io/v1beta1", + "apiVersion": "backstage.io/v1beta2", "kind": "Template", "metadata": { "name": "react-ssr-template", @@ -45,7 +45,8 @@ "action": "publish:github", "parameters": { "repoUrl": "{{ parameters.repoUrl }}" - } + }, + "if": "{{ parameters.repoUrl }}" } ], "output": { @@ -85,7 +86,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, @@ -125,9 +126,13 @@ "type": "string", "description": "The name of the action to execute." }, - "parameters": { + "input": { "type": "object", "description": "A templated object describing the inputs to the action." + }, + "if": { + "type": ["string", "boolean"], + "description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`." } } } @@ -141,7 +146,7 @@ "description": "A list of external hyperlinks, typically pointing to resources created or updated by the template", "items": { "type": "object", - "required": ["url"], + "required": [], "properties": { "url": { "type": "string", @@ -149,6 +154,12 @@ "examples": ["https://github.com/my-org/my-new-repo"], "minLength": 1 }, + "entityRef": { + "type": "string", + "description": "An entity reference to an entity in the catalog.", + "examples": ["Component:default/my-app"], + "minLength": 1 + }, "title": { "type": "string", "description": "A user friendly display name for the link.", @@ -168,6 +179,11 @@ "additionalProperties": { "type": "string" } + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json index 84ae8a4147..a9082d53e4 100644 --- a/packages/catalog-model/src/schema/shared/common.schema.json +++ b/packages/catalog-model/src/schema/shared/common.schema.json @@ -13,15 +13,18 @@ "properties": { "kind": { "type": "string", - "description": "The kind field of the entity." + "description": "The kind field of the entity.", + "minLength": 1 }, "namespace": { "type": "string", - "description": "The metadata.namespace field of the entity." + "description": "The metadata.namespace field of the entity.", + "minLength": 1 }, "name": { "type": "string", - "description": "The metadata.name field of the entity." + "description": "The metadata.name field of the entity.", + "minLength": 1 } } }, @@ -29,7 +32,7 @@ "$id": "#relation", "type": "object", "description": "A directed relation from one entity to another.", - "required": ["type", "source", "target"], + "required": ["type", "target"], "additionalProperties": false, "properties": { "type": { @@ -38,13 +41,83 @@ "pattern": "^\\w+$", "description": "The type of relation." }, - "source": { - "$ref": "#reference" - }, "target": { "$ref": "#reference" } } + }, + "status": { + "$id": "#status", + "type": "object", + "description": "The current status of the entity, as claimed by various sources.", + "required": [], + "additionalProperties": true, + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#statusItem" + } + } + } + }, + "statusItem": { + "$id": "#statusItem", + "type": "object", + "description": "A specific status item on a well known format.", + "required": ["type", "level", "message"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "level": { + "$ref": "#statusLevel", + "description": "The status level / severity of the status item." + }, + "message": { + "type": "string", + "description": "A brief message describing the status, intended for human consumption." + }, + "error": { + "$ref": "#error", + "description": "An optional serialized error object related to the status." + } + } + }, + "statusLevel": { + "$id": "#statusLevel", + "type": "string", + "description": "A status level / severity.", + "enum": ["info", "warning", "error"] + }, + "error": { + "$id": "#error", + "type": "object", + "description": "A serialized error object.", + "required": ["name", "message"], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "examples": ["Error", "InputError"], + "description": "The type name of the error", + "minLength": 1 + }, + "message": { + "type": "string", + "description": "The message of the error" + }, + "code": { + "type": "string", + "description": "An error code associated with the error" + }, + "stack": { + "type": "string", + "description": "An error stack trace" + } + } } } } diff --git a/packages/catalog-model/src/validation/ajv.ts b/packages/catalog-model/src/validation/ajv.ts new file mode 100644 index 0000000000..02d53fcd15 --- /dev/null +++ b/packages/catalog-model/src/validation/ajv.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Ajv, { Schema, ValidateFunction } from 'ajv'; +import entitySchema from '../schema/Entity.schema.json'; +import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; + +// A local cache of compiled schemas, to avoid duplicate work. +// The keys are JSON stringified versions of the schema +const compiledSchemaCache = new Map>(); + +// The core schemas that others can depend on +const refDependencyCandidates = [ + entityEnvelopeSchema, + entitySchema, + entityMetaSchema, + commonSchema, +]; + +export function throwAjvError( + errors: ValidateFunction['errors'], +): never { + if (!errors?.length) { + throw new TypeError('Unknown error'); + } + + const error = errors[0]; + throw new TypeError( + `${error.dataPath || ''} ${error.message}${ + error.params + ? ` - ${Object.entries(error.params) + .map(([key, val]) => `${key}: ${val}`) + .join(', ')}` + : '' + }`, + ); +} + +// Compiles the given schema, and makes sure to also grab any core dependencies +// that it depends on +export function compileAjvSchema( + schema: Schema, + options: { disableCache?: boolean } = {}, +): ValidateFunction { + const disableCache = options?.disableCache ?? false; + const cacheKey = disableCache ? '' : JSON.stringify(schema); + + if (!disableCache) { + const cached = compiledSchemaCache.get(cacheKey); + if (cached) { + return cached; + } + } + + const extraSchemas = getExtraSchemas(schema); + const ajv = new Ajv({ + allowUnionTypes: true, + allErrors: true, + validateSchema: true, + }); + if (extraSchemas.length) { + ajv.addSchema(extraSchemas, undefined, undefined, true); + } + const compiled = ajv.compile(schema); + + if (!disableCache) { + compiledSchemaCache.set(cacheKey, compiled); + } + + return compiled; +} + +// Find refs in the given schema and recursively in all known schemas it +// targets, collecting that list of schemas as we go +function getExtraSchemas(schema: Schema): Schema[] { + if (typeof schema !== 'object') { + return []; + } + + const seen = new Set(); + if (schema.$id) { + seen.add(schema.$id); + } + + const selected = new Array(); + + const todo: Schema[] = [schema]; + while (todo.length) { + const current = todo.pop()!; + + for (const ref of getAllRefs(current)) { + if (!seen.has(ref)) { + seen.add(ref); + + const match = refDependencyCandidates.find(c => c.$id === ref); + if (match) { + selected.push(match); + todo.push(match); + } + } + } + } + + return selected; +} + +// Naively step through the entire schema looking for "$ref": "x" pairs. The +// resulting iterator may contain duplicates. Ignores fragments, i.e. for a ref +// of "a#b", it will just yield "a". +function* getAllRefs(schema: Schema): Iterable { + const todo: any[] = [schema]; + while (todo.length) { + const current = todo.pop()!; + if (typeof current === 'object' && current) { + for (const [key, value] of Object.entries(current)) { + if (key === '$ref' && typeof value === 'string') { + yield value.split('#')[0]; + } else { + todo.push(value); + } + } + } + } +} diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts new file mode 100644 index 0000000000..7c6613936c --- /dev/null +++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator'; + +describe('entityEnvelopeSchemaValidator', () => { + const validator = entityEnvelopeSchemaValidator(); + let entity: any; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'ns', + }, + }; + }); + + it('happy path: accepts valid data', () => { + expect(() => validator(entity)).not.toThrow(); + }); + + // + // apiVersion and kind + // + + it('rejects wrong root type', () => { + expect(() => validator(7)).toThrow(/object/); + }); + + it('accepts unknown root fields', () => { + entity.blah = 7; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects missing apiVersion', () => { + delete entity.apiVersion; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects bad apiVersion type', () => { + entity.apiVersion = 7; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects empty apiVersion', () => { + entity.apiVersion = ''; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects missing kind', () => { + delete entity.kind; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects bad kind type', () => { + entity.kind = 7; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects empty kind', () => { + entity.kind = ''; + expect(() => validator(entity)).toThrow(/kind/); + }); + + // + // metadata + // + + it('rejects missing metadata', () => { + delete entity.metadata; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('rejects bad metadata type', () => { + entity.metadata = 7; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('rejects missing name', () => { + delete entity.metadata.name; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects empty name', () => { + entity.metadata.name = ''; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects bad name type', () => { + entity.metadata.name = 7; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('accepts missing namespace', () => { + delete entity.metadata.namespace; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects empty namespace', () => { + entity.metadata.namespace = ''; + expect(() => validator(entity)).toThrow(/namespace/); + }); + + it('rejects bad namespace type', () => { + entity.metadata.namespace = 7; + expect(() => validator(entity)).toThrow(/namespace/); + }); + + it('accepts unknown metadata fields', () => { + entity.metadata.blah = 7; + expect(() => validator(entity)).not.toThrow(); + }); +}); diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts new file mode 100644 index 0000000000..52ae00e399 --- /dev/null +++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Schema } from 'ajv'; +import { EntityEnvelope } from '../entity/EntityEnvelope'; +import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to an {@link EntityEnvelope} (or the given subtype) + * if it matches that schema, or throws a {@link TypeError} describing the + * errors. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * By default, the plain `EntityEnvelope` schema is used. If you pass in your + * own, it may contain `$ref` references to the following, which are resolved + * automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entityEnvelopeSchemaValidator< + T extends EntityEnvelope = EntityEnvelope +>(schema?: unknown): (data: unknown) => T { + const validate = compileAjvSchema( + schema ? (schema as Schema) : entityEnvelopeSchema, + ); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts new file mode 100644 index 0000000000..4b258aed14 --- /dev/null +++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityKindSchemaValidator } from './entityKindSchemaValidator'; +import componentSchema from '../schema/kinds/Component.v1alpha1.schema.json'; + +describe('entityKindSchemaValidator', () => { + const validator = entityKindSchemaValidator(componentSchema); + let entity: any; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521', + etag: 'lsndfkjsndfkjnsdfkjnsd==', + generation: 13, + name: 'test', + namespace: 'ns', + labels: { + 'backstage.io/custom': 'ValueStuff', + }, + annotations: { + 'example.com/bindings': 'are-secret', + }, + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com', + title: 'Website', + icon: 'website', + }, + ], + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'me', + }, + relations: [ + { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } }, + ], + status: { + items: [ + { + type: 't', + level: 'error', + message: 'm', + error: { name: 'n', message: 'm', code: '1', stack: 's' }, + }, + ], + }, + }; + }); + + it('works in the happy path', () => { + expect(validator(entity)).toBe(entity); + }); + + it('nicely rejects an unknown kind', () => { + entity.kind = 'Unknown'; + expect(validator(entity)).toBe(false); + }); + + it('nicely rejects an unknown apiVersion', () => { + entity.apiVersion = 'backstage.io/v1alpha7'; + expect(validator(entity)).toBe(false); + }); + + it('nicely rejects when both kind and apiVersion mismatch', () => { + entity.apiVersion = 'backstage.io/v1alpha7'; + entity.kind = 'Unknown'; + expect(validator(entity)).toBe(false); + }); + + it('rejects when the kind is actually breaking other rules than enum', () => { + entity.kind = 7; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects when the apiVersion is actually breaking other rules than enum', () => { + entity.apiVersion = 7; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects nicely when there is both a nice mismatch and a fatal error', () => { + entity.kind = 'Unknown'; + entity.metadata = 7; + expect(validator(entity)).toBe(false); + }); + + it('rejects on errors in other parts of the schema', () => { + entity.spec = 7; + expect(() => validator(entity)).toThrow(/spec/); + }); +}); diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts new file mode 100644 index 0000000000..c722687f9e --- /dev/null +++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Schema } from 'ajv'; +import { Entity } from '../entity'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to a `T` if it matches that schema, or `false` if the + * schema apiVersion/kind didn't apply to that data, or throws a + * {@link TypeError} describing actual errors. + * + * This validator is highly specialized, in that it has special treatment of + * the `kind` and `apiVersion` root keys. This only works if your schema has + * their rule set to `"enum"`: + * + *
+ *   "apiVersion": {
+ *      "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ *   },
+ *   "kind": {
+ *     "enum": ["Group"]
+ *   },
+ * 
+ * + * In the above example, the created validator will return `false` if and only + * if the kind and/or apiVersion mismatch. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * The given schema may contain `$ref` references to the following, which are + * resolved automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entityKindSchemaValidator( + schema: unknown, +): (data: unknown) => T | false { + const validate = compileAjvSchema(schema as Schema); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + // Only in the case where kind and/or apiVersion have enum mismatches AND + // have NO other errors, we call it a soft error. + const softCandidates = validate.errors?.filter(e => + ['/kind', '/apiVersion'].includes(e.dataPath), + ); + if ( + softCandidates?.length && + softCandidates.every(e => e.keyword === 'enum') + ) { + return false; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/validation/entitySchemaValidator.test.ts b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts new file mode 100644 index 0000000000..6ab2744c0f --- /dev/null +++ b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts @@ -0,0 +1,596 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entitySchemaValidator } from './entitySchemaValidator'; + +describe('entitySchemaValidator', () => { + const validator = entitySchemaValidator(); + let entity: any; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521', + etag: 'lsndfkjsndfkjnsdfkjnsd==', + generation: 13, + name: 'test', + namespace: 'ns', + labels: { + 'backstage.io/custom': 'ValueStuff', + }, + annotations: { + 'example.com/bindings': 'are-secret', + }, + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com', + title: 'Website', + icon: 'website', + }, + ], + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'me', + }, + relations: [ + { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } }, + ], + status: { + items: [ + { + type: 't', + level: 'error', + message: 'm', + error: { name: 'n', message: 'm', code: '1', stack: 's' }, + }, + ], + }, + }; + }); + + it('happy path: accepts valid data', () => { + expect(() => validator(entity)).not.toThrow(); + }); + + // + // apiVersion and kind + // + + it('rejects wrong root type', () => { + expect(() => validator(7)).toThrow(/object/); + }); + + it('rejects missing apiVersion', () => { + delete entity.apiVersion; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects bad apiVersion type', () => { + entity.apiVersion = 7; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects empty apiVersion', () => { + entity.apiVersion = ''; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects missing kind', () => { + delete entity.kind; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects bad kind type', () => { + entity.kind = 7; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects empty kind', () => { + entity.kind = ''; + expect(() => validator(entity)).toThrow(/kind/); + }); + + // + // metadata + // + + it('rejects missing metadata', () => { + delete entity.metadata; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('rejects bad metadata type', () => { + entity.metadata = 7; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('accepts missing uid', () => { + delete entity.metadata.uid; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad uid type', () => { + entity.metadata.uid = 7; + expect(() => validator(entity)).toThrow(/uid/); + }); + + it('rejects empty uid', () => { + entity.metadata.uid = ''; + expect(() => validator(entity)).toThrow(/uid/); + }); + + it('accepts missing etag', () => { + delete entity.metadata.etag; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad etag type', () => { + entity.metadata.etag = 7; + expect(() => validator(entity)).toThrow(/etag/); + }); + + it('rejects empty etag', () => { + entity.metadata.etag = ''; + expect(() => validator(entity)).toThrow(/etag/); + }); + + it('accepts missing generation', () => { + delete entity.metadata.generation; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad generation type', () => { + entity.metadata.generation = 'a'; + expect(() => validator(entity)).toThrow(/generation/); + }); + + it('rejects zero generation', () => { + entity.metadata.generation = 0; + expect(() => validator(entity)).toThrow(/generation/); + }); + + it('rejects non-integer generation', () => { + entity.metadata.generation = 1.5; + expect(() => validator(entity)).toThrow(/generation/); + }); + + it('rejects missing name', () => { + delete entity.metadata.name; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects bad name type', () => { + entity.metadata.name = 7; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('accepts missing namespace', () => { + delete entity.metadata.namespace; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad namespace type', () => { + entity.metadata.namespace = 7; + expect(() => validator(entity)).toThrow(/namespace/); + }); + + it('accepts missing description', () => { + delete entity.metadata.description; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad description type', () => { + entity.metadata.description = 7; + expect(() => validator(entity)).toThrow(/description/); + }); + + it('accepts missing labels', () => { + delete entity.metadata.labels; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty labels', () => { + entity.metadata.labels = {}; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad labels type', () => { + entity.metadata.labels = 7; + expect(() => validator(entity)).toThrow(/labels/); + }); + + it('accepts missing annotations', () => { + delete entity.metadata.annotations; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty annotations object', () => { + entity.metadata.annotations = {}; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad annotations type', () => { + entity.metadata.annotations = 7; + expect(() => validator(entity)).toThrow(/annotations/); + }); + + it('rejects bad tags type', () => { + entity.metadata.tags = 7; + expect(() => validator(entity)).toThrow(/tags/); + }); + + it('accepts empty tags', () => { + entity.metadata.tags = []; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects empty tag', () => { + entity.metadata.tags[0] = ''; + expect(() => validator(entity)).toThrow(/tags/); + }); + + it('rejects bad tag type', () => { + entity.metadata.tags[0] = 7; + expect(() => validator(entity)).toThrow(/tags/); + }); + + it('accepts missing links', () => { + delete entity.metadata.links; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty links', () => { + entity.metadata.links = []; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects empty links.url', () => { + entity.metadata.links[0].url = ''; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('rejects missing links.url', () => { + delete entity.metadata.links[0].url; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('rejects bad links.url type', () => { + entity.metadata.links[0].url = 7; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('rejects empty links.title', () => { + entity.metadata.links[0].title = ''; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('accepts missing links.title', () => { + delete entity.metadata.links[0].title; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad links.title type', () => { + entity.metadata.links[0].title = 7; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('rejects empty links.icon', () => { + entity.metadata.links[0].icon = ''; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('accepts missing links.icon', () => { + delete entity.metadata.links[0].icon; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad links.icon type', () => { + entity.metadata.links[0].icon = 7; + expect(() => validator(entity)).toThrow(/links/); + }); + + it('accepts unknown metadata field', () => { + entity.metadata.unknown = 7; + expect(() => validator(entity)).not.toThrow(); + }); + + // + // spec + // + + it('accepts missing spec', () => { + delete entity.spec; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects non-object spec', () => { + entity.spec = 7; + expect(() => validator(entity)).toThrow(/spec/); + }); + + it('accepts unknown spec field', () => { + entity.spec.unknown = 7; + expect(() => validator(entity)).not.toThrow(); + }); + + // + // Relations + // + + it('accepts missing relations', () => { + delete entity.relations; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty relations', () => { + entity.relations = []; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad relations type', () => { + entity.relations = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.type', () => { + delete entity.relations[0].type; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.type', () => { + entity.relations[0].type = ''; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.type type', () => { + entity.relations[0].type = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target', () => { + delete entity.relations[0].target; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target', () => { + entity.relations[0].target = ''; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target type', () => { + entity.relations[0].target = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.kind', () => { + delete entity.relations[0].target.kind; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.kind', () => { + entity.relations[0].target.kind = ''; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.kind type', () => { + entity.relations[0].target.kind = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.namespace', () => { + delete entity.relations[0].target.namespace; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.namespace', () => { + entity.relations[0].target.namespace = ''; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.namespace type', () => { + entity.relations[0].target.namespace = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.name', () => { + delete entity.relations[0].target.name; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.name', () => { + entity.relations[0].target.name = ''; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.name type', () => { + entity.relations[0].target.name = 7; + expect(() => validator(entity)).toThrow(/relations/); + }); + + it('rejects unknown relation field', () => { + entity.relations[0].unknown = 7; + expect(() => validator(entity)).toThrow(/unknown/); + }); + + // + // Status + // + + it('accepts missing status', () => { + delete entity.status; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty status', () => { + entity.status = {}; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status type', () => { + entity.status = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('accepts missing status.items', () => { + delete entity.status.items; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty status.items', () => { + entity.status.items = []; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status.items type', () => { + entity.status.items = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects bad status.items item type', () => { + entity.status.items[0] = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.type', () => { + delete entity.status.items[0].type; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects empty status.items.type', () => { + entity.status.items[0].type = ''; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.type type', () => { + entity.status.items[0].type = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.level', () => { + delete entity.status.items[0].level; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects empty status.items.level', () => { + entity.status.items[0].level = ''; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.level type', () => { + entity.status.items[0].level = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.level enum', () => { + entity.status.items[0].level = 'unknown'; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.message', () => { + delete entity.status.items[0].message; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('accepts empty status.items.message', () => { + entity.status.items[0].message = ''; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status.items.message type', () => { + entity.status.items[0].message = 7; + expect(() => validator(entity)).toThrow(/status/); + }); + + it('accepts missing status.items.error', () => { + delete entity.status.items[0].error; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects missing status.items.error.name', () => { + delete entity.status.items[0].error.name; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects empty status.items.error.name', () => { + entity.status.items[0].error.name = ''; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects bad status.items.error.name type', () => { + entity.status.items[0].error.name = 7; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects missing status.items.error.message', () => { + delete entity.status.items[0].error.message; + expect(() => validator(entity)).toThrow(/message/); + }); + + it('accepts empty status.items.error.message', () => { + entity.status.items[0].error.message = ''; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.message type', () => { + entity.status.items[0].error.message = 7; + expect(() => validator(entity)).toThrow(/message/); + }); + + it('accepts missing status.items.error.code', () => { + delete entity.status.items[0].error.code; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty status.items.error.code', () => { + entity.status.items[0].error.code = ''; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.code type', () => { + entity.status.items[0].error.code = 7; + expect(() => validator(entity)).toThrow(/code/); + }); + + it('accepts missing status.items.error.stack', () => { + delete entity.status.items[0].error.stack; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts empty status.items.error.stack', () => { + entity.status.items[0].error.stack = ''; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.stack type', () => { + entity.status.items[0].error.stack = 7; + expect(() => validator(entity)).toThrow(/stack/); + }); + + it('accepts unknown status.items field', () => { + entity.status.items[0].unknown = 7; + expect(() => validator(entity)).not.toThrow(); + }); + + it('accepts unknown status.items.error field', () => { + entity.status.items[0].error.unknown = 7; + expect(() => validator(entity)).not.toThrow(); + }); +}); diff --git a/packages/catalog-model/src/validation/entitySchemaValidator.ts b/packages/catalog-model/src/validation/entitySchemaValidator.ts new file mode 100644 index 0000000000..8a30f09d31 --- /dev/null +++ b/packages/catalog-model/src/validation/entitySchemaValidator.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Schema } from 'ajv'; +import { Entity } from '../entity/Entity'; +import entitySchema from '../schema/Entity.schema.json'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to an {@link Entity} (or the given subtype) if it + * matches that schema, or throws a {@link TypeError} describing the errors. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * By default, the plain `Entity` schema is used. If you pass in your own, it + * may contain `$ref` references to the following, which are resolved + * automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entitySchemaValidator( + schema?: unknown, +): (data: unknown) => T { + const validate = compileAjvSchema(schema ? (schema as Schema) : entitySchema); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/validation/index.ts b/packages/catalog-model/src/validation/index.ts index d679a5323c..bdf812b4ad 100644 --- a/packages/catalog-model/src/validation/index.ts +++ b/packages/catalog-model/src/validation/index.ts @@ -15,6 +15,9 @@ */ export { CommonValidatorFunctions } from './CommonValidatorFunctions'; +export { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator'; +export { entityKindSchemaValidator } from './entityKindSchemaValidator'; +export { entitySchemaValidator } from './entitySchemaValidator'; export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions'; export { makeValidator } from './makeValidator'; export type { Validators } from './types'; diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md new file mode 100644 index 0000000000..bcdc023c22 --- /dev/null +++ b/packages/cli-common/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/cli-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export function findPaths(searchDir: string): Paths; + +// @public (undocumented) +export type Paths = { + ownDir: string; + ownRoot: string; + targetDir: string; + targetRoot: string; + resolveOwn: ResolveFunc; + resolveOwnRoot: ResolveFunc; + resolveTarget: ResolveFunc; + resolveTargetRoot: ResolveFunc; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a1381495ff..8eae37ef56 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,115 @@ # @backstage/cli +## 0.7.0 + +### Minor Changes + +- 9cd3c533c: Switch from `ts-jest` to `@sucrase/jest-plugin`, improving performance and aligning transpilation with bundling. + + In order to switch back to `ts-jest`, install `ts-jest@^26.4.3` as a dependency in the root of your repo and add the following in the root `package.json`: + + ```json + "jest": { + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, + "transform": { + "\\.esm\\.js$": "@backstage/cli/config/jestEsmTransform.js", + "\\.(js|jsx|ts|tsx)$": "ts-jest", + "\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$": "@backstage/cli/config/jestFileTransform.js", + "\\.(yaml)$": "yaml-jest" + } + } + ``` + + Note that this will override the default jest transforms included with the `@backstage/cli`. + + It is possible that some test code needs a small migration as a result of this change, which stems from a difference in how `sucrase` and `ts-jest` transform module re-exports. + + Consider the following code: + + ```ts + import * as utils from './utils'; + + jest.spyOn(utils, 'myUtility').mockReturnValue(3); + ``` + + If the `./utils` import for example refers to an index file that in turn re-exports from `./utils/myUtility`, you would have to change the code to the following to work around the fact that the exported object from `./utils` ends up not having configurable properties, thus breaking `jest.spyOn`. + + ```ts + import * as utils from './utils/myUtility'; + + jest.spyOn(utils, 'myUtility').mockReturnValue(3); + ``` + +### Patch Changes + +- 7f7443308: Updated dependencies +- 21e8ebef5: Fix error message formatting in the packaging process. + + error.errors can be undefined which will lead to a TypeError, swallowing the actual error message in the process. + + For instance, if you break your tsconfig.json with invalid syntax, backstage-cli will not be able to build anything and it will be very hard to find out why because the underlying error message is hidden behind a TypeError. + +## 0.6.14 + +### Patch Changes + +- ee4eb5b40: Adjust the Webpack `devtool` module filename template to correctly resolve via the source maps to the source files. +- 84160313e: Mark the `create-github-app` command as ready for use and reveal it in the command list. +- 7e7c71417: Exclude core packages from package dependency diff. +- e7c5e4b30: Update installation instructions in README. +- 2305ab8fc: chore(deps): bump `@spotify/eslint-config-react` from 9.0.0 to 10.0.0 +- 054bcd029: Deprecated the `backend:build-image` command, pointing to the newer `backend:bundle` command. + +## 0.6.13 + +### Patch Changes + +- 1cd0cacd9: Add support for transforming yaml files in jest with 'yaml-jest' +- 7a7da5146: Bumped `eslint-config-prettier` to `8.x`. +- 3a181cff1: Bump webpack-node-externals from `2.5.2` to `3.0.0`. +- Updated dependencies [2cf98d279] +- Updated dependencies [438a512eb] + - @backstage/config-loader@0.6.3 + +## 0.6.12 + +### Patch Changes + +- 2bfec55a6: Update `fork-ts-checker-webpack-plugin` +- Updated dependencies [290405276] + - @backstage/config-loader@0.6.2 + +## 0.6.11 + +### Patch Changes + +- 2cd70e164: Add context for versions:bump on what version it was bumped to. Updated tests for the same. +- 3be844496: chore: bump `ts-node` versions to 9.1.1 +- e3fc89df6: update plugins created to use react-use 17.2.4 + +## 0.6.10 + +### Patch Changes + +- f65adcde7: Fix some transitive dependency warnings in yarn +- fc79a6dd3: Added lax option to backstage-cli app:build command +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config-loader@0.6.1 + - @backstage/config@0.1.5 + +## 0.6.9 + +### Patch Changes + +- 4e5c94249: Add `config:docs` command that opens up reference documentation for the local configuration schema in a browser. +- 1373f4f12: No longer add newly created plugins to `plugins.ts` in the app, as it is no longer needed. +- 479b29124: Added support for Datadog rum events + ## 0.6.8 ### Patch Changes diff --git a/packages/cli/README.md b/packages/cli/README.md index cd6ad8094e..1aa4233032 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,16 +4,10 @@ This package provides a CLI for developing Backstage plugins and apps. ## Installation -Install the package via npm or Yarn: +Install the package via Yarn: ```sh -$ npm install --save @backstage/cli -``` - -or - -```sh -$ yarn add @backstage/cli +yarn add @backstage/cli ``` ## Development diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index e7fd4cb688..e860e426d1 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -19,7 +19,6 @@ module.exports = { '@spotify/eslint-config-base', '@spotify/eslint-config-typescript', 'prettier', - 'prettier/@typescript-eslint', 'plugin:jest/recommended', 'plugin:monorepo/recommended', ], diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index fe45f264b5..6448136769 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -20,8 +20,6 @@ module.exports = { '@spotify/eslint-config-react', '@spotify/eslint-config-typescript', 'prettier', - 'prettier/react', - 'prettier/@typescript-eslint', 'plugin:jest/recommended', 'plugin:monorepo/recommended', ], diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 94cf059cbd..0cec82b468 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -77,18 +77,13 @@ async function getConfig() { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), }, - globals: { - 'ts-jest': { - isolatedModules: true, - }, - }, - transform: { '\\.esm\\.js$': require.resolve('./jestEsmTransform.js'), // See jestEsmTransform.js - '\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'), + '\\.(js|jsx|ts|tsx)$': require.resolve('@sucrase/jest-plugin'), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( './jestFileTransform.js', ), + '\\.(yaml)$': require.resolve('yaml-jest'), }, // A bit more opinionated diff --git a/packages/cli/package.json b/packages/cli/package.json index db7ac2774c..c94647ae25 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.6.8", + "version": "0.7.0", "private": false, "publishConfig": { "access": "public" @@ -31,8 +31,8 @@ "@babel/core": "^7.4.4", "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.6.0", + "@backstage/config": "^0.1.5", + "@backstage/config-loader": "^0.6.3", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", @@ -42,8 +42,9 @@ "@rollup/plugin-node-resolve": "^11.2.0", "@rollup/plugin-yaml": "^2.1.1", "@spotify/eslint-config-base": "^9.0.0", - "@spotify/eslint-config-react": "^9.0.0", + "@spotify/eslint-config-react": "^10.0.0", "@spotify/eslint-config-typescript": "^9.0.0", + "@sucrase/jest-plugin": "^2.1.0", "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "5.5.x", "@svgr/plugin-svgo": "5.4.x", @@ -52,7 +53,7 @@ "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^2.5.0", - "@typescript-eslint/eslint-plugin": "^v4.15.2", + "@typescript-eslint/eslint-plugin": "^v4.26.0", "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", "babel-plugin-dynamic-import-node": "^2.3.3", @@ -62,10 +63,10 @@ "commander": "^6.1.0", "css-loader": "^3.5.3", "dashify": "^2.0.0", - "diff": "^4.0.2", + "diff": "^5.0.0", "esbuild": "^0.8.56", "eslint": "^7.1.0", - "eslint-config-prettier": "^6.0.0", + "eslint-config-prettier": "^8.3.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-jest": "^24.1.0", @@ -75,17 +76,18 @@ "eslint-plugin-react-hooks": "^4.0.0", "express": "^4.17.1", "file-loader": "^6.2.0", - "fork-ts-checker-webpack-plugin": "^4.0.5", + "fork-ts-checker-webpack-plugin": "^6.2.9", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", "html-webpack-plugin": "^4.3.0", "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "lodash": "^4.17.19", "mini-css-extract-plugin": "^0.9.0", "ora": "^5.3.0", + "postcss": "^8.1.0", "raw-loader": "^4.0.1", "react": "^16.0.0", "react-dev-utils": "^11.0.4", @@ -101,27 +103,27 @@ "semver": "^7.3.2", "start-server-webpack-plugin": "^2.2.5", "style-loader": "^1.2.1", - "sucrase": "^3.17.1", + "sucrase": "^3.18.2", "tar": "^6.0.1", "terser-webpack-plugin": "^1.4.3", - "ts-jest": "^26.4.3", "ts-loader": "^8.0.17", "typescript": "^4.0.3", "url-loader": "^4.1.0", "webpack": "^4.41.6", "webpack-dev-server": "3.11.0", - "webpack-node-externals": "^2.5.0", + "webpack-node-externals": "^3.0.0", "yaml": "^1.10.0", + "yaml-jest": "^1.0.5", "yml-loader": "^2.1.0", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.6.2", - "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", - "@backstage/theme": "^0.2.5", + "@backstage/backend-common": "^0.8.2", + "@backstage/config": "^0.1.5", + "@backstage/core": "^0.7.12", + "@backstage/dev-utils": "^0.1.17", + "@backstage/test-utils": "^0.1.13", + "@backstage/theme": "^0.2.8", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", @@ -137,12 +139,12 @@ "@types/rollup-plugin-postcss": "^2.0.0", "@types/tar": "^4.0.3", "@types/webpack": "^4.41.7", - "@types/webpack-dev-server": "3.11.0", + "@types/webpack-dev-server": "^3.11.0", "@types/yarnpkg__lockfile": "^1.1.4", "del": "^5.1.0", "mock-fs": "^4.13.0", "nodemon": "^2.0.2", - "ts-node": "^8.6.2" + "ts-node": "^9.1.1" }, "files": [ "asset-types", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 21189e9427..0da4112646 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -30,6 +30,7 @@ export default async (cmd: Command) => { ...(await loadCliConfig({ args: cmd.config, fromPackage: name, + mockEnv: cmd.lax, })), }); }; diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index 654f51cd10..8434017cd9 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -15,6 +15,7 @@ */ import { Command } from 'commander'; +import { yellow } from 'chalk'; import fs from 'fs-extra'; import { join as joinPath, relative as relativePath } from 'path'; import { createDistWorkspace } from '../../lib/packager'; @@ -31,6 +32,15 @@ export default async (cmd: Command) => { return; } + console.warn( + yellow(` +The backend:build-image command is deprecated and will be removed in the future. +Please use the backend:bundle command instead along with your own Docker setup. + + https://backstage.io/docs/deployment/docker +`), + ); + const pkgPath = paths.resolveTarget(PKG_PATH); const pkg = await fs.readJson(pkgPath); const appConfigs = await findAppConfigs(); diff --git a/packages/cli/src/commands/config/docs.ts b/packages/cli/src/commands/config/docs.ts new file mode 100644 index 0000000000..e06bc42c27 --- /dev/null +++ b/packages/cli/src/commands/config/docs.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/config'; +import { mergeConfigSchemas } from '@backstage/config-loader'; +import { Command } from 'commander'; +import { JSONSchema7 as JSONSchema } from 'json-schema'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { loadCliConfig } from '../../lib/config'; + +const DOCS_URL = 'https://config.backstage.io'; + +export default async (cmd: Command) => { + const { schema: appSchemas } = await loadCliConfig({ + args: [], + fromPackage: cmd.package, + mockEnv: true, + }); + + const schema = mergeConfigSchemas( + (appSchemas.serialize().schemas as JsonObject[]).map( + _ => _.value as JSONSchema, + ), + ); + + openBrowser(`${DOCS_URL}#schema=${JSON.stringify(schema)}`); +}; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index affbf64922..5ea8cdfbbc 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -106,24 +106,6 @@ export async function addPluginDependencyToApp( }); } -export async function addPluginImportToApp( - rootDir: string, - pluginVar: string, - pluginPackage: string, -) { - const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`; - const pluginsFilePath = 'packages/app/src/plugins.ts'; - const pluginsFile = resolvePath(rootDir, pluginsFilePath); - - await Task.forItem('processing', pluginsFilePath, async () => { - await addExportStatement(pluginsFile, pluginExport).catch(error => { - throw new Error( - `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, - ); - }); - }); -} - export async function addPluginExtensionToApp( pluginId: string, extensionName: string, @@ -223,7 +205,7 @@ export default async (cmd: Command) => { return chalk.red('Please enter an ID for the plugin'); } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { return chalk.red( - 'Plugin IDs must be kebab-cased and contain only letters, digits, and dashes.', + 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.', ); } return true; @@ -320,7 +302,6 @@ export default async (cmd: Command) => { await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion); Task.section('Import plugin in app'); - await addPluginImportToApp(paths.targetRoot, pluginVar, name); await addPluginExtensionToApp(pluginId, extensionName, name); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 46b6deeb21..431659a972 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -29,6 +29,7 @@ export function registerCommands(program: CommanderStatic) { .command('app:build') .description('Build an app for a production release') .option('--stats', 'Write bundle stats to output directory') + .option('--lax', 'Do not require environment variables to be set') .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); @@ -59,11 +60,7 @@ export function registerCommands(program: CommanderStatic) { .helpOption(', --backstage-cli-help') // Let docker handle --help .option('--build', 'Build packages before packing them into the image') .description( - // TODO: Add example use cases in Backstage documentation. - // For example, if a $NPM_TOKEN needs to be exposed, run `backend:build-image --secret - // id=NPM_TOKEN,src=/NPM_TOKEN.txt`. - 'Bundles the package into a docker image. All extra args are forwarded to ' + - '`docker image build`.', + 'Bundles the package into a docker image. This command is deprecated and will be removed.', ) .action(lazy(() => import('./backend/buildImage').then(m => m.default))); @@ -140,6 +137,15 @@ export function registerCommands(program: CommanderStatic) { .description('Run tests, forwarding args to Jest, defaulting to watch mode') .action(lazy(() => import('./testCommand').then(m => m.default))); + program + .command('config:docs') + .option( + '--package ', + 'Only include the schema that applies to the given package', + ) + .description('Browse the configuration reference documentation') + .action(lazy(() => import('./config/docs').then(m => m.default))); + program .command('config:print') .option( @@ -215,10 +221,8 @@ export function registerCommands(program: CommanderStatic) { .action(lazy(() => import('./buildWorkspace').then(m => m.default))); program - .command('create-github-app ', { hidden: true }) - .description( - 'Create new GitHub App in your organization. This command is experimental and may change in the future.', - ) + .command('create-github-app ') + .description('Create new GitHub App in your organization.') .action(lazy(() => import('./create-github-app').then(m => m.default))); } diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 81d8322ffa..72d619e0bc 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -189,7 +189,7 @@ export default async () => { return chalk.red('Please enter an ID for the plugin'); } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { return chalk.red( - 'Plugin IDs must be kebab-cased and contain only letters, digits and dashes.', + 'Plugin IDs must be lowercase and contain only letters, digits and dashes.', ); } return true; diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 31803d4827..e5128fc995 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -136,7 +136,7 @@ describe('bump', () => { 'bumping @backstage/theme in b to ^2.0.0', 'Running yarn install to install new versions', '⚠️ The following packages may have breaking changes:', - ' @backstage/theme', + ' @backstage/theme : 1.0.0 ~> 2.0.0', ' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md', 'Version bump complete!', ]); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index ceb2f7f988..d046283aab 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -195,8 +195,14 @@ export default async () => { ); console.log(); - for (const name of Array.from(breakingUpdates.keys()).sort()) { - console.log(` ${chalk.yellow(name)}`); + for (const [name, { from, to }] of Array.from( + breakingUpdates.entries(), + ).sort()) { + console.log( + ` ${chalk.yellow(name)} : ${chalk.yellow(from)} ~> ${chalk.yellow( + to, + )}`, + ); let path; if (name.startsWith('@backstage/plugin-')) { diff --git a/packages/cli/src/lib/builder/packager.test.ts b/packages/cli/src/lib/builder/packager.test.ts new file mode 100644 index 0000000000..f5e101cfc6 --- /dev/null +++ b/packages/cli/src/lib/builder/packager.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { formatErrorMessage } from './packager'; + +describe('formatErrorMessage with esbuild plugin error', () => { + it('given error with missing errors array then error message should be shown', () => { + const msg = formatErrorMessage({ + code: 'PLUGIN_ERROR', + plugin: 'esbuild', + message: 'test', + }); + expect(msg).toBe('test'); + }); + it('given error with errors array then error message should have new lines', () => { + const msg = formatErrorMessage({ + code: 'PLUGIN_ERROR', + plugin: 'esbuild', + message: 'test', + id: 'index.js', + errors: [{ text: 'Dummy', location: { line: 1, column: 1 } }], + }); + expect(msg).toContain('test\n\n'); + }); +}); diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index ed76e23df3..6deded5f69 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -22,21 +22,24 @@ import { paths } from '../paths'; import { makeConfigs } from './config'; import { BuildOptions } from './types'; -function formatErrorMessage(error: any) { +export function formatErrorMessage(error: any) { let msg = ''; if (error.code === 'PLUGIN_ERROR') { if (error.plugin === 'esbuild') { - msg += `${error.message}\n\n`; - for (const { text, location } of error.errors) { - const { line, column } = location; - const path = relativePath(paths.targetDir, error.id); - const loc = chalk.cyan(`${path}:${line}:${column}`); + msg += `${error.message}`; + if (error.errors?.length) { + msg += `\n\n`; + for (const { text, location } of error.errors) { + const { line, column } = location; + const path = relativePath(paths.targetDir, error.id); + const loc = chalk.cyan(`${path}:${line}:${column}`); - if (text === 'Unexpected "<"' && error.id.endsWith('.js')) { - msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`; - } else { - msg += `${loc}: ${text}`; + if (text === 'Unexpected "<"' && error.id.endsWith('.js')) { + msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`; + } else { + msg += `${loc}: ${text}`; + } } } } else { diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 8837554f22..7270f7b01a 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -97,15 +97,18 @@ export async function createConfig( if (checksEnabled) { plugins.push( new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ); } @@ -194,6 +197,15 @@ export async function createConfig( chunkFilename: isDev ? '[name].chunk.js' : 'static/[name].[chunkhash:8].chunk.js', + ...(isDev + ? { + devtoolModuleFilenameTemplate: info => + `file:///${resolvePath(info.absoluteResourcePath).replace( + /\\/g, + '/', + )}`, + } + : {}), }, plugins, }; @@ -278,7 +290,11 @@ export async function createBackendConfig( : '[name].[chunkhash:8].chunk.js', ...(isDev ? { - devtoolModuleFilenameTemplate: 'file:///[absolute-resource-path]', + devtoolModuleFilenameTemplate: info => + `file:///${resolvePath(info.absoluteResourcePath).replace( + /\\/g, + '/', + )}`, } : {}), }, @@ -291,15 +307,18 @@ export async function createBackendConfig( ...(checksEnabled ? [ new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ] : []), diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts index a210986b2a..36e163d67e 100644 --- a/packages/cli/src/lib/diff/handlers.ts +++ b/packages/cli/src/lib/diff/handlers.ts @@ -172,6 +172,11 @@ class PackageJsonHandler { if (this.variant === 'app' && key.startsWith('plugin-')) { continue; } + // Skip checking of the core packages, since we're migrating over + if (key.startsWith('@backstage/core')) { + continue; + } + await this.syncField(key, pkgDeps, targetDeps, fieldName); } } diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 49eda9e0eb..1d2766eab4 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -31,7 +31,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", @@ -39,7 +39,7 @@ "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "msw": "^0.21.2", diff --git a/packages/codemods/.eslintrc.js b/packages/codemods/.eslintrc.js new file mode 100644 index 0000000000..7c8a092081 --- /dev/null +++ b/packages/codemods/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + rules: { + 'no-console': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: false, + }, + ], + }, +}; diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md new file mode 100644 index 0000000000..9f2ae5d214 --- /dev/null +++ b/packages/codemods/CHANGELOG.md @@ -0,0 +1,15 @@ +# @backstage/codemods + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [9bca2a252] +- Updated dependencies [e47336ea4] +- Updated dependencies [75b8537ce] +- Updated dependencies [da8cba44f] +- Updated dependencies [da8cba44f] +- Updated dependencies [9bca2a252] + - @backstage/core-app-api@0.1.2 + - @backstage/core-components@0.1.2 + - @backstage/core-plugin-api@0.1.2 diff --git a/packages/codemods/README.md b/packages/codemods/README.md new file mode 100644 index 0000000000..c40e9a8dcc --- /dev/null +++ b/packages/codemods/README.md @@ -0,0 +1,38 @@ +# @backstage/codemods + +A collection of codemods for use with Backstage projects. They are intended to improve and simplify large scale refactoring, upgrades of code to use new implementations, alignment to ADRs or project standards, or other refactoring. + +## Usage + +This package is a wrapper around [`jscodeshift`](https://github.com/facebook/jscodeshift) with some included transforms. The transforms can either be executed via the included CLI or directly via `jscodeshift`. + +To run the `core-imports` codemod towards all source files in the current directory, run the following: + +```sh +npx @backstage/codemods apply core-imports +``` + +Note that this will modify the source files directly, but it's possible to do a dry-run by adding the `--dry` flag. + +By passing a list of paths the codemod will only be applied to those paths: + +```sh +npx @backstage/codemods apply core-imports plugins/my-plugin-a plugins/my-plugin-b +``` + +To print a list of all available transforms you use the `list` command: + +```sh +npx @backstage/codemods list +``` + +You can also apply the transforms manually using `jscodeshift`. The transforms are located within the `transforms/` directory in this package, so running directly with `jscodeshift` looks like this: + +```sh +npx jscodeshift --parser=tsx --extensions=tsx,js,ts,tsx --transform=node_modules/@backstage/codemods/transforms/core-imports.js . +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/codemods/bin/backstage-codemods b/packages/codemods/bin/backstage-codemods new file mode 100755 index 0000000000..d6d449fe31 --- /dev/null +++ b/packages/codemods/bin/backstage-codemods @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const path = require('path'); + +require('ts-node').register({ + transpileOnly: true, + /* eslint-disable-next-line no-restricted-syntax */ + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, +}); + +require('../src'); diff --git a/packages/codemods/package.json b/packages/codemods/package.json new file mode 100644 index 0000000000..2ce0172b75 --- /dev/null +++ b/packages/codemods/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/codemods", + "description": "A collection of codemods for Backstage projects", + "version": "0.1.1", + "private": true, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/codemods" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "scripts": { + "start": "nodemon --", + "lint": "backstage-cli lint", + "test": "backstage-cli test" + }, + "bin": { + "backstage-codemods": "bin/backstage-codemods" + }, + "dependencies": { + "@backstage/cli-common": "0.1.1", + "@backstage/core-app-api": "*", + "@backstage/core-components": "*", + "@backstage/core-plugin-api": "*", + "chalk": "^4.0.0", + "jscodeshift": "^0.12.0", + "jscodeshift-add-imports": "^1.0.10" + }, + "devDependencies": { + "@types/jscodeshift": "^0.11.0", + "@types/node": "^14.14.32", + "commander": "^6.1.0", + "ts-node": "^9.1.1" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "bin/backstage-codemods", + "ext": "ts" + }, + "files": [ + "dist", + "transforms" + ] +} diff --git a/packages/codemods/src/action.ts b/packages/codemods/src/action.ts new file mode 100644 index 0000000000..c3eba8aecc --- /dev/null +++ b/packages/codemods/src/action.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { relative as relativePath } from 'path'; +import { spawn } from 'child_process'; +import { Command } from 'commander'; +import { findPaths } from '@backstage/cli-common'; +import { platform } from 'os'; +import { ExitCodeError } from './errors'; + +// eslint-disable-next-line no-restricted-syntax +const paths = findPaths(__dirname); + +export function createCodemodAction(name: string) { + return async (_: unknown, cmd: Command) => { + const transformPath = relativePath( + process.cwd(), + paths.resolveOwn('transforms', `${name}.js`), + ); + + const args = [ + '--parser=tsx', + '--extensions=tsx,js,ts,tsx', + '--transform', + transformPath, + '--ignore-pattern=**/node_modules/**', + ]; + + if (cmd.dry) { + args.push('--dry'); + } + + if (cmd.args.length) { + args.push(...cmd.args); + } else { + args.push('.'); + } + + console.log(`Running jscodeshift with these arguments: ${args.join(' ')}`); + + let command; + if (platform() === 'win32') { + command = 'jscodeshift'; + } else { + // jscodeshift ships a slightly broken bin script with windows + // line endings so we need to execute it using node rather than + // letting the `#!/usr/bin/env node` take care of it + command = process.argv0; + args.unshift(require.resolve('.bin/jscodeshift')); + } + + const child = spawn(command, args, { + stdio: 'inherit', + shell: true, + env: { + ...process.env, + FORCE_COLOR: 'true', + }, + }); + + if (typeof child.exitCode === 'number') { + if (child.exitCode) { + throw new ExitCodeError(child.exitCode, name); + } + return; + } + + await new Promise((resolve, reject) => { + child.once('error', error => reject(error)); + child.once('exit', code => { + if (code) { + reject(new ExitCodeError(code, name)); + } else { + resolve(); + } + }); + }); + }; +} diff --git a/packages/codemods/src/codemods.ts b/packages/codemods/src/codemods.ts new file mode 100644 index 0000000000..23e02daabd --- /dev/null +++ b/packages/codemods/src/codemods.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Codemod { + name: string; + description: string; +} + +export const codemods: Codemod[] = [ + { + name: 'core-imports', + description: + 'Updates @backstage/core imports to use @backstage/core-* imports instead.', + }, +]; diff --git a/packages/codemods/src/errors.ts b/packages/codemods/src/errors.ts new file mode 100644 index 0000000000..a1eab4c9e5 --- /dev/null +++ b/packages/codemods/src/errors.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; + +export class CustomError extends Error { + get name(): string { + return this.constructor.name; + } +} + +export class ExitCodeError extends CustomError { + readonly code: number; + + constructor(code: number, command?: string) { + if (command) { + super(`Command '${command}' exited with code ${code}`); + } else { + super(`Child exited with code ${code}`); + } + this.code = code; + } +} + +export function exitWithError(error: Error): never { + if (error instanceof ExitCodeError) { + process.stderr.write(`\n${chalk.red(error.message)}\n\n`); + process.exit(error.code); + } else { + process.stderr.write(`\n${chalk.red(`${error}`)}\n\n`); + process.exit(1); + } +} diff --git a/packages/codemods/src/index.ts b/packages/codemods/src/index.ts new file mode 100644 index 0000000000..967c906477 --- /dev/null +++ b/packages/codemods/src/index.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import program from 'commander'; +import chalk from 'chalk'; +import { codemods } from './codemods'; +import { exitWithError } from './errors'; +import { createCodemodAction } from './action'; +import { version } from '../package.json'; + +async function main(argv: string[]) { + program.name('backstage-codemods').version(version); + + const applyCommand = program + .command('apply []') + .description( + 'Apply a codemod to target directories, defaulting to the current directory', + ); + + for (const codemod of codemods) { + applyCommand + .command(`${codemod.name} []`) + .description(codemod.description) + .option('-d, --dry', 'Dry run, no changes written to files') + .action(createCodemodAction(codemod.name)); + } + + program + .command('list') + .description('List available codemods') + .action(() => { + const maxNameLength = Math.max(...codemods.map(m => m.name.length)); + for (const codemod of codemods) { + const paddedName = codemod.name.padEnd(maxNameLength, ' '); + console.log(`${paddedName} - ${codemod.description}`); + } + }); + + program.on('command:*', () => { + console.log(); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); + console.log(); + program.outputHelp(); + process.exit(1); + }); + + program.parse(argv); +} + +process.on('unhandledRejection', (rejection: unknown) => { + if (rejection instanceof Error) { + exitWithError(rejection); + } else { + exitWithError(new Error(`Unknown rejection: '${rejection}'`)); + } +}); + +main(process.argv).catch(exitWithError); diff --git a/packages/codemods/src/tests/core-imports.test.ts b/packages/codemods/src/tests/core-imports.test.ts new file mode 100644 index 0000000000..e5924156b6 --- /dev/null +++ b/packages/codemods/src/tests/core-imports.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import jscodeshift, { API } from 'jscodeshift'; +import coreImportTransform from '../../transforms/core-imports'; + +function runTransform(source: string) { + const j = jscodeshift.withParser('tsx'); + const api: API = { + j, + jscodeshift: j, + stats: () => undefined, + report: () => undefined, + }; + const result = coreImportTransform({ source, file: 'test.ts' }, api); + return result?.split('\r\n').join('\n'); +} + +describe('core-imports', () => { + it('should leave file unchanged', () => { + const input = ` +import something from 'somewhere'; + +function nothing() { +return something() +} +`; + + expect(runTransform(input)).toBe(input); + }); + + it('should refactor imports', () => { + const input = ` +/* COPYRIGHT: ME */ +import { Button as MyButton, createApiRef, createApp } from '@backstage/core'; + +const app = createApp(); +const apiRef = createApiRef(); +const button = +`; + + const output = ` +/* COPYRIGHT: ME */ +import { Button as MyButton } from '@backstage/core-components'; + +import { createApiRef } from '@backstage/core-plugin-api'; +import { createApp } from '@backstage/core-app-api'; + +const app = createApp(); +const apiRef = createApiRef(); +const button = +`; + + expect(runTransform(input)).toBe(output); + }); +}); diff --git a/packages/codemods/transforms/core-imports.js b/packages/codemods/transforms/core-imports.js new file mode 100644 index 0000000000..5ad38e02df --- /dev/null +++ b/packages/codemods/transforms/core-imports.js @@ -0,0 +1,108 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const addImports = require('jscodeshift-add-imports'); +const { resolve: resolvePath } = require('path'); +const fs = require('fs'); + +function findExports(packageName) { + const packagePath = require.resolve(`${packageName}/package.json`); + const typesPath = resolvePath(packagePath, '../dist/index.d.ts'); + const content = fs.readFileSync(typesPath, 'utf8'); + + // For each export statement in the type declarations we grab the exported symbol names + return content + .split(/export \{ (.*) \}/) + .filter((_, i) => i % 2) + .flatMap(symbolsStr => + symbolsStr + .split(', ') + .map(exported => exported.match(/.* as (.*)/)?.[1] || exported), + ); +} + +const symbolTable = { + '@backstage/core-app-api': findExports('@backstage/core-app-api'), + '@backstage/core-components': findExports('@backstage/core-components'), + '@backstage/core-plugin-api': findExports('@backstage/core-plugin-api'), +}; + +const reverseSymbolTable = Object.entries(symbolTable).reduce( + (table, [pkg, symbols]) => { + for (const symbol of symbols) { + table[symbol] = pkg; + } + return table; + }, + {}, +); + +module.exports = (file, /** @type {import('jscodeshift').API} */ api) => { + const j = api.jscodeshift; + const root = j(file.source); + + // Grab the file comment from the first node in case the import gets removed + const firstNodeComment = root.find(j.Program).get('body', 0).node.comments; + + // Find all import statements of @backstage/core + const imports = root.find(j.ImportDeclaration, { + source: { + value: '@backstage/core', + }, + }); + + if (imports.size === 0) { + return undefined; + } + + // Check what style we're using for the imports, ' or " + const useSingleQuote = + root.find(j.ImportDeclaration).nodes()[0]?.source.extra.raw[0] === "'"; + + // Then loop through all the import statement and collects each imported symbol + const importedSymbols = imports.nodes().flatMap(node => + (node.specifiers || []).flatMap(specifier => ({ + name: specifier.imported.name, // The symbol we're importing + local: specifier.local.name, // The local name, usually this is the same + })), + ); + + // Now that we gathered all the imports we want to add, we get rid of the old one + imports.remove(); + + // The convert the imports into actual import statements + const newImportStatements = importedSymbols.map(({ name, local }) => { + const targetPackage = reverseSymbolTable[name]; + if (!targetPackage) { + throw new Error(`No target package found for import of ${name}`); + } + + return j.importDeclaration( + [j.importSpecifier(j.identifier(name), j.identifier(local))], + j.literal(targetPackage), + ); + }); + + // And add the new imports. `addImports` will take care of resolving duplicates + addImports(root, newImportStatements); + + // Restore the initial file comment in case it got removed + root.find(j.Program).get('body', 0).node.comments = firstNodeComment; + + return root.toSource({ + quote: useSingleQuote ? 'single' : 'double', + }); +}; diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index cb8ef123ce..67ed7472b5 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/config-loader +## 0.6.3 + +### Patch Changes + +- 2cf98d279: Resolve the path to app-config.yaml from the current working directory. This will allow use of `yarn link` or running the CLI in other directories and improve the experience for local backstage development. +- 438a512eb: Fixed configuration schema parsing when using TypeScript `4.3`. + +## 0.6.2 + +### Patch Changes + +- 290405276: Updated dependencies + +## 0.6.1 + +### Patch Changes + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config@0.1.5 + ## 0.6.0 ### Minor Changes diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md new file mode 100644 index 0000000000..a09311b786 --- /dev/null +++ b/packages/config-loader/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/config-loader" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AppConfig } from '@backstage/config'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; + +// @public +export type ConfigSchema = { + process(appConfigs: AppConfig[], options?: ConfigProcessingOptions): AppConfig[]; + serialize(): JsonObject; +}; + +// @public +export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; + +// @public (undocumented) +export function loadConfig(options: LoadConfigOptions): Promise; + +// @public (undocumented) +export type LoadConfigOptions = { + configRoot: string; + configPaths: string[]; + env?: string; + experimentalEnvFunc?: EnvFunc; +}; + +// @public +export function loadConfigSchema(options: Options): Promise; + +// @public +export function mergeConfigSchemas(schemas: JSONSchema7[]): JSONSchema7; + +// @public +export function readEnvConfig(env: { + [name: string]: string | undefined; +}): AppConfig[]; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0a0a76c2d6..de0adb4a4a 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.6.0", + "version": "0.6.3", "private": false, "publishConfig": { "access": "public", @@ -31,12 +31,12 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", + "@backstage/config": "^0.1.5", "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", "fs-extra": "^9.0.0", - "json-schema": "^0.2.5", - "json-schema-merge-allof": "^0.7.0", + "json-schema": "^0.3.0", + "json-schema-merge-allof": "^0.8.1", "typescript-json-schema": "^0.49.0", "yaml": "^1.9.2", "yup": "^0.29.3" diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index 611413931e..5baada2d6f 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -124,7 +124,9 @@ export async function collectConfigSchemas( ); } - await Promise.all(packageNames.map(name => processItem({ name }))); + await Promise.all( + packageNames.map(name => processItem({ name, parentPath: currentDir })), + ); const tsSchemas = compileTsSchemas(tsSchemaPaths); @@ -166,6 +168,23 @@ function compileTsSchemas(paths: string[]) { }, [path.split(sep).join('/')], // Unix paths are expected for all OSes here ) as JsonObject | null; + + // This is a workaround for an API change in TypeScript 4.3 where doc comments no + // longer are represented by a single string, but instead an array of objects. + // This isn't handled by typescript-json-schema so we do the conversion here instead. + value = JSON.parse(JSON.stringify(value), (key, prop) => { + if (key === 'visibility' && Array.isArray(prop)) { + const text = prop[0]?.text; + if (!text) { + const propStr = JSON.stringify(prop); + throw new Error( + `Failed conversion of visibility schema, got ${propStr}`, + ); + } + return text; + } + return prop; + }); } catch (error) { if (error.message !== 'type Config not found') { throw error; diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index c190207547..5cababf94e 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/config +## 0.1.5 + +### Patch Changes + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. + ## 0.1.4 ### Patch Changes diff --git a/packages/config/api-report.md b/packages/config/api-report.md new file mode 100644 index 0000000000..be250778ec --- /dev/null +++ b/packages/config/api-report.md @@ -0,0 +1,90 @@ +## API Report File for "@backstage/config" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export type AppConfig = { + context: string; + data: JsonObject; +}; + +// @public (undocumented) +export type Config = { + has(key: string): boolean; + keys(): string[]; + get(key?: string): T; + getOptional(key?: string): T | undefined; + getConfig(key: string): Config; + getOptionalConfig(key: string): Config | undefined; + getConfigArray(key: string): Config[]; + getOptionalConfigArray(key: string): Config[] | undefined; + getNumber(key: string): number; + getOptionalNumber(key: string): number | undefined; + getBoolean(key: string): boolean; + getOptionalBoolean(key: string): boolean | undefined; + getString(key: string): string; + getOptionalString(key: string): string | undefined; + getStringArray(key: string): string[]; + getOptionalStringArray(key: string): string[] | undefined; +}; + +// @public (undocumented) +export class ConfigReader implements Config { + constructor(data: JsonObject | undefined, context?: string, fallback?: ConfigReader | undefined, prefix?: string); + // (undocumented) + static fromConfigs(configs: AppConfig[]): ConfigReader; + // (undocumented) + get(key?: string): T; + // (undocumented) + getBoolean(key: string): boolean; + // (undocumented) + getConfig(key: string): ConfigReader; + // (undocumented) + getConfigArray(key: string): ConfigReader[]; + // (undocumented) + getNumber(key: string): number; + // (undocumented) + getOptional(key?: string): T | undefined; + // (undocumented) + getOptionalBoolean(key: string): boolean | undefined; + // (undocumented) + getOptionalConfig(key: string): ConfigReader | undefined; + // (undocumented) + getOptionalConfigArray(key: string): ConfigReader[] | undefined; + // (undocumented) + getOptionalNumber(key: string): number | undefined; + // (undocumented) + getOptionalString(key: string): string | undefined; + // (undocumented) + getOptionalStringArray(key: string): string[] | undefined; + // (undocumented) + getString(key: string): string; + // (undocumented) + getStringArray(key: string): string[]; + // (undocumented) + has(key: string): boolean; + // (undocumented) + keys(): string[]; + } + +// @public (undocumented) +export interface JsonArray extends Array { +} + +// @public (undocumented) +export type JsonObject = { + [key in string]?: JsonValue; +}; + +// @public (undocumented) +export type JsonPrimitive = number | string | boolean | null; + +// @public (undocumented) +export type JsonValue = JsonObject | JsonArray | JsonPrimitive; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/config/package.json b/packages/config/package.json index 8f08a5dcfc..400e4548cd 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.4", + "version": "0.1.5", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 609940b8db..2282976d74 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,62 @@ # @backstage/core-api +## 0.2.22 + +### Patch Changes + +- 9bca2a252: Improve forwards compatibility with `@backstage/core-app-api` and `@backstage/core-plugin-api` by re-using route reference types and factory methods from `@backstage/core-plugin-api`. +- Updated dependencies [75b8537ce] +- Updated dependencies [da8cba44f] + - @backstage/core-plugin-api@0.1.2 + +## 0.2.21 + +### Patch Changes + +- 0160678b1: Made the `RouteRef*` types compatible with the ones exported from `@backstage/core-plugin-api`. +- Updated dependencies [031ccd45f] +- Updated dependencies [e7c5e4b30] + - @backstage/core-plugin-api@0.1.1 + - @backstage/theme@0.2.8 + +## 0.2.20 + +### Patch Changes + +- d597a50c6: Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined. + +## 0.2.19 + +### Patch Changes + +- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`. + + In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage: + + ```ts + [Symbol.observable]() { + return this; + }, + ``` + +- 65e6c4541: Remove circular dependencies + +## 0.2.18 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages + +## 0.2.17 + +### Patch Changes + +- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. +- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. +- Updated dependencies [931b21a12] + - @backstage/theme@0.2.6 + ## 0.2.16 ### Patch Changes diff --git a/packages/core-api/README.md b/packages/core-api/README.md index f7b8b6c337..a4325a9c5d 100644 --- a/packages/core-api/README.md +++ b/packages/core-api/README.md @@ -7,13 +7,13 @@ This package provides the core API used by Backstage plugins and apps. Do not install this package directly, it is reexported by `@backstage/core`. Install that instead: ```sh -$ npm install --save @backstage/core +npm install --save @backstage/core ``` or ```sh -$ yarn add @backstage/core +yarn add @backstage/core ``` ## Documentation diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 33e5ac146d..1eaeeb24b1 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.16", + "version": "0.2.22", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,8 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/core-plugin-api": "^0.1.2", + "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -38,17 +39,17 @@ "prop-types": "^15.7.2", "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.7", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.7.0", + "@backstage/test-utils": "^0.1.13", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/zen-observable": "^0.8.0", diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts index b9776ed037..fc4f1ef166 100644 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../../icons'; +import { IconComponent } from '../../icons/types'; import { Observable } from '../../types'; import { ApiRef, createApiRef } from '../system'; diff --git a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 19da7a3765..f18829d99c 100644 --- a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { AlertApi, AlertMessage } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index 43b52a254b..837c5f54db 100644 --- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -15,7 +15,7 @@ */ import { AppThemeApi, AppTheme } from '../../definitions'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; const STORAGE_KEY = 'theme'; diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts index 60a5b815e7..24468fdcb6 100644 --- a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts +++ b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts @@ -14,8 +14,4 @@ * limitations under the License. */ -// This folder contains implementations for all core APIs. -// -// Plugins should rely on these APIs for functionality as much as possible. - export { UrlPatternDiscovery } from './UrlPatternDiscovery'; diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index 51548708d0..5993ede367 100644 --- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { ErrorApi, ErrorContext } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts index e616ece5ae..f0710cb466 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; type RequestQueueEntry = { diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts index 23e9e1ddc4..a0a01d1bc9 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -21,7 +21,7 @@ import { AuthRequesterOptions, } from '../../definitions'; import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; /** diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index c91eef1fb0..3cade002de 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -212,6 +212,9 @@ describe('Integration Test', () => { expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument(); expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument(); expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument(); + + // Plugins should be discovered through element tree + expect(app.getPlugins()).toEqual([plugin1, plugin2]); }); it('runs happy paths without optional routes', async () => { diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index d2346e478c..0557f4159c 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -52,6 +52,7 @@ import { } from '../extensions/traversal'; import { IconComponent, IconComponentMap, IconKey } from '../icons'; import { BackstagePlugin } from '../plugin'; +import { pluginCollector } from '../plugin/collectors'; import { AnyRoutes } from '../plugin/types'; import { RouteRef, ExternalRouteRef, SubRouteRef } from '../routing'; import { @@ -189,7 +190,7 @@ export class PrivateAppImpl implements BackstageApp { private readonly apis: Iterable; private readonly icons: IconComponentMap; - private readonly plugins: BackstagePlugin[]; + private readonly plugins: Set>; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; @@ -201,7 +202,7 @@ export class PrivateAppImpl implements BackstageApp { constructor(options: FullAppOptions) { this.apis = options.apis; this.icons = options.icons; - this.plugins = options.plugins; + this.plugins = new Set(options.plugins); this.components = options.components; this.themes = options.themes; this.configLoader = options.configLoader; @@ -210,7 +211,7 @@ export class PrivateAppImpl implements BackstageApp { } getPlugins(): BackstagePlugin[] { - return this.plugins; + return Array.from(this.plugins); } getSystemIcon(key: IconKey): IconComponent | undefined { @@ -276,7 +277,6 @@ export class PrivateAppImpl implements BackstageApp { getProvider(): ComponentType<{}> { const appContext = new AppContextImpl(this); - const apiHolder = this.getApiHolder(); const Provider = ({ children }: PropsWithChildren<{}>) => { const appThemeApi = useMemo( @@ -292,11 +292,22 @@ export class PrivateAppImpl implements BackstageApp { routePaths: routePathCollector, routeParents: routeParentCollector, routeObjects: routeObjectCollector, + collectedPlugins: pluginCollector, }, }); validateRoutes(result.routePaths, result.routeParents); + // TODO(Rugvip): Restructure the public API so that we can get an immediate view of + // the app, rather than having to wait for the provider to render. + // For now we need to push the additional plugins we find during + // collection and then make sure we initialize things afterwards. + result.collectedPlugins.forEach(plugin => this.plugins.add(plugin)); + this.verifyPlugins(this.plugins); + + // Initialize APIs once all plugins are available + this.getApiHolder(); + return result; }, [children]); @@ -340,7 +351,7 @@ export class PrivateAppImpl implements BackstageApp { } return ( - + ) { const pluginIds = new Set(); - for (const plugin of this.plugins) { + for (const plugin of plugins) { const id = plugin.getId(); if (pluginIds.has(id)) { throw new Error(`Duplicate plugin found '${id}'`); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index cf3aaac931..df0f65f33f 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,13 +15,12 @@ */ import { ComponentType } from 'react'; -import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { IconComponent, IconComponentMap, IconKey } from '../icons/types'; import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; -import { ExternalRouteRef, RouteRef } from '../routing'; -import { AnyApiFactory } from '../apis'; +import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types'; +import { AnyApiFactory } from '../apis/system'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; -import { SubRouteRef } from '../routing/types'; export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 8ff4055e61..9f6412f960 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -47,12 +47,15 @@ export function createRoutableExtension< // Validate that the routing is wired up correctly in the App.tsx try { useRouteRef(mountPoint); - } catch { - throw new Error( - `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + - 'Routable extension components may not be rendered by other components and must be ' + - 'directly available as an element within the App provider component.', - ); + } catch (error) { + if (error?.message.startsWith('No path for ')) { + throw new Error( + `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + throw error; } return ; }; diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 1c7e92daa8..1a1bab0eb9 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { AuthRequester } from '../../apis'; import { + AuthRequester, OAuthRequestApi, AuthProvider, DiscoveryApi, diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts index 6cc9cf5237..af3d8bc9b6 100644 --- a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; import { Observable } from '../../types'; import { BehaviorSubject } from '../subjects'; diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index f57afd6760..332824e438 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -15,7 +15,7 @@ */ import { Observable } from '../../types'; -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; export type GetSessionOptions = { optional?: boolean; diff --git a/packages/core-api/src/lib/subjects.ts b/packages/core-api/src/lib/subjects.ts index 9ebde6ebbc..5239e460af 100644 --- a/packages/core-api/src/lib/subjects.ts +++ b/packages/core-api/src/lib/subjects.ts @@ -53,6 +53,10 @@ export class PublishSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } @@ -148,6 +152,10 @@ export class BehaviorSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts index b04c7b41c4..bbcca88e98 100644 --- a/packages/core-api/src/plugin/collectors.ts +++ b/packages/core-api/src/plugin/collectors.ts @@ -13,21 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ import { BackstagePlugin } from './types'; import { getComponentData } from '../extensions'; diff --git a/packages/core-api/src/routing/ExternalRouteRef.ts b/packages/core-api/src/routing/ExternalRouteRef.ts index e6af9cf8a7..af61ad8dfc 100644 --- a/packages/core-api/src/routing/ExternalRouteRef.ts +++ b/packages/core-api/src/routing/ExternalRouteRef.ts @@ -20,56 +20,9 @@ import { ExternalRouteRef, routeRefType, AnyParams, - ParamKeys, - OptionalParams, } from './types'; -export class ExternalRouteRefImpl< - Params extends AnyParams, - Optional extends boolean -> implements ExternalRouteRef { - readonly [routeRefType] = 'external'; - - constructor( - private readonly id: string, - readonly params: ParamKeys, - readonly optional: Optional, - ) {} - - toString() { - return `routeRef{type=external,id=${this.id}}`; - } -} - -export function createExternalRouteRef< - Params extends { [param in ParamKey]: string }, - Optional extends boolean = false, - ParamKey extends string = never ->(options: { - /** - * An identifier for this route, used to identify it in error messages - */ - id: string; - - /** - * The parameters that will be provided to the external route reference. - */ - params?: ParamKey[]; - - /** - * Whether or not this route is optional, defaults to false. - * - * Optional external routes are not required to be bound in the app, and - * if they aren't, `useRouteRef` will return `undefined`. - */ - optional?: Optional; -}): ExternalRouteRef, Optional> { - return new ExternalRouteRefImpl( - options.id, - (options.params ?? []) as ParamKeys>, - Boolean(options.optional) as Optional, - ); -} +export { createExternalRouteRef } from '@backstage/core-plugin-api'; export function isExternalRouteRef< Params extends AnyParams, diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 7ce2b2afbd..a6a8d60326 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -21,9 +21,10 @@ import { routeRefType, AnyParams, ParamKeys, - OptionalParams, } from './types'; -import { IconComponent } from '../icons'; +import { IconComponent } from '../icons/types'; + +export { createRouteRef } from '@backstage/core-plugin-api'; // TODO(Rugvip): Remove this in the next breaking release, it's exported but unused export type RouteRefConfig = { @@ -33,69 +34,6 @@ export type RouteRefConfig = { title: string; }; -export class RouteRefImpl - implements RouteRef { - readonly [routeRefType] = 'absolute'; - - constructor( - private readonly id: string, - readonly params: ParamKeys, - private readonly config: { - path?: string; - icon?: IconComponent; - title?: string; - }, - ) {} - - get icon() { - return this.config.icon; - } - - // TODO(Rugvip): Remove this, routes are looked up via the registry instead - get path() { - return this.config.path ?? ''; - } - - get title() { - return this.config.title ?? this.id; - } - - toString() { - return `routeRef{type=absolute,id=${this.id}}`; - } -} - -export function createRouteRef< - // Params is the type that we care about and the one to be embedded in the route ref. - // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} - Params extends { [param in ParamKey]: string }, - // ParamKey is here to make sure the Params type properly has its keys narrowed down - // to only the elements of params. Defaulting to never makes sure we end up with - // Param = {} if the params array is empty. - ParamKey extends string = never ->(config: { - /** The id of the route ref, used to identify it when printed */ - id?: string; - /** A list of parameter names that the path that this route ref is bound to must contain */ - params?: ParamKey[]; - /** @deprecated Route refs no longer decide their own path */ - path?: string; - /** @deprecated Route refs no longer decide their own icon */ - icon?: IconComponent; - /** @deprecated Route refs no longer decide their own title */ - title?: string; -}): RouteRef> { - const id = config.id || config.title; - if (!id) { - throw new Error('RouteRef must be provided a non-empty id'); - } - return new RouteRefImpl( - id, - (config.params ?? []) as ParamKeys>, - config, - ); -} - export function isRouteRef( routeRef: | RouteRef diff --git a/packages/core-api/src/routing/SubRouteRef.ts b/packages/core-api/src/routing/SubRouteRef.ts index 7ddfc89c80..61d3450e27 100644 --- a/packages/core-api/src/routing/SubRouteRef.ts +++ b/packages/core-api/src/routing/SubRouteRef.ts @@ -17,106 +17,12 @@ import { AnyParams, ExternalRouteRef, - OptionalParams, - ParamKeys, RouteRef, routeRefType, SubRouteRef, } from './types'; -// Should match the pattern in react-router -const PARAM_PATTERN = /^\w+$/; - -export class SubRouteRefImpl - implements SubRouteRef { - readonly [routeRefType] = 'sub'; - - constructor( - private readonly id: string, - readonly path: string, - readonly parent: RouteRef, - readonly params: ParamKeys, - ) {} - - toString() { - return `routeRef{type=sub,id=${this.id}}`; - } -} - -// These utility types help us infer a Param object type from a string path -// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` -type ParamPart = S extends `:${infer Param}` ? Param : never; -type ParamNames = S extends `${infer Part}/${infer Rest}` - ? ParamPart | ParamNames - : ParamPart; -type PathParams = { [name in ParamNames]: string }; - -/** - * Merges a param object type with with an optional params type into a params object - */ -type MergeParams< - P1 extends { [param in string]: string }, - P2 extends AnyParams -> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); - -/** - * Creates a SubRouteRef type given the desired parameters and parent route parameters. - * The parameters types are merged together while ensuring that there is no overlap between the two. - */ -type MakeSubRouteRef< - Params extends { [param in string]: string }, - ParentParams extends AnyParams -> = keyof Params & keyof ParentParams extends never - ? SubRouteRef>> - : never; - -export function createSubRouteRef< - Path extends string, - ParentParams extends AnyParams = never ->(config: { - id: string; - path: Path; - parent: RouteRef; -}): MakeSubRouteRef, ParentParams> { - const { id, path, parent } = config; - type Params = PathParams; - - // Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz' - const pathParams = path - .split('/') - .filter(p => p.startsWith(':')) - .map(p => p.substring(1)); - const params = [...parent.params, ...pathParams]; - - if (parent.params.some(p => pathParams.includes(p as string))) { - throw new Error( - 'SubRouteRef may not have params that overlap with its parent', - ); - } - if (!path.startsWith('/')) { - throw new Error(`SubRouteRef path must start with '/', got '${path}'`); - } - if (path.endsWith('/')) { - throw new Error(`SubRouteRef path must not end with '/', got '${path}'`); - } - for (const param of pathParams) { - if (!PARAM_PATTERN.test(param)) { - throw new Error(`SubRouteRef path has invalid param, got '${param}'`); - } - } - - // We ensure that the type of the return type is sane here - const subRouteRef = new SubRouteRefImpl( - id, - path, - parent, - params as ParamKeys>, - ) as SubRouteRef>>; - - // But skip type checking of the return value itself, because the conditional - // type checking of the parent parameter overlap is tricky to express. - return subRouteRef as any; -} +export { createSubRouteRef } from '@backstage/core-plugin-api'; export function isSubRouteRef( routeRef: diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 1fabd39cc4..0e5a786770 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -14,9 +14,15 @@ * limitations under the License. */ -import { IconComponent } from '../icons'; +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +export type { RouteRef, SubRouteRef, ExternalRouteRef }; + export type AnyParams = { [param in string]: string } | undefined; export type ParamKeys = keyof Params extends never ? [] @@ -34,46 +40,15 @@ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; -export const routeRefType: unique symbol = getOrCreateGlobalSingleton( +type RouteRefType = Exclude< + keyof RouteRef, + 'params' | 'path' | 'title' | 'icon' +>; +export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), ); -export type RouteRef = { - readonly [routeRefType]: 'absolute'; - - params: ParamKeys; - - // TODO(Rugvip): Remove all of these once plugins don't rely on the path - /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ - path: string; - /** @deprecated icons are no longer accessed via RouteRefs */ - icon?: IconComponent; - /** @deprecated titles are no longer accessed via RouteRefs */ - title?: string; -}; - -export type SubRouteRef = { - readonly [routeRefType]: 'sub'; - - parent: RouteRef; - - path: string; - - params: ParamKeys; -}; - -export type ExternalRouteRef< - Params extends AnyParams = any, - Optional extends boolean = any -> = { - readonly [routeRefType]: 'external'; - - params: ParamKeys; - - optional?: Optional; -}; - export type AnyRouteRef = | RouteRef | SubRouteRef diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts index 13a8a72570..ab0aa56a1b 100644 --- a/packages/core-api/src/types.ts +++ b/packages/core-api/src/types.ts @@ -39,9 +39,17 @@ export type Subscription = { /** * Value indicating whether the subscription is closed. */ - readonly closed: Boolean; + readonly closed: boolean; }; +// Declares the global well-known Symbol.observable +// We get the actual runtime polyfill from zen-observable +declare global { + interface SymbolConstructor { + readonly observable: symbol; + } +} + /** * Observable sequence of values and errors, see TC39. * @@ -51,12 +59,14 @@ export type Subscription = { * using many different observable implementations, such as zen-observable or RxJS 5. */ export type Observable = { + [Symbol.observable](): Observable; + /** * Subscribes to this observable to start receiving new values. */ subscribe(observer: Observer): Subscription; subscribe( - onNext: (value: T) => void, + onNext?: (value: T) => void, onError?: (error: Error) => void, onComplete?: () => void, ): Subscription; diff --git a/packages/core-app-api/.eslintrc.js b/packages/core-app-api/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/core-app-api/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md new file mode 100644 index 0000000000..4d7b7541d0 --- /dev/null +++ b/packages/core-app-api/CHANGELOG.md @@ -0,0 +1,55 @@ +# @backstage/core-app-api + +## 0.1.2 + +### Patch Changes + +- 9bca2a252: Fixes a type bug where supplying all app icons to `createApp` was required, rather than just a partial list. +- 75b8537ce: This change adds automatic error boundaries around extensions. + + This means that all exposed parts of a plugin are wrapped in a general error boundary component, that is plugin aware. The default design for the error box is borrowed from `@backstage/errors`. To override the default "fallback", one must provide a component named `ErrorBoundaryFallback` to `createApp`, like so: + + ```ts + const app = createApp({ + components: { + ErrorBoundaryFallback: props => { + // a custom fallback component + return ( + <> +

Oops.

+

+ The plugin {props.plugin.getId()} failed with{' '} + {props.error.message} +

+ + + ); + }, + }, + }); + ``` + + The props here include: + + - `error`. An `Error` object or something that inherits it that represents the error that was thrown from any inner component. + - `resetError`. A callback that will simply attempt to mount the children of the error boundary again. + - `plugin`. A `BackstagePlugin` that can be used to look up info to be presented in the error message. For instance, you may want to keep a map of your internal plugins and team names or slack channels and present these when an error occurs. Typically, you'll do that by getting the plugin ID with `plugin.getId()`. + +- da8cba44f: Deprecate and disable the extension creation methods, which were added to this package by mistake and should only exist within `@backstage/core-plugin-api`. +- 9bca2a252: Update `createApp` options to allow plugins with unknown output types in order to improve forwards and backwards compatibility. +- Updated dependencies [e47336ea4] +- Updated dependencies [75b8537ce] +- Updated dependencies [da8cba44f] + - @backstage/core-components@0.1.2 + - @backstage/core-plugin-api@0.1.2 + +## 0.1.1 + +### Patch Changes + +- e7c5e4b30: Update installation instructions in README. +- Updated dependencies [031ccd45f] +- Updated dependencies [e7c5e4b30] + - @backstage/core-plugin-api@0.1.1 + - @backstage/core-components@0.1.1 + - @backstage/theme@0.2.8 diff --git a/packages/core-app-api/README.md b/packages/core-app-api/README.md new file mode 100644 index 0000000000..f98021250f --- /dev/null +++ b/packages/core-app-api/README.md @@ -0,0 +1,17 @@ +# @backstage/core-app-api + +This package provides the core API used by Backstage apps. + +## Installation + +Install the package via Yarn: + +```sh +cd packages/app +yarn add @backstage/core-app-api +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md new file mode 100644 index 0000000000..b85c9392d7 --- /dev/null +++ b/packages/core-app-api/api-report.md @@ -0,0 +1,412 @@ +## API Report File for "@backstage/core-app-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AlertApi } from '@backstage/core-plugin-api'; +import { AlertMessage } from '@backstage/core-plugin-api'; +import { AnyApiFactory } from '@backstage/core-plugin-api'; +import { AnyApiRef } from '@backstage/core-plugin-api'; +import { ApiFactory } from '@backstage/core-plugin-api'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiRef } from '@backstage/core-plugin-api'; +import { AppConfig } from '@backstage/config'; +import { AppTheme } from '@backstage/core-plugin-api'; +import { AppThemeApi } from '@backstage/core-plugin-api'; +import { auth0AuthApiRef } from '@backstage/core-plugin-api'; +import { AuthProvider } from '@backstage/core-plugin-api'; +import { AuthRequester } from '@backstage/core-plugin-api'; +import { AuthRequesterOptions } from '@backstage/core-plugin-api'; +import { AuthRequestOptions } from '@backstage/core-plugin-api'; +import { BackstageIdentity } from '@backstage/core-plugin-api'; +import { BackstageIdentityApi } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; +import { ConfigReader } from '@backstage/config'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { ErrorApi } from '@backstage/core-plugin-api'; +import { ErrorContext } from '@backstage/core-plugin-api'; +import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FeatureFlag } from '@backstage/core-plugin-api'; +import { FeatureFlagsApi } from '@backstage/core-plugin-api'; +import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api'; +import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; +import { googleAuthApiRef } from '@backstage/core-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; +import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuthApi } from '@backstage/core-plugin-api'; +import { OAuthRequestApi } from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/core-plugin-api'; +import { oktaAuthApiRef } from '@backstage/core-plugin-api'; +import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; +import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { PendingAuthRequest } from '@backstage/core-plugin-api'; +import { PluginOutput } from '@backstage/core-plugin-api'; +import { ProfileInfo } from '@backstage/core-plugin-api'; +import { ProfileInfoApi } from '@backstage/core-plugin-api'; +import { PropsWithChildren } from 'react'; +import PropTypes from 'prop-types'; +import { ReactNode } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; +import { SessionApi } from '@backstage/core-plugin-api'; +import { SessionState } from '@backstage/core-plugin-api'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { StorageValueChange } from '@backstage/core-plugin-api'; +import { SubRouteRef } from '@backstage/core-plugin-api'; + +// @public +export class AlertApiForwarder implements AlertApi { + // (undocumented) + alert$(): Observable; + // (undocumented) + post(alert: AlertMessage): void; + } + +// @public (undocumented) +export type ApiFactoryHolder = { + get(api: ApiRef): ApiFactory | undefined; +}; + +// @public +export class ApiFactoryRegistry implements ApiFactoryHolder { + // (undocumented) + get(api: ApiRef): ApiFactory | undefined; + // (undocumented) + getAllApis(): Set; + register(scope: ApiFactoryScope, factory: ApiFactory): boolean; +} + +// @public (undocumented) +export const ApiProvider: { + ({ apis, children, }: PropsWithChildren): JSX.Element; + propTypes: { + apis: PropTypes.Validator any>; + }>>; + children: PropTypes.Requireable; + }; +}; + +// @public (undocumented) +export class ApiRegistry implements ApiHolder { + constructor(apis: Map); + // (undocumented) + static builder(): ApiRegistryBuilder; + // (undocumented) + static from(apis: ApiImpl[]): ApiRegistry; + // (undocumented) + get(api: ApiRef): T | undefined; + static with(api: ApiRef, impl: T): ApiRegistry; + with(api: ApiRef, impl: T): ApiRegistry; +} + +// @public (undocumented) +export class ApiResolver implements ApiHolder { + constructor(factories: ApiFactoryHolder); + // (undocumented) + get(ref: ApiRef): T | undefined; + static validateFactories(factories: ApiFactoryHolder, apis: Iterable): void; +} + +// @public (undocumented) +export type AppComponents = { + NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; + Router: ComponentType<{}>; + ErrorBoundaryFallback: ComponentType; + SignInPage?: ComponentType; +}; + +// @public +export type AppConfigLoader = () => Promise; + +// @public (undocumented) +export type AppContext = { + getPlugins(): BackstagePlugin[]; + getSystemIcon(key: string): IconComponent | undefined; + getComponents(): AppComponents; +}; + +// @public (undocumented) +export type AppOptions = { + apis?: Iterable; + icons?: Partial & { + [key in string]: IconComponent; + }; + plugins?: BackstagePluginWithAnyOutput[]; + components?: Partial; + themes?: AppTheme[]; + configLoader?: AppConfigLoader; + bindRoutes?(context: { + bind: AppRouteBinder; + }): void; +}; + +// @public (undocumented) +export type AppRouteBinder = (externalRoutes: ExternalRoutes, targetRoutes: PartialKeys, KeysWithType>>) => void; + +// @public (undocumented) +export class AppThemeSelector implements AppThemeApi { + constructor(themes: AppTheme[]); + // (undocumented) + activeThemeId$(): Observable; + // (undocumented) + static createWithStorage(themes: AppTheme[]): AppThemeSelector; + // (undocumented) + getActiveThemeId(): string | undefined; + // (undocumented) + getInstalledThemes(): AppTheme[]; + // (undocumented) + setActiveThemeId(themeId?: string): void; + } + +// @public (undocumented) +export class Auth0Auth { + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; +} + +// @public (undocumented) +export type BackstageApp = { + getPlugins(): BackstagePlugin[]; + getSystemIcon(key: string): IconComponent | undefined; + getProvider(): ComponentType<{}>; + getRouter(): ComponentType<{}>; +}; + +// @public (undocumented) +export type BackstagePluginWithAnyOutput = Omit, 'output'> & { + output(): (PluginOutput | UnknownPluginOutput)[]; +}; + +// @public (undocumented) +export type BootErrorPageProps = { + step: 'load-config' | 'load-chunk'; + error: Error; +}; + +export { ConfigReader } + +// @public +export function createApp(options?: AppOptions): PrivateAppImpl; + +// @public +export class ErrorAlerter implements ErrorApi { + constructor(alertApi: AlertApi, errorApi: ErrorApi); + // (undocumented) + error$(): Observable<{ + error: { + name: string; + message: string; + stack?: string | undefined; + }; + context?: ErrorContext | undefined; + }>; + // (undocumented) + post(error: Error, context?: ErrorContext): void; +} + +// @public +export class ErrorApiForwarder implements ErrorApi { + // (undocumented) + error$(): Observable<{ + error: Error; + context?: ErrorContext; + }>; + // (undocumented) + post(error: Error, context?: ErrorContext): void; + } + +// @public (undocumented) +export type ErrorBoundaryFallbackProps = { + plugin?: BackstagePlugin; + error: Error; + resetError: () => void; +}; + +// @public (undocumented) +export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null; + +// @public (undocumented) +export class GithubAuth implements OAuthApi, SessionApi { + constructor(sessionManager: SessionManager); + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): GithubAuth; + // (undocumented) + getAccessToken(scope?: string, options?: AuthRequestOptions): Promise; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + static normalizeScope(scope?: string): Set; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type GithubSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +// @public (undocumented) +export class GitlabAuth { + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T; +} + +// @public (undocumented) +export class GoogleAuth { + // (undocumented) + static create({ discoveryApi, oauthRequestApi, environment, provider, defaultScopes, }: OAuthApiCreateOptions): typeof googleAuthApiRef.T; +} + +// @public +export class LocalStorageFeatureFlags implements FeatureFlagsApi { + // (undocumented) + getRegisteredFlags(): FeatureFlag[]; + // (undocumented) + isActive(name: string): boolean; + // (undocumented) + registerFlag(flag: FeatureFlag): void; + // (undocumented) + save(options: FeatureFlagsSaveOptions): void; +} + +// @public (undocumented) +export class MicrosoftAuth { + // (undocumented) + static create({ environment, provider, oauthRequestApi, discoveryApi, defaultScopes, }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; +} + +// @public (undocumented) +export class OAuth2 implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, BackstageIdentityApi, SessionApi { + constructor(options: Options); + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, scopeTransform, }: CreateOptions): OAuth2; + // (undocumented) + getAccessToken(scope?: string | string[], options?: AuthRequestOptions): Promise; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getIdToken(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +// @public +export class OAuthRequestManager implements OAuthRequestApi { + // (undocumented) + authRequest$(): Observable; + // (undocumented) + createAuthRequester(options: AuthRequesterOptions): AuthRequester; + } + +// @public (undocumented) +export class OktaAuth { + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, defaultScopes, }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T; +} + +// @public (undocumented) +export class OneLoginAuth { + // (undocumented) + static create({ discoveryApi, environment, provider, oauthRequestApi, }: CreateOptions_2): typeof oneloginAuthApiRef.T; +} + +// @public (undocumented) +export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { + constructor(sessionManager: SessionManager); + // (undocumented) + static create({ discoveryApi, environment, provider, }: AuthApiCreateOptions): SamlAuth; + // (undocumented) + getBackstageIdentity(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + +// @public (undocumented) +export type SignInPageProps = { + onResult(result: SignInResult): void; +}; + +// @public (undocumented) +export type SignInResult = { + userId: string; + profile: ProfileInfo; + getIdToken?: () => Promise; + signOut?: () => Promise; +}; + +// @public +export class UrlPatternDiscovery implements DiscoveryApi { + static compile(pattern: string): UrlPatternDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + } + +// @public (undocumented) +export class WebStorage implements StorageApi { + constructor(namespace: string, errorApi: ErrorApi); + // (undocumented) + static create(options: CreateStorageApiOptions): WebStorage; + // (undocumented) + forBucket(name: string): WebStorage; + // (undocumented) + get(key: string): T | undefined; + // (undocumented) + observe$(key: string): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts new file mode 100644 index 0000000000..0e1d531226 --- /dev/null +++ b/packages/core-app-api/config.d.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** + * Generic frontend configuration. + */ + app: { + /** + * The public absolute root URL that the frontend. + * @visibility frontend + */ + baseUrl: string; + + /** + * The title of the app, as shown in the Backstage web interface. + * @visibility frontend + */ + title?: string; + + /** + * Information about support of this Backstage instance and how to contact the integrator team. + */ + support?: { + /** + * The primary support url. + * @visibility frontend + */ + url: string; + /** + * A list of categorized support item groupings. + */ + items: Array<{ + /** + * The title of the support item grouping. + * @visibility frontend + */ + title: string; + /** + * An optional icon for the support item grouping. + * @visibility frontend + */ + icon?: string; + /** + * A list of support links for the Backstage instance inside this grouping. + */ + links: Array<{ + /** @visibility frontend */ + url: string; + /** @visibility frontend */ + title?: string; + }>; + }>; + }; + }; + + /** + * Generic backend configuration. + */ + backend: { + /** + * The public absolute root URL that the backend is reachable at, from the browser's perspective. + * @visibility frontend + */ + baseUrl: string; + }; + + /** + * Configuration that provides information about the organization that the app is for. + */ + organization?: { + /** + * The name of the organization that the app belongs to. + * @visibility frontend + */ + name?: string; + }; + + homepage?: { + clocks?: Array<{ + /** @visibility frontend */ + label: string; + /** @visibility frontend */ + timezone: string; + }>; + }; + + /** + * Configuration that provides information on available configured authentication providers. + */ + auth?: { + /** + * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`. + * default value: 'development' + * optional values: 'development' | 'production' + * @visibility frontend + */ + environment?: string; + }; +} diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json new file mode 100644 index 0000000000..9298a53ea4 --- /dev/null +++ b/packages/core-app-api/package.json @@ -0,0 +1,65 @@ +{ + "name": "@backstage/core-app-api", + "description": "Core app API used by Backstage apps", + "version": "0.1.2", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/core-app-api" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core-components": "^0.1.2", + "@backstage/config": "^0.1.3", + "@backstage/core-plugin-api": "^0.1.2", + "@backstage/theme": "^0.2.8", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@types/react": "^16.9", + "@types/prop-types": "^15.7.3", + "prop-types": "^15.7.2", + "react": "^16.12.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/cli": "^0.7.0", + "@backstage/test-utils": "^0.1.13", + "@backstage/test-utils-core": "^0.1.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/zen-observable": "^0.8.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.3" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts new file mode 100644 index 0000000000..28cb5bc068 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AlertApi, AlertMessage, Observable } from '@backstage/core-plugin-api'; +import { PublishSubject } from '../../../lib/subjects'; + +/** + * Base implementation for the AlertApi that simply forwards alerts to consumers. + */ +export class AlertApiForwarder implements AlertApi { + private readonly subject = new PublishSubject(); + + post(alert: AlertMessage) { + this.subject.next(alert); + } + + alert$(): Observable { + return this.subject; + } +} diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/index.ts b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts new file mode 100644 index 0000000000..12ab8bc60c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AlertApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AlertApiForwarder } from './AlertApiForwarder'; diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts new file mode 100644 index 0000000000..8d36e6b10e --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppTheme } from '@backstage/core-plugin-api'; +import { AppThemeSelector } from './AppThemeSelector'; + +describe('AppThemeSelector', () => { + it('should should select new themes', async () => { + const selector = new AppThemeSelector([]); + + expect(selector.getInstalledThemes()).toEqual([]); + + const subFn = jest.fn(); + selector.activeThemeId$().subscribe(subFn); + expect(selector.getActiveThemeId()).toBe(undefined); + await 'wait a tick'; + expect(subFn).toHaveBeenLastCalledWith(undefined); + + selector.setActiveThemeId('x'); + expect(subFn).toHaveBeenLastCalledWith('x'); + expect(selector.getActiveThemeId()).toBe('x'); + + selector.setActiveThemeId(undefined); + expect(subFn).toHaveBeenLastCalledWith(undefined); + expect(selector.getActiveThemeId()).toBe(undefined); + }); + + it('should return a new array of themes', () => { + const themes = new Array(); + const selector = new AppThemeSelector(themes); + + expect(selector.getInstalledThemes()).toEqual(themes); + expect(selector.getInstalledThemes()).not.toBe(themes); + }); + + it('should store theme in local storage', async () => { + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + localStorage.setItem('theme', 'x'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x'); + localStorage.removeItem('theme'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + + const addListenerSpy = jest.spyOn(window, 'addEventListener'); + const selector = AppThemeSelector.createWithStorage([]); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith( + 'storage', + expect.any(Function), + ); + + selector.setActiveThemeId('y'); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe('y'); + + selector.setActiveThemeId(undefined); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe(null); + + localStorage.setItem('theme', 'z'); + expect(selector.getActiveThemeId()).toBe(undefined); + + const listener = addListenerSpy.mock.calls[0][1] as EventListener; + listener({ key: 'theme' } as StorageEvent); + + expect(selector.getActiveThemeId()).toBe('z'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts new file mode 100644 index 0000000000..3bd4d764ea --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppThemeApi, AppTheme, Observable } from '@backstage/core-plugin-api'; +import { BehaviorSubject } from '../../../lib/subjects'; + +const STORAGE_KEY = 'theme'; + +export class AppThemeSelector implements AppThemeApi { + static createWithStorage(themes: AppTheme[]) { + const selector = new AppThemeSelector(themes); + + if (!window.localStorage) { + return selector; + } + + const initialThemeId = + window.localStorage.getItem(STORAGE_KEY) ?? undefined; + + selector.setActiveThemeId(initialThemeId); + + selector.activeThemeId$().subscribe(themeId => { + if (themeId) { + window.localStorage.setItem(STORAGE_KEY, themeId); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + }); + + window.addEventListener('storage', event => { + if (event.key === STORAGE_KEY) { + const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; + selector.setActiveThemeId(themeId); + } + }); + + return selector; + } + + private activeThemeId: string | undefined; + private readonly subject = new BehaviorSubject(undefined); + + constructor(private readonly themes: AppTheme[]) {} + + getInstalledThemes(): AppTheme[] { + return this.themes.slice(); + } + + activeThemeId$(): Observable { + return this.subject; + } + + getActiveThemeId(): string | undefined { + return this.activeThemeId; + } + + setActiveThemeId(themeId?: string): void { + this.activeThemeId = themeId; + this.subject.next(themeId); + } +} diff --git a/plugins/techdocs/src/home/hooks/index.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts similarity index 93% rename from plugins/techdocs/src/home/hooks/index.ts rename to packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts index 96419f5e7b..cb42c0f875 100644 --- a/plugins/techdocs/src/home/hooks/index.ts +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { useOwnUser } from './useOwnUser'; +export * from './AppThemeSelector'; diff --git a/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts new file mode 100644 index 0000000000..7c7f88a3e5 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ConfigApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConfigReader } from '@backstage/config'; diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts new file mode 100644 index 0000000000..9597443b98 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UrlPatternDiscovery } from './UrlPatternDiscovery'; + +describe('UrlPatternDiscovery', () => { + it('should not require interpolation', async () => { + const discoveryApi = UrlPatternDiscovery.compile('http://example.com'); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://example.com', + ); + }); + + it('should use a plain pattern', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'http://localhost:7000/{{ pluginId }}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://localhost:7000/my-plugin', + ); + }); + + it('should allow for multiple interpolation points', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'https://{{pluginId }}.example.com/api/{{ pluginId}}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'https://my-plugin.example.com/api/my-plugin', + ); + }); + + it('should validate that the pattern is a valid URL', () => { + expect(() => { + UrlPatternDiscovery.compile('example.com'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: example.com'); + + expect(() => { + UrlPatternDiscovery.compile('http://'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: http://'); + + expect(() => { + UrlPatternDiscovery.compile('abc123'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: abc123'); + + expect(() => { + UrlPatternDiscovery.compile('http://example.com:{{pluginId}}'); + }).toThrow( + 'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId', + ); + + expect(() => { + UrlPatternDiscovery.compile('/{{pluginId}}'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a query'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a hash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts new file mode 100644 index 0000000000..a9decd9ef9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '@backstage/core-plugin-api'; + +/** + * UrlPatternDiscovery is a lightweight DiscoveryApi implementation. + * It uses a single template string to construct URLs for each plugin. + */ +export class UrlPatternDiscovery implements DiscoveryApi { + /** + * Creates a new UrlPatternDiscovery given a template. The the only + * interpolation done for the template is to replace instances of `{{pluginId}}` + * with the ID of the plugin being requested. + * + * Example pattern: `http://localhost:7000/api/{{ pluginId }}` + */ + static compile(pattern: string): UrlPatternDiscovery { + const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); + + try { + const urlStr = parts.join('pluginId'); + const url = new URL(urlStr); + if (url.hash) { + throw new Error('URL must not have a hash'); + } + if (url.search) { + throw new Error('URL must not have a query'); + } + if (urlStr.endsWith('/')) { + throw new Error('URL must not end with a slash'); + } + } catch (error) { + throw new Error(`Invalid discovery URL pattern, ${error.message}`); + } + + return new UrlPatternDiscovery(parts); + } + + private constructor(private readonly parts: string[]) {} + + async getBaseUrl(pluginId: string): Promise { + return this.parts.join(pluginId); + } +} diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts new file mode 100644 index 0000000000..24468fdcb6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { UrlPatternDiscovery } from './UrlPatternDiscovery'; diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts new file mode 100644 index 0000000000..73d5042c11 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api'; + +/** + * Decorates an ErrorApi by also forwarding error messages + * to the alertApi with an 'error' severity. + */ +export class ErrorAlerter implements ErrorApi { + constructor( + private readonly alertApi: AlertApi, + private readonly errorApi: ErrorApi, + ) {} + + post(error: Error, context?: ErrorContext) { + if (!context?.hidden) { + this.alertApi.post({ message: error.message, severity: 'error' }); + } + + return this.errorApi.post(error, context); + } + + error$() { + return this.errorApi.error$(); + } +} diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts similarity index 51% rename from plugins/catalog/src/filter/useFilteredEntities.ts rename to packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index 2d7dcfd89d..875d07c0a3 100644 --- a/plugins/catalog/src/filter/useFilteredEntities.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -13,25 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { useContext } from 'react'; -import { filterGroupsContext } from './context'; +import { ErrorApi, ErrorContext, Observable } from '@backstage/core-plugin-api'; +import { PublishSubject } from '../../../lib/subjects'; /** - * Hook that exposes the result of applying a set of filter groups. + * Base implementation for the ErrorApi that simply forwards errors to consumers. */ -export function useFilteredEntities() { - const context = useContext(filterGroupsContext); - if (!context) { - throw new Error(`Must be used inside an EntityFilterGroupsProvider`); +export class ErrorApiForwarder implements ErrorApi { + private readonly subject = new PublishSubject<{ + error: Error; + context?: ErrorContext; + }>(); + + post(error: Error, context?: ErrorContext) { + this.subject.next({ error, context }); } - return { - loading: context.loading, - error: context.error, - matchingEntities: context.matchingEntities, - availableTags: context.availableTags, - isCatalogEmpty: context.isCatalogEmpty, - reload: context.reload, - }; + error$(): Observable<{ error: Error; context?: ErrorContext }> { + return this.subject; + } } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts new file mode 100644 index 0000000000..757dfd0d8f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ErrorAlerter } from './ErrorAlerter'; +export { ErrorApiForwarder } from './ErrorApiForwarder'; diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx new file mode 100644 index 0000000000..a100f01a52 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx @@ -0,0 +1,222 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; +import { FeatureFlagState, FeatureFlagsApi } from '@backstage/core-plugin-api'; + +describe('FeatureFlags', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe('getFlags', () => { + let featureFlags: FeatureFlagsApi; + + beforeEach(() => { + featureFlags = new LocalStorageFeatureFlags(); + }); + + it('returns no flags', () => { + expect(featureFlags.getRegisteredFlags()).toEqual([]); + }); + + it('loads flags from local storage', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + 'feature-flag-four': 2, + 'feature-flag-five': 'not-valid', + }), + ); + + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + expect(featureFlags.isActive('feature-flag-four')).toBe(false); + expect(featureFlags.isActive('feature-flag-five')).toBe(false); + }); + + it('sets the correct values', () => { + featureFlags.save({ + states: { + 'feature-flag-zero': FeatureFlagState.Active, + }, + }); + + expect(featureFlags.isActive('feature-flag-zero')).toBe(true); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-zero":1}', + ); + }); + + it('deletes the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + 'feature-flag-tree': 1, + 'feature-flag-four': 0, + }), + ); + + featureFlags.save({ + states: { + 'feature-flag-one': FeatureFlagState.None, + 'feature-flag-two': FeatureFlagState.Active, + }, + }); + + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-two":1}', + ); + }); + + it('clears all values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + }), + ); + + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + + featureFlags.save({ states: {} }); + + expect(featureFlags.isActive('feature-flag-one')).toBe(false); + expect(featureFlags.isActive('feature-flag-two')).toBe(false); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + + expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); + }); + }); + + describe('getRegisteredFlags', () => { + let featureFlags: FeatureFlagsApi; + + beforeEach(() => { + featureFlags = new LocalStorageFeatureFlags(); + featureFlags.registerFlag({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); + }); + + it('should return an empty list', () => { + featureFlags = new LocalStorageFeatureFlags(); + expect(featureFlags.getRegisteredFlags()).toEqual([]); + }); + + it('should return an valid list', () => { + expect(featureFlags.getRegisteredFlags()).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should provide a copy of the list of flags', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + flags.splice(2, 1); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + ]); + expect(featureFlags.getRegisteredFlags()).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should get the correct values', () => { + const getByName = (name: string) => + featureFlags.getRegisteredFlags().find(flag => flag.name === name); + + expect(getByName('registered-flag-0')).toBeUndefined(); + expect(getByName('registered-flag-1')).toEqual({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-2')).toEqual({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-3')).toEqual({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); + }); + + it('throws an error if length is less than three characters', () => { + expect(() => + featureFlags.registerFlag({ + name: 'ab', + pluginId: 'plugin-three', + }), + ).toThrow(/minimum length of three characters/i); + }); + + it('throws an error if length is greater than 150 characters', () => { + expect(() => + featureFlags.registerFlag({ + name: + 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', + pluginId: 'plugin-three', + }), + ).toThrow(/not exceed 150 characters/i); + }); + + it('throws an error if name does not start with a lowercase letter', () => { + expect(() => + featureFlags.registerFlag({ + name: '123456789', + pluginId: 'plugin-three', + }), + ).toThrow(/start with a lowercase letter/i); + }); + + it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { + expect(() => + featureFlags.registerFlag({ + name: 'Invalid_Feature_Flag', + pluginId: 'plugin-three', + }), + ).toThrow(/only contain lowercase letters, numbers and hyphens/i); + }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx new file mode 100644 index 0000000000..800ae98f24 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FeatureFlagState, + FeatureFlagsApi, + FeatureFlag, + FeatureFlagsSaveOptions, +} from '@backstage/core-plugin-api'; + +export function validateFlagName(name: string): void { + if (name.length < 3) { + throw new Error( + `The '${name}' feature flag must have a minimum length of three characters.`, + ); + } + + if (name.length > 150) { + throw new Error( + `The '${name}' feature flag must not exceed 150 characters.`, + ); + } + + if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { + throw new Error( + `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + + 'Examples: feature-flag-one, alpha, release-2020', + ); + } +} + +/** + * Create the FeatureFlags implementation based on the API. + */ +export class LocalStorageFeatureFlags implements FeatureFlagsApi { + private registeredFeatureFlags: FeatureFlag[] = []; + private flags?: Map; + + registerFlag(flag: FeatureFlag) { + validateFlagName(flag.name); + this.registeredFeatureFlags.push(flag); + } + + getRegisteredFlags(): FeatureFlag[] { + return this.registeredFeatureFlags.slice(); + } + + isActive(name: string): boolean { + if (!this.flags) { + this.flags = this.load(); + } + return this.flags.get(name) === FeatureFlagState.Active; + } + + save(options: FeatureFlagsSaveOptions): void { + if (!this.flags) { + this.flags = this.load(); + } + if (!options.merge) { + this.flags.clear(); + } + for (const [name, state] of Object.entries(options.states)) { + this.flags.set(name, state); + } + + const enabled = Array.from(this.flags.entries()).filter( + ([, state]) => state === FeatureFlagState.Active, + ); + window.localStorage.setItem( + 'featureFlags', + JSON.stringify(Object.fromEntries(enabled)), + ); + } + + private load(): Map { + try { + const jsonStr = window.localStorage.getItem('featureFlags'); + if (!jsonStr) { + return new Map(); + } + const json = JSON.parse(jsonStr) as unknown; + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + return new Map(); + } + + const entries = Object.entries(json).filter(([name, value]) => { + validateFlagName(name); + return value === FeatureFlagState.Active; + }); + + return new Map(entries); + } catch { + return new Map(); + } + } +} diff --git a/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..33990584f3 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FeatureFlagsApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts new file mode 100644 index 0000000000..32170acc6f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockOAuthApi from './MockOAuthApi'; +import PowerIcon from '@material-ui/icons/Power'; + +describe('MockOAuthApi', () => { + it('should trigger all requests', async () => { + const authResult = { is: 'done' }; + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn().mockImplementation(() => authResult); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn().mockResolvedValue('other'); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.triggerAll(); + + await expect(Promise.all(promises)).resolves.toEqual([ + authResult, + authResult, + 'other', + 'other', + 'other', + ]); + + expect(authHandler1).toHaveBeenCalledTimes(1); + expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b'])); + expect(authHandler2).toHaveBeenCalledTimes(1); + expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c'])); + }); + + it('should reject all requests', async () => { + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn(); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn(); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.rejectAll(); + + for (const promise of promises) { + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + } + + expect(authHandler1).not.toHaveBeenCalled(); + expect(authHandler2).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts new file mode 100644 index 0000000000..46d620c329 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OAuthRequestApi, + AuthRequesterOptions, +} from '@backstage/core-plugin-api'; +import { OAuthRequestManager } from './OAuthRequestManager'; + +export default class MockOAuthApi implements OAuthRequestApi { + private readonly real = new OAuthRequestManager(); + + createAuthRequester(options: AuthRequesterOptions) { + return this.real.createAuthRequester(options); + } + + authRequest$() { + return this.real.authRequest$(); + } + + async triggerAll() { + await Promise.resolve(); // Wait a tick to allow new requests to get forwarded + + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { + subscription.unsubscribe(); + Promise.all(requests.map(request => request.trigger())).then(() => + resolve(), + ); + }); + }); + } + + async rejectAll() { + await Promise.resolve(); // Wait a tick to allow new requests to get forwarded + + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { + subscription.unsubscribe(); + requests.map(request => request.reject()); + resolve(); + }); + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts new file mode 100644 index 0000000000..280321daa0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { waitFor } from '@testing-library/react'; +import { OAuthPendingRequests } from './OAuthPendingRequests'; + +describe('OAuthPendingRequests', () => { + it('notifies new observers about current state', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const input = new Set(['a', 'b']); + target.pending().subscribe({ next, error }); + target.request(input); + + await waitFor(() => expect(next).toBeCalledTimes(2)); + expect(next.mock.calls[0][0].scopes).toBeUndefined(); + expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); + expect(error.mock.calls.length).toBe(0); + }); + + it('resolves requests and notifies observers', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + target.resolve(new Set(['a']), 'session1'); + target.resolve(new Set(['a']), 'session2'); + + await expect(request1).resolves.toBe('session1'); + await expect(request2).resolves.toBe('session1'); + expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve + expect(error).toBeCalledTimes(0); + }); + + it('can resolve through the observable', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(pendingRequest => pendingRequest.resolve('done')); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + + await expect(request1).resolves.toBe('done'); + expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution + expect(error).toBeCalledTimes(0); + }); + + it('rejects requests and notifies observers only once', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + const rejection = new Error('eek'); + + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + target.reject(rejection); + target.resolve(new Set(['a']), 'session'); + + await expect(request1).rejects.toBe(rejection); + await expect(request2).rejects.toBe(rejection); + expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve + expect(error).toBeCalledTimes(0); + }); + + it('can reject through the observable', async () => { + const target = new OAuthPendingRequests(); + const rejection = new Error('nope'); + const next = jest.fn(pendingRequest => pendingRequest.reject(rejection)); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + + await expect(request1).rejects.toBe(rejection); + expect(next).toBeCalledTimes(2); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts new file mode 100644 index 0000000000..370ba9a3a1 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from '@backstage/core-plugin-api'; +import { BehaviorSubject } from '../../../lib/subjects'; + +type RequestQueueEntry = { + scopes: Set; + resolve: (value: ResultType | PromiseLike) => void; + reject: (reason: Error) => void; +}; + +export type PendingRequest = { + scopes: Set | undefined; + resolve: (value: ResultType) => void; + reject: (reason: Error) => void; +}; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +export function joinScopes( + scopes: Set, + ...moreScopess: Set[] +): Set { + const result = new Set(scopes); + + for (const moreScopes of moreScopess) { + for (const scope of moreScopes) { + result.add(scope); + } + } + + return result; +} + +/** + * The OAuthPendingRequests class is a utility for managing and observing + * a stream of requests for oauth scopes for a single provider, and resolving + * them correctly once requests are fulfilled. + */ +export class OAuthPendingRequests { + private requests: RequestQueueEntry[] = []; + private subject = new BehaviorSubject>( + this.getCurrentPending(), + ); + + request(scopes: Set): Promise { + return new Promise((resolve, reject) => { + this.requests.push({ scopes, resolve, reject }); + + this.subject.next(this.getCurrentPending()); + }); + } + + resolve(scopes: Set, result: ResultType): void { + this.requests = this.requests.filter(request => { + if (hasScopes(scopes, request.scopes)) { + request.resolve(result); + return false; + } + return true; + }); + + this.subject.next(this.getCurrentPending()); + } + + reject(error: Error) { + this.requests.forEach(request => request.reject(error)); + this.requests = []; + + this.subject.next(this.getCurrentPending()); + } + + pending(): Observable> { + return this.subject; + } + + private getCurrentPending(): PendingRequest { + const currentScopes = + this.requests.length === 0 + ? undefined + : this.requests + .slice(1) + .reduce( + (acc, current) => joinScopes(acc, current.scopes), + this.requests[0].scopes, + ); + + return { + scopes: currentScopes, + resolve: (value: ResultType) => { + if (currentScopes) { + this.resolve(currentScopes, value); + } + }, + reject: (reason: Error) => { + if (currentScopes) { + this.reject(reason); + } + }, + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts new file mode 100644 index 0000000000..46a5362f35 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ProviderIcon from '@material-ui/icons/AcUnit'; +import { OAuthRequestManager } from './OAuthRequestManager'; + +describe('OAuthRequestManager', () => { + it('should forward a requests', async () => { + const manager = new OAuthRequestManager(); + + const reqSpy = jest.fn(); + manager.authRequest$().subscribe(reqSpy); + + const requester = manager.createAuthRequester({ + provider: { + title: 'My Provider', + icon: ProviderIcon, + }, + onAuthRequest: async () => 'hello', + }); + + expect(reqSpy).toHaveBeenCalledTimes(0); + await 'a tick'; + expect(reqSpy).toHaveBeenCalledTimes(2); + expect(reqSpy).toHaveBeenLastCalledWith([]); + + const req = requester(new Set(['my-scope'])); + + expect(reqSpy).toHaveBeenCalledTimes(3); + expect(reqSpy).toHaveBeenLastCalledWith([ + expect.objectContaining({ + reject: expect.any(Function), + trigger: expect.any(Function), + }), + ]); + + await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe( + 'not yet', + ); + + const [request] = reqSpy.mock.calls[2][0]; + request.trigger(); + + await expect(req).resolves.toBe('hello'); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts new file mode 100644 index 0000000000..e50c18d271 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OAuthRequestApi, + PendingAuthRequest, + AuthRequester, + AuthRequesterOptions, + Observable, +} from '@backstage/core-plugin-api'; +import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; +import { BehaviorSubject } from '../../../lib/subjects'; + +/** + * The OAuthRequestManager is an implementation of the OAuthRequestApi. + * + * The purpose of this class and the API is to read a stream of incoming requests + * of OAuth access tokens from different providers with varying scope, and funnel + * them all together into a single request for each OAuth provider. + */ +export class OAuthRequestManager implements OAuthRequestApi { + private readonly subject = new BehaviorSubject([]); + private currentRequests: PendingAuthRequest[] = []; + private handlerCount = 0; + + createAuthRequester(options: AuthRequesterOptions): AuthRequester { + const handler = new OAuthPendingRequests(); + + const index = this.handlerCount; + this.handlerCount++; + + handler.pending().subscribe({ + next: scopeRequest => { + const newRequests = this.currentRequests.slice(); + const request = this.makeAuthRequest(scopeRequest, options); + if (!request) { + delete newRequests[index]; + } else { + newRequests[index] = request; + } + this.currentRequests = newRequests; + // Convert from sparse array to array of present items only + this.subject.next(newRequests.filter(Boolean)); + }, + }); + + return scopes => { + return handler.request(scopes); + }; + } + + // Converts the pending request and popup options into a popup request that we can forward to subscribers. + private makeAuthRequest( + request: PendingRequest, + options: AuthRequesterOptions, + ): PendingAuthRequest | undefined { + const { scopes } = request; + if (!scopes) { + return undefined; + } + + return { + provider: options.provider, + trigger: async () => { + const result = await options.onAuthRequest(scopes); + request.resolve(result); + }, + reject: () => { + const error = new Error('Login failed, rejected by user'); + error.name = 'RejectedError'; + request.reject(error); + }, + }; + } + + authRequest$(): Observable { + return this.subject; + } +} diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts new file mode 100644 index 0000000000..0fe9bfae0f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OAuthRequestManager } from './OAuthRequestManager'; diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..da5b2509db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CreateStorageApiOptions, WebStorage } from './WebStorage'; +import { StorageApi } from '@backstage/core-plugin-api'; + +describe('WebStorage Storage API', () => { + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + const createWebStorage = ( + args?: Partial, + ): StorageApi => { + return WebStorage.create({ + errorApi: mockErrorApi, + ...args, + }); + }; + it('should return undefined for values which are unset', async () => { + const storage = createWebStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = createWebStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createWebStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createWebStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = createWebStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); + + it('should call the error api when the json can not be parsed in local storage', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing', + }); + + localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}'); + + const value = rootStorage.get('key'); + + expect(value).toBe(undefined); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + expect(mockErrorApi.post).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error when parsing JSON config from storage for: key', + }), + ); + }); + + it('should return a singleton for the same namespace and same bucket', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing/Thing ', + }); + + expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test')); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..3f49b644f6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + StorageApi, + StorageValueChange, + ErrorApi, + Observable, +} from '@backstage/core-plugin-api'; +import ObservableImpl from 'zen-observable'; + +const buckets = new Map(); + +export type CreateStorageApiOptions = { + errorApi: ErrorApi; + namespace?: string; +}; + +export class WebStorage implements StorageApi { + constructor( + private readonly namespace: string, + private readonly errorApi: ErrorApi, + ) {} + + static create(options: CreateStorageApiOptions): WebStorage { + return new WebStorage(options.namespace ?? '', options.errorApi); + } + + get(key: string): T | undefined { + try { + const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); + return storage ?? undefined; + } catch (e) { + this.errorApi.post( + new Error(`Error when parsing JSON config from storage for: ${key}`), + ); + } + + return undefined; + } + + forBucket(name: string): WebStorage { + const bucketPath = `${this.namespace}/${name}`; + if (!buckets.has(bucketPath)) { + buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi)); + } + return buckets.get(bucketPath)!; + } + + async set(key: string, data: T): Promise { + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + localStorage.removeItem(this.getKeyName(key)); + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/plugins/catalog/src/components/CatalogFilter/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts similarity index 92% rename from plugins/catalog/src/components/CatalogFilter/index.ts rename to packages/core-app-api/src/apis/implementations/StorageApi/index.ts index 5103b16307..33b0094551 100644 --- a/plugins/catalog/src/components/CatalogFilter/index.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { CatalogFilter } from './CatalogFilter'; +export { WebStorage } from './WebStorage'; diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts new file mode 100644 index 0000000000..391a709935 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Auth0Icon from '@material-ui/icons/AcUnit'; +import { auth0AuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'auth0', + title: 'Auth0', + icon: Auth0Icon, +}; + +class Auth0Auth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', `email`, `profile`], + }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +export default Auth0Auth; diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts new file mode 100644 index 0000000000..dda27d0fa3 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as Auth0Auth } from './Auth0Auth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts new file mode 100644 index 0000000000..3ff29cc6ef --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubAuth from './GithubAuth'; + +describe('GithubAuth', () => { + it('should get access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); + const githubAuth = new GithubAuth({ getSession } as any); + + expect(await githubAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts new file mode 100644 index 0000000000..2ab0e5390f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { GithubSession } from './types'; +import { + OAuthApi, + SessionApi, + SessionState, + ProfileInfo, + BackstageIdentity, + AuthRequestOptions, + Observable, +} from '@backstage/core-plugin-api'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; +import { OAuthApiCreateOptions } from '../types'; + +export type GithubAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'github', + title: 'GitHub', + icon: GithubIcon, +}; + +class GithubAuth implements OAuthApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read:user'], + }: OAuthApiCreateOptions) { + const connector = new DefaultAuthConnector({ + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: GithubAuthResponse): GithubSession { + return { + ...res, + providerInfo: { + accessToken: res.providerInfo.accessToken, + scopes: GithubAuth.normalizeScope(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + defaultScopes: new Set(defaultScopes), + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + async getAccessToken(scope?: string, options?: AuthRequestOptions) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: GithubAuth.normalizeScope(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScope(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default GithubAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/index.ts b/packages/core-app-api/src/apis/implementations/auth/github/index.ts new file mode 100644 index 0000000000..9e1722f4a4 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/types.ts b/packages/core-app-api/src/apis/implementations/auth/github/types.ts new file mode 100644 index 0000000000..95beef3668 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/github/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +export type GithubSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts new file mode 100644 index 0000000000..6a592c7fbe --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import GitlabAuth from './GitlabAuth'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GitlabAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + [ + 'read_user api write_repository', + ['read_user', 'api', 'write_repository'], + ], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const gitlabAuth = GitlabAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + gitlabAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..fbb9509e7d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GitlabIcon from '@material-ui/icons/AcUnit'; +import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'gitlab', + title: 'GitLab', + icon: GitlabIcon, +}; + +class GitlabAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read_user'], + }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} +export default GitlabAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts new file mode 100644 index 0000000000..42d7210551 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts new file mode 100644 index 0000000000..9e8569c5cf --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GoogleAuth from './GoogleAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GoogleAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['email', [`${PREFIX}userinfo.email`]], + ['profile', [`${PREFIX}userinfo.profile`]], + ['openid', ['openid']], + ['userinfo.email', [`${PREFIX}userinfo.email`]], + [ + 'userinfo.profile email', + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [ + `profile ${PREFIX}userinfo.email`, + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]], + ['a', [`${PREFIX}a`]], + ['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]], + [`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]], + [`${PREFIX}a`, [`${PREFIX}a`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const googleAuth = GoogleAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts new file mode 100644 index 0000000000..1074a192ef --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GoogleIcon from '@material-ui/icons/AcUnit'; +import { googleAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'google', + title: 'Google', + icon: GoogleIcon, +}; + +const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; + +class GoogleAuth { + static create({ + discoveryApi, + oauthRequestApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + defaultScopes = [ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ], + }: OAuthApiCreateOptions): typeof googleAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + scopeTransform(scopes: string[]) { + return scopes.map(scope => { + if (scope === 'openid') { + return scope; + } + + if (scope === 'profile' || scope === 'email') { + return `${SCOPE_PREFIX}userinfo.${scope}`; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} +export default GoogleAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/google/index.ts b/packages/core-app-api/src/apis/implementations/auth/google/index.ts new file mode 100644 index 0000000000..2521d46046 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/google/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts new file mode 100644 index 0000000000..7ef78d19cf --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './github'; +export * from './gitlab'; +export * from './google'; +export * from './oauth2'; +export * from './okta'; +export * from './saml'; +export * from './auth0'; +export * from './microsoft'; +export * from './onelogin'; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts new file mode 100644 index 0000000000..490e74b532 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MicrosoftIcon from '@material-ui/icons/AcUnit'; +import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'microsoft', + title: 'Microsoft', + icon: MicrosoftIcon, +}; + +class MicrosoftAuth { + static create({ + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + discoveryApi, + defaultScopes = [ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ], + }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +export default MicrosoftAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts new file mode 100644 index 0000000000..77328d8557 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts new file mode 100644 index 0000000000..93db2c732d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OAuth2 from './OAuth2'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const scopeTransform = (x: string[]) => x; + +describe('OAuth2', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( + 'access-token', + ); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-scope', 'my-scope2']), + ); + }); + + it('should transform scopes', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + }); + + expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-prefix/my-scope']), + ); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`${PREFIX}not-enough`]), + }, + }) + .mockRejectedValue(error); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oauth2.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oauth2.getAccessToken('more'); + const promise2 = oauth2.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Grab the expired session first + await expect(oauth2.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oauth2.getIdToken(); + const promise2 = oauth2.getIdToken(); + const promise3 = oauth2.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts new file mode 100644 index 0000000000..c6c251a4f5 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OAuth2Icon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { + AuthRequestOptions, + BackstageIdentity, + OAuthApi, + OpenIdConnectApi, + ProfileInfo, + ProfileInfoApi, + SessionState, + SessionApi, + BackstageIdentityApi, + Observable, +} from '@backstage/core-plugin-api'; +import { OAuth2Session } from './types'; +import { OAuthApiCreateOptions } from '../types'; + +type Options = { + sessionManager: SessionManager; + scopeTransform: (scopes: string[]) => string[]; +}; + +type CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'oauth2', + title: 'Your Identity Provider', + icon: OAuth2Icon, +}; + +class OAuth2 + implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = [], + scopeTransform = x => x, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OAuth2Response): OAuth2Session { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set(defaultScopes), + sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, + sessionShouldRefresh: (session: OAuth2Session) => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OAuth2({ sessionManager, scopeTransform }); + } + + private readonly sessionManager: SessionManager; + private readonly scopeTransform: (scopes: string[]) => string[]; + + constructor(options: Options) { + this.sessionManager = options.sessionManager; + this.scopeTransform = options.scopeTransform; + } + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ) { + const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); + const session = await this.sessionManager.getSession({ + ...options, + scopes: normalizedScopes, + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + private static normalizeScopes( + scopeTransform: (scopes: string[]) => string[], + scopes?: string | string[], + ): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeTransform(scopeList)); + } +} + +export default OAuth2; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts new file mode 100644 index 0000000000..52bcb1df2c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OAuth2 } from './OAuth2'; +export * from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts new file mode 100644 index 0000000000..ade0f1a6c6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +export type OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..d6b1a07d9d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OktaAuth from './OktaAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; + +const PREFIX = 'okta.'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('OktaAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['openid', ['openid']], + ['profile email', ['profile', 'email']], + [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], + ['groups.read', [`${PREFIX}groups.read`]], + [ + `${PREFIX}groups.manage groups.read, openid`, + [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'], + ], + [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const auth = OktaAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + auth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..a86e3f272d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OktaIcon from '@material-ui/icons/AcUnit'; +import { oktaAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'okta', + title: 'Okta', + icon: OktaIcon, +}; + +const OKTA_OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const OKTA_SCOPE_PREFIX: string = 'okta.'; + +class OktaAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', 'email', 'profile', 'offline_access'], + }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + scopeTransform(scopes) { + return scopes.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OktaAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/index.ts b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..4cc774b26b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/okta/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts new file mode 100644 index 0000000000..2e8c7ac2e8 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OneLoginIcon from '@material-ui/icons/AcUnit'; +import { + oneloginAuthApiRef, + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'onelogin', + title: 'onelogin', + icon: OneLoginIcon, +}; + +const OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const SCOPE_PREFIX: string = 'onelogin.'; + +class OneLoginAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions): typeof oneloginAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OneLoginAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts new file mode 100644 index 0000000000..1d163207db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OneLoginAuth } from './OneLoginAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts new file mode 100644 index 0000000000..7f747d6977 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SamlIcon from '@material-ui/icons/AcUnit'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { + ProfileInfo, + BackstageIdentity, + SessionState, + AuthRequestOptions, + ProfileInfoApi, + BackstageIdentityApi, + SessionApi, + Observable, +} from '@backstage/core-plugin-api'; +import { SamlSession } from './types'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; +import { AuthApiCreateOptions } from '../types'; + +export type SamlAuthResponse = { + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'saml', + title: 'SAML', + icon: SamlIcon, +}; + +class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + }: AuthApiCreateOptions) { + const connector = new DirectAuthConnector({ + discoveryApi, + environment, + provider, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + }); + + return new SamlAuth(authSessionStore); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } +} + +export default SamlAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts new file mode 100644 index 0000000000..c2436ab435 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { default as SamlAuth } from './SamlAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts new file mode 100644 index 0000000000..b62826b2ea --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts new file mode 100644 index 0000000000..a752e68eba --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthProvider, + DiscoveryApi, + OAuthRequestApi, +} from '@backstage/core-plugin-api'; + +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { id: string }; +}; diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts new file mode 100644 index 0000000000..d0df2760ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains implementations for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. + +export * from './auth'; + +export * from './AlertApi'; +export * from './AppThemeApi'; +export * from './ConfigApi'; +export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; +export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/core-app-api/src/apis/index.ts b/packages/core-app-api/src/apis/index.ts new file mode 100644 index 0000000000..5652dadf79 --- /dev/null +++ b/packages/core-app-api/src/apis/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './system'; +export * from './implementations'; diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.test.ts b/packages/core-app-api/src/apis/system/ApiAggregator.test.ts new file mode 100644 index 0000000000..c38aa08fad --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiAggregator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; +import { ApiAggregator } from './ApiAggregator'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiAggregator', () => { + const apiARef = createApiRef({ id: 'a' }); + const apiBRef = createApiRef({ id: 'b' }); + + it('should forward implementations', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 5], + [apiBRef, 10], + ]), + ); + expect(agg.get(apiARef)).toBe(5); + expect(agg.get(apiBRef)).toBe(10); + }); + + it('should return the first implementation', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 1], + [apiARef, 2], + ]), + ); + expect(agg.get(apiARef)).toBe(2); + expect(agg.get(apiBRef)).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiAggregator.ts b/packages/core-app-api/src/apis/system/ApiAggregator.ts new file mode 100644 index 0000000000..1299e38da0 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiAggregator.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiHolder } from '@backstage/core-plugin-api'; + +/** + * An ApiHolder that queries multiple other holders from for + * an Api implementation, returning the first one encountered.. + */ +export class ApiAggregator implements ApiHolder { + private readonly holders: ApiHolder[]; + + constructor(...holders: ApiHolder[]) { + this.holders = holders; + } + + get(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..160019604e --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ id: 'c' }); +const cFactory = { api: cRef, deps: {}, factory: () => 'y' }; + +describe('ApiFactoryRegistry', () => { + it('should be empty when created', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.getAllApis()).toEqual(new Set()); + }); + + it('should register a factory', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should prioritize factories based on scope', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('default', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('default', aFactory1)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('static', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should register multiple factories without conflict', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.register('default', bFactory)).toBe(true); + expect(registry.register('app', cFactory)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.get(bRef)).toBe(bFactory); + expect(registry.get(cRef)).toBe(cFactory); + expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); + }); + + it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => { + const ref1 = createApiRef({ id: 'a' }); + const ref2 = createApiRef({ id: 'a' }); + + const factory1 = { api: ref1, deps: {}, factory: () => 3 }; + const factory2 = { api: ref2, deps: {}, factory: () => 3 }; + + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', factory1)).toBe(true); + expect(registry.register('default', factory2)).toBe(false); + expect(registry.get(ref1)).toEqual(factory1); + expect(registry.get(ref2)).toEqual(factory1); + expect(registry.getAllApis()).toEqual(new Set([ref1])); + + expect(registry.register('app', factory2)).toBe(true); + expect(registry.get(ref1)).toEqual(factory2); + expect(registry.get(ref2)).toEqual(factory2); + expect(Array.from(registry.getAllApis())[0]).toBe(ref2); + expect(Array.from(registry.getAllApis())[0]).not.toBe(ref1); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..fe17f5a600 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiFactoryHolder } from './types'; +import { + ApiRef, + ApiFactory, + AnyApiRef, + AnyApiFactory, +} from '@backstage/core-plugin-api'; + +type ApiFactoryScope = + | 'default' // Default factories registered by core and plugins + | 'app' // Factories registered in the app, overriding default ones + | 'static'; // APIs that can't be overridden, e.g. config + +enum ScopePriority { + default = 10, + app = 50, + static = 100, +} + +type FactoryTuple = { + priority: number; + factory: AnyApiFactory; +}; + +/** + * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables + * registration of API Factories with different scope. + * + * Each scope has an assigned priority, where factories registered with + * higher priority scopes override ones with lower priority. + */ +export class ApiFactoryRegistry implements ApiFactoryHolder { + private readonly factories = new Map(); + + /** + * Register a new API factory. Returns true if the factory was added + * to the registry. + * + * A factory will not be added to the registry if there is already + * an existing factory with the same or higher priority. + */ + register( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + const priority = ScopePriority[scope]; + const existing = this.factories.get(factory.api.id); + if (existing && existing.priority >= priority) { + return false; + } + + this.factories.set(factory.api.id, { priority, factory }); + return true; + } + + get( + api: ApiRef, + ): ApiFactory | undefined { + const tuple = this.factories.get(api.id); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + const refs = new Set(); + for (const { factory } of this.factories.values()) { + refs.add(factory.api); + } + return refs; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiProvider.test.tsx b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx new file mode 100644 index 0000000000..7d38ec3cf9 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx @@ -0,0 +1,217 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Context, useContext } from 'react'; +import { + useApi, + createApiRef, + withApis, + ApiHolder, + ApiRef, +} from '@backstage/core-plugin-api'; +import { ApiProvider } from './ApiProvider'; +import { ApiRegistry } from './ApiRegistry'; +import { render } from '@testing-library/react'; +import { withLogCollector } from '@backstage/test-utils-core'; +import { getGlobalSingleton } from '../../lib/globalObject'; +import { VersionedValue } from '../../lib/versionedValues'; + +describe('ApiProvider', () => { + type Api = () => string; + const apiRef = createApiRef({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumer = () => { + const api = useApi(apiRef); + return

hook message: {api()}

; + }; + + const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should provide nested access to apis', () => { + const aRef = createApiRef({ id: 'a' }); + const bRef = createApiRef({ id: 'b' }); + + const MyComponent = () => { + const a = useApi(aRef); + const b = useApi(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + + it('should ignore deps in prototype', () => { + // 100% coverage + happy typescript = hasOwnProperty + this atrocity + const xRef = createApiRef({ id: 'x' }); + + const proto = { x: xRef }; + const props = { getMessage: { enumerable: true, value: apiRef } }; + const obj = Object.create(proto, props) as { + getMessage: typeof apiRef; + x: typeof xRef; + }; + + const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should error if no provider is available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^No provider available for api-context context/); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No provider available for api-context context/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^No provider available for api-context context/); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No provider available for api-context context/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); + + it('should error if api is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); +}); + +describe('v1 consumer', () => { + const ApiContext = getGlobalSingleton< + Context> + >('api-context'); + + function useMockApiV1(apiRef: ApiRef): T { + const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + type Api = () => string; + const apiRef = createApiRef({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx new file mode 100644 index 0000000000..93d20b12fc --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + createContext, + useContext, + ReactNode, + PropsWithChildren, +} from 'react'; +import PropTypes from 'prop-types'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiAggregator } from './ApiAggregator'; +import { getOrCreateGlobalSingleton } from '../../lib/globalObject'; +import { + VersionedValue, + createVersionedValueMap, +} from '../../lib/versionedValues'; + +type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + +type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined; +const ApiContext = getOrCreateGlobalSingleton('api-context', () => + createContext(undefined), +); + +export const ApiProvider = ({ + apis, + children, +}: PropsWithChildren) => { + const parentHolder = useContext(ApiContext)?.atVersion(1); + const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + + return ( + + ); +}; + +ApiProvider.propTypes = { + apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired, + children: PropTypes.node, +}; diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.test.ts b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts new file mode 100644 index 0000000000..0931e2c103 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiRegistry.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiRegistry', () => { + const x1Ref = createApiRef({ id: 'x1' }); + const x1DuplicateRef = createApiRef({ id: 'x1' }); + const x2Ref = createApiRef({ id: 'x2' }); + + it('should be created', () => { + const registry = ApiRegistry.from([]); + expect(registry.get(x1Ref)).toBe(undefined); + }); + + it('should be created with APIs', () => { + const registry = ApiRegistry.from([ + [x1Ref, 3], + [x2Ref, 'y'], + ]); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be built', () => { + const registry = ApiRegistry.builder().build(); + expect(registry.get(x1Ref)).toBe(undefined); + expect(registry.get(x1DuplicateRef)).toBe(undefined); + }); + + it('should be built with APIs', () => { + const builder = ApiRegistry.builder(); + builder.add(x1Ref, 3); + builder.add(x2Ref, 'y'); + + const registry = builder.build(); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be created with API', () => { + const reg1 = ApiRegistry.with(x1Ref, 3); + const reg2 = reg1.with(x2Ref, 'y'); + const reg3 = reg2.with(x2Ref, 'z'); + const reg4 = reg3.with(x1Ref, 2); + const reg5 = reg3.with(x1DuplicateRef, 4); + + expect(reg1.get(x1Ref)).toBe(3); + expect(reg1.get(x2Ref)).toBe(undefined); + expect(reg2.get(x1Ref)).toBe(3); + expect(reg2.get(x2Ref)).toBe('y'); + expect(reg3.get(x1Ref)).toBe(3); + expect(reg3.get(x2Ref)).toBe('z'); + expect(reg4.get(x1Ref)).toBe(2); + expect(reg4.get(x2Ref)).toBe('z'); + expect(reg5.get(x1Ref)).toBe(4); + expect(reg5.get(x2Ref)).toBe('z'); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts new file mode 100644 index 0000000000..4571d52cce --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiHolder } from '@backstage/core-plugin-api'; + +type ApiImpl = readonly [ApiRef, T]; + +class ApiRegistryBuilder { + private apis: [string, unknown][] = []; + + add(api: ApiRef, impl: I): I { + this.apis.push([api.id, impl]); + return impl; + } + + build(): ApiRegistry { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return new ApiRegistry(new Map(this.apis)); + } +} + +export class ApiRegistry implements ApiHolder { + static builder() { + return new ApiRegistryBuilder(); + } + + static from(apis: ApiImpl[]) { + return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl]))); + } + + /** + * Creates a new ApiRegistry with a single API implementation. + * + * @param api ApiRef for the API to add + * @param impl Implementation of the API to add + */ + static with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([[api.id, impl]])); + } + + constructor(private readonly apis: Map) {} + + /** + * Returns a new ApiRegistry with the provided API added to the existing ones. + * + * @param api ApiRef for the API to add + * @param impl Implementation of the API to add + */ + with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); + } + + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} diff --git a/packages/core-app-api/src/apis/system/ApiResolver.test.ts b/packages/core-app-api/src/apis/system/ApiResolver.test.ts new file mode 100644 index 0000000000..af8dbc28c6 --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiResolver.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; +import { ApiResolver } from './ApiResolver'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a' }); +const otherARef = createApiRef({ id: 'a' }); +const bRef = createApiRef({ id: 'b' }); +const otherBRef = createApiRef({ id: 'b' }); +const cRef = createApiRef<{ x: string }>({ id: 'c' }); +const otherCRef = createApiRef<{ x: string }>({ id: 'c' }); + +function createRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: {}, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: {}, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { b: otherBRef }, + factory: ({ b }) => ({ x: 'x', b }), + }); + return registry; +} + +function createSelfCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { a: aRef }, + factory: () => 1, + }); + return registry; +} + +function createShortCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { a: aRef }, + factory: () => 'x', + }); + return registry; +} + +function createShortCyclicRegistryWithOther() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: otherBRef, + deps: { a: otherARef }, + factory: () => 'x', + }); + return registry; +} + +function createLongCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: otherBRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { c: cRef }, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { a: aRef }, + factory: () => ({ x: 'x' }), + }); + return registry; +} + +describe('ApiResolver', () => { + it('should be created empty', () => { + const resolver = new ApiResolver(new ApiFactoryRegistry()); + expect(resolver.get(aRef)).toBe(undefined); + expect(resolver.get(bRef)).toBe(undefined); + expect(resolver.get(otherBRef)).toBe(undefined); + expect(resolver.get(cRef)).toBe(undefined); + }); + + it('should instantiate APIs', () => { + const resolver = new ApiResolver(createRegistry()); + expect(resolver.get(aRef)).toBe(1); + expect(resolver.get(otherARef)).toBe(1); + expect(resolver.get(bRef)).toBe('b'); + expect(resolver.get(otherBRef)).toBe('b'); + expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); + expect(resolver.get(cRef)).toBe(resolver.get(otherCRef)); + }); + + it('should detect self dependency cycles', () => { + const resolver = new ApiResolver(createSelfCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should detect short dependency cycles', () => { + const resolver = new ApiResolver(createShortCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect short dependency cycles with other refs', () => { + const resolver = new ApiResolver(createShortCyclicRegistryWithOther()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherARef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect long dependency cycles', () => { + const resolver = new ApiResolver(createLongCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + // Second call for same ref should still throw + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(cRef)).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should validate a factory holder', () => { + expect(() => { + ApiResolver.validateFactories(createRegistry(), [ + aRef, + bRef, + otherBRef, + cRef, + ]); + }).not.toThrow(); + }); + + it('should find self cycles with validation', () => { + const self = createSelfCyclicRegistry(); + expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should find dependency cycles with validation', () => { + const short = createShortCyclicRegistry(); + expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + + const shortOther = createShortCyclicRegistryWithOther(); + expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherARef]), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherBRef]), + ).toThrow('Circular dependency of api factory for apiRef{b}'); + + const long = createLongCyclicRegistry(); + expect(() => + ApiResolver.validateFactories(long, long.getAllApis()), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should only call factory func once', () => { + const registry = new ApiFactoryRegistry(); + const factory = jest.fn().mockReturnValue(2); + registry.register('default', { + api: aRef, + deps: {}, + factory, + }); + + const resolver = new ApiResolver(registry); + expect(factory).toHaveBeenCalledTimes(0); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(otherARef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core-app-api/src/apis/system/ApiResolver.ts b/packages/core-app-api/src/apis/system/ApiResolver.ts new file mode 100644 index 0000000000..1fbce4871a --- /dev/null +++ b/packages/core-app-api/src/apis/system/ApiResolver.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiRef, + ApiHolder, + AnyApiRef, + TypesToApiRefs, +} from '@backstage/core-plugin-api'; +import { ApiFactoryHolder } from './types'; + +export class ApiResolver implements ApiHolder { + /** + * Validate factories by making sure that each of the apis can be created + * without hitting any circular dependencies. + */ + static validateFactories( + factories: ApiFactoryHolder, + apis: Iterable, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + while (heap.length) { + const apiRef = heap.shift()!; + const factory = factories.get(apiRef); + if (!factory) { + continue; + } + + for (const dep of Object.values(factory.deps)) { + if (dep.id === api.id) { + throw new Error(`Circular dependency of api factory for ${api}`); + } + if (!allDeps.has(dep)) { + allDeps.add(dep); + heap.push(dep); + } + } + } + } + } + + private readonly apis = new Map(); + + constructor(private readonly factories: ApiFactoryHolder) {} + + get(ref: ApiRef): T | undefined { + return this.load(ref); + } + + private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { + const impl = this.apis.get(ref.id); + if (impl) { + return impl as T; + } + + const factory = this.factories.get(ref); + if (!factory) { + return undefined; + } + + if (loading.includes(factory.api)) { + throw new Error(`Circular dependency of api factory for ${factory.api}`); + } + + const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); + const api = factory.factory(deps); + this.apis.set(ref.id, api); + return api as T; + } + + private loadDeps( + dependent: ApiRef, + apis: TypesToApiRefs, + loading: AnyApiRef[], + ): T { + const impls = {} as T; + + for (const key in apis) { + if (apis.hasOwnProperty(key)) { + const ref = apis[key]; + + const api = this.load(ref, loading); + if (!api) { + throw new Error( + `No API factory available for dependency ${ref} of dependent ${dependent}`, + ); + } + impls[key] = api; + } + } + + return impls; + } +} diff --git a/packages/core-app-api/src/apis/system/index.ts b/packages/core-app-api/src/apis/system/index.ts new file mode 100644 index 0000000000..dd7c081f62 --- /dev/null +++ b/packages/core-app-api/src/apis/system/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiProvider } from './ApiProvider'; +export { ApiRegistry } from './ApiRegistry'; +export { ApiResolver } from './ApiResolver'; +export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export * from './types'; diff --git a/packages/core-app-api/src/apis/system/types.ts b/packages/core-app-api/src/apis/system/types.ts new file mode 100644 index 0000000000..f1ba82c21e --- /dev/null +++ b/packages/core-app-api/src/apis/system/types.ts @@ -0,0 +1,23 @@ +/* + * 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 { ApiFactory, ApiRef } from '@backstage/core-plugin-api'; + +export type ApiFactoryHolder = { + get( + api: ApiRef, + ): ApiFactory | undefined; +}; diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx new file mode 100644 index 0000000000..0539c2146c --- /dev/null +++ b/packages/core-app-api/src/app/App.test.tsx @@ -0,0 +1,374 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocalStorageFeatureFlags } from '../apis'; +import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { render, screen } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; +import { BrowserRouter, Routes } from 'react-router-dom'; +import { defaultAppIcons } from './icons'; +import { + configApiRef, + createApiFactory, + featureFlagsApiRef, + createPlugin, + useRouteRef, + createExternalRouteRef, + createRouteRef, + createSubRouteRef, + createRoutableExtension, +} from '@backstage/core-plugin-api'; +import { generateBoundRoutes, PrivateAppImpl } from './App'; + +describe('generateBoundRoutes', () => { + it('runs happy path', () => { + const external = { myRoute: createExternalRouteRef({ id: '1' }) }; + const ref = createRouteRef({ id: 'ref-1' }); + const result = generateBoundRoutes(({ bind }) => { + bind(external, { myRoute: ref }); + }); + + expect(result.get(external.myRoute)).toBe(ref); + }); + + it('throws on unknown keys', () => { + const external = { myRoute: createExternalRouteRef({ id: '2' }) }; + const ref = createRouteRef({ id: 'ref-2' }); + expect(() => + generateBoundRoutes(({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }), + ).toThrow('Key someOtherRoute is not an existing external route'); + }); +}); + +describe('Integration Test', () => { + const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); + const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); + const subRouteRef1 = createSubRouteRef({ + id: 'sub1', + path: '/sub1', + parent: plugin1RouteRef, + }); + const subRouteRef2 = createSubRouteRef({ + id: 'sub2', + path: '/sub2/:x', + parent: plugin1RouteRef, + }); + const subRouteRef3 = createSubRouteRef({ + id: 'sub3', + path: '/sub3', + parent: plugin2RouteRef, + }); + const subRouteRef4 = createSubRouteRef({ + id: 'sub4', + path: '/sub4/:y', + parent: plugin2RouteRef, + }); + const extRouteRef1 = createExternalRouteRef({ id: 'extRouteRef1' }); + const extRouteRef2 = createExternalRouteRef({ + id: 'extRouteRef2', + params: ['x'], + }); + const extRouteRef3 = createExternalRouteRef({ + id: 'extRouteRef3', + optional: true, + }); + const extRouteRef4 = createExternalRouteRef({ + id: 'extRouteRef4', + optional: true, + params: ['x'], + }); + + const plugin1 = createPlugin({ + id: 'blob', + // Both absolute and sub route refs should be assignable to the plugin routes + routes: { + ref1: plugin1RouteRef, + ref2: plugin2RouteRef, + ref3: subRouteRef1, + }, + externalRoutes: { + extRouteRef1, + extRouteRef2, + extRouteRef3, + extRouteRef4, + }, + }); + + const plugin2 = createPlugin({ + id: 'plugin2', + }); + + const HiddenComponent = plugin2.provide( + createRoutableExtension({ + component: () => Promise.resolve((_: { path?: string }) =>
), + mountPoint: plugin2RouteRef, + }), + ); + + const ExposedComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + const link1 = useRouteRef(plugin1RouteRef); + const link2 = useRouteRef(plugin2RouteRef); + const subLink1 = useRouteRef(subRouteRef1); + const subLink2 = useRouteRef(subRouteRef2); + const subLink3 = useRouteRef(subRouteRef3); + const subLink4 = useRouteRef(subRouteRef4); + const extLink1 = useRouteRef(extRouteRef1); + const extLink2 = useRouteRef(extRouteRef2); + const extLink3 = useRouteRef(extRouteRef3); + const extLink4 = useRouteRef(extRouteRef4); + return ( +
+ link1: {link1()} + link2: {link2({ x: 'a' })} + subLink1: {subLink1()} + subLink2: {subLink2({ x: 'a' })} + subLink3: {subLink3({ x: 'b' })} + subLink4: {subLink4({ x: 'c', y: 'd' })} + extLink1: {extLink1()} + extLink2: {extLink2({ x: 'a' })} + extLink3: {extLink3?.() ?? ''} + extLink4: {extLink4?.({ x: 'b' }) ?? ''} +
+ ); + }), + mountPoint: plugin1RouteRef, + }), + ); + + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + ErrorBoundaryFallback: () => null, + }; + + it('runs happy paths', async () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + extRouteRef3: subRouteRef1, + extRouteRef4: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('link1: /')).toBeInTheDocument(); + expect(screen.getByText('link2: /foo/a')).toBeInTheDocument(); + expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument(); + expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument(); + expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument(); + expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument(); + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument(); + expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument(); + expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument(); + + // Plugins should be discovered through element tree + expect(app.getPlugins()).toEqual([plugin1, plugin2]); + }); + + it('runs happy paths without optional routes', async () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); + expect(screen.getByText('extLink3: ')).toBeInTheDocument(); + expect(screen.getByText('extLink4: ')).toBeInTheDocument(); + }); + + it('should wait for the config to load before calling feature flags', async () => { + const storageFlags = new LocalStorageFeatureFlags(); + jest.spyOn(storageFlags, 'registerFlag'); + + const apis = [ + createApiFactory({ + api: featureFlagsApiRef, + deps: { configApi: configApiRef }, + factory() { + return storageFlags; + }, + }), + ]; + + const app = new PrivateAppImpl({ + apis, + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [ + createPlugin({ + id: 'test', + register: p => p.featureFlags.register('name'), + }), + ], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(storageFlags.registerFlag).toHaveBeenCalledWith({ + name: 'name', + pluginId: 'test', + }); + }); + + it('should throw some error when the route has duplicate params', () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + const { error: errorLogs } = withLogCollector(() => { + expect(() => + render( + + + + + + + + + , + ), + ).toThrow( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ); + }); + expect(errorLogs).toEqual([ + expect.stringContaining( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ), + expect.stringContaining( + 'The above error occurred in the component', + ), + ]); + }); +}); diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx new file mode 100644 index 0000000000..a5ba5c4bca --- /dev/null +++ b/packages/core-app-api/src/app/App.tsx @@ -0,0 +1,451 @@ +/* + * 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 { Config } from '@backstage/config'; +import React, { + ComponentType, + PropsWithChildren, + ReactElement, + useEffect, + useMemo, + useState, +} from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { + ApiProvider, + ApiRegistry, + AppThemeSelector, + ConfigReader, + LocalStorageFeatureFlags, +} from '../apis'; +import { + useApi, + AnyApiFactory, + ApiHolder, + IconComponent, + AppTheme, + appThemeApiRef, + configApiRef, + AppThemeApi, + ConfigApi, + featureFlagsApiRef, + identityApiRef, + BackstagePlugin, + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { pluginCollector } from '../plugins/collectors'; +import { + routeObjectCollector, + routeParentCollector, + routePathCollector, +} from '../routing/collectors'; +import { RoutingProvider } from '../routing/RoutingProvider'; +import { validateRoutes } from '../routing/validation'; +import { AppContextProvider } from './AppContext'; +import { AppIdentity } from './AppIdentity'; +import { AppThemeProvider } from './AppThemeProvider'; +import { + AppComponents, + AppConfigLoader, + AppContext, + AppOptions, + AppRouteBinder, + BackstageApp, + SignInPageProps, + SignInResult, +} from './types'; + +export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) { + const result = new Map(); + + if (bindRoutes) { + const bind: AppRouteBinder = ( + externalRoutes, + targetRoutes: { [name: string]: RouteRef | SubRouteRef }, + ) => { + for (const [key, value] of Object.entries(targetRoutes)) { + const externalRoute = externalRoutes[key]; + if (!externalRoute) { + throw new Error(`Key ${key} is not an existing external route`); + } + if (!value && !externalRoute.optional) { + throw new Error( + `External route ${key} is required but was undefined`, + ); + } + if (value) { + result.set(externalRoute, value); + } + } + }; + bindRoutes({ bind }); + } + + return result; +} + +type FullAppOptions = { + apis: Iterable; + icons: NonNullable; + plugins: BackstagePlugin[]; + components: AppComponents; + themes: AppTheme[]; + configLoader?: AppConfigLoader; + defaultApis: Iterable; + bindRoutes?: AppOptions['bindRoutes']; +}; + +function useConfigLoader( + configLoader: AppConfigLoader | undefined, + components: AppComponents, + appThemeApi: AppThemeApi, +): { api: ConfigApi } | { node: JSX.Element } { + // Keeping this synchronous when a config loader isn't set simplifies tests a lot + const hasConfig = Boolean(configLoader); + const config = useAsync(configLoader || (() => Promise.resolve([]))); + + let noConfigNode = undefined; + + if (hasConfig && config.loading) { + const { Progress } = components; + noConfigNode = ; + } else if (config.error) { + const { BootErrorPage } = components; + noConfigNode = ; + } + + // Before the config is loaded we can't use a router, so exit early + if (noConfigNode) { + return { + node: ( + + {noConfigNode} + + ), + }; + } + + const configReader = ConfigReader.fromConfigs(config.value ?? []); + + return { api: configReader }; +} + +class AppContextImpl implements AppContext { + constructor(private readonly app: PrivateAppImpl) {} + + getPlugins(): BackstagePlugin[] { + return this.app.getPlugins(); + } + + getSystemIcon(key: string): IconComponent | undefined { + return this.app.getSystemIcon(key); + } + + getComponents(): AppComponents { + return this.app.getComponents(); + } +} + +export class PrivateAppImpl implements BackstageApp { + private apiHolder?: ApiHolder; + private configApi?: ConfigApi; + + private readonly apis: Iterable; + private readonly icons: NonNullable; + private readonly plugins: Set>; + private readonly components: AppComponents; + private readonly themes: AppTheme[]; + private readonly configLoader?: AppConfigLoader; + private readonly defaultApis: Iterable; + private readonly bindRoutes: AppOptions['bindRoutes']; + + private readonly identityApi = new AppIdentity(); + + constructor(options: FullAppOptions) { + this.apis = options.apis; + this.icons = options.icons; + this.plugins = new Set(options.plugins); + this.components = options.components; + this.themes = options.themes; + this.configLoader = options.configLoader; + this.defaultApis = options.defaultApis; + this.bindRoutes = options.bindRoutes; + } + + getPlugins(): BackstagePlugin[] { + return Array.from(this.plugins); + } + + getSystemIcon(key: string): IconComponent | undefined { + return this.icons[key]; + } + + getComponents(): AppComponents { + return this.components; + } + + getProvider(): ComponentType<{}> { + const appContext = new AppContextImpl(this); + + const Provider = ({ children }: PropsWithChildren<{}>) => { + const appThemeApi = useMemo( + () => AppThemeSelector.createWithStorage(this.themes), + [], + ); + + const { routePaths, routeParents, routeObjects } = useMemo(() => { + const result = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + collectedPlugins: pluginCollector, + }, + }); + + validateRoutes(result.routePaths, result.routeParents); + + // TODO(Rugvip): Restructure the public API so that we can get an immediate view of + // the app, rather than having to wait for the provider to render. + // For now we need to push the additional plugins we find during + // collection and then make sure we initialize things afterwards. + result.collectedPlugins.forEach(plugin => this.plugins.add(plugin)); + this.verifyPlugins(this.plugins); + + // Initialize APIs once all plugins are available + this.getApiHolder(); + + return result; + }, [children]); + + const loadedConfig = useConfigLoader( + this.configLoader, + this.components, + appThemeApi, + ); + + const hasConfigApi = 'api' in loadedConfig; + if (hasConfigApi) { + const { api } = loadedConfig as { api: Config }; + this.configApi = api; + } + + useEffect(() => { + if (hasConfigApi) { + const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; + + for (const plugin of this.plugins.values()) { + for (const output of plugin.output()) { + switch (output.type) { + case 'feature-flag': { + featureFlagsApi.registerFlag({ + name: output.name, + pluginId: plugin.getId(), + }); + break; + } + default: + break; + } + } + } + } + }, [hasConfigApi, loadedConfig]); + + if ('node' in loadedConfig) { + // Loading or error + return loadedConfig.node; + } + + return ( + + + + + {children} + + + + + ); + }; + return Provider; + } + + getRouter(): ComponentType<{}> { + const { + Router: RouterComponent, + SignInPage: SignInPageComponent, + } = this.components; + + // This wraps the sign-in page and waits for sign-in to be completed before rendering the app + const SignInPageWrapper = ({ + component: Component, + children, + }: { + component: ComponentType; + children: ReactElement; + }) => { + const [result, setResult] = useState(); + + if (result) { + this.identityApi.setSignInResult(result); + return children; + } + + return ; + }; + + const AppRouter = ({ children }: PropsWithChildren<{}>) => { + const configApi = useApi(configApiRef); + + let { pathname } = new URL( + configApi.getOptionalString('app.baseUrl') ?? '/', + 'http://dummy.dev', // baseUrl can be specified as just a path + ); + if (pathname.endsWith('/')) { + pathname = pathname.replace(/\/$/, ''); + } + + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + this.identityApi.setSignInResult({ + userId: 'guest', + profile: { + email: 'guest@example.com', + displayName: 'Guest', + }, + }); + + return ( + + + {children}} /> + + + ); + } + + return ( + + + + {children}} /> + + + + ); + }; + + return AppRouter; + } + + private getApiHolder(): ApiHolder { + if (this.apiHolder) { + return this.apiHolder; + } + + const registry = new ApiFactoryRegistry(); + + registry.register('static', { + api: appThemeApiRef, + deps: {}, + factory: () => AppThemeSelector.createWithStorage(this.themes), + }); + registry.register('static', { + api: configApiRef, + deps: {}, + factory: () => { + if (!this.configApi) { + throw new Error( + 'Tried to access config API before config was loaded', + ); + } + return this.configApi; + }, + }); + registry.register('static', { + api: identityApiRef, + deps: {}, + factory: () => this.identityApi, + }); + + // It's possible to replace the feature flag API, but since we must have at least + // one implementation we add it here directly instead of through the defaultApis. + registry.register('default', { + api: featureFlagsApiRef, + deps: {}, + factory: () => new LocalStorageFeatureFlags(), + }); + for (const factory of this.defaultApis) { + registry.register('default', factory); + } + + for (const plugin of this.plugins) { + for (const factory of plugin.getApis()) { + if (!registry.register('default', factory)) { + throw new Error( + `Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${ + factory.api + }`, + ); + } + } + } + + for (const factory of this.apis) { + if (!registry.register('app', factory)) { + throw new Error( + `Duplicate or forbidden API factory for ${factory.api} in app`, + ); + } + } + + ApiResolver.validateFactories(registry, registry.getAllApis()); + + this.apiHolder = new ApiResolver(registry); + + return this.apiHolder; + } + + private verifyPlugins(plugins: Iterable) { + const pluginIds = new Set(); + + for (const plugin of plugins) { + const id = plugin.getId(); + if (pluginIds.has(id)) { + throw new Error(`Duplicate plugin found '${id}'`); + } + pluginIds.add(id); + } + } +} diff --git a/packages/core-app-api/src/app/AppContext.test.tsx b/packages/core-app-api/src/app/AppContext.test.tsx new file mode 100644 index 0000000000..224499b148 --- /dev/null +++ b/packages/core-app-api/src/app/AppContext.test.tsx @@ -0,0 +1,64 @@ +/* + * 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, { useContext, Context } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; +import { AppContextProvider } from './AppContext'; + +describe('v1 consumer', () => { + const AppContext = getGlobalSingleton< + Context> + >('app-context'); + + function useMockAppV1(): AppContextV1 { + const impl = useContext(AppContext)?.atVersion(1); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + it('should provide an app context', () => { + const mockContext: AppContextV1 = { + getPlugins: jest.fn(), + getComponents: jest.fn(), + getSystemIcon: jest.fn(), + }; + + const renderedHook = renderHook(() => useMockAppV1(), { + wrapper: ({ children }) => ( + + ), + }); + const result = renderedHook.result.current; + + expect(mockContext.getPlugins).toHaveBeenCalledTimes(0); + result.getPlugins(); + expect(mockContext.getPlugins).toHaveBeenCalledTimes(1); + + expect(mockContext.getComponents).toHaveBeenCalledTimes(0); + result.getComponents(); + expect(mockContext.getComponents).toHaveBeenCalledTimes(1); + + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(0); + result.getSystemIcon('icon'); + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1); + expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon'); + }); +}); diff --git a/packages/core-app-api/src/app/AppContext.tsx b/packages/core-app-api/src/app/AppContext.tsx new file mode 100644 index 0000000000..9cdd4cdc13 --- /dev/null +++ b/packages/core-app-api/src/app/AppContext.tsx @@ -0,0 +1,41 @@ +/* + * 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, PropsWithChildren } from 'react'; +import { + VersionedValue, + createVersionedValueMap, +} from '../lib/versionedValues'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; + +type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined; +const AppContext = getOrCreateGlobalSingleton('app-context', () => + createContext(undefined), +); + +type Props = { + appContext: AppContextV1; +}; + +export const AppContextProvider = ({ + appContext, + children, +}: PropsWithChildren) => { + const versionedValue = createVersionedValueMap({ 1: appContext }); + + return ; +}; diff --git a/packages/core-app-api/src/app/AppIdentity.ts b/packages/core-app-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..b2b4a0bcdc --- /dev/null +++ b/packages/core-app-api/src/app/AppIdentity.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api'; +import { SignInResult } from './types'; + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentity implements IdentityApi { + private hasIdentity = false; + private userId?: string; + private profile?: ProfileInfo; + private idTokenFunc?: () => Promise; + private signOutFunc?: () => Promise; + + getUserId(): string { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId!; + } + + getProfile(): ProfileInfo { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi profile before app was loaded', + ); + } + return this.profile!; + } + + async getIdToken(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idTokenFunc?.(); + } + + async signOut(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi signOutFunc before app was loaded', + ); + } + await this.signOutFunc?.(); + location.reload(); + } + + // This is indirectly called by the sign-in page to continue into the app. + setSignInResult(result: SignInResult) { + if (this.hasIdentity) { + return; + } + if (!result.userId) { + throw new Error('Invalid sign-in result, userId not set'); + } + if (!result.profile) { + throw new Error('Invalid sign-in result, profile not set'); + } + this.hasIdentity = true; + this.userId = result.userId; + this.profile = result.profile; + this.idTokenFunc = result.getIdToken; + this.signOutFunc = result.signOut; + } +} diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx new file mode 100644 index 0000000000..d297b05ced --- /dev/null +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -0,0 +1,97 @@ +/* + * 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, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; +import { ThemeProvider, CssBaseline } from '@material-ui/core'; +import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api'; +import { useObservable } from 'react-use'; + +// This tries to find the most accurate match, but also falls back to less +// accurate results in order to avoid errors. +function resolveTheme( + themeId: string | undefined, + shouldPreferDark: boolean, + themes: AppTheme[], +) { + if (themeId !== undefined) { + const selectedTheme = themes.find(theme => theme.id === themeId); + if (selectedTheme) { + return selectedTheme; + } + } + + if (shouldPreferDark) { + const darkTheme = themes.find(theme => theme.variant === 'dark'); + if (darkTheme) { + return darkTheme; + } + } + + const lightTheme = themes.find(theme => theme.variant === 'light'); + if (lightTheme) { + return lightTheme; + } + + return themes[0]; +} + +const useShouldPreferDarkTheme = () => { + const mediaQuery = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ); + const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches); + + useEffect(() => { + const listener = (event: MediaQueryListEvent) => { + setPrefersDark(event.matches); + }; + mediaQuery.addListener(listener); + return () => { + mediaQuery.removeListener(listener); + }; + }, [mediaQuery]); + + return shouldPreferDark; +}; + +export function AppThemeProvider({ children }: PropsWithChildren<{}>) { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + // Browser feature detection won't change over time, so ignore lint rule + const shouldPreferDark = Boolean(window.matchMedia) + ? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks + : false; + + const appTheme = resolveTheme( + themeId, + shouldPreferDark, + appThemeApi.getInstalledThemes(), + ); + if (!appTheme) { + throw new Error('App has no themes'); + } + + return ( + + {children} + + ); +} diff --git a/packages/core-app-api/src/app/createApp.test.tsx b/packages/core-app-api/src/app/createApp.test.tsx new file mode 100644 index 0000000000..a800c11ea6 --- /dev/null +++ b/packages/core-app-api/src/app/createApp.test.tsx @@ -0,0 +1,117 @@ +/* + * 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 from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { defaultConfigLoader, OptionallyWrapInRouter } from './createApp'; + +(process as any).env = { NODE_ENV: 'test' }; +const anyEnv = process.env as any; +const anyWindow = window as any; + +describe('defaultConfigLoader', () => { + afterEach(() => { + delete anyEnv.APP_CONFIG; + delete anyWindow.__APP_CONFIG__; + }); + + it('loads static config', async () => { + anyEnv.APP_CONFIG = [ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]; + + const configs = await defaultConfigLoader(); + expect(configs).toEqual([ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]); + }); + + it('loads runtime config', async () => { + anyEnv.APP_CONFIG = [ + { data: { my: 'override-config' }, context: 'a' }, + { data: { my: 'config' }, context: 'b' }, + ]; + + const configs = await (defaultConfigLoader as any)( + '{"my":"runtime-config"}', + ); + expect(configs).toEqual([ + { data: { my: 'override-config' }, context: 'a' }, + { data: { my: 'config' }, context: 'b' }, + { data: { my: 'runtime-config' }, context: 'env' }, + ]); + }); + + it('fails to load invalid missing config', async () => { + await expect(defaultConfigLoader()).rejects.toThrow( + 'No static configuration provided', + ); + }); + + it('fails to load invalid static config', async () => { + anyEnv.APP_CONFIG = { my: 'invalid-config' }; + await expect(defaultConfigLoader()).rejects.toThrow( + 'Static configuration has invalid format', + ); + }); + + it('fails to load bad runtime config', async () => { + anyEnv.APP_CONFIG = [{ data: { my: 'config' }, context: 'a' }]; + + await expect((defaultConfigLoader as any)('}')).rejects.toThrow( + 'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0', + ); + }); + + it('loads config from window.__APP_CONFIG__', async () => { + anyEnv.APP_CONFIG = [ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]; + const windowConfig = { app: { configKey: 'config-value' } }; + anyWindow.__APP_CONFIG__ = windowConfig; + + const configs = await defaultConfigLoader(); + + expect(configs).toEqual([ + ...anyEnv.APP_CONFIG, + { context: 'window', data: windowConfig }, + ]); + }); +}); + +describe('OptionallyWrapInRouter', () => { + it('should wrap with router if not yet inside a router', async () => { + const { getByText } = render( + Test, + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); + + it('should not wrap with router if already inside a router', async () => { + const { getByText } = render( + + Test + , + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx new file mode 100644 index 0000000000..1670978791 --- /dev/null +++ b/packages/core-app-api/src/app/createApp.tsx @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfig, JsonObject } from '@backstage/config'; +import { Button } from '@material-ui/core'; +import { ErrorPage, ErrorPanel, Progress } from '@backstage/core-components'; +import { darkTheme, lightTheme } from '@backstage/theme'; +import DarkIcon from '@material-ui/icons/Brightness2'; +import LightIcon from '@material-ui/icons/WbSunny'; +import React, { PropsWithChildren } from 'react'; +import { + BrowserRouter, + MemoryRouter, + useInRouterContext, +} from 'react-router-dom'; +import { PrivateAppImpl } from './App'; +import { defaultApis } from './defaultApis'; +import { defaultAppIcons } from './icons'; +import { + AppConfigLoader, + AppOptions, + BootErrorPageProps, + ErrorBoundaryFallbackProps, +} from './types'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; + +/** + * The default config loader, which expects that config is available at compile-time + * in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as + * returned by the config loader. + * + * It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string, + * which can be rewritten at runtime to contain an additional JSON config object. + * If runtime config is present, it will be placed first in the config array, overriding + * other config values. + */ +export const defaultConfigLoader: AppConfigLoader = async ( + // This string may be replaced at runtime to provide additional config. + // It should be replaced by a JSON-serialized config object. + // It's a param so we can test it, but at runtime this will always fall back to default. + runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', +) => { + const appConfig = process.env.APP_CONFIG; + if (!appConfig) { + throw new Error('No static configuration provided'); + } + if (!Array.isArray(appConfig)) { + throw new Error('Static configuration has invalid format'); + } + const configs = (appConfig.slice() as unknown) as AppConfig[]; + + // Avoiding this string also being replaced at runtime + if ( + runtimeConfigJson !== + '__app_injected_runtime_config__'.toLocaleUpperCase('en-US') + ) { + try { + const data = JSON.parse(runtimeConfigJson) as JsonObject; + if (Array.isArray(data)) { + configs.push(...data); + } else { + configs.push({ data, context: 'env' }); + } + } catch (error) { + throw new Error(`Failed to load runtime configuration, ${error}`); + } + } + + const windowAppConfig = (window as any).__APP_CONFIG__; + if (windowAppConfig) { + configs.push({ + context: 'window', + data: windowAppConfig, + }); + } + return configs; +}; + +// createApp is defined in core, and not core-api, since we need access +// to the components inside core to provide defaults. +// The actual implementation of the app class still lives in core-api, +// as it needs to be used by dev- and test-utils. + +export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) { + if (useInRouterContext()) { + return <>{children}; + } + return {children}; +} + +/** + * Creates a new Backstage App. + */ +export function createApp(options?: AppOptions) { + const DefaultNotFoundPage = () => ( + + ); + const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => { + let message = ''; + if (step === 'load-config') { + message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; + } else if (step === 'load-chunk') { + message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`; + } + // TODO: figure out a nicer way to handle routing on the error page, when it can be done. + return ( + + + + ); + }; + const DefaultErrorBoundaryFallback = ({ + error, + resetError, + plugin, + }: ErrorBoundaryFallbackProps) => { + return ( + + + + ); + }; + + const apis = options?.apis ?? []; + const icons = { ...defaultAppIcons, ...options?.icons }; + const plugins = options?.plugins ?? []; + const components = { + NotFoundErrorPage: DefaultNotFoundPage, + BootErrorPage: DefaultBootErrorPage, + Progress: Progress, + Router: BrowserRouter, + ErrorBoundaryFallback: DefaultErrorBoundaryFallback, + ...options?.components, + }; + const themes = options?.themes ?? [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + icon: , + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: darkTheme, + icon: , + }, + ]; + const configLoader = options?.configLoader ?? defaultConfigLoader; + + return new PrivateAppImpl({ + apis, + icons, + plugins: plugins as BackstagePlugin[], + components, + themes, + configLoader, + defaultApis, + bindRoutes: options?.bindRoutes, + }); +} diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/core-app-api/src/app/defaultApis.ts new file mode 100644 index 0000000000..5d84e27f89 --- /dev/null +++ b/packages/core-app-api/src/app/defaultApis.ts @@ -0,0 +1,223 @@ +/* + * 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 { + AlertApiForwarder, + ErrorApiForwarder, + ErrorAlerter, + GoogleAuth, + GithubAuth, + OAuth2, + OktaAuth, + GitlabAuth, + Auth0Auth, + MicrosoftAuth, + OAuthRequestManager, + WebStorage, + UrlPatternDiscovery, + SamlAuth, + OneLoginAuth, +} from '../apis'; + +import { + createApiFactory, + alertApiRef, + errorApiRef, + discoveryApiRef, + oauthRequestApiRef, + googleAuthApiRef, + githubAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, + auth0AuthApiRef, + microsoftAuthApiRef, + storageApiRef, + configApiRef, + samlAuthApiRef, + oneloginAuthApiRef, + oidcAuthApiRef, +} from '@backstage/core-plugin-api'; + +import OAuth2Icon from '@material-ui/icons/AcUnit'; + +export const defaultApis = [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`, + ), + }), + createApiFactory(alertApiRef, new AlertApiForwarder()), + createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => + new ErrorAlerter(alertApi, new ErrorApiForwarder()), + }), + createApiFactory({ + api: storageApiRef, + deps: { errorApi: errorApiRef }, + factory: ({ errorApi }) => WebStorage.create({ errorApi }), + }), + createApiFactory(oauthRequestApiRef, new OAuthRequestManager()), + createApiFactory({ + api: googleAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: microsoftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: githubAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oktaAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: gitlabAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: auth0AuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + Auth0Auth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oauth2ApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: samlAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => + SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oneloginAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oidcAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: OAuth2Icon, + }, + environment: configApi.getOptionalString('auth.environment'), + }), + }), +]; diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/core-app-api/src/app/icons.tsx new file mode 100644 index 0000000000..9ec278ba7f --- /dev/null +++ b/packages/core-app-api/src/app/icons.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IconComponent } from '@backstage/core-plugin-api'; +import MuiMenuBookIcon from '@material-ui/icons/MenuBook'; +import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; +import MuiChatIcon from '@material-ui/icons/Chat'; +import MuiDashboardIcon from '@material-ui/icons/Dashboard'; +import MuiEmailIcon from '@material-ui/icons/Email'; +import MuiGitHubIcon from '@material-ui/icons/GitHub'; +import MuiHelpIcon from '@material-ui/icons/Help'; +import MuiPeopleIcon from '@material-ui/icons/People'; +import MuiPersonIcon from '@material-ui/icons/Person'; +import MuiWarningIcon from '@material-ui/icons/Warning'; +import MuiDocsIcon from '@material-ui/icons/Description'; + +type AppIconsKey = + | 'brokenImage' + | 'catalog' + | 'chat' + | 'dashboard' + | 'docs' + | 'email' + | 'github' + | 'group' + | 'help' + | 'user' + | 'warning'; + +export type AppIcons = { [key in AppIconsKey]: IconComponent }; + +export const defaultAppIcons: AppIcons = { + brokenImage: MuiBrokenImageIcon, + // To be confirmed: see https://github.com/backstage/backstage/issues/4970 + catalog: MuiMenuBookIcon, + chat: MuiChatIcon, + dashboard: MuiDashboardIcon, + docs: MuiDocsIcon, + email: MuiEmailIcon, + github: MuiGitHubIcon, + group: MuiPeopleIcon, + help: MuiHelpIcon, + user: MuiPersonIcon, + warning: MuiWarningIcon, +}; diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts new file mode 100644 index 0000000000..a7cdf22a43 --- /dev/null +++ b/packages/core-app-api/src/app/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createApp } from './createApp'; +export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts new file mode 100644 index 0000000000..cf5811beae --- /dev/null +++ b/packages/core-app-api/src/app/types.ts @@ -0,0 +1,267 @@ +/* + * 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 { ComponentType } from 'react'; +import { + AnyApiFactory, + AppTheme, + ProfileInfo, + IconComponent, + BackstagePlugin, + RouteRef, + SubRouteRef, + ExternalRouteRef, + PluginOutput, +} from '@backstage/core-plugin-api'; +import { AppConfig } from '@backstage/config'; +import { AppIcons } from './icons'; + +export type BootErrorPageProps = { + step: 'load-config' | 'load-chunk'; + error: Error; +}; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + + profile: ProfileInfo; + + /** + * Function used to retrieve an ID token for the signed in user. + */ + getIdToken?: () => Promise; + + /** + * Sign out handler that will be called if the user requests to sign out. + */ + signOut?: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + +export type ErrorBoundaryFallbackProps = { + plugin?: BackstagePlugin; + error: Error; + resetError: () => void; +}; + +export type AppComponents = { + NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; + Router: ComponentType<{}>; + ErrorBoundaryFallback: ComponentType; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; +}; + +/** + * A function that loads in the App config that will be accessible via the ConfigApi. + * + * If multiple config objects are returned in the array, values in the earlier configs + * will override later ones. + */ +export type AppConfigLoader = () => Promise; + +/** + * Extracts a union of the keys in a map whose value extends the given type + */ +type KeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Takes a map Map required values and makes all keys matching Keys optional + */ +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map +> = Partial> & Required>; + +/** + * Creates a map of target routes with matching parameters based on a map of external routes. + */ +type TargetRouteMap< + ExternalRoutes extends { [name: string]: ExternalRouteRef } +> = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef | SubRouteRef + : never; +}; + +export type AppRouteBinder = < + ExternalRoutes extends { [name: string]: ExternalRouteRef } +>( + externalRoutes: ExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => void; + +// Output from newer or older plugin API versions that might not be supported by +// this version of the app API, but we don't want to break at the type checking level. +// We only use this more permissive type for the `createApp` options, as we otherwise +// want to stick to using the type for the outputs that we know about in this version +// of the app api. +type UnknownPluginOutput = { + type: string; +}; +export type BackstagePluginWithAnyOutput = Omit< + BackstagePlugin, + 'output' +> & { + output(): (PluginOutput | UnknownPluginOutput)[]; +}; + +export type AppOptions = { + /** + * A collection of ApiFactories to register in the application to either + * add add new ones, or override factories provided by default or by plugins. + */ + apis?: Iterable; + + /** + * Supply icons to override the default ones. + */ + icons?: Partial & { [key in string]: IconComponent }; + + /** + * A list of all plugins to include in the app. + */ + plugins?: BackstagePluginWithAnyOutput[]; + + /** + * Supply components to the app to override the default ones. + */ + components?: Partial; + + /** + * Themes provided as a part of the app. By default two themes are included, one + * light variant of the default backstage theme, and one dark. + * + * This is the default config: + * + * ``` + * [{ + * id: 'light', + * title: 'Light Theme', + * variant: 'light', + * theme: lightTheme, + * icon: , + * }, { + * id: 'dark', + * title: 'Dark Theme', + * variant: 'dark', + * theme: darkTheme, + * icon: , + * }] + * ``` + */ + themes?: AppTheme[]; + + /** + * A function that loads in App configuration that will be accessible via + * the ConfigApi. + * + * Defaults to an empty config. + * + * TODO(Rugvip): Omitting this should instead default to loading in configuration + * that was packaged by the backstage-cli and default docker container boot script. + */ + configLoader?: AppConfigLoader; + + /** + * A function that is used to register associations between cross-plugin route + * references, enabling plugins to navigate between each other. + * + * The `bind` function that is passed in should be used to bind all external + * routes of all used plugins. + * + * ```ts + * bindRoutes({ bind }) { + * bind(docsPlugin.externalRoutes, { + * homePage: managePlugin.routes.managePage, + * }) + * bind(homePagePlugin.externalRoutes, { + * settingsPage: settingsPlugin.routes.settingsPage, + * }) + * } + * ``` + */ + bindRoutes?(context: { bind: AppRouteBinder }): void; +}; + +export type BackstageApp = { + /** + * Returns all plugins registered for the app. + */ + getPlugins(): BackstagePlugin[]; + + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: string): IconComponent | undefined; + + /** + * Provider component that should wrap the Router created with getRouter() + * and any other components that need to be within the app context. + */ + getProvider(): ComponentType<{}>; + + /** + * Router component that should wrap the App Routes create with getRoutes() + * and any other components that should only be available while signed in. + */ + getRouter(): ComponentType<{}>; +}; + +export type AppContext = { + /** + * Get a list of all plugins that are installed in the app. + */ + getPlugins(): BackstagePlugin[]; + + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: string): IconComponent | undefined; + + /** + * Get the components registered for various purposes in the app. + */ + getComponents(): AppComponents; +}; diff --git a/packages/core-app-api/src/extensions/componentData.test.tsx b/packages/core-app-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..417fe07414 --- /dev/null +++ b/packages/core-app-api/src/extensions/componentData.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 from 'react'; +import { attachComponentData, getComponentData } from './componentData'; + +describe('elementData', () => { + it('should attach a single piece of data', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should attach several distinct pieces of data', () => { + const data1 = { foo: 'bar' }; + const data2 = { test: 'value' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data1); + attachComponentData(Component, 'second', data2); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data1); + expect(getComponentData(element, 'second')).toBe(data2); + }); + + it('returns undefined for missing data', () => { + const data = { foo: 'bar' }; + const Component1 = () => null; + const Component2 = () => null; + attachComponentData(Component2, 'my-data', data); + + const element1 = ; + const element2 = ; + expect(getComponentData(element1, 'missing')).toBeUndefined(); + expect(getComponentData(element2, 'missing')).toBeUndefined(); + }); + + it('should throw when attempting to overwrite data', () => { + const data = { foo: 'bar' }; + const MyComponent = () => null; + attachComponentData(MyComponent, 'my-data', data); + expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow( + 'Attempted to attach duplicate data "my-data" to component "MyComponent"', + ); + }); + + describe('works across versions', () => { + function getDataSymbol() { + const Component = () => null; + attachComponentData(Component, 'my-data', {}); + const [symbol] = Object.getOwnPropertySymbols(Component); + return symbol; + } + + it('should should be able to get data from older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect((element as any).type[symbol].map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + (Component as any)[symbol] = { + map: new Map([['my-data', data]]), + }; + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should be able to get data from newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + const container = (global as any)[ + '__@backstage/component-data-store__' + ].get(element.type); + expect(container.map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + (global as any)['__@backstage/component-data-store__'].set(Component, { + map: new Map([['my-data', data]]), + }); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + }); +}); diff --git a/packages/core-app-api/src/extensions/componentData.tsx b/packages/core-app-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..fc8594039c --- /dev/null +++ b/packages/core-app-api/src/extensions/componentData.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType, ReactNode } from 'react'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x +const DATA_KEY = Symbol('backstage-component-data'); + +type ComponentWithData

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type DataContainer = { + map: Map; +}; + +type MaybeComponentNode = ReactNode & { + type?: ComponentType & { [DATA_KEY]?: DataContainer }; +}; + +// The store is bridged across versions using the global object +const store = getOrCreateGlobalSingleton( + 'component-data-store', + () => new WeakMap, DataContainer>(), +); + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + let container = store.get(component) || dataComponent[DATA_KEY]; + if (!container) { + container = { map: new Map() }; + store.set(component, container); + dataComponent[DATA_KEY] = container; + } + + if (container.map.has(type)) { + const name = component.displayName || component.name; + throw new Error( + `Attempted to attach duplicate data "${type}" to component "${name}"`, + ); + } + + container.map.set(type, data); +} + +export function getComponentData( + node: ReactNode, + type: string, +): T | undefined { + if (!node) { + return undefined; + } + + const component = (node as MaybeComponentNode).type; + if (!component) { + return undefined; + } + + const container = store.get(component) || component[DATA_KEY]; + if (!container) { + return undefined; + } + + return container.map.get(type) as T | undefined; +} diff --git a/packages/core-app-api/src/extensions/extensions.tsx b/packages/core-app-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..a9121c4d15 --- /dev/null +++ b/packages/core-app-api/src/extensions/extensions.tsx @@ -0,0 +1,54 @@ +/* + * 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 { Extension, RouteRef } from '@backstage/core-plugin-api'; + +type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + +const ERROR_MESSAGE = 'Import this from @backstage/core-plugin-api'; + +/** @deprecated Import from @backstage/core-plugin-api instead */ +export function createRoutableExtension< + T extends (props: any) => JSX.Element | null +>(_options: { + component: () => Promise; + mountPoint: RouteRef; +}): Extension { + throw new Error(ERROR_MESSAGE); +} + +/** @deprecated Import from @backstage/core-plugin-api instead */ +export function createComponentExtension< + T extends (props: any) => JSX.Element | null +>(_options: { component: ComponentLoader }): Extension { + throw new Error(ERROR_MESSAGE); +} + +/** @deprecated Import from @backstage/core-plugin-api instead */ +export function createReactExtension< + T extends (props: any) => JSX.Element | null +>(_options: { + component: ComponentLoader; + data?: Record; +}): Extension { + throw new Error(ERROR_MESSAGE); +} diff --git a/packages/core-app-api/src/extensions/index.ts b/packages/core-app-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/core-app-api/src/extensions/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { attachComponentData, getComponentData } from './componentData'; +export { + createReactExtension, + createRoutableExtension, + createComponentExtension, +} from './extensions'; diff --git a/packages/core-app-api/src/extensions/traversal.test.tsx b/packages/core-app-api/src/extensions/traversal.test.tsx new file mode 100644 index 0000000000..38571fdcc7 --- /dev/null +++ b/packages/core-app-api/src/extensions/traversal.test.tsx @@ -0,0 +1,104 @@ +/* + * 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, { Children, isValidElement } from 'react'; +import { + childDiscoverer, + createCollector, + traverseElementTree, +} from './traversal'; + +describe('discovery', () => { + it('should collect element names', () => { + const root = ( +

+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [childDiscoverer], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual([ + 'main', + 'div', + 'hr', + 'div', + 'h1', + 'p', + 'h2', + 'span', + ]); + }); + + it('should collect element names while skipping one level of children', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [ + el => + Children.toArray(el.props.children).flatMap(child => + isValidElement(child) ? child?.props?.children : [], + ), + ], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); + }); +}); diff --git a/packages/core-app-api/src/extensions/traversal.ts b/packages/core-app-api/src/extensions/traversal.ts new file mode 100644 index 0000000000..4431bbd24c --- /dev/null +++ b/packages/core-app-api/src/extensions/traversal.ts @@ -0,0 +1,129 @@ +/* + * 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 { isValidElement, ReactNode, ReactElement, Children } from 'react'; + +export type Discoverer = (element: ReactElement) => ReactNode; + +export type Collector = () => { + accumulator: Result; + visit( + accumulator: Result, + element: ReactElement, + parent: ReactElement | undefined, + context: Context, + ): Context; +}; + +/** + * A function that allows you to traverse a tree of React elements using + * varying methods to discover child nodes and collect data along the way. + */ +export function traverseElementTree(options: { + root: ReactNode; + discoverers: Discoverer[]; + collectors: { [name in keyof Results]: Collector }; +}): Results { + const collectors: { + [name in string]: ReturnType>; + } = {}; + + // Bootstrap all collectors, initializing the accumulators and providing the visitor function + for (const name in options.collectors) { + if (options.collectors.hasOwnProperty(name)) { + collectors[name] = options.collectors[name](); + } + } + + // Internal representation of an element in the tree that we're iterating over + type QueueItem = { + node: ReactNode; + parent: ReactElement | undefined; + contexts: { [name in string]: unknown }; + }; + + const queue = [ + { + node: Children.toArray(options.root), + parent: undefined, + contexts: {}, + } as QueueItem, + ]; + + while (queue.length !== 0) { + const { node, parent, contexts } = queue.shift()!; + + // While the parent and the element we pass on to collectors and discoverers + // have been validated and are known to be React elements, the child nodes + // emitted by the discoverers are not. + Children.forEach(node, element => { + if (!isValidElement(element)) { + return; + } + + const nextContexts: QueueItem['contexts'] = {}; + + // Collectors populate their result data using the current node, and compute + // context for the next iteration + for (const name in collectors) { + if (collectors.hasOwnProperty(name)) { + const collector = collectors[name]; + + nextContexts[name] = collector.visit( + collector.accumulator, + element, + parent, + contexts[name], + ); + } + } + + // Discoverers provide ways to continue the traversal from the current element + for (const discoverer of options.discoverers) { + const children = discoverer(element); + if (children) { + queue.push({ + node: children, + parent: element, + contexts: nextContexts, + }); + } + } + }); + } + + return Object.fromEntries( + Object.entries(collectors).map(([name, c]) => [name, c.accumulator]), + ) as Results; +} + +export function createCollector( + accumulatorFactory: () => Result, + visit: ReturnType>['visit'], +): Collector { + return () => ({ accumulator: accumulatorFactory(), visit }); +} + +export function childDiscoverer(element: ReactElement): ReactNode { + return element.props?.children; +} + +export function routeElementDiscoverer(element: ReactElement): ReactNode { + if (element.props?.path && element.props?.element) { + return element.props?.element; + } + return undefined; +} diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts new file mode 100644 index 0000000000..bec176fda8 --- /dev/null +++ b/packages/core-app-api/src/index.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as index from '.'; + +describe('index', () => { + it('exports the app api', () => { + expect(index).toEqual({ + // Public API + createApp: expect.any(Function), + ApiProvider: expect.any(Function), + // TODO(Rugvip): Figure out if we need these + ApiFactoryRegistry: expect.any(Function), + ApiResolver: expect.any(Function), + ApiRegistry: expect.any(Function), + + // Components + FlatRoutes: expect.any(Function), + + // Utility API Implementations + AlertApiForwarder: expect.any(Function), + AppThemeSelector: expect.any(Function), + Auth0Auth: expect.any(Function), + ConfigReader: expect.any(Function), + ErrorAlerter: expect.any(Function), + ErrorApiForwarder: expect.any(Function), + GithubAuth: expect.any(Function), + GitlabAuth: expect.any(Function), + GoogleAuth: expect.any(Function), + LocalStorageFeatureFlags: expect.any(Function), + MicrosoftAuth: expect.any(Function), + OAuth2: expect.any(Function), + OAuthRequestManager: expect.any(Function), + OktaAuth: expect.any(Function), + OneLoginAuth: expect.any(Function), + SamlAuth: expect.any(Function), + UrlPatternDiscovery: expect.any(Function), + WebStorage: expect.any(Function), + }); + }); +}); diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts new file mode 100644 index 0000000000..816e788a2b --- /dev/null +++ b/packages/core-app-api/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './apis'; +export * from './app'; +export * from './routing'; diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts new file mode 100644 index 0000000000..391b86952d --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ProviderIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from './DefaultAuthConnector'; +import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; +import * as loginPopup from '../loginPopup'; +import { UrlPatternDiscovery } from '../../apis'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +const defaultOptions = { + discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), + environment: 'production', + provider: { + id: 'my-provider', + title: 'My Provider', + icon: ProviderIcon, + }, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ + ...res, + scopes: new Set(res.scopes.split(' ')), + expiresAt: new Date(Date.now() + expiresInSeconds * 1000), + }), +}; + +describe('DefaultAuthConnector', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should refresh a session', async () => { + server.use( + rest.get('*', (_req, res, ctx) => + res( + ctx.json({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + scopes: 'a b c', + expiresInSeconds: '60', + }), + ), + ), + ); + + const helper = new DefaultAuthConnector(defaultOptions); + const session = await helper.refreshSession(); + expect(session.idToken).toBe('mock-id-token'); + expect(session.accessToken).toBe('mock-access-token'); + expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); + expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000); + expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000); + }); + + it('should handle failure to refresh session', async () => { + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'Error: Network NOPE')), + ), + ); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed, Error: Network NOPE', + ); + }); + + it('should handle failure response when refreshing session', async () => { + server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed, NOPE', + ); + }); + + it('should fail if popup was rejected', async () => { + const mockOauth = new MockOAuthApi(); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + const promise = helper.createSession({ scopes: new Set(['a', 'b']) }); + await mockOauth.rejectAll(); + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + }); + + it('should create a session', async () => { + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: 'a b', + expiresInSeconds: 3600, + }); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + + const sessionPromise = helper.createSession({ + scopes: new Set(['a', 'b']), + }); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: + 'http://my-host/api/auth/my-provider/start?scope=a%20b&env=production', + }); + + await expect(sessionPromise).resolves.toEqual({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: expect.any(Set), + expiresAt: expect.any(Date), + }); + }); + + it('should instantly show popup if option is set', async () => { + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue('my-session'); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: str => str, + }); + + const sessionPromise = helper.createSession({ + scopes: new Set(), + instantPopup: true, + }); + + await expect(sessionPromise).resolves.toBe('my-session'); + + expect(popupSpy).toBeCalledTimes(1); + }); + + it('should use join func to join scopes', async () => { + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ scopes: '' }); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + joinScopes: scopes => `-${[...scopes].join('')}-`, + oauthRequestApi: mockOauth, + }); + + helper.createSession({ scopes: new Set(['a', 'b']) }); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: + 'http://my-host/api/auth/my-provider/start?scope=-ab-&env=production', + }); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts new file mode 100644 index 0000000000..1498657281 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -0,0 +1,204 @@ +/* + * 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 { + AuthRequester, + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '@backstage/core-plugin-api'; +import { showLoginPopup } from '../loginPopup'; +import { AuthConnector, CreateSessionOptions } from './types'; + +type Options = { + /** + * DiscoveryApi instance used to locate the auth backend endpoint. + */ + discoveryApi: DiscoveryApi; + /** + * Environment hint passed on to auth backend, for example 'production' or 'development' + */ + environment: string; + /** + * Information about the auth provider to be shown to the user. + * The ID Must match the backend auth plugin configuration, for example 'google'. + */ + provider: AuthProvider & { id: string }; + /** + * API used to instantiate an auth requester. + */ + oauthRequestApi: OAuthRequestApi; + /** + * Function used to join together a set of scopes, defaults to joining with a space character. + */ + joinScopes?: (scopes: Set) => string; + /** + * Function used to transform an auth response into the session type. + */ + sessionTransform?(response: any): AuthSession | Promise; +}; + +function defaultJoinScopes(scopes: Set) { + return [...scopes].join(' '); +} + +/** + * DefaultAuthConnector is the default auth connector in Backstage. It talks to the + * backend auth plugin through the standardized API, and requests user permission + * via the OAuthRequestApi. + */ +export class DefaultAuthConnector + implements AuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string; + private readonly provider: AuthProvider & { id: string }; + private readonly joinScopesFunc: (scopes: Set) => string; + private readonly authRequester: AuthRequester; + private readonly sessionTransform: (response: any) => Promise; + + constructor(options: Options) { + const { + discoveryApi, + environment, + provider, + joinScopes = defaultJoinScopes, + oauthRequestApi, + sessionTransform = id => id, + } = options; + + this.authRequester = oauthRequestApi.createAuthRequester({ + provider, + onAuthRequest: scopes => this.showPopup(scopes), + }); + + this.discoveryApi = discoveryApi; + this.environment = environment; + this.provider = provider; + this.joinScopesFunc = joinScopes; + this.sessionTransform = sessionTransform; + } + + async createSession(options: CreateSessionOptions): Promise { + if (options.instantPopup) { + return this.showPopup(options.scopes); + } + return this.authRequester(options.scopes); + } + + async refreshSession(): Promise { + const res = await fetch( + await this.buildUrl('/refresh', { optional: true }), + { + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }, + ).catch(error => { + throw new Error(`Auth refresh request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error( + `Auth refresh request failed, ${res.statusText}`, + ); + error.status = res.status; + throw error; + } + + const authInfo = await res.json(); + + if (authInfo.error) { + const error = new Error(authInfo.error.message); + if (authInfo.error.name) { + error.name = authInfo.error.name; + } + throw error; + } + return await this.sessionTransform(authInfo); + } + + async removeSession(): Promise { + const res = await fetch(await this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } + + private async showPopup(scopes: Set): Promise { + const scope = this.joinScopesFunc(scopes); + const popupUrl = await this.buildUrl('/start', { scope }); + + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: new URL(popupUrl).origin, + width: 450, + height: 730, + }); + + return await this.sessionTransform(payload); + } + + private async buildUrl( + path: string, + query?: { [key: string]: string | boolean | undefined }, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + const queryString = this.buildQueryString({ + ...query, + env: this.environment, + }); + + return `${baseUrl}/${this.provider.id}${path}${queryString}`; + } + + private buildQueryString(query?: { + [key: string]: string | boolean | undefined; + }): string { + if (!query) { + return ''; + } + + const queryString = Object.entries(query) + .map(([key, value]) => { + if (typeof value === 'string') { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } else if (value) { + return encodeURIComponent(key); + } + return undefined; + }) + .filter(Boolean) + .join('&'); + + if (!queryString) { + return ''; + } + return `?${queryString}`; + } +} diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts new file mode 100644 index 0000000000..e5b3ca1219 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuthProvider, DiscoveryApi } from '@backstage/core-plugin-api'; +import { showLoginPopup } from '../loginPopup'; + +type Options = { + discoveryApi: DiscoveryApi; + environment?: string; + provider: AuthProvider & { id: string }; +}; +export class DirectAuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string | undefined; + private readonly provider: AuthProvider & { id: string }; + + constructor(options: Options) { + const { discoveryApi, environment, provider } = options; + + this.discoveryApi = discoveryApi; + this.environment = environment; + this.provider = provider; + } + + async createSession(): Promise { + const popupUrl = await this.buildUrl('/start'); + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: new URL(popupUrl).origin, + width: 450, + height: 730, + }); + + return { + ...payload, + id: payload.profile.email, + }; + } + + async refreshSession(): Promise {} + + async removeSession(): Promise { + const res = await fetch(await this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } + + private async buildUrl(path: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; + } +} diff --git a/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts new file mode 100644 index 0000000000..cd7986ffd0 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockAuthConnector, mockAccessToken } from './MockAuthConnector'; + +describe('MockAuthConnector', () => { + it('should return mock tokens', async () => { + const helper = new MockAuthConnector(); + + await expect(helper.createSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + + await expect(helper.refreshSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts new file mode 100644 index 0000000000..9134fd0773 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/MockAuthConnector.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthConnector } from './types'; + +export const mockAccessToken = 'mock-access-token'; + +type MockSession = { + accessToken: string; + expiresAt: Date; + scopes: string; +}; + +const defaultMockSession: MockSession = { + accessToken: mockAccessToken, + expiresAt: new Date(), + scopes: 'profile email', +}; + +export class MockAuthConnector implements AuthConnector { + constructor(private readonly mockSession: MockSession = defaultMockSession) {} + + async createSession() { + return this.mockSession; + } + + async refreshSession() { + return this.mockSession; + } + + async removeSession() {} +} diff --git a/packages/core-app-api/src/lib/AuthConnector/index.ts b/packages/core-app-api/src/lib/AuthConnector/index.ts new file mode 100644 index 0000000000..388619e2c1 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DefaultAuthConnector } from './DefaultAuthConnector'; +export { DirectAuthConnector } from './DirectAuthConnector'; +export * from './types'; diff --git a/packages/core-app-api/src/lib/AuthConnector/types.ts b/packages/core-app-api/src/lib/AuthConnector/types.ts new file mode 100644 index 0000000000..46175a265f --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type CreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + +/** + * An AuthConnector is responsible for realizing auth session actions + * by for example communicating with a backend or interacting with the user. + */ +export type AuthConnector = { + createSession(options: CreateSessionOptions): Promise; + refreshSession(): Promise; + removeSession(): Promise; +}; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts new file mode 100644 index 0000000000..5c960f7876 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { AuthSessionStore } from './AuthSessionStore'; +import { SessionManager } from './types'; + +const defaultOptions = { + storageKey: 'my-key', + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +class LocalStorage { + private store: Record = {}; + + getItem(key: string) { + return this.store[key] || null; + } + setItem(key: string, value: string) { + this.store[key] = value.toString(); + } + removeItem(key: string) { + delete this.store[key]; + } +} + +class MockManager implements SessionManager { + setSession = jest.fn(); + getSession = jest.fn(); + removeSession = jest.fn(); + sessionState$ = jest.fn(); +} + +describe('GheAuth AuthSessionStore', () => { + beforeEach(() => { + delete (window as any).localStorage; + (window as any).localStorage = new LocalStorage(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should load session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('a b c'); + expect(manager.getSession).not.toHaveBeenCalled(); + expect(manager.setSession).toHaveBeenCalledWith('a b c'); + }); + + it('should not use session without enough scope', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('a b c d'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe( + 'a b c d', + ); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not use expired session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ + manager, + ...defaultOptions, + sessionShouldRefresh: () => true, + }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not load missing session', async () => { + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + + expect(localStorage.getItem('my-key')).toBe('"123"'); + }); + + it('should ignore bad session values', async () => { + localStorage.setItem('my-key', 'derp'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should clear session', () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.removeSession(); + + expect(localStorage.getItem('my-key')).toBe(null); + expect(manager.removeSession).toHaveBeenCalled(); + }); + + it('should forward sessionState calls', () => { + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.sessionState$(); + expect(manager.sessionState$).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts new file mode 100644 index 0000000000..224036d283 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -0,0 +1,131 @@ +/* + * 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 { + SessionManager, + MutableSessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { SessionScopeHelper } from './common'; + +type Options = { + /** The connector used for acting on the auth session */ + manager: MutableSessionManager; + /** Storage key to use to store sessions */ + storageKey: string; + /** Used to get the scope of the session */ + sessionScopes?: SessionScopesFunc; + /** Used to check if the session needs to be refreshed, defaults to never refresh */ + sessionShouldRefresh?: SessionShouldRefreshFunc; +}; + +/** + * AuthSessionStore decorates another SessionManager with a functionality + * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. + */ +export class AuthSessionStore implements SessionManager { + private readonly manager: MutableSessionManager; + private readonly storageKey: string; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly helper: SessionScopeHelper; + + constructor(options: Options) { + const { + manager, + storageKey, + sessionScopes, + sessionShouldRefresh = () => false, + } = options; + + this.manager = manager; + this.storageKey = storageKey; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ + sessionScopes, + defaultScopes: new Set(), + }); + } + + async getSession(options: GetSessionOptions): Promise { + const { scopes } = options; + const session = this.loadSession(); + + if (this.helper.sessionExistsAndHasScope(session, scopes)) { + const shouldRefresh = this.sessionShouldRefreshFunc(session!); + + if (!shouldRefresh) { + this.manager.setSession(session!); + return session!; + } + } + + const newSession = await this.manager.getSession(options); + this.saveSession(newSession); + return newSession; + } + + async removeSession() { + localStorage.removeItem(this.storageKey); + await this.manager.removeSession(); + } + + sessionState$() { + return this.manager.sessionState$(); + } + + private loadSession(): T | undefined { + try { + const sessionJson = localStorage.getItem(this.storageKey); + if (sessionJson) { + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); + return session; + } + + return undefined; + } catch (error) { + localStorage.removeItem(this.storageKey); + return undefined; + } + } + + private saveSession(session: T | undefined) { + if (session === undefined) { + localStorage.removeItem(this.storageKey); + } else { + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); + } + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts new file mode 100644 index 0000000000..ce273847cd --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +import { SessionState } from '@backstage/core-plugin-api'; + +const defaultOptions = { + sessionScopes: (session: { scopes: Set }) => session.scopes, + sessionShouldRefresh: (session: { expired: boolean }) => session.expired, +}; + +describe('RefreshingAuthSessionManager', () => { + it('should save result from createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expired: false }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession, removeSession }, + ...defaultOptions, + } as any); + const stateSubscriber = jest.fn(); + manager.sessionState$().subscribe(stateSubscriber); + + await Promise.resolve(); // Wait a tick for observer to post a value + + expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(refreshSession).toBeCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + + expect(removeSession).toHaveBeenCalledTimes(0); + await manager.removeSession(); + expect(removeSession).toHaveBeenCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + [SessionState.SignedOut], + ]); + }); + + it('should ask consent only if scopes have changed', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: false, + }); + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['b']) }); + expect(createSession).toBeCalledTimes(2); + }); + + it('should check for session expiry', async () => { + const createSession = jest.fn(); + const refreshSession = jest + .fn() + .mockRejectedValueOnce(new Error('NOPE')) + .mockResolvedValue({ scopes: new Set(['a']) }); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: true, + }); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(2); + }); + + it('should handle user closed popup', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockRejectedValueOnce(new Error('some error')); + await expect( + manager.getSession({ scopes: new Set(['a']) }), + ).rejects.toThrow('some error'); + }); + + it('should not get optional session', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ optional: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(0); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should forward option to instantly show auth popup and not attempt refresh', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ instantPopup: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(1); + expect(createSession).toHaveBeenCalledWith({ + scopes: new Set(), + instantPopup: true, + }); + expect(refreshSession).toBeCalledTimes(0); + }); + + it('should remove session straight away', async () => { + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts new file mode 100644 index 0000000000..a098b384f9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -0,0 +1,148 @@ +/* + * 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 { + SessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper, hasScopes } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes: SessionScopesFunc; + /** Used to check if the session needs to be refreshed */ + sessionShouldRefresh: SessionShouldRefreshFunc; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * RefreshingAuthSessionManager manages an underlying session that has + * and expiration time and needs to be refreshed periodically. + */ +export class RefreshingAuthSessionManager implements SessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly sessionScopesFunc: SessionScopesFunc; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly stateTracker = new SessionStateTracker(); + + private refreshPromise?: Promise; + private currentSession: T | undefined; + + constructor(options: Options) { + const { + connector, + defaultScopes = new Set(), + sessionScopes, + sessionShouldRefresh, + } = options; + + this.connector = connector; + this.sessionScopesFunc = sessionScopes; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); + if (!shouldRefresh) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.collapsedSessionRefresh(); + const currentScopes = this.sessionScopesFunc(this.currentSession!); + const refreshedScopes = this.sessionScopesFunc(refreshedSession); + if (hasScopes(refreshedScopes, currentScopes)) { + this.currentSession = refreshedSession; + } + return refreshedSession; + } catch (error) { + if (options.optional) { + return undefined; + } + throw error; + } + } + + // The user may still have a valid refresh token in their cookies. Attempt to + // initiate a fresh session through the backend using that refresh token. + // + // We skip this check if an instant login popup is requested, as we need to + // stay in a synchronous call stack from the user interaction. The downside + // is that that the user will sometimes be requested to log in even if they + // already had an existing session. + if (!this.currentSession && !options.instantPopup) { + try { + const newSession = await this.collapsedSessionRefresh(); + this.currentSession = newSession; + // The session might not have the scopes requested so go back and check again + return this.getSession(options); + } catch { + // If the refresh attempt fails we assume we don't have a session, so continue to create one. + } + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } + + private async collapsedSessionRefresh(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.connector.refreshSession(); + + try { + const session = await this.refreshPromise; + this.stateTracker.setIsSignedIn(true); + return session; + } finally { + delete this.refreshPromise; + } + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..72ca789cc9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BehaviorSubject } from '../subjects'; +import { Observable, SessionState } from '@backstage/core-plugin-api'; + +export class SessionStateTracker { + private readonly subject = new BehaviorSubject( + SessionState.SignedOut, + ); + + private signedIn: boolean = false; + + setIsSignedIn(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.subject.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } + + sessionState$(): Observable { + return this.subject; + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts new file mode 100644 index 0000000000..6280750875 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { StaticAuthSessionManager } from './StaticAuthSessionManager'; + +const defaultOptions = { + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +describe('StaticAuthSessionManager', () => { + const baseConnector = { + refreshSession() { + throw new Error('refreshSession should not be called'); + }, + removeSession() { + throw new Error('removeSession should not be called'); + }, + }; + + it('should get session by creating session once', async () => { + const createSession = jest.fn().mockResolvedValue('my-session'); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it('should fail to get session if user rejects the request', async () => { + const createSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).rejects.toThrow('NOPE'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ optional: true })).resolves.toBe( + undefined, + ); + }); + + it('should only request auth once for same scopes', async () => { + const createSession = jest + .fn() + .mockImplementation(({ scopes }) => [...scopes].join(' ')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe( + 'a b', + ); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it('should remove session and reload', async () => { + const removeSession = jest.fn(); + const manager = new StaticAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts new file mode 100644 index 0000000000..e4f144b0a9 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -0,0 +1,83 @@ +/* + * 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 { MutableSessionManager, GetSessionOptions } from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes?: (session: T) => Set; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * StaticAuthSessionManager manages an underlying session that does not expire. + */ +export class StaticAuthSessionManager implements MutableSessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly stateTracker = new SessionStateTracker(); + + private currentSession: T | undefined; + + constructor(options: Options) { + const { connector, defaultScopes = new Set(), sessionScopes } = options; + + this.connector = connector; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + setSession(session: T | undefined): void { + this.currentSession = session; + this.stateTracker.setIsSignedIn(Boolean(session)); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + return this.currentSession; + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/common.ts b/packages/core-app-api/src/lib/AuthSessionManager/common.ts new file mode 100644 index 0000000000..ff2897535d --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/common.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SessionScopesFunc } from './types'; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +type ScopeHelperOptions = { + sessionScopes: SessionScopesFunc | undefined; + defaultScopes?: Set; +}; + +export class SessionScopeHelper { + constructor(private readonly options: ScopeHelperOptions) {} + + sessionExistsAndHasScope( + session: T | undefined, + scopes?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scopes) { + return true; + } + if (this.options.sessionScopes === undefined) { + return true; + } + const sessionScopes = this.options.sessionScopes(session); + return hasScopes(sessionScopes, scopes); + } + + getExtendedScope(session: T | undefined, scopes?: Set) { + const newScope = new Set(this.options.defaultScopes); + if (session && this.options.sessionScopes !== undefined) { + const sessionScopes = this.options.sessionScopes(session); + for (const scope of sessionScopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } +} diff --git a/packages/core-app-api/src/lib/AuthSessionManager/index.ts b/packages/core-app-api/src/lib/AuthSessionManager/index.ts new file mode 100644 index 0000000000..5f4dde8662 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; +export * from './types'; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/types.ts b/packages/core-app-api/src/lib/AuthSessionManager/types.ts new file mode 100644 index 0000000000..0bed895fba --- /dev/null +++ b/packages/core-app-api/src/lib/AuthSessionManager/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable, SessionState } from '@backstage/core-plugin-api'; + +export type GetSessionOptions = { + optional?: boolean; + instantPopup?: boolean; + scopes?: Set; +}; + +/** + * A sessions manager keeps track of the current session and makes sure that + * multiple simultaneous requests for sessions with different scope are handled + * in a correct way. + */ +export type SessionManager = { + getSession(options: GetSessionOptions): Promise; + + removeSession(): Promise; + + sessionState$(): Observable; +}; + +/** + * An extension of the session manager where the session can also be pushed from the manager. + */ +export interface MutableSessionManager extends SessionManager { + setSession(session: T | undefined): void; +} + +/** + * A function called to determine the scopes of a session. + */ +export type SessionScopesFunc = (session: T) => Set; + +/** + * A function called to determine whether it's time for a session to refresh. + * + * This should return true before the session expires, for example, if a session + * expires after 60 minutes, you could return true if the session is older than 45 minutes. + */ +export type SessionShouldRefreshFunc = (session: T) => boolean; diff --git a/packages/core-app-api/src/lib/globalObject.test.ts b/packages/core-app-api/src/lib/globalObject.test.ts new file mode 100644 index 0000000000..e72f027b46 --- /dev/null +++ b/packages/core-app-api/src/lib/globalObject.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, + setGlobalSingleton, +} from './globalObject'; + +const anyGlobal = global as any; + +describe('getGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + const myOtherThing = {}; + + anyGlobal['__@backstage/my-thing__'] = myThing; + expect(getGlobalSingleton('my-thing')).toBe(myThing); + expect(getGlobalSingleton('my-thing')).toBe(myThing); + anyGlobal['__@backstage/my-thing__'] = myOtherThing; + expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); + }); + + it('should throw if the value is not set', () => { + expect(() => getGlobalSingleton('my-thing')).toThrow( + 'Global my-thing is not set', + ); + }); +}); + +describe('getOrCreateGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + anyGlobal['__@backstage/my-thing__'] = myThing; + + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + }); + + it('should should create a new value', () => { + const myNewThing = {}; + + expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); + expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( + myNewThing, + ); + expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); + +describe('setGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should set a global value', () => { + setGlobalSingleton('my-thing', 'global value'); + + expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); + }); + + it('should throw if global value is set', () => { + anyGlobal['__@backstage/my-thing__'] = 'already defined'; + expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( + 'Global my-thing is already se', + ); + }); +}); diff --git a/packages/core-app-api/src/lib/globalObject.ts b/packages/core-app-api/src/lib/globalObject.ts new file mode 100644 index 0000000000..87be58499d --- /dev/null +++ b/packages/core-app-api/src/lib/globalObject.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +function getGlobalObject() { + if (typeof window !== 'undefined' && window.Math === Math) { + return window; + } + if (typeof self !== 'undefined' && self.Math === Math) { + return self; + } + // eslint-disable-next-line no-new-func + return Function('return this')(); +} + +const globalObject = getGlobalObject(); + +const makeKey = (id: string) => `__@backstage/${id}__`; + +/** + * Used to provide a global singleton value, failing if it is already set. + */ +export function setGlobalSingleton(id: string, value: unknown): void { + const key = makeKey(id); + if (key in globalObject) { + throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + } + globalObject[key] = value; +} + +/** + * Used to access a global singleton value, failing if it is not already set. + */ +export function getGlobalSingleton(id: string): T { + const key = makeKey(id); + if (!(key in globalObject)) { + throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + } + + return globalObject[key]; +} + +/** + * Serializes access to a global singleton value, with the first caller creating the value. + */ +export function getOrCreateGlobalSingleton( + id: string, + supplier: () => T, +): T { + const key = makeKey(id); + + let value = globalObject[key]; + if (value) { + return value; + } + + value = supplier(); + globalObject[key] = value; + return value; +} diff --git a/packages/core-app-api/src/lib/index.ts b/packages/core-app-api/src/lib/index.ts new file mode 100644 index 0000000000..10f213b50f --- /dev/null +++ b/packages/core-app-api/src/lib/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './subjects'; +export * from './loginPopup'; +export * from './AuthConnector'; +export * from './AuthSessionManager'; diff --git a/packages/core-app-api/src/lib/loginPopup.test.ts b/packages/core-app-api/src/lib/loginPopup.test.ts new file mode 100644 index 0000000000..98541c268e --- /dev/null +++ b/packages/core-app-api/src/lib/loginPopup.test.ts @@ -0,0 +1,218 @@ +/* + * 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 { showLoginPopup } from './loginPopup'; + +describe('showLoginPopup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should show an auth popup', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + name: 'test-popup', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + ); + expect(openSpy.mock.calls[0][1]).toBe('test-popup'); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + listener({} as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + // None of these should be accepted + listener({ source: popupMock } as MessageEvent); + listener({ origin: 'my-origin' } as MessageEvent); + listener({ data: { type: 'authorization_response' } } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: {}, + } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: { type: 'not-auth-result', response: {} }, + } as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + const myResponse = {}; + + // This should be accepted as a valid sessions response + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + response: myResponse, + }, + } as MessageEvent); + + await expect(payloadPromise).resolves.toBe(myResponse); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup returns error', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + error: { + message: 'NOPE', + name: 'NopeError', + }, + }, + } as MessageEvent); + + await expect(payloadPromise).rejects.toThrow({ + name: 'NopeError', + message: 'NOPE', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup is closed', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://localhost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, popup was closed', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should indicate if origin does not match', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://differenthost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, Incorrect app origin, expected http://differenthost', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/core-app-api/src/lib/loginPopup.ts b/packages/core-app-api/src/lib/loginPopup.ts new file mode 100644 index 0000000000..716c4f9651 --- /dev/null +++ b/packages/core-app-api/src/lib/loginPopup.ts @@ -0,0 +1,143 @@ +/* + * 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. + */ + +/** + * Options used to open a login popup. + */ +export type LoginPopupOptions = { + /** + * The URL that the auth popup should point to + */ + url: string; + + /** + * The name of the popup, as in second argument to window.open + */ + name: string; + + /** + * The origin of the final popup page that will post a message to this window. + */ + origin: string; + + /** + * The width of the popup in pixels, defaults to 500 + */ + width?: number; + + /** + * The height of the popup in pixels, defaults to 700 + */ + height?: number; +}; + +type AuthResult = + | { + type: 'authorization_response'; + response: unknown; + } + | { + type: 'authorization_response'; + error: { + name: string; + message: string; + }; + }; + +/** + * Show a popup pointing to a URL that starts an auth flow. Implementing the receiving + * end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00 + * + * The redirect handler of the flow should use postMessage to communicate back + * to the app window. The message posted to the app must match the AuthResult type. + * + * The returned promise resolves to the response of the message that was posted from the auth popup. + */ +export function showLoginPopup(options: LoginPopupOptions): Promise { + return new Promise((resolve, reject) => { + const width = options.width || 500; + const height = options.height || 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + options.url, + options.name, + `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, + ); + + let targetOrigin = ''; + + if (!popup || typeof popup.closed === 'undefined' || popup.closed) { + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); + return; + } + + const messageListener = (event: MessageEvent) => { + if (event.source !== popup) { + return; + } + if (event.origin !== options.origin) { + return; + } + const { data } = event; + + if (data.type === 'config_info') { + targetOrigin = data.targetOrigin; + return; + } + + if (data.type !== 'authorization_response') { + return; + } + const authResult = data as AuthResult; + + if ('error' in authResult) { + const error = new Error(authResult.error.message); + error.name = authResult.error.name; + // TODO: proper error type + // error.extra = authResult.error.extra; + reject(error); + } else { + resolve(authResult.response); + } + done(); + }; + + const intervalId = setInterval(() => { + if (popup.closed) { + const errMessage = `Login failed, ${ + targetOrigin && targetOrigin !== window.location.origin + ? `Incorrect app origin, expected ${targetOrigin}` + : 'popup was closed' + }`; + const error = new Error(errMessage); + error.name = 'PopupClosedError'; + reject(error); + done(); + } + }, 100); + + function done() { + window.removeEventListener('message', messageListener); + clearInterval(intervalId); + } + + window.addEventListener('message', messageListener); + }); +} diff --git a/packages/core-app-api/src/lib/subjects.test.ts b/packages/core-app-api/src/lib/subjects.test.ts new file mode 100644 index 0000000000..31ed26d97d --- /dev/null +++ b/packages/core-app-api/src/lib/subjects.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { PublishSubject, BehaviorSubject } from './subjects'; + +function observerSpy() { + return { + next: jest.fn(), + error: jest.fn(), + complete: jest.fn(), + }; +} + +describe('PublishSubject', () => { + it('should be completed', async () => { + const subj = new PublishSubject(); + subj.complete(); + + const spy = observerSpy(); + subj.subscribe(spy); + await 'a tick'; + + expect(spy.next).not.toHaveBeenCalled(); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('PublishSubject is closed'); + expect(() => subj.error(new Error())).toThrow('PublishSubject is closed'); + expect(() => subj.complete()).toThrow('PublishSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + const spy2 = observerSpy(); + subj.subscribe(spy1); + const sub = subj.subscribe(spy2); + + subj.next(42); + + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.next).toHaveBeenCalledWith(42); + expect(spy2.next).toHaveBeenCalledTimes(1); + expect(spy2.next).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(spy1.next).toHaveBeenCalledTimes(2); + expect(spy1.next).toHaveBeenCalledWith(1337); + expect(spy2.next).toHaveBeenCalledTimes(1); + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy2.error).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); + +describe('BehaviorSubject', () => { + it('should be completed', async () => { + const subj = new BehaviorSubject(0); + subj.complete(); + + const next = jest.fn(); + const complete = jest.fn(); + subj.subscribe({ next, complete }); + await 'a tick'; + + expect(complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('BehaviorSubject is closed'); + expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed'); + expect(() => subj.complete()).toThrow('BehaviorSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new BehaviorSubject(0); + + const obs1 = jest.fn(); + const obs2 = jest.fn(); + subj.subscribe(obs1); + const sub = subj.subscribe(obs2); + await 'a tick'; + + expect(obs1).toHaveBeenCalledTimes(1); + expect(obs1).toHaveBeenCalledWith(0); + expect(obs2).toHaveBeenCalledTimes(1); + expect(obs2).toHaveBeenCalledWith(0); + + subj.next(42); + + expect(obs1).toHaveBeenCalledTimes(2); + expect(obs1).toHaveBeenCalledWith(42); + expect(obs2).toHaveBeenCalledTimes(2); + expect(obs2).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(obs1).toHaveBeenCalledTimes(3); + expect(obs1).toHaveBeenCalledWith(1337); + expect(obs2).toHaveBeenCalledTimes(2); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new BehaviorSubject(0); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + await 'a tick'; + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy1.next).toHaveBeenCalledWith(0); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).toHaveBeenCalledWith(0); + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); diff --git a/packages/core-app-api/src/lib/subjects.ts b/packages/core-app-api/src/lib/subjects.ts new file mode 100644 index 0000000000..52870538f0 --- /dev/null +++ b/packages/core-app-api/src/lib/subjects.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from '@backstage/core-plugin-api'; +import ObservableImpl from 'zen-observable'; + +// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects. +// If we add a more complete Observables library they should be replaced. + +/** + * A basic implementation of ReactiveX publish subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * See http://reactivex.io/documentation/subject.html + */ +export class PublishSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private terminatingError?: Error; + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + [Symbol.observable]() { + return this; + } + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} + +/** + * A basic implementation of ReactiveX behavior subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * The BehaviorSubject will emit the most recently emitted value or error + * whenever a new observer subscribes to the subject. + * + * See http://reactivex.io/documentation/subject.html + */ +export class BehaviorSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private currentValue: T; + private terminatingError?: Error; + + constructor(value: T) { + this.currentValue = value; + } + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + subscriber.next(this.currentValue); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + [Symbol.observable]() { + return this; + } + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.currentValue = value; + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} diff --git a/packages/core-app-api/src/lib/versionedValues.test.ts b/packages/core-app-api/src/lib/versionedValues.test.ts new file mode 100644 index 0000000000..6ba7db4970 --- /dev/null +++ b/packages/core-app-api/src/lib/versionedValues.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createVersionedValueMap } from './versionedValues'; + +describe('createVersionedValueMap', () => { + it('should be empty', () => { + const map = createVersionedValueMap({}); + + // @ts-expect-error + expect(map.atVersion(1 as any)).toBe(undefined); + }); + + it('should access values by version', () => { + const map = createVersionedValueMap({ 1: 'v1', 2: 'v2' }); + + expect(map.atVersion(1)).toBe('v1'); + expect(map.atVersion(2)).toBe('v2'); + + // @ts-expect-error + expect(map.atVersion(0)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(NaN)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(Infinity)).toBe(undefined); + }); +}); diff --git a/packages/core-app-api/src/lib/versionedValues.ts b/packages/core-app-api/src/lib/versionedValues.ts new file mode 100644 index 0000000000..88e4e90084 --- /dev/null +++ b/packages/core-app-api/src/lib/versionedValues.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The versioned value interface is a container for a set of values that + * can be looked up by version. It is intended to be used as a container + * for values that can be versioned independently of package versions. + */ +export type VersionedValue = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +/** + * Creates a container for a map of versioned values that implements VersionedValue. + */ +export function createVersionedValueMap< + Versions extends { [version: number]: any } +>(versions: Versions): VersionedValue { + Object.freeze(versions); + return { + atVersion(version) { + return versions[version]; + }, + }; +} diff --git a/packages/core-app-api/src/plugins/collectors.test.tsx b/packages/core-app-api/src/plugins/collectors.test.tsx new file mode 100644 index 0000000000..46c19ec7bf --- /dev/null +++ b/packages/core-app-api/src/plugins/collectors.test.tsx @@ -0,0 +1,106 @@ +/* + * 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, { PropsWithChildren } from 'react'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, + createComponentExtension, +} from '@backstage/core-plugin-api'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { pluginCollector } from './collectors'; + +const mockConfig = () => ({ id: 'foo' }); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const pluginA = createPlugin({ id: 'my-plugin-a' }); +const pluginB = createPlugin({ id: 'my-plugin-b' }); +const pluginC = createPlugin({ id: 'my-plugin-c' }); + +const ref1 = createRouteRef(mockConfig()); +const ref2 = createRouteRef(mockConfig()); + +const Extension1 = pluginA.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = pluginB.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = pluginA.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension4 = pluginB.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension5 = pluginC.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); + +describe('collection', () => { + it('should collect the plugins', () => { + const root = ( + + + +
+ +
+
+ {[]} + Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { plugins } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + plugins: pluginCollector, + }, + }); + + expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); + }); +}); diff --git a/packages/core-app-api/src/plugins/collectors.ts b/packages/core-app-api/src/plugins/collectors.ts new file mode 100644 index 0000000000..8e5b623164 --- /dev/null +++ b/packages/core-app-api/src/plugins/collectors.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +export const pluginCollector = createCollector( + () => new Set>(), + (acc, node) => { + const plugin = getComponentData>( + node, + 'core.plugin', + ); + if (plugin) { + acc.add(plugin); + } + }, +); diff --git a/packages/core-app-api/src/plugins/index.ts b/packages/core-app-api/src/plugins/index.ts new file mode 100644 index 0000000000..95794a7487 --- /dev/null +++ b/packages/core-app-api/src/plugins/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { pluginCollector } from './collectors'; diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx new file mode 100644 index 0000000000..a9b83d1016 --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -0,0 +1,111 @@ +/* + * 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, RenderResult } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; +import { AppContext } from '../app'; +import { AppContextProvider } from '../app/AppContext'; +import { FlatRoutes } from './FlatRoutes'; + +function makeRouteRenderer(node: ReactNode) { + let rendered: RenderResult | undefined = undefined; + return (path: string) => { + const content = ( + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown) as AppContext + } + > + + + ); + if (rendered) { + rendered.unmount(); + rendered.rerender(content); + } else { + rendered = render(content); + } + return rendered; + }; +} + +describe('FlatRoutes', () => { + it('renders some routes', () => { + const renderRoute = makeRouteRenderer( + + a} /> + b} /> + , + ); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); + expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); + expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + }); + + it('is not sensitive to ordering and overlapping routes', () => { + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + a-1} /> + a} /> + a-2} /> + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); + renderRoute('').unmount(); + + // This uses regular Routes from react-router, not that a-2 renders a, which is the behavior we're working around + const renderBadRoute = makeRouteRenderer({routes}); + expect(renderBadRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderBadRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderBadRoute('/a-2').getByText('a')).toBeInTheDocument(); + }); + + it('renders children straight as outlets', () => { + const MyPage = () => { + return <>Outlet: {useOutlet()}; + }; + + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + }> + a + + }> + a-b + + }> + b + + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); + expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx new file mode 100644 index 0000000000..54ef092965 --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -0,0 +1,87 @@ +/* + * 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, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { useApp } from '@backstage/core-plugin-api'; + +type RouteObject = { + path: string; + element: JSX.Element; + children?: RouteObject[]; +}; + +// Similar to the same function from react-router, this collects routes from the +// children, but only the first level of routes +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + const { children } = child.props; + + if (child.type === Fragment) { + return createRoutesFromChildren(children); + } + + let path = child.props.path as string | undefined; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: children && [ + { + path: '/*', + element: children, + }, + ], + }, + ]; + }); +} + +type FlatRoutesProps = { + children: ReactNode; +}; + +export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + const routes = createRoutesFromChildren(props.children) + // Routes are sorted to work around a bug where prefixes are unexpectedly matched + .sort((a, b) => b.path.localeCompare(a.path)) + // We make sure all routes have '/*' appended, except '/' + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }); + + // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop + routes.push({ + element: , + path: '/*', + }); + + return useRoutes(routes); +}; diff --git a/packages/core-app-api/src/routing/RouteResolver.test.ts b/packages/core-app-api/src/routing/RouteResolver.test.ts new file mode 100644 index 0000000000..f75617e7a5 --- /dev/null +++ b/packages/core-app-api/src/routing/RouteResolver.test.ts @@ -0,0 +1,297 @@ +/* + * 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 { + createRouteRef, + createSubRouteRef, + createExternalRouteRef, + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; +import { RouteResolver } from './RouteResolver'; +import { MATCH_ALL_ROUTE } from './collectors'; + +const element = () => null; +const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] }; + +const ref1 = createRouteRef({ id: 'rr1' }); +const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); +const ref3 = createRouteRef({ id: 'rr3', params: ['y'] }); +const subRef1 = createSubRouteRef({ + id: 'srr1', + parent: ref1, + path: '/foo', +}); +const subRef2 = createSubRouteRef({ + id: 'srr2', + parent: ref1, + path: '/foo/:a', +}); +const subRef3 = createSubRouteRef({ + id: 'srr3', + parent: ref2, + path: '/bar', +}); +const subRef4 = createSubRouteRef({ + id: 'srr4', + parent: ref2, + path: '/bar/:a', +}); +const externalRef1 = createExternalRouteRef({ id: 'err1' }); +const externalRef2 = createExternalRouteRef({ + id: 'err2', + optional: true, +}); +const externalRef3 = createExternalRouteRef({ id: 'err3', params: ['x'] }); +const externalRef4 = createExternalRouteRef({ + id: 'err4', + optional: true, + params: ['x'], +}); + +describe('RouteResolver', () => { + it('should not resolve anything with an empty resolver', () => { + const r = new RouteResolver(new Map(), new Map(), [], new Map()); + + expect(r.resolve(ref1, '/')?.()).toBe(undefined); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe(undefined); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route', () => { + const r = new RouteResolver( + new Map([[ref1, '/my-route']]), + new Map(), + [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], + new Map(), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route with a param and with a parent', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + ]), + new Map([[ref2, ref1]]), + [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x'); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe( + '/my-route/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe( + '/my-route/my-parent/4x/bar/4a', + ); + expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route'); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe( + '/my-route/my-parent/5x', + ); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe( + '/my-route/my-parent/6x/bar', + ); + }); + + it('should resolve the most specific match', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/deep'], + [ref2, '/root/:x'], + [ref3, '/sub/:y'], + ]), + new Map([ + [ref3, ref2], + [ref1, ref3], + ]), + [ + { + routeRefs: new Set([ref2]), + path: '/root/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref3]), + path: '/sub/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref1]), + path: '/deep', + ...rest, + }, + ], + }, + ], + }, + ], + new Map(), + ); + + expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x'); + expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y'); + + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(() => r.resolve(ref1, '/root/x')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep'); + // Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here + expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe( + '/root/x/sub/y/deep', + ); + }); + + it('should resolve an absolute route with multiple parents', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + [ref3, '/my-grandparent/:y'], + ]), + new Map([ + [ref1, ref2], + [ref2, ref3], + ]), + [ + { + routeRefs: new Set([ref3]), + path: '/my-grandparent/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + const l = '/my-grandparent/my-y/my-parent/my-x'; + expect(r.resolve(ref1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo', + ); + expect(() => r.resolve(subRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a', + ); + expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe( + '/my-grandparent/my-y/my-parent/4x/bar/4a', + ); + expect( + r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }), + ).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a'); + expect(r.resolve(externalRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(externalRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef2, l)?.()).toBe(undefined); + expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe( + '/my-grandparent/my-y/my-parent/5x', + ); + expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe( + '/my-grandparent/my-y/my-parent/6x/bar', + ); + expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + }); +}); diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts new file mode 100644 index 0000000000..38158f750e --- /dev/null +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -0,0 +1,225 @@ +/* + * 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 { generatePath, matchRoutes } from 'react-router-dom'; +import { + AnyRouteRef, + BackstageRouteObject, + AnyParams, + RouteFunc, + routeRefType, + isRouteRef, + isSubRouteRef, + isExternalRouteRef, +} from './types'; +import { + RouteRef, + ExternalRouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; + +// Joins a list of paths together, avoiding trailing and duplicate slashes +function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Resolves the absolute route ref that our target route ref is pointing pointing to, as well + * as the relative target path. + * + * Returns an undefined target ref if one could not be fully resolved. + */ +function resolveTargetRef( + anyRouteRef: AnyRouteRef, + routePaths: Map, + routeBindings: Map, +): readonly [RouteRef | undefined, string] { + // First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append. + // For sub routes it will be the parent path, while for external routes it will be the bound route. + let targetRef: RouteRef; + let subRoutePath = ''; + if (isRouteRef(anyRouteRef)) { + targetRef = anyRouteRef; + } else if (isSubRouteRef(anyRouteRef)) { + targetRef = anyRouteRef.parent; + subRoutePath = anyRouteRef.path; + } else if (isExternalRouteRef(anyRouteRef)) { + const resolvedRoute = routeBindings.get(anyRouteRef); + if (!resolvedRoute) { + return [undefined, '']; + } + if (isRouteRef(resolvedRoute)) { + targetRef = resolvedRoute; + } else if (isSubRouteRef(resolvedRoute)) { + targetRef = resolvedRoute.parent; + subRoutePath = resolvedRoute.path; + } else { + throw new Error( + `ExternalRouteRef was bound to invalid target, ${resolvedRoute}`, + ); + } + } else if (anyRouteRef[routeRefType]) { + throw new Error( + `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, + ); + } else { + throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`); + } + + // Bail if no absolute path could be resolved + if (!targetRef) { + return [undefined, '']; + } + + // Find the path that our target route is bound to + const resolvedPath = routePaths.get(targetRef); + if (!resolvedPath) { + return [undefined, '']; + } + + // SubRouteRefs join the path from the parent route with its own path + const targetPath = joinPaths(resolvedPath, subRoutePath); + return [targetRef, targetPath]; +} + +/** + * Resolves the complete base path for navigating to the target RouteRef. + */ +function resolveBasePath( + targetRef: RouteRef, + sourceLocation: Parameters[1], + routePaths: Map, + routeParents: Map, + routeObjects: BackstageRouteObject[], +) { + // While traversing the app element tree we build up the routeObjects structure + // used here. It is the same kind of structure that react-router creates, with the + // addition that associated route refs are stored throughout the tree. This lets + // us look up all route refs that can be reached from our source location. + // Because of the similar route object structure, we can use `matchRoutes` from + // react-router to do the lookup of our current location. + const match = matchRoutes(routeObjects, sourceLocation) ?? []; + + // While we search for a common routing root between our current location and + // the target route, we build a list of all route refs we find that we need + // to traverse to reach the target. + const refDiffList = Array(); + + let matchIndex = -1; + for ( + let targetSearchRef: RouteRef | undefined = targetRef; + targetSearchRef; + targetSearchRef = routeParents.get(targetSearchRef) + ) { + // The match contains a list of all ancestral route refs present at our current location + // Starting at the desired target ref and traversing back through its parents, we search + // for a target ref that is present in the match for our current location. When a match + // is found it means we have found a common base to resolve the route from. + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!), + ); + if (matchIndex !== -1) { + break; + } + + // Every time we move a step up in the ancestry of the target ref, we add the current ref + // to the diff list, which ends up being the list of route refs to traverse form the common base + // in order to reach our target. + refDiffList.unshift(targetSearchRef); + } + + // 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 (refDiffList.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 caller would have no way of knowing + // what parameters those are. + const diffPath = joinPaths( + ...refDiffList.slice(0, -1).map(ref => { + const path = routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${targetRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }), + ); + + return parentPath + diffPath; +} + +export class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined { + // First figure out what our target absolute ref is, as well as our target path. + const [targetRef, targetPath] = resolveTargetRef( + anyRouteRef, + this.routePaths, + this.routeBindings, + ); + if (!targetRef) { + return undefined; + } + + // Next we figure out the base path, which is the combination of the common parent path + // between our current location and our target location, as well as the additional path + // that is the difference between the parent path and the base of our target location. + const basePath = resolveBasePath( + targetRef, + sourceLocation, + this.routePaths, + this.routeParents, + this.routeObjects, + ); + + const routeFunc: RouteFunc = (...[params]) => { + return basePath + generatePath(targetPath, params); + }; + return routeFunc; + } +} diff --git a/packages/core-app-api/src/routing/RoutingProvider.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.test.tsx new file mode 100644 index 0000000000..b75e68e7f0 --- /dev/null +++ b/packages/core-app-api/src/routing/RoutingProvider.test.tsx @@ -0,0 +1,382 @@ +/* + * 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, { + PropsWithChildren, + ReactElement, + useContext, + Context, +} from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { + createPlugin, + useRouteRef, + createRoutableExtension, + createRouteRef, + createExternalRouteRef, + RouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { RoutingProvider } from './RoutingProvider'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { validateRoutes } from './validation'; +import { RouteResolver } from './RouteResolver'; +import { AnyRouteRef, RouteFunc } from './types'; +import { AppContextProvider } from '../app/AppContext'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ id: 'ref1' }); +const ref2 = createRouteRef({ id: 'ref2' }); +const ref3 = createRouteRef({ id: 'ref3' }); +const ref4 = createRouteRef({ id: 'ref4' }); +const ref5 = createRouteRef({ id: 'ref5', params: ['x'] }); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ + id: '5', + optional: true, + params: ['z'], +}); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: AnyRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; + return ( +
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +const mockContext = { + getComponents: () => ({ Progress: () => null } as any), + getSystemIcon: jest.fn(), + getPlugins: jest.fn(), +}; + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, ref3], + [eRefB, ref1], + [eRefC, ref2], + [eRefD, ref1], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + 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 = ( + + + + + + + + ); + + 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', + ); + }); +}); + +describe('v1 consumer', () => { + const RoutingContext = getGlobalSingleton< + Context> + >('routing-context'); + + function useMockRouteRefV1( + routeRef: AnyRouteRef, + location: string, + ): RouteFunc | undefined { + const resolver = useContext(RoutingContext)?.atVersion(1); + if (!resolver) { + throw new Error('no impl'); + } + return resolver.resolve(routeRef, location); + } + + it('should resolve routes', () => { + const routeRef1 = createRouteRef({ id: 'ref1' }); + const routeRef2 = createRouteRef({ id: 'ref2' }); + const routeRef3 = createRouteRef({ id: 'ref3', params: ['x'] }); + + const renderedHook = renderHook( + ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), + { + initialProps: { + routeRef: routeRef1 as AnyRouteRef, + }, + wrapper: ({ children }) => ( + , string>([ + [routeRef2, '/foo'], + [routeRef3, '/bar/:x'], + ]) + } + routeParents={new Map()} + routeObjects={[]} + routeBindings={new Map()} + children={children} + /> + ), + }, + ); + + expect(renderedHook.result.current).toBe(undefined); + renderedHook.rerender({ routeRef: routeRef2 }); + expect(renderedHook.result.current?.()).toBe('/foo'); + renderedHook.rerender({ routeRef: routeRef3 }); + expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x'); + }); +}); diff --git a/packages/core-app-api/src/routing/RoutingProvider.tsx b/packages/core-app-api/src/routing/RoutingProvider.tsx new file mode 100644 index 0000000000..86507d2ff4 --- /dev/null +++ b/packages/core-app-api/src/routing/RoutingProvider.tsx @@ -0,0 +1,64 @@ +/* + * 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 } from 'react'; +import { + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/core-plugin-api'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { + createVersionedValueMap, + VersionedValue, +} from '../lib/versionedValues'; +import { RouteResolver } from './RouteResolver'; +import { BackstageRouteObject } from './types'; + +type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; +const RoutingContext = getOrCreateGlobalSingleton('routing-context', () => + createContext(undefined), +); + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + ); + + const versionedValue = createVersionedValueMap({ 1: resolver }); + return ( + + {children} + + ); +}; diff --git a/packages/core-app-api/src/routing/collectors.test.tsx b/packages/core-app-api/src/routing/collectors.test.tsx new file mode 100644 index 0000000000..50c8ec1772 --- /dev/null +++ b/packages/core-app-api/src/routing/collectors.test.tsx @@ -0,0 +1,377 @@ +/* + * 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, { PropsWithChildren } from 'react'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; + +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { + createRoutableExtension, + createRouteRef, + createPlugin, + RouteRef, +} from '@backstage/core-plugin-api'; +import { attachComponentData } from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ id: 'ref1' }); +const ref2 = createRouteRef({ id: 'ref2' }); +const ref3 = createRouteRef({ id: 'ref3' }); +const ref4 = createRouteRef({ id: 'ref4' }); +const ref5 = createRouteRef({ id: 'ref5' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +const AggregationComponent = ({ + children, +}: PropsWithChildren<{ + path: string; +}>) => <>{children}; + +attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +function routeObj( + path: string, + refs: RouteRef[], + children: any[] = [], + type: 'mounted' | 'gathered' = 'mounted', +) { + return { + path: path, + caseSensitive: false, + element: type, + routeRefs: new Set(refs), + children: [ + { + path: '/*', + caseSensitive: false, + element: 'match-all', + routeRefs: new Set(), + }, + ...children, + ], + }; +} + +describe('discovery', () => { + it('should collect routes', () => { + const list = [ +
, +
, +
+ +
, + ]; + + const root = ( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); + }); + + it('should handle all react router Route patterns', () => { + const root = ( + + + + + + + + } + /> + }> + } /> + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
+ ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2], [], 'gathered'), + routeObj( + '/bar', + [ref3], + [routeObj('/baz', [ref4, ref5], [], 'gathered')], + ), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + 'gathered', + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); + }); +}); diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx new file mode 100644 index 0000000000..e940a2cada --- /dev/null +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; +import { BackstageRouteObject } from './types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), + (acc, node, parent, ctxPath: string | undefined) => { + // The context path is used during mount point gathering to assign the same path + // to all discovered mount points + let currentCtxPath = ctxPath; + + if (parent?.props.element === node) { + return currentCtxPath; + } + + // Start gathering mount points when we encounter a mount point gathering flag + if (getComponentData(node, 'core.gatherMountPoints')) { + const path: string | undefined = node.props?.path; + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + currentCtxPath = path; + } + + const routeRef = getMountPoint(node); + if (routeRef) { + let path: string | undefined = node.props?.path; + // If we're gathering mount points we use the context path as out path, unless + // the element has its own path, in which case we use that instead and stop gathering + if (currentCtxPath) { + if (path) { + currentCtxPath = undefined; + } else { + path = currentCtxPath; + } + } + if (!path) { + throw new Error('Mounted routable extension must have a path'); + } + acc.set(routeRef, path); + } + return currentCtxPath; + }, +); + +export const routeParentCollector = createCollector( + () => new Map(), + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { + if (parent?.props.element === node) { + return parentRouteRef; + } + + let nextParent = parentRouteRef; + + const routeRef = getMountPoint(node); + if (routeRef) { + // "sticky" route ref is when we've encountered a mount point gatherer, and we want a + // mount points beneath it to have the same parent, regardless of internal structure + if (parentRouteRef && 'sticky' in parentRouteRef) { + acc.set(routeRef, parentRouteRef.sticky); + + // When we encounter a mount point with an explicit path, we stop gathering + // mount points withing the children and remove the sticky state + if (node.props?.path) { + nextParent = routeRef; + } else { + nextParent = parentRouteRef; + } + } else { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; + } + } + + // Mount point gatherers are marked as "sticky" + if (getComponentData(node, 'core.gatherMountPoints')) { + return { sticky: nextParent }; + } + + return nextParent; + }, +); + +// We always add a child that matches all subroutes but without any route refs. This makes +// sure that we're always able to match each route no matter how deep the navigation goes. +// The route resolver then takes care of selecting the most specific match in order to find +// mount points that are as deep in the routing tree as possible. +export const MATCH_ALL_ROUTE: BackstageRouteObject = { + caseSensitive: false, + path: '/*', + element: 'match-all', // These elements aren't used, so we add in a bit of debug information + routeRefs: new Set(), +}; + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; + if (parent?.props.element === node) { + return parentObj; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: 'mounted', + routeRefs: new Set([routeRef]), + children: [MATCH_ALL_ROUTE], + }; + parentChildren.push(newObject); + return newObject; + } + + parentObj?.routeRefs.add(routeRef); + } + + const isGatherer = getComponentData( + node, + 'core.gatherMountPoints', + ); + if (isGatherer) { + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: 'gathered', + routeRefs: new Set(), + children: [MATCH_ALL_ROUTE], + }; + parentChildren.push(newObject); + return newObject; + } + + return parentObj; + }, +); diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts new file mode 100644 index 0000000000..7982333f4b --- /dev/null +++ b/packages/core-app-api/src/routing/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FlatRoutes } from './FlatRoutes'; diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts new file mode 100644 index 0000000000..53128642f9 --- /dev/null +++ b/packages/core-app-api/src/routing/types.ts @@ -0,0 +1,86 @@ +/* + * 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 { + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +type RouteRefType = Exclude< + keyof RouteRef, + 'params' | 'path' | 'title' | 'icon' +>; +export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +export type AnyParams = { [param in string]: string } | undefined; + +export type AnyRouteRef = + | RouteRef + | SubRouteRef + | ExternalRouteRef; + +// 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]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} + +export function isRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is RouteRef { + return routeRef[routeRefType] === 'absolute'; +} + +export function isSubRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is SubRouteRef { + return routeRef[routeRefType] === 'sub'; +} + +export function isExternalRouteRef< + Params extends AnyParams, + Optional extends boolean +>( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is ExternalRouteRef { + return routeRef[routeRefType] === 'external'; +} diff --git a/packages/core-app-api/src/routing/validation.ts b/packages/core-app-api/src/routing/validation.ts new file mode 100644 index 0000000000..2d32471a14 --- /dev/null +++ b/packages/core-app-api/src/routing/validation.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyRouteRef } from './types'; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + 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}`, + ); + } + } + } + } + } +} diff --git a/packages/core-app-api/src/setupTests.ts b/packages/core-app-api/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/packages/core-app-api/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/packages/core-components/.eslintrc.js b/packages/core-components/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/core-components/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/core-components/.npmrc b/packages/core-components/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/packages/core-components/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md new file mode 100644 index 0000000000..83e5bad9f5 --- /dev/null +++ b/packages/core-components/CHANGELOG.md @@ -0,0 +1,50 @@ +# @backstage/core-components + +## 0.1.2 + +### Patch Changes + +- e47336ea4: Use app.title for helmet in header +- 75b8537ce: This change adds automatic error boundaries around extensions. + + This means that all exposed parts of a plugin are wrapped in a general error boundary component, that is plugin aware. The default design for the error box is borrowed from `@backstage/errors`. To override the default "fallback", one must provide a component named `ErrorBoundaryFallback` to `createApp`, like so: + + ```ts + const app = createApp({ + components: { + ErrorBoundaryFallback: props => { + // a custom fallback component + return ( + <> +

Oops.

+

+ The plugin {props.plugin.getId()} failed with{' '} + {props.error.message} +

+ + + ); + }, + }, + }); + ``` + + The props here include: + + - `error`. An `Error` object or something that inherits it that represents the error that was thrown from any inner component. + - `resetError`. A callback that will simply attempt to mount the children of the error boundary again. + - `plugin`. A `BackstagePlugin` that can be used to look up info to be presented in the error message. For instance, you may want to keep a map of your internal plugins and team names or slack channels and present these when an error occurs. Typically, you'll do that by getting the plugin ID with `plugin.getId()`. + +- Updated dependencies [75b8537ce] +- Updated dependencies [da8cba44f] + - @backstage/core-plugin-api@0.1.2 + +## 0.1.1 + +### Patch Changes + +- e7c5e4b30: Update installation instructions in README. +- Updated dependencies [031ccd45f] +- Updated dependencies [e7c5e4b30] + - @backstage/core-plugin-api@0.1.1 + - @backstage/theme@0.2.8 diff --git a/packages/core-components/README.md b/packages/core-components/README.md new file mode 100644 index 0000000000..4d12e903b3 --- /dev/null +++ b/packages/core-components/README.md @@ -0,0 +1,17 @@ +# @backstage/core-components + +This package provides the core components used by Backstage plugins and apps + +## Installation + +Install the package via Yarn: + +```sh +cd # if within a monorepo +yarn add @backstage/core-components +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/core-components/package.json b/packages/core-components/package.json new file mode 100644 index 0000000000..42a98b859a --- /dev/null +++ b/packages/core-components/package.json @@ -0,0 +1,92 @@ +{ + "name": "@backstage/core-components", + "description": "Core components used by Backstage plugins and apps", + "version": "0.1.2", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/core-components" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.5", + "@backstage/core-plugin-api": "^0.1.2", + "@backstage/errors": "^0.1.1", + "@backstage/theme": "^0.2.8", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", + "@types/dagre": "^0.7.44", + "@types/prop-types": "^15.7.3", + "@types/react": "^16.9", + "@types/react-sparklines": "^1.7.0", + "@types/react-text-truncate": "^0.14.0", + "classnames": "^2.2.6", + "clsx": "^1.1.0", + "d3-selection": "^2.0.0", + "d3-shape": "^2.0.0", + "d3-zoom": "^2.0.0", + "dagre": "^0.8.5", + "immer": "^9.0.1", + "lodash": "^4.17.15", + "material-table": "^1.69.1", + "pluralize": "^8.0.0", + "prop-types": "^15.7.2", + "qs": "^6.9.4", + "rc-progress": "^3.0.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-helmet": "6.1.0", + "react-hook-form": "^6.15.4", + "react-markdown": "^5.0.2", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-sparklines": "^1.7.0", + "react-syntax-highlighter": "^15.4.3", + "react-text-truncate": "^0.16.0", + "react-use": "^17.2.4", + "remark-gfm": "^1.0.0", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/core-app-api": "^0.1.2", + "@backstage/cli": "^0.7.0", + "@backstage/test-utils": "^0.1.13", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/classnames": "^2.2.9", + "@types/d3-selection": "^2.0.0", + "@types/d3-shape": "^2.0.0", + "@types/d3-zoom": "^2.0.0", + "@types/google-protobuf": "^3.7.2", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-helmet": "^6.1.0", + "@types/zen-observable": "^0.8.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx new file mode 100644 index 0000000000..624337c49b --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 from 'react'; +import { AlertDisplay } from './AlertDisplay'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { + ApiProvider, + ApiRegistry, + AlertApiForwarder, +} from '@backstage/core-app-api'; +import Observable from 'zen-observable'; +import { renderInTestApp } from '@backstage/test-utils'; + +const TEST_MESSAGE = 'TEST_MESSAGE'; + +describe('', () => { + it('renders without exploding', async () => { + const apiRegistry = ApiRegistry.from([ + [alertApiRef, new AlertApiForwarder()], + ]); + + const { queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(TEST_MESSAGE)).not.toBeInTheDocument(); + }); + + it('renders with message', async () => { + const apiRegistry = ApiRegistry.from([ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of({ message: TEST_MESSAGE }); + }, + }, + ], + ]); + + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText(TEST_MESSAGE)).toBeInTheDocument(); + }); + + describe('with multiple messages', () => { + let apiRegistry: ApiRegistry; + + beforeEach(() => { + apiRegistry = ApiRegistry.from([ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of( + { message: 'message one' }, + { message: 'message two' }, + { message: 'message three' }, + ); + }, + }, + ], + ]); + }); + + it('renders first message', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('message one')).toBeInTheDocument(); + }); + + it('renders a count of remaining messages', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('(2 older messages)')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx new file mode 100644 index 0000000000..c841d234f6 --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx @@ -0,0 +1,76 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { Snackbar, IconButton } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { Alert } from '@material-ui/lab'; +import { AlertMessage, useApi, alertApiRef } from '@backstage/core-plugin-api'; +import pluralize from 'pluralize'; + +// TODO: improve on this and promote to a shared component for use by all apps. +export const AlertDisplay = () => { + const [messages, setMessages] = useState>([]); + const alertApi = useApi(alertApiRef); + + useEffect(() => { + const subscription = alertApi + .alert$() + .subscribe(message => setMessages(msgs => msgs.concat(message))); + + return () => { + subscription.unsubscribe(); + }; + }, [alertApi]); + + if (messages.length === 0) { + return null; + } + + const [firstMessage] = messages; + + const handleClose = () => { + setMessages(msgs => msgs.filter(msg => msg !== firstMessage)); + }; + + return ( + + + + + } + severity={firstMessage.severity} + > + + {firstMessage.message.toString()} + {messages.length > 1 && ( + {` (${messages.length - 1} older ${pluralize( + 'message', + messages.length - 1, + )})`} + )} + + + + ); +}; diff --git a/packages/core-components/src/components/AlertDisplay/index.ts b/packages/core-components/src/components/AlertDisplay/index.ts new file mode 100644 index 0000000000..72aa1c5ad8 --- /dev/null +++ b/packages/core-components/src/components/AlertDisplay/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AlertDisplay } from './AlertDisplay'; diff --git a/packages/core-components/src/components/Avatar/Avatar.stories.tsx b/packages/core-components/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000000..5ac628d72b --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,42 @@ +/* + * 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 from 'react'; +import { Avatar } from './Avatar'; + +export default { + title: 'Data Display/Avatar', + component: Avatar, +}; + +export const Default = () => ( + +); + +export const NameFallback = () => ; + +export const Empty = () => ; + +export const CustomStyling = () => ( + +); diff --git a/packages/core-components/src/components/Avatar/Avatar.test.tsx b/packages/core-components/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 0000000000..da6ca8f42e --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { Avatar } from './Avatar'; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render(); + + expect(getByText('JD')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-components/src/components/Avatar/Avatar.tsx b/packages/core-components/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..95aa4a8ced --- /dev/null +++ b/packages/core-components/src/components/Avatar/Avatar.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { CSSProperties } from 'react'; +import { + Avatar as MaterialAvatar, + createStyles, + makeStyles, + Theme, +} from '@material-ui/core'; +import { extractInitials, stringToColor } from './utils'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + avatar: { + width: '4rem', + height: '4rem', + color: '#fff', + fontWeight: theme.typography.fontWeightBold, + letterSpacing: '1px', + textTransform: 'uppercase', + }, + }), +); + +export type AvatarProps = { + displayName?: string; + picture?: string; + customStyles?: CSSProperties; +}; + +export const Avatar = ({ displayName, picture, customStyles }: AvatarProps) => { + const classes = useStyles(); + return ( + + {displayName && extractInitials(displayName)} + + ); +}; diff --git a/packages/core-components/src/components/Avatar/index.ts b/packages/core-components/src/components/Avatar/index.ts new file mode 100644 index 0000000000..962414634e --- /dev/null +++ b/packages/core-components/src/components/Avatar/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Avatar } from './Avatar'; diff --git a/packages/core-components/src/components/Avatar/util.test.ts b/packages/core-components/src/components/Avatar/util.test.ts new file mode 100644 index 0000000000..94de957e8e --- /dev/null +++ b/packages/core-components/src/components/Avatar/util.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { extractInitials, stringToColor } from './utils'; + +describe('stringToColor', () => { + it('extract color', async () => { + expect(stringToColor('Jenny Doe')).toEqual('#7809fa'); + }); +}); + +describe('extractInitials', () => { + it('extract initials', async () => { + expect(extractInitials('Jenny Doe')).toEqual('JD'); + }); + + it('extract single letter for short name', async () => { + expect(extractInitials('Doe')).toEqual('D'); + }); + + it('limit the initials to two letters', async () => { + expect(extractInitials('John Jonathan Doe')).toEqual('JJ'); + }); +}); diff --git a/packages/core-components/src/components/Avatar/utils.ts b/packages/core-components/src/components/Avatar/utils.ts new file mode 100644 index 0000000000..5990a72955 --- /dev/null +++ b/packages/core-components/src/components/Avatar/utils.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function stringToColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.substr(-2); + } + return color; +} + +export function extractInitials(value: string) { + return value.match(/\b\w/g)!.join('').substring(0, 2); +} diff --git a/packages/core-components/src/components/Button/Button.stories.tsx b/packages/core-components/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..0fee0300ab --- /dev/null +++ b/packages/core-components/src/components/Button/Button.stories.tsx @@ -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, { ComponentType } from 'react'; +import { Button } from './Button'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api'; +import { + Divider, + Link, + List, + ListItem, + ListItemText, + Typography, + Button as MaterialButton, +} from '@material-ui/core'; +// We don't want to export RoutingProvider from core-app-api, but it's way easier to +// use here. This hack only works in storybook stories. +// TODO: Export a nicer to user routing provider, perhaps from test-utils +// eslint-disable-next-line monorepo/no-internal-import +import { RoutingProvider } from '@backstage/core-app-api/src/routing/RoutingProvider'; + +const routeRef = createRouteRef({ + id: 'storybook.test-route', +}); + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Inputs/Button', + component: Button, + decorators: [ + (Story: ComponentType<{}>) => ( + <> + + A collection of buttons that should be used in the Backstage + interface. These leverage the properties inherited from{' '} + + Material-UI Button + + , but include an opinionated set that align to the Backstage design. + + + + + + +
+
+ +
+ +
+
+
+ + ), + ], +}; + +export const Default = () => { + const link = useRouteRef(routeRef); + // Design Permutations: + // color = default | primary | secondary + // variant = contained | outlined | text + return ( + + + + Default Button: + This is the default button design which should be used in most cases. +
+
color="primary" variant="contained"
+
+ + +
+ + + Secondary Button: + Used for actions that cancel, skip, and in general perform negative + functions, etc. +
+
color="secondary" variant="contained"
+
+ + +
+ + + Tertiary Button: + Used commonly in a ButtonGroup and when the button function itself is + not a primary function on a page. +
+
color="default" variant="outlined"
+
+ + +
+
+ ); +}; + +export const ButtonLinks = () => { + const link = useRouteRef(routeRef); + + const handleClick = () => { + return 'Your click worked!'; + }; + + return ( + <> + + { + // TODO: Refactor to use new routing mechanisms + } + + +   has props for both Material-UI's component as well as for + react-router-dom's Route object. + + + + +   links to a statically defined route. In general, this should be + avoided. + + + + + View URL + +   links to a defined URL using Material-UI's Button. + + + + + Trigger Event + +   triggers an onClick event using Material-UI's Button. + + + + ); +}; diff --git a/packages/core-components/src/components/Button/Button.test.tsx b/packages/core-components/src/components/Button/Button.test.tsx new file mode 100644 index 0000000000..8bae5f2767 --- /dev/null +++ b/packages/core-components/src/components/Button/Button.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Button } from './Button'; +import { Route, Routes } from 'react-router'; + +describe(' + , + ), + ); + + expect(() => getByText(testString)).toThrow(); + await act(async () => { + fireEvent.click(getByText(buttonLabel)); + }); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core-components/src/components/Button/Button.tsx b/packages/core-components/src/components/Button/Button.tsx new file mode 100644 index 0000000000..ca45b3da7f --- /dev/null +++ b/packages/core-components/src/components/Button/Button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Button as MaterialButton } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Button component + * Makes the Button to utilise react-router + */ +export const Button = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core-components/src/components/Button/index.ts b/packages/core-components/src/components/Button/index.ts new file mode 100644 index 0000000000..7b584ed799 --- /dev/null +++ b/packages/core-components/src/components/Button/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Button } from './Button'; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx new file mode 100644 index 0000000000..3416e94260 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx @@ -0,0 +1,105 @@ +/* + * 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, { useState } from 'react'; +import { CheckboxTree } from './CheckboxTree'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Generic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Generic subcategory name 2', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Generic subcategory name 3', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +export default { + title: 'Inputs/CheckboxTree', + component: CheckboxTree, +}; + +export const Default = () => ( + {}} + label="default" + subCategories={CHECKBOX_TREE_ITEMS} + /> +); + +export const DynamicTree = () => { + function generateTree(showMore: boolean = false) { + const t = [ + { + label: 'Show more', + options: [], + }, + ]; + + if (showMore) { + t.push({ + label: 'More', + options: [], + }); + } + + return t; + } + + const [tree, setTree] = useState(generateTree()); + + return ( + { + setTree(generateTree(state.some(c => c.category === 'Show more'))); + }} + label="default" + subCategories={tree} + /> + ); +}; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx new file mode 100644 index 0000000000..0750867ff3 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { CheckboxTree } from './CheckboxTree'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Generic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +const minProps = { + onChange: jest.fn(), + label: 'Default', + subCategories: CHECKBOX_TREE_ITEMS, +}; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText, getByTestId } = render(); + + expect(getByText('Generic subcategory name 1')).toBeInTheDocument(); + const checkbox = await getByTestId('expandable'); + + // Simulate click on expandable arrow + fireEvent.click(checkbox); + + // Simulate click on option + const option = getByText('Option 1'); + expect(getByText('Option 1')).toBeInTheDocument(); + fireEvent.click(option); + expect(minProps.onChange).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx new file mode 100644 index 0000000000..d6d3410913 --- /dev/null +++ b/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx @@ -0,0 +1,358 @@ +/* + * 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. + */ +/* eslint-disable guard-for-in */ +import { + Checkbox, + Collapse, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from '@material-ui/core'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import produce from 'immer'; +import { isEqual } from 'lodash'; +import React, { useEffect, useReducer } from 'react'; +import { usePrevious } from 'react-use'; + +type IndexedObject = { + [key: string]: T; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + minWidth: 10, + maxWidth: 360, + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: 'transparent', + }, + '&:active': { + animation: 'none', + transform: 'none', + }, + }, + nested: { + paddingLeft: theme.spacing(5), + height: '32px', + '&:hover': { + backgroundColor: 'transparent', + }, + }, + listItemIcon: { + minWidth: 10, + }, + listItem: { + '&:hover': { + backgroundColor: 'transparent', + }, + }, + text: { + '& span, & svg': { + fontWeight: 'normal', + fontSize: 14, + }, + }, + }), +); + +/* SUB_CATEGORY */ + +type SubCategory = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options?: Option[]; +}; + +type SubCategoryWithIndexedOptions = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options: IndexedObject