diff --git a/.changeset/afraid-chairs-warn.md b/.changeset/afraid-chairs-warn.md new file mode 100644 index 0000000000..1ae2a7513e --- /dev/null +++ b/.changeset/afraid-chairs-warn.md @@ -0,0 +1,6 @@ +--- +'@backstage/errors': patch +--- + +Deprecate `parseErrorResponse` in favour of `parseErrorResponseBody`. Deprecate `data` field inside `ErrorResponse` in favour of `body`. +Rename the error name for unknown errors from `unknown` to `error`. diff --git a/.changeset/angry-countries-cheat.md b/.changeset/angry-countries-cheat.md deleted file mode 100644 index e97e0ae56c..0000000000 --- a/.changeset/angry-countries-cheat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Switched to dynamically determining the packages that are unsafe to repack when executing the CLI within the Backstage main repo. diff --git a/.changeset/backstage-changelog.js b/.changeset/backstage-changelog.js index ea8ca2300b..22038b348b 100644 --- a/.changeset/backstage-changelog.js +++ b/.changeset/backstage-changelog.js @@ -23,13 +23,13 @@ const { async function getDependencyReleaseLine(changesets, dependenciesUpdated) { if (dependenciesUpdated.length === 0) return ''; - const updatedDepenenciesList = dependenciesUpdated.map( + const updatedDependenciesList = dependenciesUpdated.map( dependency => ` - ${dependency.name}@${dependency.newVersion}`, ); // Return one `Updated dependencies` bullet instead of repeating for each changeset; this // sacrifices the commit shas for brevity. - return ['- Updated dependencies', ...updatedDepenenciesList].join('\n'); + return ['- Updated dependencies', ...updatedDependenciesList].join('\n'); } module.exports = { diff --git a/.changeset/curly-points-hide.md b/.changeset/curly-points-hide.md new file mode 100644 index 0000000000..ba513e5915 --- /dev/null +++ b/.changeset/curly-points-hide.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-tech-insights-backend': patch +'@backstage/plugin-tech-insights-backend-module-jsonfc': patch +--- + +Update README docs to use correct function/parameter names diff --git a/.changeset/dry-spies-cover.md b/.changeset/dry-spies-cover.md deleted file mode 100644 index 7006dee5c4..0000000000 --- a/.changeset/dry-spies-cover.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-components': patch -'@backstage/theme': patch ---- - -Will Add warning variant to `DismissableBanner` component. diff --git a/.changeset/early-dragons-wave.md b/.changeset/early-dragons-wave.md new file mode 100644 index 0000000000..641f36a9f3 --- /dev/null +++ b/.changeset/early-dragons-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Bump `react-jsonschema-form` diff --git a/.changeset/forty-teachers-argue.md b/.changeset/forty-teachers-argue.md new file mode 100644 index 0000000000..2e32719037 --- /dev/null +++ b/.changeset/forty-teachers-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Switched to using the standardized JSON error responses for all provider endpoints. diff --git a/.changeset/giant-bees-applaud.md b/.changeset/giant-bees-applaud.md new file mode 100644 index 0000000000..c4591dbc64 --- /dev/null +++ b/.changeset/giant-bees-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Switch the default test coverage provider from the jest default one to `'v8'`, which provides much better coverage information when using the default Backstage test setup. This is considered a bug fix as the current coverage information is often very inaccurate. diff --git a/.changeset/large-mugs-repair.md b/.changeset/large-mugs-repair.md new file mode 100644 index 0000000000..5043fb80e1 --- /dev/null +++ b/.changeset/large-mugs-repair.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Disable ES transforms in tests transformed by the `jestSucraseTransform.js`. This is not considered a breaking change since all code is already transpiled this way in the development setup. diff --git a/.changeset/large-pears-agree.md b/.changeset/large-pears-agree.md new file mode 100644 index 0000000000..60f755eff0 --- /dev/null +++ b/.changeset/large-pears-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +DefaultTechDocsCollator is now included in the search backend, and the Search Page updated with the SearchType component that includes the techdocs type diff --git a/.changeset/long-bugs-kiss.md b/.changeset/long-bugs-kiss.md deleted file mode 100644 index 49e0edae72..0000000000 --- a/.changeset/long-bugs-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kafka': patch ---- - -Use IdentityApi to provide Auth Token for KafkaBackendClient Api calls diff --git a/.changeset/loud-bats-flow.md b/.changeset/loud-bats-flow.md deleted file mode 100644 index af4837dbbe..0000000000 --- a/.changeset/loud-bats-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/test-utils': patch ---- - -Update Keyboard deprecation with a link to the recommended successor diff --git a/.changeset/lovely-cars-sneeze.md b/.changeset/lovely-cars-sneeze.md deleted file mode 100644 index 8e6bfd691c..0000000000 --- a/.changeset/lovely-cars-sneeze.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Expose some classes and interfaces public so TaskWorkers can run externally from the scaffolder API. diff --git a/.changeset/pretty-trains-appear.md b/.changeset/pretty-trains-appear.md new file mode 100644 index 0000000000..9a7b313524 --- /dev/null +++ b/.changeset/pretty-trains-appear.md @@ -0,0 +1,34 @@ +--- +'@backstage/backend-common': patch +'@backstage/cli': patch +'@backstage/core-app-api': patch +'@backstage/create-app': patch +'@backstage/techdocs-common': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-azure-devops-backend': patch +'@backstage/plugin-badges-backend': patch +'@backstage/plugin-bazaar-backend': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-code-coverage-backend': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-jenkins-backend': patch +'@backstage/plugin-proxy-backend': patch +'@backstage/plugin-rollbar-backend': patch +'@backstage/plugin-search-backend': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Change default port of backend from 7000 to 7007. + +This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + +You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + +``` +backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 +``` + +More information can be found here: https://backstage.io/docs/conf/writing diff --git a/.changeset/purple-grapes-attack.md b/.changeset/purple-grapes-attack.md new file mode 100644 index 0000000000..a2a99690e9 --- /dev/null +++ b/.changeset/purple-grapes-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-common': minor +--- + +Accept configApi rather than enabled flag in PermissionClient constructor. diff --git a/.changeset/sharp-carrots-press.md b/.changeset/sharp-carrots-press.md new file mode 100644 index 0000000000..a7c31d6759 --- /dev/null +++ b/.changeset/sharp-carrots-press.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-config-schema': patch +'@backstage/plugin-scaffolder': patch +--- + +Fixed a missing `await` when throwing server side errors diff --git a/.changeset/smart-fans-complain.md b/.changeset/smart-fans-complain.md new file mode 100644 index 0000000000..db404f110d --- /dev/null +++ b/.changeset/smart-fans-complain.md @@ -0,0 +1,42 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +**BREAKING** EntitiesSearchFilter fields have changed. + +EntitiesSearchFilter now has only two fields: `key` and `value`. The `matchValueIn` and `matchValueExists` fields are no longer are supported. Previous filters written using the `matchValueIn` and `matchValueExists` fields can be rewritten as follows: + +Filtering by existence of key only: + +```diff + filter: { + { + key: 'abc', +- matchValueExists: true, + }, + } +``` + +Filtering by key and values: + +```diff + filter: { + { + key: 'abc', +- matchValueExists: true, +- matchValueIn: ['xyz'], ++ values: ['xyz'], + }, + } +``` + +Negation of filters can now be achieved through a `not` object: + +``` +filter: { + not: { + key: 'abc', + values: ['xyz'], + }, +} +``` diff --git a/.changeset/smooth-vans-boil.md b/.changeset/smooth-vans-boil.md new file mode 100644 index 0000000000..39b2ea386f --- /dev/null +++ b/.changeset/smooth-vans-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Index User entities by displayName to be able to search by full name. Added displayName (if present) to the 'text' field in the indexed document. diff --git a/.changeset/spicy-panthers-thank.md b/.changeset/spicy-panthers-thank.md deleted file mode 100644 index 6c28b002f4..0000000000 --- a/.changeset/spicy-panthers-thank.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -I have added default icons for the catalog, scaffolder, techdocs, and search. diff --git a/.changeset/techdocs-buh-bump-ts.md b/.changeset/techdocs-buh-bump-ts.md deleted file mode 100644 index 438843e04b..0000000000 --- a/.changeset/techdocs-buh-bump-ts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Default TechDocs container used at docs generation-time is now [v0.3.5](https://github.com/backstage/techdocs-container/releases/tag/v0.3.5). diff --git a/.changeset/weak-berries-sing.md b/.changeset/weak-berries-sing.md new file mode 100644 index 0000000000..1b0cb8d719 --- /dev/null +++ b/.changeset/weak-berries-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Update the default routes to use id instead of title diff --git a/.changeset/wet-seas-deliver.md b/.changeset/wet-seas-deliver.md new file mode 100644 index 0000000000..da5679eb8e --- /dev/null +++ b/.changeset/wet-seas-deliver.md @@ -0,0 +1,63 @@ +--- +'@backstage/core-app-api': patch +'@backstage/test-utils': patch +--- + +The `ApiRegistry` from `@backstage/core-app-api` class has been deprecated and will be removed in a future release. To replace it, we have introduced two new helpers that are exported from `@backstage/test-utils`, namely `TestApiProvider` and `TestApiRegistry`. + +These two new helpers are more tailored for writing tests and development setups, as they allow for partial implementations of each of the APIs. + +When migrating existing code it is typically best to prefer usage of `TestApiProvider` when possible, so for example the following code: + +```tsx +render( + + {...} + +) +``` + +Would be migrated to this: + +```tsx +render( + + {...} + +) +``` + +In cases where the `ApiProvider` is used in a more standalone way, for example to reuse a set of APIs across multiple tests, the `TestApiRegistry` can be used instead. Note that the `TestApiRegistry` only has a single static factory method, `.from()`, and it is slightly different from the existing `.from()` method on `ApiRegistry` in that it doesn't require the API pairs to be wrapped in an outer array. + +Usage that looks like this: + +```ts +const apis = ApiRegistry.with( + identityApiRef, + mockIdentityApi as unknown as IdentityApi, +).with(configApiRef, new ConfigReader({})); +``` + +OR like this: + +```ts +const apis = ApiRegistry.from([ + [identityApiRef, mockIdentityApi as unknown as IdentityApi], + [configApiRef, new ConfigReader({})], +]); +``` + +Would be migrated to this: + +```ts +const apis = TestApiRegistry.from( + [identityApiRef, mockIdentityApi], + [configApiRef, new ConfigReader({})], +); +``` + +If your app is still using the `ApiRegistry` to construct the `apis` for `createApp`, we recommend that you move over to use the new method of supplying API factories instead, using `createApiFactory`. diff --git a/.changeset/yellow-pandas-draw.md b/.changeset/yellow-pandas-draw.md new file mode 100644 index 0000000000..6d4f8116b3 --- /dev/null +++ b/.changeset/yellow-pandas-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Pin sidebar by default for easier navigation diff --git a/.changeset/young-sheep-impress.md b/.changeset/young-sheep-impress.md new file mode 100644 index 0000000000..b69447d4d4 --- /dev/null +++ b/.changeset/young-sheep-impress.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Letting GraphiQL use headers diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b637524a52..9be92d294e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,7 +18,9 @@ /plugins/techdocs-backend @backstage/techdocs-core /plugins/ilert @backstage/reviewers @yacut /plugins/home @backstage/techdocs-core +/packages/embedded-techdocs-app @backstage/techdocs-core /packages/search-common @backstage/techdocs-core +/packages/techdocs-cli @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7e478953bc..d65e31b4c4 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -52,6 +52,7 @@ configmaps configs const cookiecutter +cron css Datadog dataflow @@ -62,6 +63,7 @@ Debounce declaratively deduplicated deps +dependabot destructured dev devops @@ -162,6 +164,8 @@ Monorepo monorepos msgraph msw +mutex +mutexes mysql namespace namespaced diff --git a/.github/workflows/snyk-github-issue-sync.yml b/.github/workflows/snyk-github-issue-sync.yml new file mode 100644 index 0000000000..7b93374609 --- /dev/null +++ b/.github/workflows/snyk-github-issue-sync.yml @@ -0,0 +1,63 @@ +name: 'Snyk Github Issue Sync' + +on: + schedule: + - cron: '0 */4 * * *' + +jobs: + sync: + if: github.repository == 'backstage/backstage' # prevent running on forks + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: Create Snyk report + uses: snyk/actions/node@master + continue-on-error: true # Snyk CLI exits with error when vulnerabilities are found + with: + args: > + --yarn-workspaces + --org=backstage-dgh + --strict-out-of-sync=false + --json-file-output=snyk.json + json: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + - name: Update Github issues + run: yarn ts-node scripts/snyk-github-issue-sync.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/techdocs-e2e.yml b/.github/workflows/techdocs-e2e.yml new file mode 100644 index 0000000000..191274ec84 --- /dev/null +++ b/.github/workflows/techdocs-e2e.yml @@ -0,0 +1,42 @@ +name: Techdocs E2E Test + +on: + pull_request: + paths-ignore: + - '.changeset/**' + - 'contrib/**' + - 'docs/**' + - 'microsite/**' + +jobs: + verify: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + + - name: install dependencies + run: yarn install --frozen-lockfile + + - name: generate types + run: yarn tsc + + - name: build techdocs-cli + working-directory: packages/techdocs-cli + run: yarn build + + - name: Install mkdocs & techdocs-core + run: python -m pip install mkdocs-techdocs-core + + - name: techdocs-cli e2e test + working-directory: packages/techdocs-cli + run: yarn test:e2e:ci diff --git a/.tugboat/config.yml b/.tugboat/config.yml index b8c3c127c8..883708bef7 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,7 +1,7 @@ services: backstage: image: tugboatqa/node:lts - expose: 7000 + expose: 7007 default: true commands: init: @@ -14,4 +14,4 @@ services: - yarn workspace example-app build start: # wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty. - - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000 + - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7007 diff --git a/ADOPTERS.md b/ADOPTERS.md index a2015086c3..3f32a22c23 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,64 +1,73 @@ -| 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) | [@sagacity](https://github.com/sagacity) | 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) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | 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. | -| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐ŸŒ•๐Ÿš€๐Ÿง‘โ€๐Ÿš€ | -| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | -| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | -| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | -| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | -| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | -| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | -| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | -| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | -| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | -| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | -| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | -| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | -| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | -| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | -| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | -| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | -| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | -| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | -| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | -| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | -| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐Ÿš€. | -| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | -| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. | -| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. | -| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. | -| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. | -| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation.| -| [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc.| +| 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) | [@sagacity](https://github.com/sagacity) | 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) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | 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. | +| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐ŸŒ•๐Ÿš€๐Ÿง‘โ€๐Ÿš€ | +| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | +| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | +| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | +| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | +| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | +| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | +| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | +| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | +| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | +| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | +| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | +| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | +| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | +| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | +| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | +| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | +| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | +| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | +| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | +| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | +| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐Ÿš€. | +| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | +| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. | +| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. | +| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. | +| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. | +| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation. | +| [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc. | +| [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. | +| [Palo Alto Networks](https://www.paloaltonetworks.com) | [Jeremy Guarini](https://github.com/jeremyguarini), [Brian Lomeland](https://github.com/bbbmmmlll), [Palo Alto Networks](https://github.com/PaloAltoNetworks) | Developer portal, service catalog, documentation and tooling | +| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem | +| [Tradeshift](https://www.tradeshift.com/) | [Soren Mathiasen](https://github.com/sorenmat) | Developer Portal: documentation, monitoring, service templates, service catalog for our micro services | +| [Unity](https://unity.com) | [Ted Cordery](https://github.com/TeddyBallGame) | A centralized service catalog with documentation for our service engineers. | +| [PicPay](https://www.picpay.com) | [Luis Baroni](https://github.com/lcsbaroni), [Renata Poluceno](https://github.com/renatapoluceno), [PicPay](https://github.com/picpay) | Developer portal for building services throught templates, service catalog with ownership of services, documentation and metrics providing autonomy and visibility for all. | +| [Epic Games](https://www.epicgames.com) | [Brian Jung](https://github.com/brian-at-epic), [Jeff Goldian](https://github.com/jeffgoldian-Epic) | Developer Portal: Service Catalog, Documentation, Software Templates and more making our internal teams' lives easier! | +| [Globo](https://globo.com) | [Carlos Gusmรฃo](https://github.com/caeugusmao), [Guilherme Vierno](https://github.com/vierno), [Denis Aoki](https://github.com/dnsaoki2), [Maycon Dionisio](https://github.com/MayconDionisio), | Reduce the friction of accessing the information engineers need about Globo's digital services through a coherent and centralized experience. | +| [QBE](https://www.qbe.com/) | [Daniel Steel](https://github.com/danielsteelqbe), [Pete Jespers](https://github.com/petejespersqbe) | Developer portal allowing our global teams to explore and create applications, documentation and cloud infrastructure easily and quickly ๐Ÿš€ | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99d63cf0b4..31f912f43e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,9 @@ We use [changesets](https://github.com/atlassian/changesets) to help us prepare Any time a patch, minor, or major change aligning to [Semantic Versioning](https://semver.org) is made to any published package in `packages/` or `plugins/`, a changeset should be used. It helps to align your change to the [Backstage stability index](https://backstage.io/docs/overview/stability-index) for the package you are changing, for example, when to provide additional clarity on deprecation or impacting changes which will then be included into CHANGELOGs. -In general, changesets are not needed for the documentation, build utilities, contributed samples in `contrib/`, or the [example `packages/app`](packages/app). +In general, changesets are only needed for changes to packages within `packages/` or `plugins/` directories, and only for the packages that are not marked as `private`. Changesets are also not needed for changes that do not affect the published version of each package, for example changes to tests or in-line source code comments. + +Changesets **are** needed for new packages, as that is what triggers the package to be part of the next release. ### How to create a changeset diff --git a/README.md b/README.md index 13b7c945cf..1078631926 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ # [Backstage](https://backstage.io) -> ๐Ÿ– All of the maintainers will be taking a wellness break Nov. 1โ€“5. The repo and Discord may be quieter than usual, but not to worry. Weโ€™ll have coverage plans in place and be back in full force, rested and restored, on Nov. 8. ๐Ÿ– - [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects) [![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22) diff --git a/app-config.yaml b/app-config.yaml index 05d03c26b0..3eab123027 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,9 +23,9 @@ app: title: '#backstage' backend: - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 database: client: sqlite3 connection: ':memory:' diff --git a/contrib/chart/backstage/files/app-config.development.yaml.tpl b/contrib/chart/backstage/files/app-config.development.yaml.tpl index 6d3ded16c1..105ba3b259 100644 --- a/contrib/chart/backstage/files/app-config.development.yaml.tpl +++ b/contrib/chart/backstage/files/app-config.development.yaml.tpl @@ -1,7 +1,7 @@ backend: lighthouseHostname: {{ include "lighthouse.serviceName" . | quote }} listen: - port: {{ .Values.appConfig.backend.listen.port | default 7000 }} + port: {{ .Values.appConfig.backend.listen.port | default 7007 }} database: client: {{ .Values.appConfig.backend.database.client | quote }} connection: diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index 9f70dac6dc..6ffe076e57 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -26,7 +26,7 @@ backend: repository: martinaif/backstage-k8s-demo-backend tag: 20210423T1550 pullPolicy: IfNotPresent - containerPort: 7000 + containerPort: 7007 serviceType: ClusterIP postgresCertMountEnabled: true resources: @@ -96,7 +96,7 @@ appConfig: backend: baseUrl: https://demo.example.com listen: - port: 7000 + port: 7007 cors: origin: https://demo.example.com database: diff --git a/contrib/docker/devops/makefile b/contrib/docker/devops/makefile index 3ae89a161b..048edc8886 100644 --- a/contrib/docker/devops/makefile +++ b/contrib/docker/devops/makefile @@ -9,8 +9,8 @@ docker_name_prefix := backstage-make # to the host computer frontend_port := 3000 frontend_host_port := 3000 -backend_port := 7000 -backend_host_port := 7000 +backend_port := 7007 +backend_host_port := 7007 # path to this "makefile" my_dir_path = $(dir $(CURDIR)/$(firstword $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST))) @@ -191,10 +191,10 @@ test: check-tests check: check-code check-docs check-type-dependencies check-styles # run development instance -# BUG: the frontend seems to run on "$(backend_port)" (7000 default). +# BUG: the frontend seems to run on "$(backend_port)" (7007 default). # The documentation states "This is going to start two things, -# the frontend (:3000) and the backend (:7000)." -# However, the frontend seems to end up running on 7000. +# the frontend (:3000) and the backend (:7007)." +# However, the frontend seems to end up running on 7007. .PHONY: dev dev: build @docker run --rm -it \ diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml index f0695753fd..c8d60a9c1b 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml @@ -22,6 +22,6 @@ spec: image: spotify/backstage-backend:latest imagePullPolicy: IfNotPresent ports: - - containerPort: 7000 + - containerPort: 7007 name: backend protocol: TCP diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml index d91808ed28..a293dc12fb 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml @@ -63,7 +63,7 @@ backend: pullPolicy: IfNotPresent service: type: ClusterIP - port: 7000 + port: 7007 ingress: enabled: false annotations: diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml index 4d947b7afc..e1da35b598 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml @@ -30,6 +30,6 @@ spec: component: backend ports: - name: backend - port: 7000 + port: 7007 protocol: TCP targetPort: backend diff --git a/cypress/cypress.json b/cypress/cypress.json index 3ef3df8e65..7335d0f62a 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:7000", + "baseUrl": "http://localhost:7007", "integrationFolder": "./src/integration", "supportFile": "./src/support", "fixturesFolder": "./src/fixtures", diff --git a/docs/FAQ.md b/docs/FAQ.md index c95e10ad4e..ea4ae352dd 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,7 +117,7 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. -### Why can't I dynamically install plugins without modifications the app? +### Why can't I dynamically install plugins without modifications to the app? This decision is part of the core architecture and development flow of Backstage. Plugins have a lot of freedom in what they provide and how they are diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md new file mode 100644 index 0000000000..aca7e42d96 --- /dev/null +++ b/docs/api/deprecations.md @@ -0,0 +1,63 @@ +--- +id: deprecations +title: Deprecations +description: A list of active and past deprecations +--- + +## Introduction + +This page contains extended documentation for some of the deprecations in +various parts of Backstage. It is not an exhaustive list as most deprecation +only come in the form of a changelog notice and a console warning. The +deprecations listed here are the ones that need a bit more guidance than what +fits in a console message. + +### App Theme + +`Released 2021-11-12 in @backstage/core-plugin-api v0.1.13` + +In order to provide more flexibility in what types of themes can be used and how +they are applied, the `theme` property on the `AppTheme` type is being +deprecated and replaced by a `Provider` property instead. The `Provider` +property is a React component that will be mounted at the root of the app +whenever that theme is active. This also removes the tight connection to MUI and +opens up for other type of themes, and removes the hardcoded usage of +``. + +To migrate an existing theme, remove the `theme` property and move it over to a +new `Provider` component, using `ThemeProvider` from MUI to provide the new +theme, along with ``. For example a theme that currently looks like +this: + +```tsx +const darkTheme = { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + theme: darkTheme, +}; +``` + +Would be migrated to the following: + +```tsx +const darkTheme = { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + Provider: ({ children }) => ( + + {children} + + ), +}; +``` + +Note that the existing `AppTheme` type still requires the `theme` property to be +set since it's the type that's consumed in the `AppThemeApi`, and it would be a +breaking change to make `theme` optional. This means that if you currently +construct the themes that you pass on to `createApp` using `AppTheme` as an +intermediate type, you will need to work around this in some way, for example by +passing the themes to `createApp` more directly. diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 1b364bcdae..133653df44 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -199,7 +199,7 @@ export a class that `implements` the target API, for example: ```ts export class IgnoringErrorApi implements ErrorApi { - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { // ignore error } } diff --git a/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md index a9a5ef5849..0893c105f4 100644 --- a/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md +++ b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md @@ -1,6 +1,6 @@ --- id: adrs-adr012 -title: ADR000: Use Luxon.toLocaleString and date/time presets +title: ADR012: Use Luxon.toLocaleString and date/time presets description: Architecture Decision Record (ADR) for using Luxon's toLocaleString method and date/time presets for displaying dates and times --- diff --git a/docs/assets/features/techdocs/techdocs-cli-serve-preview.png b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png new file mode 100644 index 0000000000..bcd48982f4 Binary files /dev/null and b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png differ diff --git a/docs/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md index 472a6a0abc..07d6869fe3 100644 --- a/docs/auth/add-auth-provider.md +++ b/docs/auth/add-auth-provider.md @@ -229,7 +229,7 @@ name. ### Test the new provider -You can `curl -i localhost:7000/api/auth/providerA/start` and which should +You can `curl -i localhost:7007/api/auth/providerA/start` and which should provide a `302` redirect with a `Location` header. Paste the url from that header into a web browser and you should be able to trigger the authorization flow. diff --git a/docs/auth/atlassian/provider.md b/docs/auth/atlassian/provider.md index 38774ca3cc..cded6e73ca 100644 --- a/docs/auth/atlassian/provider.md +++ b/docs/auth/atlassian/provider.md @@ -28,7 +28,7 @@ Name your integration and click on the `Create` button. Settings for local development: -- Callback URL: `http://localhost:7000/api/auth/atlassian` +- Callback URL: `http://localhost:7007/api/auth/atlassian` - Use rotating refresh tokens - For permissions, you **must** enable `View user profile` for the currently logged-in user, under `User identity API` diff --git a/docs/auth/auth0/provider.md b/docs/auth/auth0/provider.md index 05553c3fd3..80106a73c9 100644 --- a/docs/auth/auth0/provider.md +++ b/docs/auth/auth0/provider.md @@ -17,7 +17,7 @@ provider that can authenticate users using OAuth. - Application type: Single Page Web Application 4. Click on the Settings tab 5. Add under `Application URIs` > `Allowed Callback URLs`: - `http://localhost:7000/api/auth/auth0/handler/frame` + `http://localhost:7007/api/auth/auth0/handler/frame` 6. Click `Save Changes` ## Configuration diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index ae09d5dbee..63dfb815e0 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -20,7 +20,7 @@ Click Add Consumer. Settings for local development: - Application name: Backstage (or your custom app name) -- Callback URL: `http://localhost:7000/api/auth/bitbucket` +- Callback URL: `http://localhost:7007/api/auth/bitbucket` - Other are optional - (IMPORTANT) **Permissions: Account - Read, Workspace membership - Read** diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index d8803e392d..14b99bfea5 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -24,7 +24,7 @@ Settings for local development: - Application name: Backstage (or your custom app name) - Homepage URL: `http://localhost:3000` -- Authorization callback URL: `http://localhost:7000/api/auth/github` +- Authorization callback URL: `http://localhost:7007/api/auth/github` ## Configuration diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index fd64ddac14..90939a1f03 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -17,7 +17,7 @@ should point to your Backstage backend auth handler. Settings for local development: - Name: Backstage (or your custom app name) -- Redirect URI: `http://localhost:7000/api/auth/gitlab/handler/frame` +- Redirect URI: `http://localhost:7007/api/auth/gitlab/handler/frame` - Scopes: read_user ## Configuration diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md index 6216b21704..d3cd8f2dd2 100644 --- a/docs/auth/google/provider.md +++ b/docs/auth/google/provider.md @@ -26,7 +26,7 @@ To support Google authentication, you must create OAuth credentials: - `Name`: Backstage (or your custom app name) - `Authorized JavaScript origins`: http://localhost:3000 - `Authorized Redirect URIs`: - http://localhost:7000/api/auth/google/handler/frame + http://localhost:7007/api/auth/google/handler/frame 7. Click Create ## Configuration diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 1b81ff76f1..1e24235f1a 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -21,7 +21,7 @@ To support Azure authentication, you must create an App Registration: 4. Register an application - Name: Backstage (or your custom app name) - Redirect URI: Web > - `http://localhost:7000/api/auth/microsoft/handler/frame` + `http://localhost:7007/api/auth/microsoft/handler/frame` 5. Navigate to **Certificates & secrets > New client secret** to create a secret ## Configuration diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index b5aaabe4f1..35394094f8 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -22,8 +22,8 @@ To add Okta authentication, you must create an Application from Okta: - `App integration name`: `Backstage` (or your custom app name) - `Grant type`: `Authorization Code` & `Refresh Token` - `Sign-in redirect URIs`: - `http://localhost:7000/api/auth/okta/handler/frame` - - `Sign-out redirect URIs`: `http://localhost:7000` + `http://localhost:7007/api/auth/okta/handler/frame` + - `Sign-out redirect URIs`: `http://localhost:7007` - `Controlled access`: (select as appropriate) - Click Save diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md index a11304ae84..c3572367d2 100644 --- a/docs/auth/onelogin/provider.md +++ b/docs/auth/onelogin/provider.md @@ -18,7 +18,7 @@ To support OneLogin authentication, you must create an Application: 3. Click Save 4. Go to the Configuration tab for the Application and set: - `Login Url`: `http://localhost:3000` - - `Redirect URIs`: `http://localhost:7000/api/auth/onelogin/handler/frame` + - `Redirect URIs`: `http://localhost:7007/api/auth/onelogin/handler/frame` 5. Click Save 6. Go to the SSO tab for the Application and set: - `Token Endpoint` > `Authentication Method`: `POST` diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 4288d23b17..7945d6c980 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -16,8 +16,8 @@ app: baseUrl: http://localhost:3000 backend: - listen: 0.0.0.0:7000 - baseUrl: http://localhost:7000 + listen: 0.0.0.0:7007 + baseUrl: http://localhost:7007 organization: name: CNCF @@ -67,13 +67,15 @@ production build. ## Configuration Files -It is possible to have multiple configuration files, both to support different -environments, but also to define configuration that is local to specific -packages. The configuration files to load are selected using a `--config ` -flag, and it is possible to load any number of files. Paths are relative to the -working directory of the executed process, for example `package/backend`. This -means that to select a config file in the repo root when running the backend, -you would use `--config ../../my-config.yaml`. +It is possible to have multiple configuration files (bundled and/or remote), +both to support different environments, but also to define configuration that is +local to specific packages. The configuration files to load are selected using a +`--config ` flag, and it is possible to load any number of +files. Paths are relative to the working directory of the executed process, for +example `package/backend`. This means that to select a config file in the repo +root when running the backend, you would use `--config ../../my-config.yaml`, +and for config file on a config server you would use +`--config https://some.domain.io/app-config.yaml` If no `config` flags are specified, the default behavior is to load `app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root. @@ -85,7 +87,7 @@ are NOT loaded. To include them you need to explicitly include them with a flag, for example: ```shell -yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml +yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml --config https://some.domain.io/app-config.yaml ``` All loaded configuration files are merged together using the following rules: diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 7230f5cf16..7cbd3cea89 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -105,11 +105,11 @@ docker image build . -f packages/backend/Dockerfile --tag backstage To try out the image locally you can run the following: ```sh -docker run -it -p 7000:7000 backstage +docker run -it -p 7007:7007 backstage ``` You should then start to get logs in your terminal, and then you can open your -browser at `http://localhost:7000` +browser at `http://localhost:7007` ## Multi-stage Build @@ -208,11 +208,11 @@ docker image build -t backstage . To try out the image locally you can run the following: ```sh -docker run -it -p 7000:7000 backstage +docker run -it -p 7007:7007 backstage ``` You should then start to get logs in your terminal, and then you can open your -browser at `http://localhost:7000` +browser at `http://localhost:7007` ## Separate Frontend diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 2c52e0e308..a80ba1816e 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -351,7 +351,7 @@ spec: imagePullPolicy: IfNotPresent ports: - name: http - containerPort: 7000 + containerPort: 7007 envFrom: - secretRef: name: postgres-secrets @@ -361,11 +361,11 @@ spec: # https://backstage.io/docs/plugins/observability#health-checks # readinessProbe: # httpGet: -# port: 7000 +# port: 7007 # path: /healthcheck # livenessProbe: # httpGet: -# port: 7000 +# port: 7007 # path: /healthcheck ``` @@ -449,7 +449,7 @@ spec: ``` The `selector` here is telling the Service which pods to target, and the port -mapping translates normal HTTP port 80 to the backend http port (7000) on the +mapping translates normal HTTP port 80 to the backend http port (7007) on the pod. Apply this Service to the Kubernetes cluster: @@ -464,10 +464,10 @@ reveal**_, you can forward a local port to the service: ```shell $ sudo kubectl port-forward --namespace=backstage svc/backstage 80:80 -Forwarding from 127.0.0.1:80 -> 7000 +Forwarding from 127.0.0.1:80 -> 7007 ``` -This shows port 7000 since `port-forward` doesn't _really_ support services, so +This shows port 7007 since `port-forward` doesn't _really_ support services, so it cheats by looking up the first pod for a service and connecting to the mapped pod port. @@ -486,7 +486,7 @@ organization: backend: baseUrl: http://localhost listen: - port: 7000 + port: 7007 cors: origin: http://localhost ``` diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index aa40c1e539..27219e529a 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -109,6 +109,25 @@ const routes = ( ); ``` +### Using the Search Modal + +In `Root.tsx`, add the `SidebarSearchModal` component: + +```bash +import { SidebarSearchModal } from '@backstage/plugin-search'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + +... +``` + +For more information about using `Root.tsx`, please see +[the changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#0315). + ## Adding Search to the Backend Add the following plugins into your backend app: diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 8f855776a0..e80e8a1842 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -172,6 +172,7 @@ This page itself can be exported as a routable extension in the plugin: ```ts export const CustomCatalogIndexPage = myPlugin.provide( createRoutableExtension({ + name: 'CustomCatalogIndexPage', component: () => import('./components/CustomCatalogPage').then(m => m.CustomCatalogPage), mountPoint: catalogRouteRef, diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 7100e86ed0..57473ce4c7 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -144,7 +144,8 @@ spec: Note that to be able to read from targets that are outside of the normal integration points such as `github.com`, you'll need to explicitly allow it by -adding an entry in the `backend.reading.allow` list. For example: +adding an entry in the `backend.reading.allow` list. Paths can be specified to +further restrict targets For example: ```yml backend: @@ -153,6 +154,8 @@ backend: allow: - host: example.com - host: '*.examples.org' + - host: example.net + paths: ['/api/'] ``` ## Common to All Kinds: The Envelope diff --git a/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md b/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md index 280eab083d..4cc9a6a8e6 100644 --- a/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md +++ b/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md @@ -125,8 +125,8 @@ input schema. input: - address: '{{ json parameters.address }}' + address: ${{ parameters.address }} -- number: '{{ parameters.number }}' -+ number: ${{ parameters.number }} # this will now make sure that the type of number is a number ๐Ÿ™ +- test: '{{ parameters.test }}' ++ test: ${{ parameters.test }} # this will now make sure that the type of test is a number ๐Ÿ™ ``` ## `parseRepoUrl` is now a `filter` diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 07c4891a6c..a564200bfe 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -8,6 +8,10 @@ If you're wanting to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used along side our [built-in actions](./builtin-actions.md). +> Note: When adding custom actions, the actions array will **replace the +> built-in actions too**. To ensure you can continue to include he builtin +> actions, see below to include them during registration of your action. + ### Writing your Custom Action Your custom action can live where you choose, but simplest is to include it @@ -91,6 +95,8 @@ argument. It looks like the following: - `createTemporaryDirectory` a function to call to give you a temporary directory somewhere on the runner so you can store some files there rather than polluting the `workspacePath` +- `ctx.metadata` - an object containing a `name` field, indicating the template + name. More metadata fields may be added later. ### Registering Custom Actions @@ -113,22 +119,6 @@ return await createRouter({ There's another property you can pass here, which is an array of `actions` which will set the available actions that the scaffolder has access to. -```ts -const actions = [createNewFileAction()]; -return await createRouter({ - containerRunner, - logger, - config, - database, - catalogClient, - reader, - actions, -}); -``` - -**NOTE** - the actions array will replace the built-in actions too, so if you -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 { ScmIntegrations } from '@backstage/integration'; @@ -144,7 +134,6 @@ const builtInActions = createBuiltinActions({ }); const actions = [...builtInActions, createNewFileAction()]; - return await createRouter({ containerRunner, logger, diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index f95177e029..81f2622312 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -284,8 +284,8 @@ to publish to. And it can be any host that is listed in your `integrations` config in `app-config.yaml`. The `RepoUrlPicker` is a custom field that we provide part of the -`plugin-scaffolder`. It's currently not possible to create your own fields yet, -but contributions are welcome! :) +`plugin-scaffolder`. You can provide your own custom fields by +[writing your own Custom Field Extensions](./writing-custom-field-extensions.md) #### The Owner Picker diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md new file mode 100644 index 0000000000..5548adf7f0 --- /dev/null +++ b/docs/features/techdocs/cli.md @@ -0,0 +1,241 @@ +# TechDocs CLI + +Utility command line interface for managing TechDocs sites in +[Backstage](https://github.com/backstage/backstage). + +https://backstage.io/docs/features/techdocs/techdocs-overview + +## Features + +- Supports local development/preview of a TechDocs site in a Backstage app. +- Supports generation and publishing of a documentation site in a CI/CD + workflow. + +```bash +techdocs-cli --help +Usage: techdocs-cli [options] [command] + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + generate|build [options] Generate TechDocs documentation site using mkdocs. + publish [options] Publish generated TechDocs site to an external storage AWS S3, + Google GCS, etc. + serve:mkdocs [options] Serve a documentation project locally using mkdocs serve. + serve [options] Serve a documentation project locally in a Backstage app-like + environment + help [command] display help for command +``` + +## Installation + +You can always use [`npx`](https://github.com/npm/npx) to run the latest version +of `techdocs-cli` - + +```bash +npx @techdocs/cli [command] +``` + +Or you can install it using [npm](https://www.npmjs.com/package/@techdocs/cli) - + +```bash +npm install -g @techdocs/cli +techdocs-cli [command] +``` + +## Usage + +### Preview TechDocs site locally in a Backstage like environment + +```bash +techdocs-cli serve +``` + +![A preview of techdocs-cli serve command](../../assets/features/techdocs/techdocs-cli-serve-preview.png) + +By default, Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) is used to +make sure all the dependencies are installed. However, Docker can be disabled +with `--no-docker` flag. + +The command starts two local servers - an MkDocs preview server on port 8000 and +a Backstage app server on port 3000. The Backstage app has a custom TechDocs API +implementation, which uses the MkDocs preview server as a proxy to fetch the +generated documentation files and assets. + +NOTE: When using a custom `techdocs` docker image, make sure the entry point is +also `ENTRYPOINT ["mkdocs"]`. + +Command reference: + +```bash +Usage: techdocs-cli serve [options] + +Serve a documentation project locally in a Backstage app-like environment + +Options: + -i, --docker-image The mkdocs docker container to use (default: "spotify/techdocs") + --no-docker Do not use Docker, use MkDocs executable in current user environment. + --mkdocs-port Port for MkDocs server to use (default: "8000") + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Generate TechDocs site from a documentation project + +```bash +techdocs-cli generate +``` + +Alias: `techdocs-cli build` + +The generate command uses the +[`@backstage/techdocs-common`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) +package from Backstage for consistency. A Backstage app can also generate and +publish TechDocs sites if `techdocs.builder` is set to `'local'` in +`app-config.yaml`. See +[configuration reference](https://backstage.io/docs/features/techdocs/configuration). + +By default, this command uses Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) to make +sure all the dependencies are installed. But it can be disabled using +`--no-docker` flag. + +Command reference: + +```bash +techdocs-cli generate --help +Usage: techdocs-cli generate|build [options] + +Generate TechDocs documentation site using MkDocs. + +Options: + --source-dir Source directory containing mkdocs.yml and docs/ directory. (default: ".") + --output-dir Output directory containing generated TechDocs site. (default: "./site/") + --docker-image The mkdocs docker container to use (default: "spotify/techdocs:v0.3.4") + --no-pull Do not pull the latest docker image + --no-docker Do not use Docker, use MkDocs executable and plugins in current user environment. + --techdocs-ref The repository hosting documentation source files e.g. + github:https://ghe.mycompany.net.com/org/repo. + This value is same as the backstage.io/techdocs-ref annotation of the corresponding + Backstage entity. + It is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml + if not found. + --etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored + in techdocs_metadata.json. + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Publish generated TechDocs sites + +```bash +techdocs-cli publish --publisher-type --storage-name --entity +``` + +After generating a TechDocs site using `techdocs-cli generate`, use the publish +command to upload the static generated files on a cloud storage (AWS/GCS) bucket +or (Azure) container which your Backstage app can read from. + +The value for `--entity` must be the Backstage entity which the generated +TechDocs site belongs to. You can find the values in your Entity's +`catalog-info.yaml` file. If namespace is missing in the `catalog-info.yaml`, +use `default`. The directory structure used in the storage bucket is +`namespace/kind/name/`. + +Note that the values are case-sensitive. An example for `--entity` is +`default/Component/`. + +Command reference: + +```bash +Usage: techdocs-cli publish [options] + +Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc. + +Options: + --publisher-type (Required always) awsS3 | googleGcs | azureBlobStorage + - same as techdocs.publisher.type in Backstage + app-config.yaml + --storage-name (Required always) In case of AWS/GCS, use the bucket + name. In case of Azure, use container name. Same as + techdocs.publisher.[TYPE].bucketName + --entity (Required always) Entity uid separated by / in + namespace/kind/name order (case-sensitive). Example: + default/Component/myEntity + --legacyUseCaseSensitiveTripletPaths Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). + Only use if your TechDocs backend is configured the same way + --azureAccountName (Required for Azure) specify when --publisher-type + azureBlobStorage + --azureAccountKey Azure Storage Account key to use for authentication. + If not specified, you must set AZURE_TENANT_ID, + AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment + variables. + --awsRoleArn Optional AWS ARN of role to be assumed. + --awsEndpoint Optional AWS endpoint to send requests to. + --awsS3ForcePathStyle Optional AWS S3 option to force path style. + --directory Path of the directory containing generated files to + publish (default: "./site/") + -h, --help display help for command +``` + +### Migrate content for case-insensitive access + +Prior to the beta version of TechDocs (`v[0.11.0]`), TechDocs were stored in +object storage using a case-sensitive entity triplet (e.g. +`default/API/name/index.html`). This resulted in a limitation where that exact +case was required in the Backstage URL in order to read/render TechDocs content. +As of `v[0.11.0]` of the TechDocs plugin, any case is allowed in the URL (e.g. +`default/api/name`), matching the behavior of the Catalog plugin. + +Backstage instances created with TechDocs `v[0.11.0]` or later do not need this +command. However, when upgrading to this version from an older version of +TechDocs, the `migrate` command can be used prior to deployment to ensure docs +remain accessible without having to rebuild all docs. + +Prior to upgrading to `v[0.11.0]` or greater, run this command to copy all +assets to their lower-case triplet equivalents like this: + +```bash +techdocs-cli migrate --publisher-type --storage-name --verbose +``` + +Once migrated and the upgraded version of the Backstage plugin has been +deployed, you can clean up the legacy, case-sensitive triplet files by +re-running the command with the `--removeOriginal` flag passed, which _moves_ +(rather than copies) the files. Note: this deletes files and is therefore a +destructive operation that should performed with caution. + +```bash +techdocs-cli migrate --publisher-type --storage-name --removeOriginal --verbose +``` + +Afterward, update your TechDocs CLI to `v[0.7.0]` to ensure further publishing +happens using a lower-case entity triplet. + +Note: arguments for this command largely match those of the `publish` command, +depending on your chosen storage provider. Run `techdocs-cli migrate --help` for +details. + +#### Authentication + +You need to make sure that your environment is able to authenticate with the +target cloud provider. `techdocs-cli` uses the official Node.js clients provided +by AWS (v2), Google Cloud and Azure. You can authenticate using environment +variables and/or by other means (`~/.aws/credentials`, `~/.config/gcloud` etc.) + +Refer to the Authentication section of the following documentation depending +upon your cloud storage provider - + +- [Google Cloud Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-google-gcs-bucket-with-techdocs) +- [AWS S3](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs) +- [Azure Blob Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-azure-blob-storage-container-with-techdocs) + +## Development + +You are welcome to contribute to TechDocs CLI to improve it and support new +features! See the project +[README](https://github.com/backstage/backstage/blob/main/src/packages/techdocs-cli/README.md) +for more information. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index fbe9f36540..4148749a62 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -106,6 +106,12 @@ techdocs: # This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. s3ForcePathStyle: false + # (Optional) AWS Server Side Encryption + # Defaults to undefined. + # If not set, encrypted buckets will fail to publish. + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + sse: 'aws:kms' # or AES256 + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. azureBlobStorage: @@ -132,11 +138,11 @@ techdocs: # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. - requestUrl: http://localhost:7000/api/techdocs + requestUrl: http://localhost:7007/api/techdocs # (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware # to serve files from either a local directory or an External storage provider. # You don't have to specify this anymore. - storageUrl: http://localhost:7000/api/techdocs/static/docs + storageUrl: http://localhost:7007/api/techdocs/static/docs ``` diff --git a/docs/getting-started/contributors.md b/docs/getting-started/contributors.md index 28b062b5ca..9154eae4d1 100644 --- a/docs/getting-started/contributors.md +++ b/docs/getting-started/contributors.md @@ -43,7 +43,7 @@ the project root. Make sure you have run the above mentioned commands first. $ yarn dev ``` -This is going to start two things, the frontend (:3000) and the backend (:7000). +This is going to start two things, the frontend (:3000) and the backend (:7007). This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 9632be71bf..5a970ef143 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -37,7 +37,7 @@ guide to do a repository-based installation. - `docker` [installation](https://docs.docker.com/engine/install/) - `git` [installation](https://github.com/git-guides/install-git) - If the system is not directly accessible over your network, the following - ports need to be opened: 3000, 7000 + ports need to be opened: 3000, 7007 ### Create your Backstage App diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 873c26a099..daad7bf195 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -70,7 +70,7 @@ cd packages/backend yarn start ``` -That starts up a backend instance on port 7000. +That starts up a backend instance on port 7007. In the other window, we will then launch the frontend. This command is run from the project root, not inside the backend directory. diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 0397ffa09c..18fdef892a 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -41,6 +41,7 @@ lint Lint a package test Run tests, forwarding args to Jest, defaulting to watch mode clean Delete cache directories +create Open up an interactive guide to creating new things in your app create-plugin Creates a new plugin in the current repository remove-plugin Removes plugin in the current repository @@ -277,6 +278,44 @@ Options: -h, --help display help for command ``` +## create + +Scope: `root` + +The `create` command opens up an interactive guide for you to create new things +in your app. If you do not pass in any options it is completely interactive, but +it is possible to pre-select what you want to create using the `--select` flag, +and provide options using `--options`, for example: + +```bash +backstage-cli create --select plugin --option id=foo +``` + +This command is typically added as script in the root `package.json` to be +executed with `yarn backstage-create`, using options that are appropriate for +the organization that owns the app repo. For example you may have it set up like +this: + +```json +{ + "scripts": { + "backstage-create": "backstage-cli create --scope internal --no-private --npm-registry https://acme.org/npm" + } +} +``` + +```text +Usage: backstage-cli create [options] + +Options: + --select Select the thing you want to be creating upfront + --option = Pre-fill options for the creation process (default: []) + --scope The scope to use for new packages + --npm-registry The package registry to use for new packages + --no-private Do not mark new packages as private + -h, --help display help for command +``` + ## create-plugin Scope: `root` @@ -512,7 +551,8 @@ 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 + --lax Do not require environment variables to be set + --frontend Only validate the frontend configuration --config <path> Config files to load instead of app-config.yaml (default: []) -h, --help display help for command ``` diff --git a/docs/openapi/definitions/auth.yaml b/docs/openapi/definitions/auth.yaml index 411ce69253..032d89523d 100644 --- a/docs/openapi/definitions/auth.yaml +++ b/docs/openapi/definitions/auth.yaml @@ -21,7 +21,7 @@ externalDocs: description: Backstage official documentation url: https://github.com/backstage/backstage/blob/master/docs/README.md servers: - - url: http://localhost:7000/api/auth/ + - url: http://localhost:7007/api/auth/ tags: - name: provider description: List of endpoints per provider diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index 7adf2650df..ace9a89968 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -261,10 +261,13 @@ analytics events captured. Use it like this: ```tsx -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { analyticsApiRef } from '@backstage/core-plugin-api'; -import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; describe('SomeComponent', () => { it('should capture event on click', () => { @@ -274,9 +277,9 @@ describe('SomeComponent', () => { // Render the component being tested const { getByText } = render( wrapInTestApp( - + - , + , ), ); diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 1d728b76aa..5ff0e5a62f 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -44,11 +44,11 @@ cd plugins/carmen-backend yarn start ``` -This will think for a bit, and then say `Listening on :7000`. In a different +This will think for a bit, and then say `Listening on :7007`. In a different terminal window, now run ```sh -curl localhost:7000/carmen/health +curl localhost:7007/carmen/health ``` This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it @@ -107,7 +107,7 @@ root), you should be able to fetch data from it. ```sh # Note the extra /api here -curl localhost:7000/api/carmen/health +curl localhost:7007/api/carmen/health ``` This should return `{"status":"ok"}` like before. Success! diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index 8d22c05a79..33568ac561 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -136,6 +136,7 @@ a component: ```ts export const FooPage = plugin.provide( createRoutableExtension({ + name: 'FooPage', component: () => import('./components/FooPage').then(m => m.FooPage), mountPoint: fooPageRouteRef, }), @@ -417,6 +418,7 @@ export const myPlugin = createPlugin({ export const MyPage = myPlugin.provide( createRoutableExtension({ + name: 'MyPage', component: () => import('./components/MyPage').then(m => m.MyPage), mountPoint: rootRouteRef, }), diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index f61fae0968..362ecc5d5a 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -65,6 +65,7 @@ export const examplePlugin = createPlugin({ // Each extension should also be exported from your plugin package. export const ExamplePage = examplePlugin.provide( createRoutableExtension({ + name: 'ExamplePage', // The component needs to be lazy-loaded. It's what will actually be rendered in the end. component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 83fcfbddba..9e8b67710a 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -73,6 +73,7 @@ export const examplePlugin = createPlugin({ export const ExamplePage = examplePlugin.provide( createRoutableExtension({ + name: 'ExamplePage', component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), mountPoint: rootRouteRef, diff --git a/lerna.json b/lerna.json index 322929db1d..4621689664 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.0" + "version": "0.0.0" } diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 520a2a5f10..2df8ce84f3 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -1,5 +1,5 @@ --- -title: Starting Phase 2: The Service Catalog +title: 'Starting Phase 2: The Service Catalog' author: Stefan ร…lund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index 98fbad57c5..f9d7359526 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -1,5 +1,5 @@ --- -title: Announcing TechDocs: Spotifyโ€™s docs-like-code plugin for Backstage +title: 'Announcing TechDocs: Spotifyโ€™s docs-like-code plugin for Backstage' author: Gary Niemen, Spotify authorURL: https://github.com/garyniemen --- diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index d265698694..139e9a7ce9 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -1,5 +1,5 @@ --- -title: New Cost Insights plugin: The engineerโ€™s solution to taming cloud costs +title: 'New Cost Insights plugin: The engineerโ€™s solution to taming cloud costs' author: Janisa Anandamohan, Spotify authorURL: https://twitter.com/janisa_a --- diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md index a0d11a580f..9b8c4637bd 100644 --- a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md +++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md @@ -1,5 +1,5 @@ --- -title: New Backstage feature: Kubernetes for Service owners +title: 'New Backstage feature: Kubernetes for Service owners' author: Matthew Clarke, Spotify authorURL: https://github.com/mclarke47 --- diff --git a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md index 0a4ae572c2..6e6c9b7819 100644 --- a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md +++ b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md @@ -1,5 +1,5 @@ --- -title: Announcing the Backstage Search platform: a customizable search tool built just for you +title: 'Announcing the Backstage Search platform: a customizable search tool built just for you' author: Emma Indal, Spotify authorURL: https://www.linkedin.com/in/emma-indal --- diff --git a/microsite/data/plugins/splunk-on-call.yaml b/microsite/data/plugins/splunk-on-call.yaml index 15ceb86a1b..bb0e25111a 100644 --- a/microsite/data/plugins/splunk-on-call.yaml +++ b/microsite/data/plugins/splunk-on-call.yaml @@ -1,7 +1,7 @@ --- title: Splunk On-Call -author: Spotify -authorUrl: https://github.com/spotify +author: ayshiff +authorUrl: https://github.com/ayshiff category: Monitoring description: Splunk On-Call offers a simple way to identify incidents and escalation policies. documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call diff --git a/microsite/sidebars.json b/microsite/sidebars.json index bee9cd7180..a24d5897ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -105,6 +105,7 @@ "features/techdocs/configuration", "features/techdocs/using-cloud-storage", "features/techdocs/configuring-ci-cd", + "features/techdocs/cli", "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" @@ -151,7 +152,10 @@ { "type": "subcategory", "label": "GitLab", - "ids": ["integrations/gitlab/locations"] + "ids": [ + "integrations/gitlab/locations", + "integrations/gitlab/discovery" + ] }, { "type": "subcategory", @@ -253,7 +257,8 @@ "type": "subcategory", "label": "API Reference", "ids": ["reference/index"] - } + }, + "api/deprecations" ], "Tutorials": [ "tutorials/journey", @@ -275,7 +280,8 @@ "architecture-decisions/adrs-adr008", "architecture-decisions/adrs-adr009", "architecture-decisions/adrs-adr010", - "architecture-decisions/adrs-adr011" + "architecture-decisions/adrs-adr011", + "architecture-decisions/adrs-adr012" ], "FAQ": ["FAQ"] } diff --git a/microsite/yarn.lock b/microsite/yarn.lock index f3834ac734..c03ecf989a 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -1624,15 +1624,6 @@ cli-width@^2.0.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== -clipboard@^2.0.0: - version "2.0.6" - resolved "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376" - integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg== - dependencies: - good-listener "^1.2.2" - select "^1.1.2" - tiny-emitter "^2.0.0" - clone-response@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" @@ -1687,9 +1678,9 @@ color-name@^1.0.0, color-name@~1.1.4: integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== color-string@^1.5.4: - version "1.5.4" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" - integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + version "1.6.0" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" + integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -2158,11 +2149,6 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegate@^3.1.2: - version "3.2.0" - resolved "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" - integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== - depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3097,13 +3083,6 @@ globule@^1.0.0: lodash "~4.17.10" minimatch "~3.0.2" -good-listener@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" - integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= - dependencies: - delegate "^3.1.2" - got@^7.0.0: version "7.1.0" resolved "https://registry.npmjs.org/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" @@ -3276,9 +3255,9 @@ highlight.js@^9.16.2: integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hsl-regex@^1.0.0: version "1.0.0" @@ -4815,9 +4794,9 @@ path-key@^2.0.0, path-key@^2.0.1: integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + version "1.0.7" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@0.1.7: version "0.1.7" @@ -4850,6 +4829,11 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" + integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== + pify@^2.0.0, pify@^2.2.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -5189,13 +5173,12 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.23, postcss@^7.0.27, postcss@^7.0.32: - version "7.0.35" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" - integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + version "7.0.39" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" + integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== dependencies: - chalk "^2.4.2" + picocolors "^0.2.1" source-map "^0.6.1" - supports-color "^6.1.0" prepend-http@^1.0.1: version "1.0.4" @@ -5213,11 +5196,9 @@ prettier@^2.4.1: integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== prismjs@^1.22.0: - version "1.23.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" - integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== - optionalDependencies: - clipboard "^2.0.0" + version "1.25.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@~2.0.0: version "2.0.1" @@ -5709,11 +5690,6 @@ seek-bzip@^1.0.5: dependencies: commander "^2.8.1" -select@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" - integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= - semver-regex@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" @@ -5766,9 +5742,9 @@ serve-static@1.14.1: send "0.17.1" set-getter@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" - integrity sha1-12nBgsnVpR9AkUXy+6guXoboA3Y= + version "0.1.1" + resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102" + integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw== dependencies: to-object-path "^0.3.0" @@ -6150,13 +6126,6 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -6250,11 +6219,6 @@ timsort@^0.3.0: resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tiny-emitter@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-lr@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" @@ -6502,9 +6466,9 @@ url-parse-lax@^3.0.0: prepend-http "^2.0.0" url-parse@^1.4.3: - version "1.4.7" - resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" - integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + version "1.5.3" + resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" diff --git a/mkdocs.yml b/mkdocs.yml index 2f4aa4203f..48df25afef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,7 +61,6 @@ nav: - 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' - Getting Started: 'features/search/getting-started.md' @@ -78,6 +77,7 @@ nav: - 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' + - CLI: 'features/techdocs/cli.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' @@ -100,6 +100,7 @@ nav: - Org Data: 'integrations/github/org.md' - GitLab: - Locations: 'integrations/gitlab/locations.md' + - Discovery: 'integrations/gitlab/discovery.md' - Google GCS: - Locations: 'integrations/google-cloud-storage/locations.md' - LDAP: @@ -163,6 +164,7 @@ nav: - API Reference: - Guides: - Utility APIs: 'api/utility-apis.md' + - Deprecations: 'api/deprecations.md' - Tutorials: - Future developer journey: 'tutorials/journey.md' - Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md' diff --git a/package.json b/package.json index f5d20fd39d..16ba8461ac 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", - "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", + "backstage-create": "backstage-cli create --scope backstage --no-private", + "create-plugin": "yarn backstage-create --select plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install", "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", + "techdocs-cli": "node scripts/techdocs-cli.js", + "techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js", "prepare": "husky install", "lock:check": "yarn-lock-check" }, @@ -46,30 +49,32 @@ "resolutions": { "**/@graphql-codegen/cli/**/ws": "^7.4.6", "**/@roadiehq/**/@backstage/plugin-catalog": "*", - "**/@roadiehq/**/@backstage/catalog-model": "*", - "graphql-language-service-interface": "2.8.2", - "graphql-language-service-parser": "1.9.0" + "**/@roadiehq/**/@backstage/catalog-model": "*" }, "version": "1.0.0", "dependencies": { - "@microsoft/api-documenter": "^7.13.47", + "@microsoft/api-documenter": "^7.13.68", "@microsoft/api-extractor": "^7.18.7", "@microsoft/api-extractor-model": "^7.13.5", "@microsoft/tsdoc": "^0.13.2" }, "devDependencies": { - "@types/webpack": "^5.28.0", "@changesets/cli": "^2.14.0", + "@octokit/rest": "^18.12.0", "@spotify/prettier-config": "^11.0.0", + "@types/webpack": "^5.28.0", "command-exists": "^1.2.9", + "cross-env": "^7.0.0", "concurrently": "^6.0.0", "eslint-plugin-notice": "^0.9.10", "fs-extra": "9.1.0", "husky": "^6.0.0", "lerna": "^4.0.0", "lint-staged": "^11.1.2", + "minimist": "^1.2.5", "prettier": "^2.2.1", "shx": "^0.3.2", + "ts-node": "^10.4.0", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config", diff --git a/packages/app-defaults/.eslintrc.js b/packages/app-defaults/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/app-defaults/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/app-defaults/CHANGELOG.md b/packages/app-defaults/CHANGELOG.md new file mode 100644 index 0000000000..b2b50ae48b --- /dev/null +++ b/packages/app-defaults/CHANGELOG.md @@ -0,0 +1,10 @@ +# @backstage/app-defaults + +## 0.1.1 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 diff --git a/packages/app-defaults/README.md b/packages/app-defaults/README.md new file mode 100644 index 0000000000..15a0bd3d24 --- /dev/null +++ b/packages/app-defaults/README.md @@ -0,0 +1,17 @@ +# @backstage/app-defaults + +This package provides a default wiring of a Backstage app that avoids boilerplate when setting up a standard Backstage app. + +## Installation + +Install the package via Yarn: + +```sh +cd packages/app +yarn add @backstage/app-defaults +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/app-defaults/api-report.md b/packages/app-defaults/api-report.md new file mode 100644 index 0000000000..57ea61ad8b --- /dev/null +++ b/packages/app-defaults/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/app-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AppComponents } from '@backstage/core-app-api'; +import { AppIcons } from '@backstage/core-app-api'; +import { AppOptions } from '@backstage/core-app-api'; +import { AppTheme } from '@backstage/core-plugin-api'; +import { BackstageApp } from '@backstage/core-app-api'; +import { IconComponent } from '@backstage/core-plugin-api'; + +// @public +export function createApp( + options?: Omit & OptionalAppOptions, +): BackstageApp; + +// @public +export type OptionalAppOptions = { + icons?: Partial & { + [key in string]: IconComponent; + }; + themes?: (Partial & Omit)[]; + components?: Partial; +}; +``` diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json new file mode 100644 index 0000000000..7388a44ce0 --- /dev/null +++ b/packages/app-defaults/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/app-defaults", + "description": "Provides the default wiring of a Backstage App", + "version": "0.1.1", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/app-defaults" + }, + "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.7.4", + "@backstage/core-app-api": "^0.1.21", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "react": "^16.12.0", + "react-router-dom": "6.0.0-beta.0" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react": "*" + }, + "files": [ + "dist" + ] +} diff --git a/packages/app-defaults/src/createApp.test.tsx b/packages/app-defaults/src/createApp.test.tsx new file mode 100644 index 0000000000..45988a4840 --- /dev/null +++ b/packages/app-defaults/src/createApp.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { screen } from '@testing-library/react'; +import { renderWithEffects } from '@backstage/test-utils'; +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { createApp } from './createApp'; + +describe('Optional ThemeProvider', () => { + it('should render app with user-provided ThemeProvider', async () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: MemoryRouter, + ErrorBoundaryFallback: () => null, + ThemeProvider: ({ children }: PropsWithChildren<{}>) => ( +
{children}
+ ), + }; + + const App = createApp({ components }).getProvider(); + + await renderWithEffects(); + + expect(screen.getByRole('main')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-defaults/src/createApp.tsx b/packages/app-defaults/src/createApp.tsx new file mode 100644 index 0000000000..d85dfa04eb --- /dev/null +++ b/packages/app-defaults/src/createApp.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { apis, components, icons, themes } from './defaults'; +import { + AppTheme, + BackstagePlugin, + IconComponent, +} from '@backstage/core-plugin-api'; +import { + AppComponents, + AppOptions, + AppIcons, + createSpecializedApp, +} from '@backstage/core-app-api'; + +/** + * Creates a new Backstage App using a default set of components, icons and themes unless + * they are explicitly provided. + * + * @public + */ +export function createApp( + options?: Omit & OptionalAppOptions, +) { + return createSpecializedApp({ + ...options, + apis: options?.apis ?? [], + bindRoutes: options?.bindRoutes, + components: { + ...components, + ...options?.components, + }, + configLoader: options?.configLoader, + defaultApis: apis, + icons: { + ...icons, + ...options?.icons, + }, + plugins: (options?.plugins as BackstagePlugin[]) ?? [], + themes: options?.themes ?? themes, + }); +} + +/** + * The set of app options that {@link createApp} will provide defaults for + * if they are not passed in explicitly. + * + * @public + */ +export type OptionalAppOptions = { + /** + * A set of icons to override the default icons with. + * + * The override is applied for each icon individually. + * + * @public + */ + icons?: Partial & { + [key in string]: IconComponent; + }; + + /** + * A set of themes that override all of the default app themes. + * + * If this option is provided none of the default themes will be used. + * + * @public + */ + themes?: (Partial & Omit)[]; // TODO: simplify once AppTheme is updated + + /** + * A set of components to override the default components with. + * + * The override is applied for each icon individually. + * + * @public + */ + components?: Partial; +}; diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts new file mode 100644 index 0000000000..d3c76d52bd --- /dev/null +++ b/packages/app-defaults/src/defaults/apis.ts @@ -0,0 +1,274 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, + NoOpAnalyticsApi, + ErrorApiForwarder, + ErrorAlerter, + GoogleAuth, + GithubAuth, + OAuth2, + OktaAuth, + GitlabAuth, + Auth0Auth, + MicrosoftAuth, + BitbucketAuth, + OAuthRequestManager, + WebStorage, + UrlPatternDiscovery, + SamlAuth, + OneLoginAuth, + UnhandledErrorForwarder, + AtlassianAuth, +} from '@backstage/core-app-api'; + +import { + createApiFactory, + alertApiRef, + analyticsApiRef, + errorApiRef, + discoveryApiRef, + oauthRequestApiRef, + googleAuthApiRef, + githubAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, + auth0AuthApiRef, + microsoftAuthApiRef, + storageApiRef, + configApiRef, + samlAuthApiRef, + oneloginAuthApiRef, + oidcAuthApiRef, + bitbucketAuthApiRef, + atlassianAuthApiRef, +} from '@backstage/core-plugin-api'; + +export const apis = [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`, + ), + }), + createApiFactory({ + api: alertApiRef, + deps: {}, + factory: () => new AlertApiForwarder(), + }), + createApiFactory({ + api: analyticsApiRef, + deps: {}, + factory: () => new NoOpAnalyticsApi(), + }), + createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => { + const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); + UnhandledErrorForwarder.forward(errorApi, { hidden: false }); + return errorApi; + }, + }), + createApiFactory({ + api: storageApiRef, + deps: { errorApi: errorApiRef }, + factory: ({ errorApi }) => WebStorage.create({ errorApi }), + }), + createApiFactory({ + api: oauthRequestApiRef, + deps: {}, + factory: () => 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: () => null, + }, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: bitbucketAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['team'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: atlassianAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return AtlassianAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), +]; diff --git a/packages/app-defaults/src/defaults/components.test.tsx b/packages/app-defaults/src/defaults/components.test.tsx new file mode 100644 index 0000000000..865eeed8a7 --- /dev/null +++ b/packages/app-defaults/src/defaults/components.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { OptionallyWrapInRouter } from './components'; + +describe('OptionallyWrapInRouter', () => { + it('should wrap with router if not yet inside a router', async () => { + render(Test); + + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('should not wrap with router if already inside a router', async () => { + render( + + Test + , + ); + + expect(screen.getByText('Test')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-defaults/src/defaults/components.tsx b/packages/app-defaults/src/defaults/components.tsx new file mode 100644 index 0000000000..139bea4862 --- /dev/null +++ b/packages/app-defaults/src/defaults/components.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from 'react'; +import Button from '@material-ui/core/Button'; +import { ErrorPanel, Progress, ErrorPage } from '@backstage/core-components'; +import { + MemoryRouter, + useInRouterContext, + BrowserRouter, +} from 'react-router-dom'; +import { + AppComponents, + BootErrorPageProps, + ErrorBoundaryFallbackProps, +} from '@backstage/core-plugin-api'; + +export function OptionallyWrapInRouter({ children }: { children: ReactNode }) { + if (useInRouterContext()) { + return <>{children}; + } + return {children}; +} + +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 ( + + + + ); +}; + +/** + * Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}. + * + * @public + */ +export const components: AppComponents = { + Progress, + Router: BrowserRouter, + NotFoundErrorPage: DefaultNotFoundPage, + BootErrorPage: DefaultBootErrorPage, + ErrorBoundaryFallback: DefaultErrorBoundaryFallback, +}; diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/app-defaults/src/defaults/icons.tsx similarity index 63% rename from packages/core-app-api/src/app/icons.tsx rename to packages/app-defaults/src/defaults/icons.tsx index b234a292b1..c4f2d2b7e4 100644 --- a/packages/core-app-api/src/app/icons.tsx +++ b/packages/app-defaults/src/defaults/icons.tsx @@ -35,52 +35,27 @@ import MuiPeopleIcon from '@material-ui/icons/People'; import MuiPersonIcon from '@material-ui/icons/Person'; import MuiWarningIcon from '@material-ui/icons/Warning'; -type AppIconsKey = - | 'brokenImage' - | 'catalog' - | 'scaffolder' - | 'techdocs' - | 'search' - | 'chat' - | 'dashboard' - | 'docs' - | 'email' - | 'github' - | 'group' - | 'help' - | 'kind:api' - | 'kind:component' - | 'kind:domain' - | 'kind:group' - | 'kind:location' - | 'kind:system' - | 'kind:user' - | 'user' - | 'warning'; - -export type AppIcons = { [key in AppIconsKey]: IconComponent }; - -export const defaultAppIcons: AppIcons = { - brokenImage: MuiBrokenImageIcon, +export const icons = { + brokenImage: MuiBrokenImageIcon as IconComponent, // To be confirmed: see https://github.com/backstage/backstage/issues/4970 - catalog: MuiMenuBookIcon, - scaffolder: MuiCreateNewFolderIcon, - techdocs: MuiSubjectIcon, - search: MuiSearchIcon, - chat: MuiChatIcon, - dashboard: MuiDashboardIcon, - docs: MuiDocsIcon, - email: MuiEmailIcon, - github: MuiGitHubIcon, - group: MuiPeopleIcon, - help: MuiHelpIcon, - 'kind:api': MuiExtensionIcon, - 'kind:component': MuiMemoryIcon, - 'kind:domain': MuiApartmentIcon, - 'kind:group': MuiPeopleIcon, - 'kind:location': MuiLocationOnIcon, - 'kind:system': MuiCategoryIcon, - 'kind:user': MuiPersonIcon, - user: MuiPersonIcon, - warning: MuiWarningIcon, + catalog: MuiMenuBookIcon as IconComponent, + scaffolder: MuiCreateNewFolderIcon as IconComponent, + techdocs: MuiSubjectIcon as IconComponent, + search: MuiSearchIcon as IconComponent, + chat: MuiChatIcon as IconComponent, + dashboard: MuiDashboardIcon as IconComponent, + docs: MuiDocsIcon as IconComponent, + email: MuiEmailIcon as IconComponent, + github: MuiGitHubIcon as IconComponent, + group: MuiPeopleIcon as IconComponent, + help: MuiHelpIcon as IconComponent, + 'kind:api': MuiExtensionIcon as IconComponent, + 'kind:component': MuiMemoryIcon as IconComponent, + 'kind:domain': MuiApartmentIcon as IconComponent, + 'kind:group': MuiPeopleIcon as IconComponent, + 'kind:location': MuiLocationOnIcon as IconComponent, + 'kind:system': MuiCategoryIcon as IconComponent, + 'kind:user': MuiPersonIcon as IconComponent, + user: MuiPersonIcon as IconComponent, + warning: MuiWarningIcon as IconComponent, }; diff --git a/packages/app-defaults/src/defaults/index.ts b/packages/app-defaults/src/defaults/index.ts new file mode 100644 index 0000000000..d9ff18bc8c --- /dev/null +++ b/packages/app-defaults/src/defaults/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { apis } from './apis'; +export { components } from './components'; +export { icons } from './icons'; +export { themes } from './themes'; diff --git a/packages/app-defaults/src/defaults/themes.tsx b/packages/app-defaults/src/defaults/themes.tsx new file mode 100644 index 0000000000..90a4a0f47f --- /dev/null +++ b/packages/app-defaults/src/defaults/themes.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { darkTheme, lightTheme } from '@backstage/theme'; +import DarkIcon from '@material-ui/icons/Brightness2'; +import LightIcon from '@material-ui/icons/WbSunny'; +import { ThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { AppTheme } from '@backstage/core-plugin-api'; + +export const themes: AppTheme[] = [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + icon: , + theme: lightTheme, + Provider: ({ children }) => ( + + {children} + + ), + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + theme: darkTheme, + Provider: ({ children }) => ( + + {children} + + ), + }, +]; diff --git a/packages/app-defaults/src/index.ts b/packages/app-defaults/src/index.ts new file mode 100644 index 0000000000..16a6a693a5 --- /dev/null +++ b/packages/app-defaults/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides the default wiring of a Backstage App + * + * @packageDocumentation + */ + +export { createApp } from './createApp'; +export type { OptionalAppOptions } from './createApp'; diff --git a/packages/app-defaults/src/setupTests.ts b/packages/app-defaults/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/app-defaults/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 1dee41af56..f6f6049d68 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,50 @@ # example-app +## 0.2.53 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/plugin-cost-insights@0.11.11 + - @backstage/cli@0.9.0 + - @backstage/plugin-gcp-projects@0.3.9 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-scaffolder@0.11.11 + - @backstage/plugin-api-docs@0.6.14 + - @backstage/plugin-azure-devops@0.1.4 + - @backstage/plugin-badges@0.2.14 + - @backstage/plugin-catalog-graph@0.2.2 + - @backstage/plugin-catalog-import@0.7.4 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/plugin-circleci@0.2.29 + - @backstage/plugin-cloudbuild@0.2.28 + - @backstage/plugin-code-coverage@0.1.18 + - @backstage/plugin-explore@0.3.21 + - @backstage/plugin-github-actions@0.4.24 + - @backstage/plugin-home@0.4.6 + - @backstage/plugin-jenkins@0.5.12 + - @backstage/plugin-kafka@0.2.21 + - @backstage/plugin-kubernetes@0.4.20 + - @backstage/plugin-lighthouse@0.2.30 + - @backstage/plugin-newrelic@0.3.9 + - @backstage/plugin-pagerduty@0.3.18 + - @backstage/plugin-rollbar@0.3.19 + - @backstage/plugin-search@0.4.18 + - @backstage/plugin-sentry@0.3.29 + - @backstage/plugin-tech-radar@0.4.12 + - @backstage/plugin-techdocs@0.12.6 + - @backstage/plugin-todo@0.1.15 + - @backstage/plugin-user-settings@0.3.11 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + - @backstage/plugin-shortcuts@0.1.14 + - @backstage/app-defaults@0.1.1 + - @backstage/integration-react@0.1.14 + - @backstage/plugin-graphiql@0.2.21 + - @backstage/plugin-org@0.3.28 + ## 0.2.51 ### Patch Changes diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js index 6a18d16198..11f31cfed3 100644 --- a/packages/app/cypress/integration/components/search/SearchPage.js +++ b/packages/app/cypress/integration/components/search/SearchPage.js @@ -14,7 +14,7 @@ * limitations under the License. */ -const API_ENDPOINT = 'http://localhost:7000/api/search/query'; +const API_ENDPOINT = 'http://localhost:7007/api/search/query'; describe('SearchPage', () => { describe('Given a search context with a term, results, and filter values', () => { diff --git a/packages/app/package.json b/packages/app/package.json index 9b443ff67e..448b1cbcf2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,48 +1,49 @@ { "name": "example-app", - "version": "0.2.51", + "version": "0.2.53", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/cli": "^0.8.0", - "@backstage/core-app-api": "^0.1.18", - "@backstage/core-components": "^0.7.1", - "@backstage/core-plugin-api": "^0.1.11", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-api-docs": "^0.6.12", - "@backstage/plugin-azure-devops": "^0.1.1", - "@backstage/plugin-badges": "^0.2.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-graph": "^0.2.1", - "@backstage/plugin-catalog-import": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/plugin-circleci": "^0.2.27", - "@backstage/plugin-cloudbuild": "^0.2.27", - "@backstage/plugin-code-coverage": "^0.1.15", - "@backstage/plugin-cost-insights": "^0.11.10", - "@backstage/plugin-explore": "^0.3.20", - "@backstage/plugin-gcp-projects": "^0.3.8", - "@backstage/plugin-github-actions": "^0.4.22", - "@backstage/plugin-graphiql": "^0.2.20", - "@backstage/plugin-home": "^0.4.4", - "@backstage/plugin-jenkins": "^0.5.11", - "@backstage/plugin-kafka": "^0.2.19", - "@backstage/plugin-kubernetes": "^0.4.17", - "@backstage/plugin-lighthouse": "^0.2.29", + "@backstage/app-defaults": "^0.1.1", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.21", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-api-docs": "^0.6.14", + "@backstage/plugin-azure-devops": "^0.1.4", + "@backstage/plugin-badges": "^0.2.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-graph": "^0.2.2", + "@backstage/plugin-catalog-import": "^0.7.4", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-circleci": "^0.2.29", + "@backstage/plugin-cloudbuild": "^0.2.28", + "@backstage/plugin-code-coverage": "^0.1.18", + "@backstage/plugin-cost-insights": "^0.11.11", + "@backstage/plugin-explore": "^0.3.21", + "@backstage/plugin-gcp-projects": "^0.3.9", + "@backstage/plugin-github-actions": "^0.4.24", + "@backstage/plugin-graphiql": "^0.2.21", + "@backstage/plugin-home": "^0.4.6", + "@backstage/plugin-jenkins": "^0.5.12", + "@backstage/plugin-kafka": "^0.2.21", + "@backstage/plugin-kubernetes": "^0.4.20", + "@backstage/plugin-lighthouse": "^0.2.30", "@backstage/plugin-new-relic-dashboard": "^0.1.0", - "@backstage/plugin-newrelic": "^0.3.8", - "@backstage/plugin-org": "^0.3.27", - "@backstage/plugin-pagerduty": "0.3.17", - "@backstage/plugin-rollbar": "^0.3.18", - "@backstage/plugin-scaffolder": "^0.11.8", - "@backstage/plugin-search": "^0.4.15", - "@backstage/plugin-sentry": "^0.3.26", - "@backstage/plugin-shortcuts": "^0.1.12", - "@backstage/plugin-tech-radar": "^0.4.11", - "@backstage/plugin-techdocs": "^0.12.3", - "@backstage/plugin-todo": "^0.1.14", - "@backstage/plugin-user-settings": "^0.3.10", + "@backstage/plugin-newrelic": "^0.3.9", + "@backstage/plugin-org": "^0.3.28", + "@backstage/plugin-pagerduty": "0.3.18", + "@backstage/plugin-rollbar": "^0.3.19", + "@backstage/plugin-scaffolder": "^0.11.11", + "@backstage/plugin-search": "^0.4.18", + "@backstage/plugin-sentry": "^0.3.29", + "@backstage/plugin-shortcuts": "^0.1.14", + "@backstage/plugin-tech-radar": "^0.4.12", + "@backstage/plugin-techdocs": "^0.12.6", + "@backstage/plugin-todo": "^0.1.15", + "@backstage/plugin-user-settings": "^0.3.11", "@backstage/search-common": "^0.2.0", "@backstage/theme": "^0.2.11", "@material-ui/core": "^4.12.2", @@ -64,8 +65,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.19", - "@rjsf/core": "^3.0.0", + "@backstage/test-utils": "^0.1.22", + "@rjsf/core": "^3.2.1", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index aa6781ce61..8c7cefeb2c 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -27,14 +27,14 @@ describe('App', () => { data: { app: { title: 'Test', - support: { url: 'http://localhost:7000/support' }, + support: { url: 'http://localhost:7007/support' }, }, - backend: { baseUrl: 'http://localhost:7000' }, + backend: { baseUrl: 'http://localhost:7007' }, lighthouse: { baseUrl: 'http://localhost:3003', }, techdocs: { - storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 109ae7922e..401665b62e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,7 +26,8 @@ import { RELATION_PART_OF, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, @@ -93,7 +94,6 @@ const app = createApp({ // Custom icon example alert: AlarmIcon, }, - components: { SignInPage: props => { return ( diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 7a666a020a..f365310595 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -29,7 +29,7 @@ import LogoIcon from './LogoIcon'; import { NavLink } from 'react-router-dom'; import { GraphiQLIcon } from '@backstage/plugin-graphiql'; import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; -import { SidebarSearch } from '@backstage/plugin-search'; +import { SidebarSearchModal } from '@backstage/plugin-search'; import { Shortcuts } from '@backstage/plugin-shortcuts'; import { Sidebar, @@ -79,7 +79,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - + {/* Global nav, not org-specific */} diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 089cb9c7cf..a6878cd28b 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { EntityLayout } from '@backstage/plugin-catalog'; import { DefaultStarredEntitiesApi, @@ -22,7 +21,11 @@ import { starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; import { githubActionsApiRef } from '@backstage/plugin-github-actions'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiProvider, +} from '@backstage/test-utils'; import React from 'react'; import { cicdContent } from './EntityPage'; @@ -45,22 +48,22 @@ describe('EntityPage Test', () => { const mockedApi = { listWorkflowRuns: jest.fn().mockResolvedValue([]), - getWorkflow: jest.fn(), - getWorkflowRun: jest.fn(), - reRunWorkflow: jest.fn(), - listJobsForWorkflowRun: jest.fn(), - downloadJobLogsForWorkflowRun: jest.fn(), - } as jest.Mocked; - - const apis = ApiRegistry.with(githubActionsApiRef, mockedApi).with( - starredEntitiesApiRef, - new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), - ); + }; describe('cicdContent', () => { it('Should render GitHub Actions View', async () => { const rendered = await renderInTestApp( - + @@ -68,7 +71,7 @@ describe('EntityPage Test', () => { - , + , ); expect(rendered.getByText('ExampleComponent')).toBeInTheDocument(); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3ce0ffb30f..1ebf171a93 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -35,6 +35,7 @@ import { } from '@backstage/plugin-api-docs'; import { EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, } from '@backstage/plugin-azure-devops'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; @@ -270,6 +271,18 @@ const errorsContent = ( ); +const pullRequestsContent = ( + + + + + + + + + +); + const overviewContent = ( {entityWarningContent} @@ -390,7 +403,7 @@ const serviceEntityPage = ( - + {pullRequestsContent} @@ -455,7 +468,7 @@ const websiteEntityPage = ( - + {pullRequestsContent} diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index f0368b55d4..3ab36cc679 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,38 @@ # @backstage/backend-common +## 0.9.10 + +### Patch Changes + +- d7c1e0e34a: Added the `isDatabaseConflictError` function. +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. +- 1daada3a06: Paths can be specified in backend.reading.allow to further restrict allowed targets +- 7ad9a07b27: Adding config prop `pluginDivisionMode` to allow plugins using the `pg` client to create their own management schemas in the db. This allows `pg` client plugins to work in separate schemas in the same db. +- 01f74aa878: Add `AbortSignal` support to `UrlReader` +- a8732a1200: Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely. + + After this change, the backend logger will be able to actually capture the error as it happens: + + ``` + 2021-11-11T10:54:21.334Z backstage info Initializing http server + 2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000 + ``` + +- 26b5da1c1a: Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output. +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/cli-common@0.1.6 + +## 0.9.9 + +### Patch Changes + +- 8c4cad0bf2: AWSS3UrlReader now throws a `NotModifiedError` (exported from @backstage/backend-common) when s3 returns a 304 response. +- 0611f3b3e2: Reading app config from a remote server +- Updated dependencies + - @backstage/config-loader@0.7.2 + ## 0.9.8 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index a19447034e..e3119e78a8 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -6,6 +6,7 @@ /// /// +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; import { AwsS3Integration } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; @@ -48,7 +49,7 @@ export class AwsS3UrlReader implements UrlReader { // (undocumented) read(url: string): Promise; // (undocumented) - readTree(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) @@ -72,7 +73,7 @@ export class AzureUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -94,7 +95,7 @@ export class BitbucketUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -374,6 +375,9 @@ export class GitlabUrlReader implements UrlReader { export { isChildPath }; +// @public +export function isDatabaseConflictError(e: unknown): boolean; + // @public export function loadBackendConfig(options: { logger: Logger_2; @@ -415,6 +419,7 @@ export type ReadTreeOptions = { }, ): boolean; etag?: string; + signal?: AbortSignal_2; }; // @public @@ -468,6 +473,7 @@ export type ReadTreeResponseFile = { // @public export type ReadUrlOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public @@ -505,6 +511,7 @@ export type RunContainerOptions = { // @public export type SearchOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index bc5e026375..3690069431 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -69,6 +69,19 @@ export interface Config { * Defaults to true if unspecified. */ ensureExists?: boolean; + /** + * How plugins databases are managed/divided in the provided database instance. + * + * `database` -> Plugins are each given their own database to manage their schemas/tables. + * + * `schema` -> Plugins will be given their own schema (in the specified/default database) + * to manage their tables. + * + * NOTE: Currently only supported by the `pg` client. + * + * @default database + */ + pluginDivisionMode?: 'database' | 'schema'; /** Plugin specific database configuration and client override */ plugin?: { [pluginId: string]: { @@ -130,6 +143,14 @@ export interface Config { * The host may also contain a port, for example `example.com:8080`. */ host: string; + + /** + * An optional list of paths. In case they are present only targets matching + * any of them will are allowed. You can use trailing slashes to make sure only + * subdirectories are allowed, for example `/mydir/` will allow targets with + * paths like `/mydir/a` but will block paths like `/mydir2`. + */ + paths?: string[]; }>; }; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index a174862b4a..9e0187137d 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.9.8", + "version": "0.9.10", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.7.1", + "@backstage/config-loader": "^0.8.0", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", "@backstage/types": "^0.1.1", @@ -39,7 +39,7 @@ "@lerna/project": "^4.0.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", - "@types/dockerode": "^3.2.1", + "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", "archiver": "^5.0.2", "aws-sdk": "^2.840.0", @@ -47,7 +47,7 @@ "concat-stream": "^2.0.0", "cors": "^2.8.5", "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", + "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "9.1.0", @@ -62,6 +62,7 @@ "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", + "node-abort-controller": "^3.0.1", "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", @@ -79,8 +80,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index cfb655cbeb..941ad26e65 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -22,10 +22,13 @@ import { loadConfigSchema, loadConfig, ConfigSchema, + ConfigTarget, } from '@backstage/config-loader'; import { AppConfig, Config, ConfigReader } from '@backstage/config'; import { JsonValue } from '@backstage/types'; +import { isValidUrl } from './urls'; + import { setRootLoggerRedactionList } from './logging/rootLogger'; // Fetch the schema and get all the secrets to pass to the rootLogger for redaction @@ -178,7 +181,10 @@ export async function loadBackendConfig(options: { argv: string[]; }): Promise { const args = parseArgs(options.argv); - const configPaths: string[] = [args.config ?? []].flat(); + + const configTargets: ConfigTarget[] = [args.config ?? []] + .flat() + .map(arg => (isValidUrl(arg) ? { url: arg } : { path: resolvePath(arg) })); /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); @@ -194,9 +200,10 @@ export async function loadBackendConfig(options: { }); const config = new ObservableConfigProxy(options.logger); - const configs = await loadConfig({ + const { appConfigs } = await loadConfig({ configRoot: paths.targetRoot, - configPaths: configPaths.map(opt => resolvePath(opt)), + configPaths: [], + configTargets: configTargets, watch: { onChange(newConfigs) { options.logger.info( @@ -220,14 +227,16 @@ export async function loadBackendConfig(options: { }); options.logger.info( - `Loaded config from ${configs.map(c => c.context).join(', ')}`, + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, ); - config.setConfig(ConfigReader.fromConfigs(configs)); + config.setConfig(ConfigReader.fromConfigs(appConfigs)); // Subscribe to config changes and update the redaction list for logging - updateRedactionList(schema, configs, options.logger); - config.subscribe(() => updateRedactionList(schema, configs, options.logger)); + updateRedactionList(schema, appConfigs, options.logger); + config.subscribe(() => + updateRedactionList(schema, appConfigs, options.logger), + ); return config; } diff --git a/packages/backend-common/src/database/DatabaseManager.test.ts b/packages/backend-common/src/database/DatabaseManager.test.ts index e839123908..f2fe855234 100644 --- a/packages/backend-common/src/database/DatabaseManager.test.ts +++ b/packages/backend-common/src/database/DatabaseManager.test.ts @@ -15,13 +15,18 @@ */ import { ConfigReader } from '@backstage/config'; import { omit } from 'lodash'; -import { createDatabaseClient, ensureDatabaseExists } from './connection'; +import { + createDatabaseClient, + ensureDatabaseExists, + ensureSchemaExists, +} from './connection'; import { DatabaseManager } from './DatabaseManager'; jest.mock('./connection', () => ({ ...jest.requireActual('./connection'), createDatabaseClient: jest.fn(), ensureDatabaseExists: jest.fn(), + ensureSchemaExists: jest.fn(), })); describe('DatabaseManager', () => { @@ -314,5 +319,191 @@ describe('DatabaseManager', () => { expect.stringContaining('userdbname'), ); }); + + it('plugin sets schema override for pg client', async () => { + const overrideConfig = { + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }; + const testManager = DatabaseManager.fromConfig( + new ConfigReader(overrideConfig), + ); + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: config.backend.database.connection, + }); + + expect(overrides).toMatchObject({ + searchPath: [pluginId], + }); + }); + + it('plugin does not provide schema override for non pg client', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'sqlite3', + connection: config.backend.database.connection, + }); + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is set to database', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'database', + connection: 'some-file-path', + }, + }, + }), + ); + + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is not set', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('pluginDivisionMode ensures that each plugin schema exists', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(ensureSchemaExists).mock.calls; + const [_, schemaName] = mockCalls[0]; + + expect(schemaName).toEqual('testdbname'); + }); + + it('pluginDivisionMode allows connection overrides for plugins', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + plugin: { + testdbname: { + connection: { + database: 'database_name_overriden', + host: 'newhost', + }, + }, + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: { + database: 'database_name_overriden', + host: 'newhost', + user: 'foo', + password: 'bar', + }, + }); + expect(overrides).toHaveProperty('searchPath', ['testdbname']); + expect(overrides).toHaveProperty( + 'connection.database', + 'database_name_overriden', + ); + }); }); }); diff --git a/packages/backend-common/src/database/DatabaseManager.ts b/packages/backend-common/src/database/DatabaseManager.ts index c0511c6b78..cf5e801d66 100644 --- a/packages/backend-common/src/database/DatabaseManager.ts +++ b/packages/backend-common/src/database/DatabaseManager.ts @@ -13,17 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Knex } from 'knex'; import { omit } from 'lodash'; import { Config, ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { - createDatabaseClient, - ensureDatabaseExists, createNameOverride, + ensureDatabaseExists, normalizeConnection, + createSchemaOverride, + ensureSchemaExists, + createDatabaseClient, } from './connection'; import { PluginDatabaseManager } from './types'; +import { mergeDatabaseConfig } from './config'; /** * Provides a config lookup path for a plugin's config block. @@ -79,14 +83,15 @@ export class DatabaseManager { * Provides the canonical database name for a given plugin. * * This method provides the effective database name which is determined using global - * and plugin specific database config. If no explicit database name is configured, - * this method will provide a generated name which is the pluginId prefixed with - * 'backstage_plugin_'. + * and plugin specific database config. If no explicit database name is configured + * and `pluginDivisionMode` is not `schema`, this method will provide a generated name + * which is the pluginId prefixed with 'backstage_plugin_'. If `pluginDivisionMode` is + * `schema`, it will fallback to using the default database for the knex instance. * * @param pluginId Lookup the database name for given plugin * @returns String representing the plugin's database name */ - private getDatabaseName(pluginId: string): string { + private getDatabaseName(pluginId: string): string | undefined { const connection = this.getConnectionConfig(pluginId); if (this.getClientType(pluginId).client === 'sqlite3') { @@ -95,11 +100,16 @@ export class DatabaseManager { (connection as Knex.Sqlite3ConnectionConfig)?.filename ?? ':memory:' ); } + + const databaseName = (connection as Knex.ConnectionConfig)?.database; + + // `pluginDivisionMode` as `schema` should use overridden databaseName if supplied or fallback to default knex database + if (this.getPluginDivisionModeConfig() === 'schema') { + return databaseName; + } + // all other supported databases should fallback to an auto-prefixed name - return ( - (connection as Knex.ConnectionConfig)?.database ?? - `${this.prefix}${pluginId}` - ); + return databaseName ?? `${this.prefix}${pluginId}`; } /** @@ -136,13 +146,18 @@ export class DatabaseManager { ); } + private getPluginDivisionModeConfig(): string { + return this.config.getOptionalString('pluginDivisionMode') ?? 'database'; + } + /** * Provides a Knex connection plugin config by combining base and plugin config. * * This method provides a baseConfig for a plugin database connector. If the client type * has not been overridden, the global connection config will be included with plugin * specific config as the base. Values from the plugin connection take precedence over the - * base. Base database name is omitted for all supported databases excluding SQLite. + * base. Base database name is omitted for all supported databases excluding SQLite unless + * `pluginDivisionMode` is set to `schema`. */ private getConnectionConfig( pluginId: string, @@ -153,10 +168,13 @@ export class DatabaseManager { this.config.get('connection'), this.config.getString('client'), ); - // As databases cannot be shared, the `database` property from the base connection - // is omitted. SQLite3's `filename` property is an exception as this is used as a + // Databases cannot be shared unless the `pluginDivisionMode` is set to `schema`. The + // `database` property from the base connection is omitted unless `pluginDivisionMode` + // is set to `schema`. SQLite3's `filename` property is an exception as this is used as a // directory elsewhere so we preserve `filename`. - baseConnection = omit(baseConnection, 'database'); + if (this.getPluginDivisionModeConfig() !== 'schema') { + baseConnection = omit(baseConnection, 'database'); + } // get and normalize optional plugin specific database connection const connection = normalizeConnection( @@ -165,7 +183,7 @@ export class DatabaseManager { ); return { - // include base connection if client type has not been overriden + // include base connection if client type has not been overridden ...(overridden ? {} : baseConnection), ...connection, }; @@ -187,6 +205,16 @@ export class DatabaseManager { }; } + /** + * Provides a partial Knex.Config database schema override for a given plugin. + * + * @param pluginId Target plugin to get database schema override + * @returns Partial Knex.Config with database schema override + */ + private getSchemaOverrides(pluginId: string): Knex.Config | undefined { + return createSchemaOverride(this.getClientType(pluginId).client, pluginId); + } + /** * Provides a partial Knex.Config database name override for a given plugin. * @@ -194,10 +222,10 @@ export class DatabaseManager { * @returns Partial Knex.Config with database name override */ private getDatabaseOverrides(pluginId: string): Knex.Config { - return createNameOverride( - this.getClientType(pluginId).client, - this.getDatabaseName(pluginId), - ); + const databaseName = this.getDatabaseName(pluginId); + return databaseName + ? createNameOverride(this.getClientType(pluginId).client, databaseName) + : {}; } /** @@ -211,8 +239,8 @@ export class DatabaseManager { this.getConfigForPlugin(pluginId) as JsonObject, ); - if (this.getEnsureExistsConfig(pluginId)) { - const databaseName = this.getDatabaseName(pluginId); + const databaseName = this.getDatabaseName(pluginId); + if (databaseName && this.getEnsureExistsConfig(pluginId)) { try { await ensureDatabaseExists(pluginConfig, databaseName); } catch (error) { @@ -222,9 +250,24 @@ export class DatabaseManager { } } - return createDatabaseClient( - pluginConfig, + let schemaOverrides; + if (this.getPluginDivisionModeConfig() === 'schema') { + try { + schemaOverrides = this.getSchemaOverrides(pluginId); + await ensureSchemaExists(pluginConfig, pluginId); + } catch (error) { + throw new Error( + `Failed to connect to the database to make sure that schema for plugin '${pluginId}' exists, ${error}`, + ); + } + } + + const databaseClientOverrides = mergeDatabaseConfig( + {}, this.getDatabaseOverrides(pluginId), + schemaOverrides, ); + + return createDatabaseClient(pluginConfig, databaseClientOverrides); } } diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 869722ddf7..6f02e7b2ed 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -18,8 +18,24 @@ import { ConfigReader } from '@backstage/config'; import { createDatabaseClient, createNameOverride, + createSchemaOverride, + ensureSchemaExists, parseConnectionString, } from './connection'; +import { pgConnector } from './connectors'; + +const mocked = (f: Function) => f as jest.Mock; + +jest.mock('./connectors', () => { + const connectors = jest.requireActual('./connectors'); + return { + ...connectors, + pgConnector: { + ...connectors.pgConnector, + ensureSchemaExists: jest.fn(), + }, + }; +}); describe('database connection', () => { describe('createDatabaseClient', () => { @@ -152,4 +168,63 @@ describe('database connection', () => { expect(() => parseConnectionString('sqlite://')).toThrow(); }); }); + + describe('createSchemaOverride', () => { + it('returns Knex config for postgres', () => { + expect(createSchemaOverride('pg', 'testpg')).toHaveProperty( + 'searchPath', + ['testpg'], + ); + }); + + it('throws error for sqlite', () => { + expect(createSchemaOverride('sqlite3', 'testsqlite')).toBeUndefined(); + }); + + it('returns Knex config for mysql', () => { + expect(createSchemaOverride('mysql', 'testmysql')).toBeUndefined(); + }); + + it('throws an error for unknown connection', () => { + expect(createSchemaOverride('unknown', 'testname')).toBeUndefined(); + }); + }); + + describe('ensureSchemaExists', () => { + it('returns sucessfully with pg client', async () => { + await ensureSchemaExists( + new ConfigReader({ + client: 'pg', + schema: 'catalog', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }), + 'catalog', + ); + + const mockCalls = mocked( + pgConnector.ensureSchemaExists as Function, + ).mock.calls.splice(-1); + const [baseConfig, schemaName] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }); + + expect(schemaName).toEqual('catalog'); + }); + + it('throws error for non pg client', () => { + return expect( + ensureSchemaExists( + new ConfigReader({ + client: 'sqlite3', + schema: 'catalog', + connection: ':memory:', + }), + 'catalog', + ), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 1c5cce3718..3308a26573 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -82,6 +82,23 @@ export async function ensureDatabaseExists( ); } +/** + * Ensures that the given schemas all exist, creating them if they do not. + * + * @public + */ +export async function ensureSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const client: DatabaseClient = dbConfig.getString('client'); + + return await ConnectorMapping[client]?.ensureSchemaExists?.( + dbConfig, + ...schemas, + ); +} + /** * Provides a Knex.Config object with the provided database name for a given client. */ @@ -99,6 +116,23 @@ export function createNameOverride( } } +/** + * Provides a Knex.Config object with the provided database schema for a given client. Currently only supported by `pg`. + */ +export function createSchemaOverride( + client: string, + name: string, +): Partial { + try { + return ConnectorMapping[client]?.createSchemaOverride?.(name); + } catch (e) { + throw new InputError( + `Unable to create database schema override for '${client}' connector`, + e, + ); + } +} + /** * Parses a connection string for a given client and provides a connection config. */ diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts new file mode 100644 index 0000000000..2db5ecd454 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 defaultSchemaOverride from './defaultSchemaOverride'; + +describe('defaultNameOverride()', () => { + it('returns a partial knex static connection config with searchPath set to [schemaName]', () => { + const schemaName = 'schemaName'; + expect(defaultSchemaOverride(schemaName)).toHaveProperty('searchPath', [ + schemaName, + ]); + }); +}); diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts new file mode 100644 index 0000000000..62ad8ab8c8 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Knex } from 'knex'; + +/** + * Provides a partial knex config with schema name override. + * + * @param name schema name to get config override for + */ +export default function defaultSchemaOverride( + name: string, +): Partial { + return { + searchPath: [name], + }; +} diff --git a/packages/backend-common/src/database/connectors/postgres.test.ts b/packages/backend-common/src/database/connectors/postgres.test.ts index ac988a2880..553d019573 100644 --- a/packages/backend-common/src/database/connectors/postgres.test.ts +++ b/packages/backend-common/src/database/connectors/postgres.test.ts @@ -76,6 +76,24 @@ describe('postgres', () => { }); }); + it('overrides the schema name', () => { + const mockConnection = { + ...createMockConnection(), + schema: 'schemaName', + }; + + expect( + buildPgDatabaseConfig(createConfig(mockConnection), { + searchPath: ['schemaName'], + }), + ).toEqual({ + client: 'pg', + connection: mockConnection, + searchPath: ['schemaName'], + useNullAsDefault: true, + }); + }); + it('adds additional config settings', () => { const mockConnection = createMockConnection(); diff --git a/packages/backend-common/src/database/connectors/postgres.ts b/packages/backend-common/src/database/connectors/postgres.ts index 62de2e001e..823d50f73f 100644 --- a/packages/backend-common/src/database/connectors/postgres.ts +++ b/packages/backend-common/src/database/connectors/postgres.ts @@ -21,6 +21,7 @@ import { ForwardedError } from '@backstage/errors'; import { mergeDatabaseConfig } from '../config'; import { DatabaseConnector } from '../types'; import defaultNameOverride from './defaultNameOverride'; +import defaultSchemaOverride from './defaultSchemaOverride'; /** * Creates a knex postgres database connection @@ -135,6 +136,29 @@ export async function ensurePgDatabaseExists( } } +/** + * Creates the missing Postgres schema if it does not exist + * + * @param dbConfig The database config + * @param schemas The name of the schemas to create + */ +export async function ensurePgSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const admin = createPgDatabaseClient(dbConfig); + + try { + const ensureSchema = async (database: string) => { + await admin.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [database]); + }; + + await Promise.all(schemas.map(ensureSchema)); + } finally { + await admin.destroy(); + } +} + /** * PostgreSQL database connector. * @@ -143,6 +167,8 @@ export async function ensurePgDatabaseExists( export const pgConnector: DatabaseConnector = Object.freeze({ createClient: createPgDatabaseClient, ensureDatabaseExists: ensurePgDatabaseExists, + ensureSchemaExists: ensurePgSchemaExists, createNameOverride: defaultNameOverride, + createSchemaOverride: defaultSchemaOverride, parseConnectionString: parsePgConnectionString, }); diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index 7fcb8bf930..2a820f7367 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -28,3 +28,4 @@ export { } from './connection'; export type { PluginDatabaseManager } from './types'; +export { isDatabaseConflictError } from './util'; diff --git a/packages/backend-common/src/database/types.ts b/packages/backend-common/src/database/types.ts index edcba1e643..e96f86980b 100644 --- a/packages/backend-common/src/database/types.ts +++ b/packages/backend-common/src/database/types.ts @@ -45,6 +45,11 @@ export interface DatabaseConnector { * database name. */ createNameOverride(name: string): Partial; + /** + * createSchemaOverride provides a partial knex config sufficient to override a + * PostgreSQL schema name within utilizing the `searchPath` knex configuration. + */ + createSchemaOverride?(name: string): Partial; /** * parseConnectionString produces a knex connection config object representing * a database connection string. @@ -64,4 +69,16 @@ export interface DatabaseConnector { dbConfig: Config, ...databases: Array ): Promise; + + /** + * ensureSchemaExists performs a side-effect to ensure schema names passed in are + * present. + * + * Calling this function on schemas which already exist should do nothing. + * Missing schemas should be created if needed. + */ + ensureSchemaExists?( + dbConfig: Config, + ...schemas: Array + ): Promise; } diff --git a/packages/backend-common/src/database/util.ts b/packages/backend-common/src/database/util.ts new file mode 100644 index 0000000000..607554f294 --- /dev/null +++ b/packages/backend-common/src/database/util.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tries to deduce whether a thrown error is a database conflict. + * + * @public + * @param e - A thrown error + * @returns True if the error looks like it was a conflict error thrown by a + * known database engine + */ +export function isDatabaseConflictError(e: unknown) { + const message = (e as any)?.message; + + return ( + typeof message === 'string' && + (/SQLITE_CONSTRAINT: UNIQUE/.test(message) || + /unique constraint/.test(message)) + ); +} diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index c1e092706b..2f1e44b293 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -37,7 +37,7 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery { * for the internal one. * * The basePath defaults to `/api`, meaning the default full internal - * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`. + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. */ static fromConfig(config: Config, options?: { basePath?: string }) { const basePath = options?.basePath ?? '/api'; diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 192decb653..0a664f9656 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -48,6 +48,19 @@ describe('rootLogger', () => { ); }); + it('redacts but ignores empty and one-character secrets', () => { + const logger = createRootLogger(); + jest.spyOn(logger, 'write'); + setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', 'Q', '']); + logger.info('Logging SECRET-1 and SECRET_2 and Q'); + + expect(logger.write).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Logging [REDACTED] and [REDACTED] and Q', + }), + ); + }); + describe('createRootLogger', () => { it('creates a new logger', () => { const oldLogger = getRootLogger(); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index b05763a42c..2a4226f88f 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -21,7 +21,7 @@ import { coloredFormat } from './formats'; import { escapeRegExp } from '../util/escapeRegExp'; let rootLogger: winston.Logger; -let redactionRegExp: RegExp; +let redactionRegExp: RegExp | undefined; /** @public */ export function getRootLogger(): winston.Logger { @@ -34,11 +34,18 @@ export function setRootLogger(newLogger: winston.Logger) { } export function setRootLoggerRedactionList(redactionList: string[]) { - if (redactionList.length) { + // Exclude secrets that are empty or just one character in length. These + // typically mean that you are running local dev or tests, or using the + // --lax flag which sets things to just 'x'. So exclude those. + const filtered = redactionList.filter(r => r.length > 1); + + if (filtered.length) { redactionRegExp = new RegExp( - `(${redactionList.map(escapeRegExp).join('|')})`, + `(${filtered.map(escapeRegExp).join('|')})`, 'g', ); + } else { + redactionRegExp = undefined; } } diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.test.ts b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts index 301543e8c9..a3ae8b647f 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.test.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts @@ -27,6 +27,7 @@ import { UrlReaderPredicateTuple } from './types'; import AWSMock from 'aws-sdk-mock'; import aws from 'aws-sdk'; import path from 'path'; +import { NotModifiedError } from '@backstage/errors'; const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), @@ -131,29 +132,37 @@ describe('AwsS3UrlReader', () => { }); describe('read', () => { - AWSMock.setSDKInstance(aws); - AWSMock.mock( - 'S3', - 'getObject', - Buffer.from( - require('fs').readFileSync( - path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'), + let awsS3UrlReader: AwsS3UrlReader; + + beforeAll(() => { + AWSMock.setSDKInstance(aws); + AWSMock.mock( + 'S3', + 'getObject', + Buffer.from( + require('fs').readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsS3/awsS3-mock-object.yaml', + ), + ), ), - ), - ); - const s3 = new aws.S3(); - const awsS3UrlReader = new AwsS3UrlReader( - new AwsS3Integration( - readAwsS3IntegrationConfig( - new ConfigReader({ - host: 'amazonaws.com', - accessKeyId: 'fake-access-key', - secretAccessKey: 'fake-secret-key', - }), + ); + + const s3 = new aws.S3(); + awsS3UrlReader = new AwsS3UrlReader( + new AwsS3Integration( + readAwsS3IntegrationConfig( + new ConfigReader({ + host: 'amazonaws.com', + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }), + ), ), - ), - { s3, treeResponseFactory }, - ); + { s3, treeResponseFactory }, + ); + }); it('returns contents of an object in a bucket', async () => { const response = await awsS3UrlReader.read( @@ -176,32 +185,39 @@ describe('AwsS3UrlReader', () => { }); describe('readUrl', () => { - AWSMock.setSDKInstance(aws); + let awsS3UrlReader: AwsS3UrlReader; - AWSMock.mock( - 'S3', - 'getObject', - Buffer.from( - require('fs').readFileSync( - path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'), + beforeAll(() => { + AWSMock.setSDKInstance(aws); + + AWSMock.mock( + 'S3', + 'getObject', + Buffer.from( + require('fs').readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsS3/awsS3-mock-object.yaml', + ), + ), ), - ), - ); + ); - const s3 = new aws.S3(); + const s3 = new aws.S3(); - const awsS3UrlReader = new AwsS3UrlReader( - new AwsS3Integration( - readAwsS3IntegrationConfig( - new ConfigReader({ - host: 'amazonaws.com', - accessKeyId: 'fake-access-key', - secretAccessKey: 'fake-secret-key', - }), + awsS3UrlReader = new AwsS3UrlReader( + new AwsS3Integration( + readAwsS3IntegrationConfig( + new ConfigReader({ + host: 'amazonaws.com', + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }), + ), ), - ), - { s3, treeResponseFactory }, - ); + { s3, treeResponseFactory }, + ); + }); it('returns contents of an object in a bucket', async () => { const response = await awsS3UrlReader.readUrl( @@ -223,40 +239,89 @@ describe('AwsS3UrlReader', () => { ); }); }); + + describe('readUrl with etag', () => { + let awsS3UrlReader: AwsS3UrlReader; + + beforeAll(() => { + AWSMock.setSDKInstance(aws); + + AWSMock.mock('S3', 'getObject', (_, callback) => { + callback({ statusCode: 304 }, null); + }); + + const s3 = new aws.S3(); + + awsS3UrlReader = new AwsS3UrlReader( + new AwsS3Integration( + readAwsS3IntegrationConfig( + new ConfigReader({ + host: 'amazonaws.com', + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }), + ), + ), + { s3, treeResponseFactory }, + ); + }); + + it('returns contents of an object in a bucket', async () => { + await expect( + awsS3UrlReader.readUrl( + 'https://test-bucket.s3.us-east-2.amazonaws.com/awsS3-mock-object.yaml', + { + etag: 'abc123', + }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); + describe('readTree', () => { - const object: aws.S3.Types.Object = { - Key: 'awsS3-mock-object.yaml', - }; - const objectList: aws.S3.ObjectList = [object]; - const output: aws.S3.Types.ListObjectsV2Output = { - Contents: objectList, - }; - AWSMock.setSDKInstance(aws); - AWSMock.mock('S3', 'listObjectsV2', output); + let awsS3UrlReader: AwsS3UrlReader; - AWSMock.mock( - 'S3', - 'getObject', - Buffer.from( - require('fs').readFileSync( - path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'), - ), - ), - ); + beforeAll(() => { + const object: aws.S3.Types.Object = { + Key: 'awsS3-mock-object.yaml', + }; - const s3 = new aws.S3(); - const awsS3UrlReader = new AwsS3UrlReader( - new AwsS3Integration( - readAwsS3IntegrationConfig( - new ConfigReader({ - host: '.amazonaws.com', - accessKeyId: 'fake-access-key', - secretAccessKey: 'fake-secret-key', - }), + const objectList: aws.S3.ObjectList = [object]; + const output: aws.S3.Types.ListObjectsV2Output = { + Contents: objectList, + }; + + AWSMock.setSDKInstance(aws); + AWSMock.mock('S3', 'listObjectsV2', output); + + AWSMock.mock( + 'S3', + 'getObject', + Buffer.from( + require('fs').readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsS3/awsS3-mock-object.yaml', + ), + ), ), - ), - { s3, treeResponseFactory }, - ); + ); + + const s3 = new aws.S3(); + awsS3UrlReader = new AwsS3UrlReader( + new AwsS3Integration( + readAwsS3IntegrationConfig( + new ConfigReader({ + host: '.amazonaws.com', + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }), + ), + ), + { s3, treeResponseFactory }, + ); + }); + it('returns contents of an object in a bucket', async () => { const response = await awsS3UrlReader.readTree( 'https://test.s3.us-east-2.amazonaws.com', diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.ts b/packages/backend-common/src/reading/AwsS3UrlReader.ts index 7fd0f6ba19..0d806fcfaa 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.ts @@ -18,6 +18,7 @@ import aws, { Credentials, S3 } from 'aws-sdk'; import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import { ReaderFactory, + ReadTreeOptions, ReadTreeResponse, ReadTreeResponseFactory, ReadUrlOptions, @@ -27,7 +28,7 @@ import { } from './types'; import getRawBody from 'raw-body'; import { AwsS3Integration, ScmIntegrations } from '@backstage/integration'; -import { ForwardedError } from '@backstage/errors'; +import { ForwardedError, NotModifiedError } from '@backstage/errors'; import { ListObjectsV2Output, ObjectList } from 'aws-sdk/clients/s3'; const parseURL = ( @@ -92,7 +93,7 @@ export class AwsS3UrlReader implements UrlReader { ) {} /** - * If accesKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: + * If accessKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html */ private static buildCredentials( @@ -154,20 +155,28 @@ export class AwsS3UrlReader implements UrlReader { }; } - const response = this.deps.s3.getObject(params); - const buffer = await getRawBody(response.createReadStream()); - const etag = (await response.promise()).ETag; + const request = this.deps.s3.getObject(params); + options?.signal?.addEventListener('abort', () => request.abort()); + const buffer = await getRawBody(request.createReadStream()); + const etag = (await request.promise()).ETag; return { buffer: async () => buffer, etag: etag, }; } catch (e) { + if (e.statusCode === 304) { + throw new NotModifiedError(); + } + throw new ForwardedError('Could not retrieve file from S3', e); } } - async readTree(url: string): Promise { + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { try { const { path, bucket, region } = parseURL(url); const allObjects: ObjectList = []; @@ -176,13 +185,13 @@ export class AwsS3UrlReader implements UrlReader { let output: ListObjectsV2Output; do { aws.config.update({ region: region }); - output = await this.deps.s3 - .listObjectsV2({ - Bucket: bucket, - ContinuationToken: continuationToken, - Prefix: path, - }) - .promise(); + const request = this.deps.s3.listObjectsV2({ + Bucket: bucket, + ContinuationToken: continuationToken, + Prefix: path, + }); + options?.signal?.addEventListener('abort', () => request.abort()); + output = await request.promise(); if (output.Contents) { output.Contents.forEach(contents => { allObjects.push(contents); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 6015b044bc..82d1192b17 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -55,21 +55,34 @@ export class AzureUrlReader implements UrlReader { ) {} async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not implemented yet. + const { signal } = options ?? {}; + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch( - builtUrl, - getAzureRequestOptions(this.integration.config), - ); + response = await fetch(builtUrl, { + ...getAzureRequestOptions(this.integration.config), + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -79,19 +92,12 @@ export class AzureUrlReader implements UrlReader { throw new Error(message); } - async readUrl( - url: string, - _options?: ReadUrlOptions, - ): Promise { - // TODO etag is not implemented yet. - const buffer = await this.read(url); - return { buffer: async () => buffer }; - } - async readTree( url: string, options?: ReadTreeOptions, ): Promise { + const { etag, filter, signal } = options ?? {}; + // TODO: Support filepath based reading tree feature like other providers // Get latest commit SHA @@ -109,16 +115,16 @@ export class AzureUrlReader implements UrlReader { } const commitSha = (await commitsAzureResponse.json()).value[0].commitId; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } - const archiveAzureResponse = await fetch( - getAzureDownloadUrl(url), - getAzureRequestOptions(this.integration.config, { + const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), { + ...getAzureRequestOptions(this.integration.config, { Accept: 'application/zip', }), - ); + ...(signal && { signal }), + }); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; if (archiveAzureResponse.status === 404) { @@ -139,7 +145,7 @@ export class AzureUrlReader implements UrlReader { return await this.deps.treeResponseFactory.fromZipArchive({ stream: archiveAzureResponse.body as unknown as Readable, etag: commitSha, - filter: options?.filter, + filter, subpath, }); } @@ -158,6 +164,7 @@ export class AzureUrlReader implements UrlReader { const tree = await this.readTree(treeUrl.toString(), { etag: options?.etag, + signal: options?.signal, filter: p => (matcher ? matcher.match(p) : true), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 4ae0ea37a8..c1a113a50d 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -77,18 +77,33 @@ export class BitbucketUrlReader implements UrlReader { } async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not supported yet + const { signal } = options ?? {}; const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config); - const options = getBitbucketRequestOptions(this.integration.config); + const requestOptions = getBitbucketRequestOptions(this.integration.config); let response: Response; try { - response = await fetch(bitbucketUrl.toString(), options); + response = await fetch(bitbucketUrl.toString(), { + ...requestOptions, + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } if (response.ok) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; @@ -98,15 +113,6 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - async readUrl( - url: string, - _options?: ReadUrlOptions, - ): Promise { - // TODO etag is not implemented yet. - const buffer = await this.read(url); - return { buffer: async () => buffer }; - } - async readTree( url: string, options?: ReadTreeOptions, diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 124abb799c..9d6edee765 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -77,6 +77,10 @@ describe('FetchUrlReader', () => { { host: 'example.com:700' }, { host: '*.examples.org' }, { host: '*.examples.org:700' }, + { + host: 'foobar.org', + paths: ['/dir1/'], + }, ], }, }, @@ -106,6 +110,9 @@ describe('FetchUrlReader', () => { expect(predicate(new URL('https://examples.org:700/test'))).toBe(false); expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true); expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir1/subpath'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir12'))).toBe(false); + expect(predicate(new URL('https://foobar.org/'))).toBe(false); }); describe('read', () => { diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 732d3b9f59..d82b1f7f52 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -24,6 +24,7 @@ import { SearchResponse, UrlReader, } from './types'; +import path from 'path'; /** * A UrlReader that does a plain fetch of the URL. @@ -39,18 +40,30 @@ export class FetchUrlReader implements UrlReader { * `host`: * Either full hostnames to match, or subdomain wildcard matchers with a leading `*`. * For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not. + * + * `paths`: + * An optional list of paths which are allowed. If the list is omitted all paths are allowed. */ static factory: ReaderFactory = ({ config }) => { const predicates = config .getOptionalConfigArray('backend.reading.allow') ?.map(allowConfig => { + const paths = allowConfig.getOptionalStringArray('paths'); + const checkPath = paths + ? (url: URL) => { + const targetPath = path.posix.normalize(url.pathname); + return paths.some(allowedPath => + targetPath.startsWith(allowedPath), + ); + } + : (_url: URL) => true; const host = allowConfig.getString('host'); if (host.startsWith('*.')) { const suffix = host.slice(1); - return (url: URL) => url.host.endsWith(suffix); + return (url: URL) => url.host.endsWith(suffix) && checkPath(url); } - return (url: URL) => url.host === host; + return (url: URL) => url.host === host && checkPath(url); }) ?? []; const reader = new FetchUrlReader(); @@ -73,6 +86,7 @@ export class FetchUrlReader implements UrlReader { headers: { ...(options?.etag && { 'If-None-Match': options.etag }), }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index ca16e23fec..755e0f17e6 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -110,6 +110,7 @@ export class GithubUrlReader implements UrlReader { ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -164,7 +165,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, options, ); } @@ -188,7 +189,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, ); return { files, etag: commitSha }; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 3fc9673e8b..128aed3c85 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -66,6 +66,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadUrlOptions, ): Promise { + const { etag, signal } = options ?? {}; const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; @@ -73,8 +74,9 @@ export class GitlabUrlReader implements UrlReader { response = await fetch(builtUrl, { headers: { ...getGitLabRequestOptions(this.integration.config).headers, - ...(options?.etag && { 'If-None-Match': options.etag }), + ...(etag && { 'If-None-Match': etag }), }, + ...(signal && { signal }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -102,6 +104,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { + const { etag, signal } = options ?? {}; const { ref, full_name, filepath } = parseGitUrl(url); // Use GitLab API to get the default branch @@ -140,7 +143,10 @@ export class GitlabUrlReader implements UrlReader { full_name, )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!commitsGitlabResponse.ok) { const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; @@ -152,7 +158,7 @@ export class GitlabUrlReader implements UrlReader { const commitSha = (await commitsGitlabResponse.json())[0].id; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } @@ -161,7 +167,10 @@ export class GitlabUrlReader implements UrlReader { `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, )}/repository/archive?sha=${branch}`, - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!archiveGitLabResponse.ok) { const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; @@ -191,6 +200,7 @@ export class GitlabUrlReader implements UrlReader { const tree = await this.readTree(treeUrl, { etag: options?.etag, + signal: options?.signal, filter: path => matcher.match(stripFirstDirectoryFromPath(path)), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 7e0904f18b..16edf81ba6 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -17,6 +17,7 @@ import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { AbortSignal } from 'node-abort-controller'; /** * A generic interface for fetching plain data from URLs. @@ -101,6 +102,15 @@ export type ReadUrlOptions = { * of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -165,6 +175,15 @@ export type ReadTreeOptions = { * rest of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -291,6 +310,15 @@ export type SearchOptions = { * search will return the rest of SearchResponse along with a new etag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index ed16c9c9a7..b085cccfd6 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -40,7 +40,7 @@ import { } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; -export const DEFAULT_PORT = 7000; +export const DEFAULT_PORT = 7007; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; // taken from the helmet source code - don't seem to be exported @@ -174,27 +174,27 @@ export class ServiceBuilderImpl implements ServiceBuilder { const server: http.Server = httpsSettings ? await createHttpsServer(app, httpsSettings, logger) : createHttpServer(app, logger); + const stoppableServer = stoppable(server, 0); + + useHotCleanup(this.module, () => + stoppableServer.stop((e: any) => { + if (e) console.error(e); + }), + ); return new Promise((resolve, reject) => { - app.on('error', e => { - logger.error(`Failed to start up on port ${port}, ${e}`); + function handleStartupError(e: unknown) { + server.close(); reject(e); + } + + server.on('error', handleStartupError); + + server.listen(port, host, () => { + server.off('error', handleStartupError); + logger.info(`Listening on ${host}:${port}`); + resolve(stoppableServer); }); - - const stoppableServer = stoppable( - server.listen(port, host, () => { - logger.info(`Listening on ${host}:${port}`); - }), - 0, - ); - - useHotCleanup(this.module, () => - stoppableServer.stop((e: any) => { - if (e) console.error(e); - }), - ); - - resolve(stoppableServer); }); } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 57b814da63..d69b00f04c 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -63,8 +63,8 @@ type CustomOrigin = ( * @example * ```json * { - * baseUrl: "http://localhost:7000", - * listen: "0.0.0.0:7000" + * baseUrl: "http://localhost:7007", + * listen: "0.0.0.0:7007" * } * ``` */ diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 3765cbcfdd..2ad379f31e 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -34,7 +34,7 @@ export type ServiceBuilder = { * * If no port is specified, the service will first look for an environment * variable named PORT and use that if present, otherwise it picks a default - * port (7000). + * port (7007). * * @param port - The port to listen on */ diff --git a/packages/backend-common/src/urls.test.ts b/packages/backend-common/src/urls.test.ts new file mode 100644 index 0000000000..c2a67fb849 --- /dev/null +++ b/packages/backend-common/src/urls.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { isValidUrl } from './urls'; + +describe('isValidUrl', () => { + it('should return true for url', () => { + const validUrl = isValidUrl('http://some.valid.url'); + expect(validUrl).toBe(true); + }); + + it('should return false for absolute path', () => { + const validUrl = isValidUrl('/some/absolute/path'); + expect(validUrl).toBe(false); + }); + + it('should return false for relative path', () => { + const validUrl = isValidUrl('../some/relative/path'); + expect(validUrl).toBe(false); + }); +}); diff --git a/packages/backend-common/src/urls.ts b/packages/backend-common/src/urls.ts new file mode 100644 index 0000000000..848cea25d9 --- /dev/null +++ b/packages/backend-common/src/urls.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/packages/backend-tasks/.eslintrc.js b/packages/backend-tasks/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/backend-tasks/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/backend-tasks/README.md b/packages/backend-tasks/README.md new file mode 100644 index 0000000000..d3c361c02c --- /dev/null +++ b/packages/backend-tasks/README.md @@ -0,0 +1,35 @@ +# @backstage/backend-tasks + +Common distributed task management for Backstage backends. + +## Usage + +Add the library to your backend package: + +```sh +# From your Backstage root directory +cd packages/backend +yarn add @backstage/backend-tasks +``` + +then make use of its facilities as necessary: + +```typescript +import { TaskScheduler } from '@backstage/backend-tasks'; +import { Duration } from 'luxon'; + +const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin'); + +await scheduler.scheduleTask({ + id: 'refresh-things', + frequency: Duration.fromObject({ minutes: 10 }), + fn: async () => { + await entityProvider.run(); + }, +}); +``` + +## 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-tasks/api-report.md b/packages/backend-tasks/api-report.md new file mode 100644 index 0000000000..eeb6412840 --- /dev/null +++ b/packages/backend-tasks/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/backend-tasks" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; +import { Config } from '@backstage/config'; +import { DatabaseManager } from '@backstage/backend-common'; +import { Duration } from 'luxon'; +import { Logger as Logger_2 } from 'winston'; + +// @public +export interface PluginTaskScheduler { + scheduleTask(task: TaskDefinition): Promise; +} + +// @public +export interface TaskDefinition { + fn: TaskFunction; + frequency: Duration; + id: string; + initialDelay?: Duration; + signal?: AbortSignal_2; + timeout: Duration; +} + +// @public +export type TaskFunction = + | ((abortSignal: AbortSignal_2) => void | Promise) + | (() => void | Promise); + +// @public +export class TaskScheduler { + constructor(databaseManager: DatabaseManager, logger: Logger_2); + forPlugin(pluginId: string): PluginTaskScheduler; + // (undocumented) + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger_2; + }, + ): TaskScheduler; +} +``` diff --git a/packages/backend-tasks/migrations/20210928160613_init.js b/packages/backend-tasks/migrations/20210928160613_init.js new file mode 100644 index 0000000000..21d80ae24c --- /dev/null +++ b/packages/backend-tasks/migrations/20210928160613_init.js @@ -0,0 +1,64 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // + // tasks + // + await knex.schema.createTable('backstage_backend_tasks__tasks', table => { + table.comment('Tasks used for scheduling work on multiple workers'); + table + .text('id') + .primary() + .notNullable() + .comment('The unique ID of this particular task'); + table + .text('settings_json') + .notNullable() + .comment('JSON serialized object with properties for this task'); + table + .dateTime('next_run_start_at') + .notNullable() + .comment('The next time that the task should be started'); + table + .text('current_run_ticket') + .nullable() + .comment('A unique ticket for the current task run'); + table + .dateTime('current_run_started_at') + .nullable() + .comment('The time that the current task run started'); + table + .dateTime('current_run_expires_at') + .nullable() + .comment('The time that the current task run will time out'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // + // tasks + // + await knex.schema.dropTable('backstage_backend_tasks__tasks'); +}; diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json new file mode 100644 index 0000000000..2bc6474615 --- /dev/null +++ b/packages/backend-tasks/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/backend-tasks", + "description": "Common distributed task management library for Backstage backends", + "version": "0.1.0", + "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-tasks" + }, + "keywords": [ + "backstage" + ], + "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.9.10", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.4", + "@backstage/types": "^0.1.1", + "@types/luxon": "^2.0.4", + "knex": "^0.95.1", + "lodash": "^4.17.21", + "luxon": "^2.0.2", + "node-abort-controller": "^3.0.1", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "zod": "^3.9.5" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^0.1.9", + "@backstage/cli": "^0.9.0", + "jest": "^26.0.1", + "wait-for-expect": "^3.0.2" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/packages/backend-tasks/src/database/migrateBackendTasks.ts b/packages/backend-tasks/src/database/migrateBackendTasks.ts new file mode 100644 index 0000000000..cc0f4349ac --- /dev/null +++ b/packages/backend-tasks/src/database/migrateBackendTasks.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { resolvePackagePath } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DB_MIGRATIONS_TABLE } from './tables'; + +const migrationsDir = resolvePackagePath( + '@backstage/backend-tasks', + 'migrations', +); + +export async function migrateBackendTasks(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + tableName: DB_MIGRATIONS_TABLE, + }); +} diff --git a/packages/backend-tasks/src/database/tables.ts b/packages/backend-tasks/src/database/tables.ts new file mode 100644 index 0000000000..63aad6e42a --- /dev/null +++ b/packages/backend-tasks/src/database/tables.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const DB_MIGRATIONS_TABLE = 'backstage_backend_tasks__knex_migrations'; +export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks'; + +export type DbTasksRow = { + id: string; + settings_json: string; + next_run_start_at: Date; + current_run_ticket?: string; + current_run_started_at?: Date | string; + current_run_expires_at?: Date | string; +}; diff --git a/packages/backend-tasks/src/index.ts b/packages/backend-tasks/src/index.ts new file mode 100644 index 0000000000..dd75aca68c --- /dev/null +++ b/packages/backend-tasks/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common distributed task management library for Backstage backends + * + * @packageDocumentation + */ + +export * from './tasks'; diff --git a/packages/backend-tasks/src/setupTests.ts b/packages/backend-tasks/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/packages/backend-tasks/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts new file mode 100644 index 0000000000..e387b85413 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; + +describe('PluginTaskManagerImpl', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function init(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + const manager = new PluginTaskSchedulerImpl( + async () => knex, + getVoidLogger(), + ); + return { knex, manager }; + } + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('scheduleTask', () => { + it.each(databases.eachSupportedId())( + 'can run the happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); + }); +}); diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts new file mode 100644 index 0000000000..93975bc327 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Knex } from 'knex'; +import { Logger } from 'winston'; +import { TaskWorker } from './TaskWorker'; +import { PluginTaskScheduler, TaskDefinition } from './types'; +import { validateId } from './util'; + +/** + * Implements the actual task management. + */ +export class PluginTaskSchedulerImpl implements PluginTaskScheduler { + constructor( + private readonly databaseFactory: () => Promise, + private readonly logger: Logger, + ) {} + + async scheduleTask(task: TaskDefinition): Promise { + validateId(task.id); + + const knex = await this.databaseFactory(); + + const worker = new TaskWorker(task.id, task.fn, knex, this.logger); + await worker.start( + { + version: 1, + initialDelayDuration: task.initialDelay?.toISO(), + recurringAtMostEveryDuration: task.frequency.toISO(), + timeoutAfterDuration: task.timeout.toISO(), + }, + { + signal: task.signal, + }, + ); + } +} diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts new file mode 100644 index 0000000000..8b90afff42 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { sleep } from './util'; + +/** + * Makes sure to auto-expire and clean up things that time out or for other + * reasons should not be left lingering. + */ +export class PluginTaskSchedulerJanitor { + private readonly knex: Knex; + private readonly waitBetweenRuns: Duration; + private readonly logger: Logger; + + constructor(options: { + knex: Knex; + waitBetweenRuns: Duration; + logger: Logger; + }) { + this.knex = options.knex; + this.waitBetweenRuns = options.waitBetweenRuns; + this.logger = options.logger; + } + + async start(abortSignal?: AbortSignal) { + while (!abortSignal?.aborted) { + try { + await this.runOnce(); + } catch (e) { + this.logger.warn(`Error while performing janitorial tasks, ${e}`); + } + + await sleep(this.waitBetweenRuns, abortSignal); + } + } + + private async runOnce() { + // SQLite currently (Oct 1 2021) returns a number for returning() + // statements, effectively ignoring them and instead returning the outcome + // of the delete() - and knex also emits a warning about that fact, which + // is why we avoid that entirely for the sqlite3 driver. + // https://github.com/knex/knex/issues/4370 + // https://github.com/mapbox/node-sqlite3/issues/1453 + + const dbNull = this.knex.raw('null'); + + const tasksQuery = this.knex(DB_TASKS_TABLE) + .where('current_run_expires_at', '<', this.knex.fn.now()) + .update({ + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + if (this.knex.client.config.client === 'sqlite3') { + const tasks = await tasksQuery; + this.logger.warn(`${tasks} tasks timed out and were lost`); + } else { + const tasks = await tasksQuery.returning(['id']); + for (const { id } of tasks) { + this.logger.warn(`Task timed out and was lost: ${id}`); + } + } + } +} diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.test.ts b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts new file mode 100644 index 0000000000..ce8e797503 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import { TaskScheduler } from './TaskScheduler'; +import waitForExpect from 'wait-for-expect'; + +describe('TaskScheduler', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function createDatabase( + databaseId: TestDatabaseId, + ): Promise { + const knex = await databases.init(databaseId); + const databaseManager: Partial = { + forPlugin: () => ({ + getClient: async () => knex, + }), + }; + return databaseManager as DatabaseManager; + } + + it.each(databases.eachSupportedId())( + 'can return a working plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.ts b/packages/backend-tasks/src/tasks/TaskScheduler.ts new file mode 100644 index 0000000000..f1668a6691 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DatabaseManager, getRootLogger } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { once } from 'lodash'; +import { Duration } from 'luxon'; +import { Logger } from 'winston'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; +import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor'; +import { PluginTaskScheduler } from './types'; + +/** + * Deals with the scheduling of distributed tasks. + * + * @public + */ +export class TaskScheduler { + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger; + }, + ): TaskScheduler { + const databaseManager = + options?.databaseManager ?? DatabaseManager.fromConfig(config); + const logger = (options?.logger || getRootLogger()).child({ + type: 'taskManager', + }); + return new TaskScheduler(databaseManager, logger); + } + + constructor( + private readonly databaseManager: DatabaseManager, + private readonly logger: Logger, + ) {} + + /** + * Instantiates a task manager instance for the given plugin. + * + * @param pluginId - The unique ID of the plugin, for example "catalog" + * @returns A {@link PluginTaskScheduler} instance + */ + forPlugin(pluginId: string): PluginTaskScheduler { + const databaseFactory = once(async () => { + const knex = await this.databaseManager.forPlugin(pluginId).getClient(); + + await migrateBackendTasks(knex); + + const janitor = new PluginTaskSchedulerJanitor({ + knex, + waitBetweenRuns: Duration.fromObject({ minutes: 1 }), + logger: this.logger, + }); + janitor.start(); + + return knex; + }); + + return new PluginTaskSchedulerImpl( + databaseFactory, + this.logger.child({ plugin: pluginId }), + ); + } +} diff --git a/packages/backend-tasks/src/tasks/TaskWorker.test.ts b/packages/backend-tasks/src/tasks/TaskWorker.test.ts new file mode 100644 index 0000000000..2f977ca639 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskWorker } from './TaskWorker'; +import { TaskSettingsV1 } from './types'; + +describe('TaskWorker', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each(databases.eachSupportedId())( + 'goes through the expected states, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: Duration.fromMillis(1000).toISO(), + recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + expect(JSON.parse(row.settings_json)).toEqual({ + version: 1, + initialDelayDuration: 'PT1S', + recurringAtMostEveryDuration: 'PT2S', + timeoutAfterDuration: 'PT60S', + }); + + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'not-ready-yet', + }); + + waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + }); + }); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + true, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'runs tasks more than once even when the task throws, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn().mockRejectedValue(new Error('failed')); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: undefined, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + worker.start(settings); + + waitForExpect(() => { + expect(fn).toBeCalledTimes(3); + }); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'does not clobber ticket lock when stolen, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await knex(DB_TASKS_TABLE) + .where('id', '=', 'task1') + .update({ current_run_ticket: 'stolen' }); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'stolen', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'gracefully handles a disappeared task row, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn(async () => {}); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker1 = new TaskWorker('task1', fn, knex, logger); + await worker1.persistTask(settings); + await knex(DB_TASKS_TABLE).where('id', '=', 'task1').delete(); + await expect(worker1.findReadyTask()).resolves.toEqual({ + result: 'abort', + }); + + const worker2 = new TaskWorker('task2', fn, knex, logger); + await worker2.persistTask(settings); + await expect(worker2.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await knex(DB_TASKS_TABLE).where('id', '=', 'task2').delete(); + await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe( + false, + ); + + const worker3 = new TaskWorker('task3', fn, knex, logger); + await worker3.persistTask(settings); + await expect(worker3.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe( + true, + ); + await knex(DB_TASKS_TABLE).where('id', '=', 'task3').delete(); + await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.ts b/packages/backend-tasks/src/tasks/TaskWorker.ts new file mode 100644 index 0000000000..991d97b956 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.ts @@ -0,0 +1,249 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { v4 as uuid } from 'uuid'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types'; +import { delegateAbortController, nowPlus, sleep } from './util'; + +const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 }); + +/** + * Performs the actual work of a task. + * + * @private + */ +export class TaskWorker { + private readonly taskId: string; + private readonly fn: TaskFunction; + private readonly knex: Knex; + private readonly logger: Logger; + + constructor(taskId: string, fn: TaskFunction, knex: Knex, logger: Logger) { + this.taskId = taskId; + this.fn = fn; + this.knex = knex; + this.logger = logger; + } + + async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) { + try { + await this.persistTask(settings); + } catch (e) { + throw new Error(`Failed to persist task, ${e}`); + } + + this.logger.info( + `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, + ); + + (async () => { + try { + while (!options?.signal?.aborted) { + const runResult = await this.runOnce(options?.signal); + if (runResult.result === 'abort') { + break; + } + + await sleep(WORK_CHECK_FREQUENCY, options?.signal); + } + this.logger.info(`Task worker finished: ${this.taskId}`); + } catch (e) { + this.logger.warn(`Task worker failed unexpectedly, ${e}`); + } + })(); + } + + /** + * Makes a single attempt at running the task to completion, if ready. + * + * @returns The outcome of the attempt + */ + async runOnce( + signal?: AbortSignal, + ): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'failed' } + | { result: 'completed' } + > { + const findResult = await this.findReadyTask(); + if ( + findResult.result === 'not-ready-yet' || + findResult.result === 'abort' + ) { + return findResult; + } + + const taskSettings = findResult.settings; + const ticket = uuid(); + + const claimed = await this.tryClaimTask(ticket, taskSettings); + if (!claimed) { + return { result: 'not-ready-yet' }; + } + + // Abort the task execution either if the worker is stopped, or if the + // task timeout is hit + const taskAbortController = delegateAbortController(signal); + const timeoutHandle = setTimeout(() => { + taskAbortController.abort(); + }, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds')); + + try { + await this.fn(taskAbortController.signal); + } catch (e) { + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'failed' }; + } finally { + clearTimeout(timeoutHandle); + } + + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'completed' }; + } + + /** + * Perform the initial store of the task info + */ + async persistTask(settings: TaskSettingsV1) { + // Perform an initial parse to ensure that we will definitely be able to + // read it back again. + taskSettingsV1Schema.parse(settings); + + const settingsJson = JSON.stringify(settings); + const startAt = settings.initialDelayDuration + ? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex) + : this.knex.fn.now(); + + // It's OK if the task already exists; if it does, just replace its + // settings with the new value and start the loop as usual. + await this.knex(DB_TASKS_TABLE) + .insert({ + id: this.taskId, + settings_json: settingsJson, + next_run_start_at: startAt, + }) + .onConflict('id') + .merge(['settings_json']); + } + + /** + * Check if the task is ready to run + */ + async findReadyTask(): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'ready'; settings: TaskSettingsV1 } + > { + const [row] = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .select({ + settingsJson: 'settings_json', + ready: this.knex.raw( + ` + CASE + WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE + ELSE FALSE + END`, + [this.knex.fn.now()], + ), + }); + + if (!row) { + this.logger.info( + 'No longer able to find task; aborting and assuming that it has been unregistered or expired', + ); + return { result: 'abort' }; + } else if (!row.ready) { + return { result: 'not-ready-yet' }; + } + + try { + const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson)); + return { result: 'ready', settings }; + } catch (e) { + this.logger.info( + `Task "${this.taskId}" is no longer able to parse task settings; aborting and assuming that a ` + + `newer version of the task has been issued and being handled by other workers, ${e}`, + ); + return { result: 'abort' }; + } + } + + /** + * Attempts to claim a task that's ready for execution, on this worker's + * behalf. We should not attempt to perform the work unless the claim really + * goes through. + * + * @param ticket - A globally unique string that changes for each invocation + * @param settings - The settings of the task to claim + * @returns True if it was successfully claimed + */ + async tryClaimTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const startedAt = this.knex.fn.now(); + const expiresAt = settings.timeoutAfterDuration + ? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex) + : this.knex.raw('null'); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .whereNull('current_run_ticket') + .update({ + current_run_ticket: ticket, + current_run_started_at: startedAt, + current_run_expires_at: expiresAt, + }); + + return rows === 1; + } + + async tryReleaseTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const { recurringAtMostEveryDuration } = settings; + + // We make an effort to keep the datetime calculations in the database + // layer, making sure to not have to perform conversions back and forth and + // leaning on the database as a central clock source + const dbNull = this.knex.raw('null'); + const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds'); + const nextRun = + this.knex.client.config.client === 'sqlite3' + ? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`]) + : this.knex.raw(`next_run_start_at + interval '${dt} seconds'`); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .where('current_run_ticket', '=', ticket) + .update({ + next_run_start_at: nextRun, + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + return rows === 1; + } +} diff --git a/packages/backend-tasks/src/tasks/index.ts b/packages/backend-tasks/src/tasks/index.ts new file mode 100644 index 0000000000..9e0a06f71c --- /dev/null +++ b/packages/backend-tasks/src/tasks/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { TaskScheduler } from './TaskScheduler'; +export type { + PluginTaskScheduler, + TaskDefinition, + TaskFunction, +} from './types'; diff --git a/packages/backend-tasks/src/tasks/types.ts b/packages/backend-tasks/src/tasks/types.ts new file mode 100644 index 0000000000..4693af7ef3 --- /dev/null +++ b/packages/backend-tasks/src/tasks/types.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { z } from 'zod'; + +/** + * A function that can be called as a scheduled task. + * + * It may optionally accept an abort signal argument. When the signal triggers, + * processing should abort and return as quickly as possible. + * + * @public + */ +export type TaskFunction = + | ((abortSignal: AbortSignal) => void | Promise) + | (() => void | Promise); + +/** + * Options that apply to the invocation of a given task. + * + * @public + */ +export interface TaskDefinition { + /** + * A unique ID (within the scope of the plugin) for the task. + */ + id: string; + + /** + * The actual task function to be invoked regularly. + */ + fn: TaskFunction; + + /** + * An abort signal that, when triggered, will stop the recurring execution of + * the task. + */ + signal?: AbortSignal; + + /** + * The maximum amount of time that a single task invocation can take, before + * it's considered timed out and gets "released" such that a new invocation + * is permitted to take place (possibly, then, on a different worker). + * + * If no value is given for this field then there is no timeout. This is + * potentially dangerous. + */ + timeout: Duration; + + /** + * The amount of time that should pass between task invocation starts. + * Essentially, this equals roughly how often you want the task to run. + * + * This is a best effort value; under some circumstances there can be + * deviations. For example, if the task runtime is longer than the frequency + * and the timeout has not been given or not been exceeded yet, the next + * invocation of this task will be delayed until after the previous one + * finishes. + * + * The system does its best to avoid overlapping invocations. + * + * If no value is given for this field then the task will only be invoked + * once (on any worker) and then unscheduled automatically. + */ + frequency: Duration; + + /** + * The amount of time that should pass before the first invocation happens. + * + * This can be useful in cold start scenarios to stagger or delay some heavy + * compute jobs. + * + * If no value is given for this field then the first invocation will happen + * as soon as possible. + */ + initialDelay?: Duration; +} + +/** + * Deals with the scheduling of distributed tasks, for a given plugin. + * + * @public + */ +export interface PluginTaskScheduler { + /** + * Schedules a task function for coordinated exclusive invocation across + * workers. + * + * If the task was already scheduled since before by us or by another party, + * its options are just overwritten with the given options, and things + * continue from there. + * + * @param definition - The task definition + */ + scheduleTask(task: TaskDefinition): Promise; +} + +function isValidOptionalDurationString(d: string | undefined): boolean { + try { + return !d || Duration.fromISO(d).isValid === true; + } catch { + return false; + } +} + +export const taskSettingsV1Schema = z.object({ + version: z.literal(1), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + recurringAtMostEveryDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + timeoutAfterDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), +}); + +/** + * The properties that control a scheduled task (version 1). + */ +export type TaskSettingsV1 = z.infer; diff --git a/packages/backend-tasks/src/tasks/util.test.ts b/packages/backend-tasks/src/tasks/util.test.ts new file mode 100644 index 0000000000..f74669a7f4 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Duration } from 'luxon'; +import { AbortController } from 'node-abort-controller'; +import { delegateAbortController, sleep, validateId } from './util'; + +describe('util', () => { + describe('validateId', () => { + it.each(['a', 'a_b', 'ab123c_2'])( + 'accepts valid inputs, %p', + async input => { + expect(validateId(input)).toBeUndefined(); + }, + ); + + it.each(['', 'a!', 'A', 'a-b', 'a.b', '_a', 'a_', null, Symbol('a')])( + 'rejects invalid inputs, %p', + async input => { + expect(() => validateId(input as any)).toThrow(); + }, + ); + }); + + describe('sleep', () => { + it('finishes the wait as expected with no signal', async () => { + const ac = new AbortController(); + const start = Date.now(); + await sleep(Duration.fromObject({ seconds: 1 }), ac.signal); + expect(Date.now() - start).toBeGreaterThan(800); + }, 5_000); + + it('aborts properly on the signal', async () => { + const ac = new AbortController(); + const promise = sleep(Duration.fromObject({ seconds: 10 }), ac.signal); + ac.abort(); + await promise; + expect(true).toBe(true); + }, 1_000); + }); + + describe('delegateAbortController', () => { + it('inherits parent abort state', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + parent.abort(); + expect(parent.signal.aborted).toBe(true); + expect(child.signal.aborted).toBe(true); + }); + + it('does not inherit from child to parent', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + child.abort(); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(true); + }); + }); +}); diff --git a/packages/backend-tasks/src/tasks/util.ts b/packages/backend-tasks/src/tasks/util.ts new file mode 100644 index 0000000000..0509f29363 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { InputError } from '@backstage/errors'; +import { Knex } from 'knex'; +import { DateTime, Duration } from 'luxon'; +import { AbortController, AbortSignal } from 'node-abort-controller'; + +// Keep the IDs compatible with e.g. Prometheus +export function validateId(id: string) { + if (typeof id !== 'string' || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(id)) { + throw new InputError( + `${id} is not a valid ID, expected string of lowercase characters and digits separated by underscores`, + ); + } +} + +export function dbTime(t: Date | string): DateTime { + if (typeof t === 'string') { + return DateTime.fromSQL(t); + } + return DateTime.fromJSDate(t); +} + +export function nowPlus(duration: Duration | undefined, knex: Knex) { + const seconds = duration?.as('seconds') ?? 0; + if (!seconds) { + return knex.fn.now(); + } + return knex.client.config.client === 'sqlite3' + ? knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]) + : knex.raw(`now() + interval '${seconds} seconds'`); +} + +/** + * Sleep for the given duration, but return sooner if the abort signal + * triggers. + * + * @param duration - The amount of time to sleep, at most + * @param abortSignal - An optional abort signal that short circuits the wait + */ +export async function sleep( + duration: Duration, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + return; + } + + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + + const done = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + abortSignal?.removeEventListener('abort', done); + resolve(); + }; + + timeoutHandle = setTimeout(done, duration.as('milliseconds')); + abortSignal?.addEventListener('abort', done); + }); +} + +/** + * Creates a new AbortController that, in addition to working as a regular + * standalone controller, also gets aborted if the given parent signal + * reaches aborted state. + * + * @param parent - The "parent" signal that can trigger the delegate + */ +export function delegateAbortController(parent?: AbortSignal): AbortController { + const delegate = new AbortController(); + + if (parent) { + if (parent.aborted) { + delegate.abort(); + } else { + const onParentAborted = () => { + delegate.abort(); + }; + + const onChildAborted = () => { + parent.removeEventListener('abort', onParentAborted); + }; + + parent.addEventListener('abort', onParentAborted, { once: true }); + delegate.signal.addEventListener('abort', onChildAborted, { once: true }); + } + } + + return delegate; +} diff --git a/packages/backend-test-utils/.snyk b/packages/backend-test-utils/.snyk new file mode 100644 index 0000000000..ad62599e66 --- /dev/null +++ b/packages/backend-test-utils/.snyk @@ -0,0 +1,47 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAR-1579155: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579152: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579147: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536758: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536531: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536528: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z +patch: {} diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md index 287e40a296..9927aa3cc7 100644 --- a/packages/backend-test-utils/CHANGELOG.md +++ b/packages/backend-test-utils/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/backend-test-utils +## 0.1.9 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- Updated dependencies + - @backstage/cli@0.9.0 + - @backstage/backend-common@0.9.10 + ## 0.1.8 ### Patch Changes diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 2f29c2412b..c3d4f730b7 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-test-utils", "description": "Test helpers library for Backstage backends", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,18 +30,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/cli": "^0.8.0", + "@backstage/backend-common": "^0.9.10", + "@backstage/cli": "^0.9.0", "@backstage/config": "^0.1.9", "knex": "^0.95.1", "mysql2": "^2.2.5", "pg": "^8.3.0", "sqlite3": "^5.0.1", - "testcontainers": "^7.10.0", + "testcontainers": "^7.23.0", "uuid": "^8.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "jest": "^26.0.1" }, "files": [ diff --git a/packages/backend/.snyk b/packages/backend/.snyk new file mode 100644 index 0000000000..ad62599e66 --- /dev/null +++ b/packages/backend/.snyk @@ -0,0 +1,47 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAR-1579155: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579152: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579147: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536758: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536531: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536528: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z +patch: {} diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 19e9bcdd67..7cf7d9ddaa 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,20 @@ # example-backend +## 0.2.52 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/plugin-jenkins-backend@0.1.7 + - @backstage/plugin-search-backend-module-elasticsearch@0.0.5 + - @backstage/plugin-scaffolder-backend@0.15.12 + - @backstage/plugin-azure-devops-backend@0.2.0 + - @backstage/catalog-client@0.5.1 + - @backstage/plugin-auth-backend@0.4.7 + - @backstage/plugin-catalog-backend@0.17.3 + - @backstage/plugin-scaffolder-backend-module-rails@0.1.7 + ## 0.2.50 ### Patch Changes diff --git a/packages/backend/README.md b/packages/backend/README.md index e6f0c899ca..c2145705e0 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -37,7 +37,7 @@ Substitute `x` for actual values, or leave them as dummy values just to try out the backend without using the auth or sentry features. You can also, instead of using dummy values for a huge number of environment variables, remove those config directly from app-config.yaml file located in the root folder. -The backend starts up on port 7000 per default. +The backend starts up on port 7007 per default. ### Debugging diff --git a/packages/backend/package.json b/packages/backend/package.json index f1d58252a4..4fb692f040 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.50", + "version": "0.2.52", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,39 +24,39 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/integration": "^0.6.8", - "@backstage/plugin-app-backend": "^0.3.17", - "@backstage/plugin-auth-backend": "^0.4.5", - "@backstage/plugin-azure-devops-backend": "^0.1.3", + "@backstage/plugin-app-backend": "^0.3.19", + "@backstage/plugin-auth-backend": "^0.4.8", + "@backstage/plugin-azure-devops-backend": "^0.2.1", "@backstage/plugin-badges-backend": "^0.1.11", - "@backstage/plugin-catalog-backend": "^0.17.1", + "@backstage/plugin-catalog-backend": "^0.17.4", "@backstage/plugin-code-coverage-backend": "^0.1.14", "@backstage/plugin-graphql-backend": "^0.1.9", - "@backstage/plugin-jenkins-backend": "^0.1.6", + "@backstage/plugin-jenkins-backend": "^0.1.7", "@backstage/plugin-kubernetes-backend": "^0.3.18", - "@backstage/plugin-kafka-backend": "^0.2.10", + "@backstage/plugin-kafka-backend": "^0.2.11", "@backstage/plugin-proxy-backend": "^0.2.13", "@backstage/plugin-rollbar-backend": "^0.1.15", - "@backstage/plugin-scaffolder-backend": "^0.15.10", - "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.5", + "@backstage/plugin-scaffolder-backend": "^0.15.13", + "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7", "@backstage/plugin-search-backend": "^0.2.6", "@backstage/plugin-search-backend-node": "^0.4.2", - "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.4", + "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5", "@backstage/plugin-search-backend-module-pg": "^0.2.1", - "@backstage/plugin-techdocs-backend": "^0.10.5", - "@backstage/plugin-tech-insights-backend": "^0.1.0", + "@backstage/plugin-techdocs-backend": "^0.10.8", + "@backstage/plugin-tech-insights-backend": "^0.1.1", "@backstage/plugin-tech-insights-node": "^0.1.0", "@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.0", "@backstage/plugin-todo-backend": "^0.1.13", "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", - "dockerode": "^3.2.1", - "example-app": "^0.2.51", + "dockerode": "^3.3.1", + "example-app": "^0.2.53", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", @@ -68,8 +68,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0", - "@types/dockerode": "^3.2.1", + "@backstage/cli": "^0.9.0", + "@types/dockerode": "^3.3.0", "@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 ffdce949b7..f978e84da9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -48,12 +48,12 @@ import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; import search from './plugins/search'; import techdocs from './plugins/techdocs'; +import techInsights from './plugins/techInsights'; import todo from './plugins/todo'; import graphql from './plugins/graphql'; import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; -import techInsights from './plugins/techInsights'; import { PluginEnvironment } from './types'; function makeCreateEnv(config: Config) { diff --git a/packages/backend/src/plugins/techInsights.ts b/packages/backend/src/plugins/techInsights.ts index abab1d133d..8ab6342a4f 100644 --- a/packages/backend/src/plugins/techInsights.ts +++ b/packages/backend/src/plugins/techInsights.ts @@ -17,10 +17,12 @@ import { createRouter, buildTechInsightsContext, createFactRetrieverRegistration, + entityOwnershipFactRetriever, + entityMetadataFactRetriever, + techdocsFactRetriever, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { CatalogClient } from '@backstage/catalog-client'; import { JsonRulesEngineFactCheckerFactory, JSON_RULE_ENGINE_CHECK_TYPE, @@ -38,41 +40,12 @@ export default async function createPlugin({ database, discovery, factRetrievers: [ - createFactRetrieverRegistration('5 4 * * 6', { - // Example cron, At 04:05 on Saturday. - id: 'testRetriever', - version: '1.1.2', - entityFilter: [{ kind: 'component' }], // EntityFilter to be used in the future (creating checks, graphs etc.) to figure out which entities this fact retrieves data for. - schema: { - examplenumberfact: { - type: 'integer', - description: 'Example fact returning a number', - }, - }, - handler: async _ctx => { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - const entities = await catalogClient.getEntities({ - filter: [{ kind: 'component' }], - }); - - return Promise.resolve( - entities.items.map(it => { - return { - entity: { - namespace: it.metadata.namespace!!, - kind: it.kind, - name: it.metadata.name, - }, - facts: { - examplenumberfact: 2, - }, - }; - }), - ); - }, - }), + createFactRetrieverRegistration( + '* * * * *', // Example cron, every minute + entityOwnershipFactRetriever, + ), + createFactRetrieverRegistration('* * * * *', entityMetadataFactRetriever), + createFactRetrieverRegistration('* * * * *', techdocsFactRetriever), ], factCheckerFactory: new JsonRulesEngineFactCheckerFactory({ checks: [ @@ -81,14 +54,33 @@ export default async function createPlugin({ type: JSON_RULE_ENGINE_CHECK_TYPE, name: 'simpleTestCheck', description: 'Simple Check For Testing', - factIds: ['testRetriever'], + factIds: [ + 'entityMetadataFactRetriever', + 'techdocsFactRetriever', + 'entityOwnershipFactRetriever', + ], rule: { conditions: { all: [ { - fact: 'examplenumberfact', - operator: 'lessThan', - value: 5, + fact: 'hasGroupOwner', + operator: 'equal', + value: true, + }, + { + fact: 'hasTitle', + operator: 'equal', + value: true, + }, + { + fact: 'hasDescription', + operator: 'equal', + value: true, + }, + { + fact: 'hasAnnotationBackstageIoTechdocsRef', + operator: 'equal', + value: true, }, ], }, diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 8243d09486..1b11e3bcb2 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/catalog-client +## 0.5.2 + +### Patch Changes + +- 3bf2238187: Update to the right version of @backstage/errors +- Updated dependencies + - @backstage/catalog-model@0.9.7 + +## 0.5.1 + +### Patch Changes + +- 39e92897e4: Improved API documentation for catalog-client. + ## 0.5.0 ### Minor Changes diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index b7bb713957..e16ca740f6 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -7,7 +7,7 @@ import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; import { Location as Location_2 } from '@backstage/catalog-model'; -// @public (undocumented) +// @public export type AddLocationRequest = { type?: string; target: string; @@ -15,126 +15,106 @@ export type AddLocationRequest = { presence?: 'optional' | 'required'; }; -// @public (undocumented) +// @public export type AddLocationResponse = { location: Location_2; entities: Entity[]; exists?: boolean; }; -// @public (undocumented) +// @public export const CATALOG_FILTER_EXISTS: unique symbol; -// @public (undocumented) +// @public export interface CatalogApi { - // (undocumented) addLocation( location: AddLocationRequest, options?: CatalogRequestOptions, ): Promise; - // (undocumented) getEntities( request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise>; - // (undocumented) getEntityAncestors( request: CatalogEntityAncestorsRequest, 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) refreshEntity( entityRef: string, options?: CatalogRequestOptions, ): Promise; - // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, ): Promise; - // (undocumented) removeLocationById( id: string, options?: CatalogRequestOptions, ): Promise; } -// @public (undocumented) +// @public 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) getEntityAncestors( request: CatalogEntityAncestorsRequest, 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) refreshEntity( entityRef: string, options?: CatalogRequestOptions, ): Promise; - // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, ): Promise; - // (undocumented) removeLocationById( id: string, options?: CatalogRequestOptions, ): Promise; } -// @public (undocumented) +// @public export type CatalogEntitiesRequest = { filter?: | Record[] @@ -143,12 +123,12 @@ export type CatalogEntitiesRequest = { fields?: string[] | undefined; }; -// @public (undocumented) +// @public export type CatalogEntityAncestorsRequest = { entityRef: string; }; -// @public (undocumented) +// @public export type CatalogEntityAncestorsResponse = { root: EntityName; items: { @@ -157,12 +137,12 @@ export type CatalogEntityAncestorsResponse = { }[]; }; -// @public (undocumented) +// @public export type CatalogListResponse = { items: T[]; }; -// @public (undocumented) +// @public export type CatalogRequestOptions = { token?: string; }; diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index d4ba2c20f6..4ab727b6b3 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-client", "description": "An isomorphic client for the catalog backend", - "version": "0.5.0", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/errors": "^0.1.3", + "@backstage/catalog-model": "^0.9.7", + "@backstage/errors": "^0.1.4", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/jest": "^26.0.7", "msw": "^0.35.0" }, diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 75944467d8..29d01fbe5c 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -39,7 +39,11 @@ import { } from './types/api'; import { DiscoveryApi } from './types/discovery'; -/** @public */ +/** + * A frontend and backend compatible client for communicating with the Backstage Catalog. + * + * @public + * */ export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; @@ -47,6 +51,16 @@ export class CatalogClient implements CatalogApi { this.discoveryApi = options.discoveryApi; } + /** + * Gets the Ancestors of an Entity. + * + * @param request - A request type for retrieving Entity ancestors. + * @param options - An object with your preferred options. + * + * @returns A CatalogEntityAncestorsResponse. + * + * @public + */ async getEntityAncestors( request: CatalogEntityAncestorsRequest, options?: CatalogRequestOptions, @@ -61,6 +75,16 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Gets a Location by Id. + * + * @param id - A string containing the Id. + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + * + * @public + */ async getLocationById( id: string, options?: CatalogRequestOptions, @@ -72,6 +96,16 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Gets a set of Entities. + * + * @param request - A request type for retrieving an Entity. + * @param options - An object with your preferred options. + * + * @returns A CatalogListResponse. + * + * @public + */ async getEntities( request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, @@ -139,6 +173,16 @@ export class CatalogClient implements CatalogApi { return { items: entities.sort(refCompare) }; } + /** + * Gets a given Entity based on a provided name. + * + * @param compoundName - A string containing the name. + * @param options - An object with your preferred options. + * + * @returns An {@link catalog-model#Entity}. + * + * @public + */ async getEntityByName( compoundName: EntityName, options?: CatalogRequestOptions, @@ -153,6 +197,14 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Refreshes an Entity. + * + * @param entityRef - A string containing the entityREf + * @param options - An object with your preferred options. + * + * @public + */ async refreshEntity(entityRef: string, options?: CatalogRequestOptions) { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/refresh`, @@ -171,6 +223,16 @@ export class CatalogClient implements CatalogApi { } } + /** + * Adds a location. + * + * @param options - An object with your preferred options. + * @param AddLocationRequest - A request object for adding locations. + * + * @returns An AddLocationResponse + * + * @public + */ async addLocation( { type = 'url', target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, @@ -206,6 +268,16 @@ export class CatalogClient implements CatalogApi { }; } + /** + * Gets an origin Location By Entity. + * + * @param entity - An Entity + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + * + * @public + */ async getOriginLocationByEntity( entity: Entity, options?: CatalogRequestOptions, @@ -225,6 +297,16 @@ export class CatalogClient implements CatalogApi { .find(l => locationCompound === stringifyLocationReference(l)); } + /** + * Gets a Location by Entity. + * + * @param entity - An Entity + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + * + * @public + */ async getLocationByEntity( entity: Entity, options?: CatalogRequestOptions, @@ -243,6 +325,14 @@ export class CatalogClient implements CatalogApi { .find(l => locationCompound === stringifyLocationReference(l)); } + /** + * Removes a location as identified by Id. + * + * @param id - A string containing the Id + * @param options - An object with your preferred options. + * + * @public + */ async removeLocationById( id: string, options?: CatalogRequestOptions, @@ -254,6 +344,14 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Removes an Entity as identified by Uid. + * + * @param uid - A string containing the Uid + * @param options - An object with your preferred options. + * + * @public + */ async removeEntityByUid( uid: string, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index c7025e9452..6344caff7c 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -16,10 +16,18 @@ import { Entity, EntityName, Location } from '@backstage/catalog-model'; -/** @public */ +/** + * A Symbol to define if a catalog filter exists or not. + * + * @public + */ export const CATALOG_FILTER_EXISTS = Symbol('CATALOG_FILTER_EXISTS'); -/** @public */ +/** + * A request type for retrieving catalog Entities. + * + * @public + */ export type CatalogEntitiesRequest = { filter?: | Record[] @@ -28,75 +36,176 @@ export type CatalogEntitiesRequest = { fields?: string[] | undefined; }; -/** @public */ +/** + * A request type for Catalog Entity Ancestor information. + * + * @public + */ export type CatalogEntityAncestorsRequest = { entityRef: string; }; -/** @public */ +/** + * A response type for Catalog Entity Ancestor information. + * + * @public + */ export type CatalogEntityAncestorsResponse = { root: EntityName; items: { entity: Entity; parents: EntityName[] }[]; }; -/** @public */ +/** + * A response type for the result of a catalog operation in list form. + * + * @public + */ export type CatalogListResponse = { items: T[]; }; -/** @public */ +/** + * Options you can pass into a catalog request for additional information. + * + * @public + */ export type CatalogRequestOptions = { token?: string; }; -/** @public */ +/** + * Public functions for interacting with the Catalog API. + * + * @public + */ export interface CatalogApi { - // Entities + /** + * Gets the Entities from the catalog based on your request and options. + * + * @param request - An object with your filters and fields. + * @param options - An object with your preferred options. + * + * @returns A CatalogListResponse with items typed Catalog Model Entity. + * + */ getEntities( request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise>; + /** + * Gets the Entity ancestor information from the catalog based on your request and options. + * + * @param request - An object with your filters and fields. + * @param options - An object with your preferred options. + * + * @returns A CatalogEntityAncestorsResponse. + */ getEntityAncestors( request: CatalogEntityAncestorsRequest, options?: CatalogRequestOptions, ): Promise; + /** + * Gets a single Entity from the catalog by Entity name. + * + * @param name - A complete Entity name, with the full kind-namespace-name triplet. + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Entity}. + */ getEntityByName( name: EntityName, options?: CatalogRequestOptions, ): Promise; + /** + * Removes a single Entity from the catalog by Entity UID. + * + * @param uid - A string of the Entity UID. + * @param options - An object with your preferred options. + * + */ removeEntityByUid( uid: string, options?: CatalogRequestOptions, ): Promise; + /** + * Refreshes an Entity in the catalog. + * + * @param entityRef - A string in the form of 'Kind/default:foo'. + * @param options - An object with your preferred options. + * + */ refreshEntity( entityRef: string, options?: CatalogRequestOptions, ): Promise; // Locations + /** + * Gets a Location object by ID from the catalog. + * + * @param id - A string in of the Location Id. + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + */ getLocationById( id: string, options?: CatalogRequestOptions, ): Promise; + /** + * Gets origin location by Entity. + * + * @param entity - An {@link catalog-model#Entity}. + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + */ getOriginLocationByEntity( entity: Entity, options?: CatalogRequestOptions, ): Promise; + /** + * Gets Location by Entity. + * + * @param entity - An {@link catalog-model#Entity}. + * @param options - An object with your preferred options. + * + * @returns A {@link catalog-model#Location_2}. + */ getLocationByEntity( entity: Entity, options?: CatalogRequestOptions, ): Promise; + /** + * Adds a Location. + * + * @param location - A request type for adding a Location to the catalog. + * @param options - An object with your preferred options. + * + * @returns A AddLocationResponse. + */ addLocation( location: AddLocationRequest, options?: CatalogRequestOptions, ): Promise; + /** + * Removes a Location by Id. + * + * @param id - A string in of the Location Id. + * @param options - An object with your preferred options. + * + */ removeLocationById( id: string, options?: CatalogRequestOptions, ): Promise; } -/** @public */ +/** + * A request type for adding a Location to the catalog. + * + * @public + */ export type AddLocationRequest = { type?: string; target: string; @@ -104,7 +213,11 @@ export type AddLocationRequest = { presence?: 'optional' | 'required'; }; -/** @public */ +/** + * A response type for adding a Location to the catalog. + * + * @public + */ export type AddLocationResponse = { location: Location; entities: Entity[]; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index e0c067c9c7..efd6513cdd 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/catalog-model +## 0.9.7 + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. + ## 0.9.6 ### Patch Changes diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index b64e536578..0ba07aec3c 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-model", "description": "Types and validators that help describe the model of a Backstage Catalog", - "version": "0.9.6", + "version": "0.9.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -36,13 +36,13 @@ "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.13", "ajv": "^7.0.3", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "uuid": "^8.0.0", "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" diff --git a/packages/cli-common/CHANGELOG.md b/packages/cli-common/CHANGELOG.md index f3978798c6..e588c02edf 100644 --- a/packages/cli-common/CHANGELOG.md +++ b/packages/cli-common/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli-common +## 0.1.6 + +### Patch Changes + +- 677bfc2dd0: Keep backstage.json in sync + + The `versions:bump` script now takes care about updating the `version` property inside `backstage.json` file. The file is created if is not present. + ## 0.1.5 ### Patch Changes diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index 4b61cfedb8..34f56c3f19 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -3,6 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +// @public +export const BACKSTAGE_JSON = 'backstage.json'; + // @public export function findPaths(searchDir: string): Paths; diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 5916cdb017..cdb4b01b6d 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.5", + "version": "0.1.6", "private": false, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index e7cda19514..8809764b43 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -20,6 +20,6 @@ * @packageDocumentation */ -export { findPaths } from './paths'; +export { findPaths, BACKSTAGE_JSON } from './paths'; export { isChildPath } from './isChildPath'; export type { Paths, ResolveFunc } from './paths'; diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index c9f486752f..2b1ca34ca5 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -167,3 +167,10 @@ export function findPaths(searchDir: string): Paths { resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths), }; } + +/** + * The name of the backstage's config file + * + * @public + */ +export const BACKSTAGE_JSON = 'backstage.json'; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 48f38cbc8a..407c4fcbf2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/cli +## 0.9.0 + +### Minor Changes + +- 25f637f39f: Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority. + +### Patch Changes + +- 677bfc2dd0: Keep backstage.json in sync + + The `versions:bump` script now takes care about updating the `version` property inside `backstage.json` file. The file is created if is not present. + +- 8809b6c0dd: Update the json-schema dependency version. +- fdfd2f8a62: remove double config dep +- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. +- 6dcfe227a2: Added a scaffolder backend module template for the `create` command. +- 4ca3542fdd: Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead. +- 867ea81d15: bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1 +- 16d06f6ac3: Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/cli-common@0.1.6 + +## 0.8.2 + +### Patch Changes + +- dd355bca46: Switched to dynamically determining the packages that are unsafe to repack when executing the CLI within the Backstage main repo. +- b393c4d4be: Fixed the `config:check` command that was incorrectly only validating frontend configuration. Also added a `--frontend` flag to the command which maintains that behavior. +- 0611f3b3e2: Reading app config from a remote server +- ec64d9590c: Make `ExitCodeError` call `super` early to avoid compiler warnings +- 8af66229e7: Bumped `@spotify/eslint-config-react` from `v10` to `v12`, dropping support for Node.js v12. +- a197708da9: Bumped `@spotify/eslint-config-typescript` from `v10` to `v12`, dropping support for Node.js v12. +- Updated dependencies + - @backstage/config-loader@0.7.2 + ## 0.8.1 ### Patch Changes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index e889d23c3a..1d33372c60 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -95,6 +95,7 @@ async function getProjectConfig(targetPath, displayName) { ...(displayName && { displayName }), rootDir: path.resolve(targetPath, 'src'), coverageDirectory: path.resolve(targetPath, 'coverage'), + coverageProvider: 'v8', collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], moduleNameMapper: { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), diff --git a/packages/cli/config/jestSucraseTransform.js b/packages/cli/config/jestSucraseTransform.js index 01acfee38d..c88909d21f 100644 --- a/packages/cli/config/jestSucraseTransform.js +++ b/packages/cli/config/jestSucraseTransform.js @@ -46,7 +46,11 @@ function process(source, filePath) { } if (transforms) { - return transform(source, { transforms, filePath }).code; + return transform(source, { + transforms, + filePath, + disableESTransforms: true, + }).code; } return source; diff --git a/packages/cli/package.json b/packages/cli/package.json index c023313fe7..1eed5a742c 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.8.1", + "version": "0.9.0", "private": false, "publishConfig": { "access": "public" @@ -28,22 +28,22 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.7.1", + "@backstage/config-loader": "^0.8.0", "@backstage/errors": "^0.1.4", "@backstage/types": "^0.1.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", "@octokit/request": "^5.4.12", - "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-yaml": "^3.0.0", "@spotify/eslint-config-base": "^12.0.0", - "@spotify/eslint-config-react": "^10.0.0", - "@spotify/eslint-config-typescript": "^10.0.0", + "@spotify/eslint-config-react": "^12.0.0", + "@spotify/eslint-config-typescript": "^12.0.0", "@sucrase/jest-plugin": "^2.1.1", "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "5.5.x", @@ -81,7 +81,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.2", @@ -117,14 +117,14 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.9.8", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/core-app-api": "^0.1.21", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", + "@backstage/theme": "^0.2.13", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 6bd575f9bf..b640ee4f37 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/commands/config/print.ts @@ -25,6 +25,7 @@ export default async (cmd: Command) => { args: cmd.config, fromPackage: cmd.package, mockEnv: cmd.lax, + fullVisibility: !cmd.frontend, }); const visibility = getVisibilityOption(cmd); const data = serializeConfigData(appConfigs, schema, visibility); diff --git a/packages/cli/src/commands/config/validate.ts b/packages/cli/src/commands/config/validate.ts index 9041272c5a..2d3ce60366 100644 --- a/packages/cli/src/commands/config/validate.ts +++ b/packages/cli/src/commands/config/validate.ts @@ -22,5 +22,6 @@ export default async (cmd: Command) => { args: cmd.config, fromPackage: cmd.package, mockEnv: cmd.lax, + fullVisibility: !cmd.frontend, }); }; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index a5ea565cae..c80129662c 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -263,7 +263,6 @@ export default async (cmd: Command) => { const pluginDir = isMonoRepo ? paths.resolveTargetRoot('plugins', pluginId) : paths.resolveTargetRoot(pluginId); - const ownerIds = parseOwnerIds(answers.owner); const { version: pluginVersion } = isMonoRepo ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) : { version: '0.1.0' }; @@ -318,12 +317,8 @@ export default async (cmd: Command) => { await addPluginExtensionToApp(pluginId, extensionName, name); } - if (ownerIds && ownerIds.length) { - await addCodeownersEntry( - codeownersPath!, - `/plugins/${pluginId}`, - ownerIds, - ); + if (answers.owner) { + await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner); } Task.log(); diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts new file mode 100644 index 0000000000..64067e1b7e --- /dev/null +++ b/packages/cli/src/commands/create/create.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 from 'os'; +import fs from 'fs-extra'; +import { join as joinPath } from 'path'; +import { Command } from 'commander'; +import { FactoryRegistry } from '../../lib/create/FactoryRegistry'; +import { paths } from '../../lib/paths'; +import { assertError } from '@backstage/errors'; +import { Task } from '../../lib/tasks'; + +function parseOptions(optionStrings: string[]): Record { + const options: Record = {}; + + for (const str of optionStrings) { + const [key] = str.split('=', 1); + const value = str.slice(key.length + 1); + if (!key || str[key.length] !== '=') { + throw new Error( + `Invalid option '${str}', must be of the format =`, + ); + } + options[key] = value; + } + + return options; +} + +export default async (cmd: Command) => { + const cmdOpts = cmd.opts(); + + const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select); + + const providedOptions = parseOptions(cmdOpts.option); + const options = await FactoryRegistry.populateOptions( + factory, + providedOptions, + ); + + let isMonoRepo = false; + try { + const rootPackageJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + if (rootPackageJson.workspaces) { + isMonoRepo = true; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + let defaultVersion = '0.1.0'; + try { + const rootLernaJson = await fs.readJson( + paths.resolveTargetRoot('lerna.json'), + ); + if (rootLernaJson.version) { + defaultVersion = rootLernaJson.version; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + const tempDirs = new Array(); + async function createTemporaryDirectory(name: string): Promise { + const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name)); + tempDirs.push(dir); + return dir; + } + + let modified = false; + try { + await factory.create(options, { + isMonoRepo, + defaultVersion, + scope: cmdOpts.scope?.replace(/^@/, ''), + npmRegistry: cmdOpts.npmRegistry, + private: Boolean(cmdOpts.private), + createTemporaryDirectory, + markAsModified() { + modified = true; + }, + }); + + Task.log(); + Task.log(`๐ŸŽ‰ Successfully created ${factory.name}`); + Task.log(); + } catch (error) { + assertError(error); + Task.error(error.message); + + if (modified) { + Task.log('It seems that something went wrong in the creation process ๐Ÿค”'); + Task.log(); + Task.log( + 'We have left the changes that were made intact in case you want to', + ); + Task.log( + 'continue manually, but you can also revert the changes and try again.', + ); + + Task.error(`๐Ÿ”ฅ Failed to create ${factory.name}!`); + } + } finally { + for (const dir of tempDirs) { + try { + await fs.remove(dir); + } catch (error) { + console.error( + `Failed to remove temporary directory '${dir}', ${error}`, + ); + } + } + } +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 302f76cc5d..a37d5e9ffe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); + program + .command('create') + .storeOptionsAsProperties(false) + .description( + 'Open up an interactive guide to creating new things in your app', + ) + .option( + '--select ', + 'Select the thing you want to be creating upfront', + ) + .option( + '--option =', + 'Pre-fill options for the creation process', + (opt, arr: string[]) => [...arr, opt], + [], + ) + .option('--scope ', 'The scope to use for new packages') + .option( + '--npm-registry ', + 'The package registry to use for new packages', + ) + .option('--no-private', 'Do not mark new packages as private') + .action(lazy(() => import('./create/create').then(m => m.default))); + program .command('create-plugin') .option( @@ -172,6 +196,7 @@ export function registerCommands(program: CommanderStatic) { 'Only load config schema that applies to the given package', ) .option('--lax', 'Do not require environment variables to be set') + .option('--frontend', 'Only validate the frontend configuration') .option(...configOption) .description( 'Validate that the given configuration loads and matches schema', diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 0390320128..8859b5d4cd 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -177,9 +177,9 @@ describe('removePlugin', () => { fse.readFileSync(mockedCodeownersPath, 'utf8'), ); await addCodeownersEntry( - testFilePath!, path.join('plugins', testPluginName), - ['@thisIsAtestTeam', 'test@gmail.com'], + '@thisIsAtestTeam test@gmail.com', + testFilePath, ); await removePluginFromCodeOwners(testFilePath, testPluginName); expect(testFileContent).toBe(codeOwnersFileContent); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 96bd1ab6fd..bc4049f1f4 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path'; import { paths } from '../../lib/paths'; import { mapDependencies } from '../../lib/versioning'; import * as runObj from '../../lib/run'; -import bump from './bump'; +import bump, { bumpBackstageJsonVersion } from './bump'; import { withLogCollector } from '@backstage/test-utils'; // Remove log coloring to simplify log matching @@ -141,7 +141,7 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(3); + expect(runObj.runPlain).toHaveBeenCalledTimes(4); expect(runObj.runPlain).toHaveBeenCalledWith( 'yarn', 'info', @@ -245,3 +245,64 @@ describe('bump', () => { }); }); }); + +describe('bumpBackstageJsonVersion', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should bump version in backstage.json', async () => { + mockFs({ + '/backstage.json': JSON.stringify({ version: '0.0.1' }), + }); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); + + it("should create backstage.json if doesn't exist", async () => { + mockFs({}); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); +}); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 7b967839b2..3898c1654a 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -27,6 +27,7 @@ import { Lockfile, } from '../../lib/versioning'; import { includedFilter, forbiddenDuplicatesFilter } from './lint'; +import { BACKSTAGE_JSON } from '@backstage/cli-common'; const DEP_TYPES = [ 'dependencies', @@ -182,6 +183,10 @@ export default async () => { await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); }); + console.log(); + + await bumpBackstageJsonVersion(); + console.log(); console.log( `Running ${chalk.blue('yarn install')} to install new versions`, @@ -271,6 +276,41 @@ function createVersionFinder() { }; } +export async function bumpBackstageJsonVersion() { + const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON); + const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => { + if (e.code === 'ENOENT') { + // gracefully continue in case the file doesn't exist + return; + } + throw e; + }); + + const info = await fetchPackageInfo('@backstage/create-app'); + const { latest } = info['dist-tags']; + + if (backstageJson?.version === latest) { + return; + } + + console.log( + chalk.yellow( + typeof backstageJson === 'undefined' + ? `Creating ${BACKSTAGE_JSON}` + : `Bumping version in ${BACKSTAGE_JSON}`, + ), + ); + + await fs.writeJson( + backstageJsonPath, + { ...backstageJson, version: latest }, + { + spaces: 2, + encoding: 'utf8', + }, + ); +} + async function workerThreads( count: number, items: IterableIterator, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index cf9e873a66..863728b8f8 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -32,6 +32,23 @@ export const transforms = (options: TransformOptions): Transforms => { const extraTransforms = isDev ? ['react-hot-loader'] : []; + // This ensures that styles inserted from the style-loader and any + // async style chunks are always given lower priority than JSS styles. + // Note that this function is stringified and executed in the browser + // after transpilation, so stick to simple syntax + function insertBeforeJssStyles(element: any) { + const head = document.head; + // This makes sure that any style elements we insert get put before the + // dynamic styles from JSS, such as the ones from `makeStyles()`. + // TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it. + const firstJssNode = head.querySelector('style[data-jss]'); + if (!firstJssNode) { + head.appendChild(element); + } else { + head.insertBefore(element, firstJssNode); + } + } + const loaders = [ { test: /\.(tsx?)$/, @@ -112,7 +129,14 @@ export const transforms = (options: TransformOptions): Transforms => { { test: /\.css$/i, use: [ - isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader, + isDev + ? { + loader: require.resolve('style-loader'), + options: { + insert: insertBeforeJssStyles, + }, + } + : MiniCssExtractPlugin.loader, { loader: require.resolve('css-loader'), options: { @@ -132,6 +156,7 @@ export const transforms = (options: TransformOptions): Transforms => { new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash:8].css', chunkFilename: 'static/[name].[id].[contenthash:8].css', + insert: insertBeforeJssStyles, // Only applies to async chunks }), ); } diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 563bd1052d..5734be95b8 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import path from 'path'; +import { paths } from '../paths'; const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; const USER_ID_RE = /^@[-\w]+$/; @@ -30,14 +31,14 @@ type CodeownersEntry = { export async function getCodeownersFilePath( rootDir: string, ): Promise { - const paths = [ + const possiblePaths = [ path.join(rootDir, '.github', 'CODEOWNERS'), path.join(rootDir, '.gitlab', 'CODEOWNERS'), path.join(rootDir, 'docs', 'CODEOWNERS'), path.join(rootDir, 'CODEOWNERS'), ]; - for (const p of paths) { + for (const p of possiblePaths) { if (await fs.pathExists(p)) { return p; } @@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean { } export function parseOwnerIds( - spaceSeparatedOwnerIds: string, + spaceSeparatedOwnerIds: string | undefined, ): string[] | undefined { if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') { return undefined; @@ -70,11 +71,24 @@ export function parseOwnerIds( } export async function addCodeownersEntry( - codeownersFilePath: string, ownedPath: string, - ownerIds: string[], -): Promise { - const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n'); + ownerStr: string, + codeownersFilePath?: string, +): Promise { + const ownerIds = parseOwnerIds(ownerStr); + if (!ownerIds || ownerIds.length === 0) { + return false; + } + + let filePath = codeownersFilePath; + if (!filePath) { + filePath = await getCodeownersFilePath(paths.targetRoot); + if (!filePath) { + return false; + } + } + + const allLines = (await fs.readFile(filePath, 'utf8')).split('\n'); // Only keep comments from the top of the file const commentLines = []; @@ -117,5 +131,7 @@ export async function addCodeownersEntry( const newLines = [...commentLines, '', ...newDeclarationLines, '']; - await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8'); + await fs.writeFile(filePath, newLines.join('\n'), 'utf8'); + + return true; } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 3688d60089..d343a554f8 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -14,19 +14,30 @@ * limitations under the License. */ -import { loadConfig, loadConfigSchema } from '@backstage/config-loader'; +import { + ConfigTarget, + loadConfig, + loadConfigSchema, +} from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import { paths } from './paths'; +import { isValidUrl } from './urls'; type Options = { args: string[]; fromPackage?: string; mockEnv?: boolean; withFilteredKeys?: boolean; + fullVisibility?: boolean; }; export async function loadCliConfig(options: Options) { - const configPaths = options.args.map(arg => paths.resolveTarget(arg)); + const configTargets: ConfigTarget[] = []; + options.args.forEach(arg => { + if (!isValidUrl(arg)) { + configTargets.push({ path: paths.resolveTarget(arg) }); + } + }); // Consider all packages in the monorepo when loading in config const { Project } = require('@lerna/project'); @@ -43,12 +54,13 @@ export async function loadCliConfig(options: Options) { packagePaths: [paths.resolveTargetRoot('package.json')], }); - const appConfigs = await loadConfig({ + const { appConfigs } = await loadConfig({ experimentalEnvFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, configRoot: paths.targetRoot, - configPaths, + configPaths: [], + configTargets: configTargets, }); // printing to stderr to not clobber stdout in case the cli command @@ -59,7 +71,9 @@ export async function loadCliConfig(options: Options) { try { const frontendAppConfigs = schema.process(appConfigs, { - visibility: ['frontend'], + visibility: options.fullVisibility + ? ['frontend', 'backend', 'secret'] + : ['frontend'], withFilteredKeys: options.withFilteredKeys, }); const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs); diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts new file mode 100644 index 0000000000..9072d46c4c --- /dev/null +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; +import inquirer from 'inquirer'; +import { AnyFactory, Prompt } from './types'; +import * as factories from './factories'; +import partition from 'lodash/partition'; + +function applyPromptMessageTransforms( + prompt: Prompt, + transforms: { + message: (msg: string) => string; + error: (msg: string) => string; + }, +): Prompt { + return { + ...prompt, + message: + prompt.message && + (async answers => { + if (typeof prompt.message === 'function') { + return transforms.message(await prompt.message(answers)); + } + return transforms.message(await prompt.message!); + }), + validate: + prompt.validate && + (async (...args) => { + const result = await prompt.validate!(...args); + if (typeof result === 'string') { + return transforms.error(result); + } + return result; + }), + }; +} + +export class FactoryRegistry { + private static factoryMap = new Map( + Object.values(factories).map(factory => [factory.name, factory]), + ); + + static async interactiveSelect(preselected?: string): Promise { + let selected = preselected; + + if (!selected) { + const answers = await inquirer.prompt<{ name: string }>([ + { + type: 'list', + name: 'name', + message: 'What do you want to create?', + choices: Array.from(this.factoryMap.values()).map(factory => ({ + name: `${factory.name} - ${factory.description}`, + value: factory.name, + })), + }, + ]); + selected = answers.name; + } + + const factory = this.factoryMap.get(selected); + if (!factory) { + throw new Error(`Unknown selection '${selected}'`); + } + return factory; + } + + static async populateOptions( + factory: AnyFactory, + provided: Record, + ): Promise> { + let currentOptions = provided; + + if (factory.optionsDiscovery) { + const discoveredOptions = await factory.optionsDiscovery(); + currentOptions = { + ...currentOptions, + ...(discoveredOptions as Record), + }; + } + + if (factory.optionsPrompts) { + const [hasAnswers, needsAnswers] = partition( + factory.optionsPrompts, + option => option.name in currentOptions, + ); + + for (const option of hasAnswers) { + const value = provided[option.name]; + + if (option.validate) { + const result = option.validate(value); + if (result !== true) { + throw new Error(`Invalid option '${option.name}'. ${result}`); + } + } + } + + currentOptions = await inquirer.prompt( + needsAnswers.map(option => + applyPromptMessageTransforms(option, { + message: chalk.blue, + error: chalk.red, + }), + ), + currentOptions, + ); + } + + return currentOptions; + } +} diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts new file mode 100644 index 0000000000..6f4a73c02d --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { backendPlugin } from './backendPlugin'; + +describe('backendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a backend plugin', async () => { + mockFs({ + '/root': { + packages: { + backend: { + 'package.json': JSON.stringify({}), + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(backendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await backendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-backend', + 'Checking Prerequisites:', + `availability plugins${sep}test-backend`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'copying index.ts', + 'templating run.ts.hbs', + 'copying setupTests.ts', + 'copying router.test.ts', + 'copying router.ts', + 'templating standaloneServer.ts.hbs', + 'Installing:', + `moving plugins${sep}test-backend`, + 'backend adding dependency', + ]); + + await expect( + fs.readJson('/root/packages/backend/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test-backend': '^1.0.0', + }, + }); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts new file mode 100644 index 0000000000..4d03a4da7b --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const backendPlugin = createFactory({ + name: 'backend-plugin', + description: 'A new backend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const id = `${options.id}-backend`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-backend-plugin', + values: { + id, + name, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) { + await Task.forItem('backend', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/backend/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/common/prompts.ts b/packages/cli/src/lib/create/factories/common/prompts.ts new file mode 100644 index 0000000000..9c7672ddfb --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/prompts.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Prompt } from '../../types'; +import { parseOwnerIds } from '../../../codeowners'; + +export function pluginIdPrompt(): Prompt<{ id: string }> { + return { + type: 'input', + name: 'id', + message: 'Enter the ID of the plugin [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the ID of the plugin'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }; +} + +export function ownerPrompt(): Prompt<{ + owner?: string; + codeOwnersPath?: string; +}> { + return { + type: 'input', + name: 'owner', + message: 'Enter an owner to add to CODEOWNERS [optional]', + when: opts => Boolean(opts.codeOwnersPath), + validate: (value: string) => { + if (!value) { + return true; + } + + const ownerIds = parseOwnerIds(value); + if (!ownerIds) { + return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).'; + } + + return true; + }, + }; +} diff --git a/packages/cli/src/lib/create/factories/common/tasks.test.ts b/packages/cli/src/lib/create/factories/common/tasks.test.ts new file mode 100644 index 0000000000..49381676e7 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep } from 'path'; +import { createMockOutputStream, mockPaths } from './testUtils'; +import { CreateContext } from '../../types'; +import { executePluginPackageTemplate } from './tasks'; + +mockPaths({ + ownDir: '/own', + targetRoot: '/root', +}); + +describe('executePluginPackageTemplate', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should execute template', async () => { + mockFs({ + '/root': { + 'yarn.lock': ` +some-package@^1.1.0: + version "1.5.0" +`, + }, + '/own': { + templates: { + 'test-template': { + 'package.json.hbs': ` +{ + "name": "my-{{id}}-plugin", + {{#if makePrivate}} + "private": true, + {{/if}} + "description": "testing", + "dependencies": { + "some-package": "{{ versionQuery 'some-package' '1.3.0' }}", + "other-package": "{{ versionQuery 'other-package' '2.3.0' }}" + } +} +`, + subdir: { + 'templated.txt.hbs': 'Hello {{id}}!', + 'not-templated.txt': 'Hello {{id}}!', + }, + }, + }, + }, + }); + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + + let modified = false; + await executePluginPackageTemplate( + { + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + markAsModified: () => { + modified = true; + }, + } as CreateContext, + { + templateName: 'test-template', + targetDir: '/target', + values: { + id: 'testing', + makePrivate: true, + }, + }, + ); + + expect(modified).toBe(true); + expect(output).toEqual([ + 'Checking Prerequisites:', + `availability ..${sep}target`, + 'creating temp dir', + 'Executing Template:', + 'templating package.json.hbs', + 'copying not-templated.txt', + 'templating templated.txt.hbs', + 'Installing:', + `moving ..${sep}target`, + ]); + await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + "name": "my-testing-plugin", + "private": true, + "description": "testing", + "dependencies": { + "some-package": "^1.1.0", + "other-package": "^2.3.0" + } +} +`); + await expect( + fs.readFile('/target/subdir/templated.txt', 'utf8'), + ).resolves.toBe('Hello testing!'); + await expect( + fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + ).resolves.toBe('Hello {{id}}!'); + }); +}); diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts new file mode 100644 index 0000000000..93af1e2b00 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import { resolve as resolvePath, relative as relativePath } from 'path'; +import { paths } from '../../../paths'; +import { Task, templatingTask } from '../../../tasks'; +import { Lockfile } from '../../../versioning'; +import { createPackageVersionProvider } from '../../../version'; +import { CreateContext } from '../../types'; + +export async function executePluginPackageTemplate( + ctx: CreateContext, + options: { + templateName: string; + targetDir: string; + values: Record; + }, +) { + const { targetDir } = options; + + let lockfile: Lockfile | undefined; + try { + lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + } catch { + /* ignored */ + } + + Task.section('Checking Prerequisites'); + const shortPluginDir = relativePath(paths.targetRoot, targetDir); + await Task.forItem('availability', shortPluginDir, async () => { + if (await fs.pathExists(targetDir)) { + throw new Error( + `A package with the same plugin ID already exists at ${chalk.cyan( + shortPluginDir, + )}. Please try again with a different ID.`, + ); + } + }); + + const tempDir = await Task.forItem('creating', 'temp dir', async () => { + return await ctx.createTemporaryDirectory('backstage-create'); + }); + + Task.section('Executing Template'); + await templatingTask( + paths.resolveOwn('templates', options.templateName), + tempDir, + options.values, + createPackageVersionProvider(lockfile), + ); + + // Format package.json if it exists + const pkgJsonPath = resolvePath(tempDir, 'package.json'); + if (await fs.pathExists(pkgJsonPath)) { + const pkgJson = await fs.readJson(pkgJsonPath); + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + } + + Task.section('Installing'); + await Task.forItem('moving', shortPluginDir, async () => { + await fs.move(tempDir, targetDir).catch(error => { + throw new Error( + `Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`, + ); + }); + }); + + ctx.markAsModified(); +} diff --git a/packages/cli/src/lib/create/factories/common/testUtils.ts b/packages/cli/src/lib/create/factories/common/testUtils.ts new file mode 100644 index 0000000000..01081a7486 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 no-control-regex */ + +import { WriteStream } from 'tty'; +import { resolve as resolvePath } from 'path'; +import { paths } from '../../../paths'; + +export function mockPaths(options: { + ownDir?: string; + ownRoot?: string; + targetDir?: string; + targetRoot?: string; +}): void { + const { ownDir, ownRoot, targetDir, targetRoot } = options; + if (ownDir) { + paths.ownDir = ownDir; + jest + .spyOn(paths, 'resolveOwn') + .mockImplementation((...ps) => resolvePath(ownDir, ...ps)); + } + if (ownRoot) { + jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot); + jest + .spyOn(paths, 'resolveOwnRoot') + .mockImplementation((...ps) => resolvePath(ownRoot, ...ps)); + } + if (targetDir) { + paths.targetDir = targetDir; + jest + .spyOn(paths, 'resolveTarget') + .mockImplementation((...ps) => resolvePath(targetDir, ...ps)); + } + if (targetRoot) { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath(targetRoot, ...ps)); + } +} + +export function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => { + let clean = msg; + // Remove terminal color escape sequences + clean = clean.replace(/\x1B\[\d\dm/g, ''); + // Remove any non-ascii + clean = clean.replace(/[^\x00-\x7F]+/g, ''); + clean = clean.trim(); + output.push(clean); + }, + } as unknown as WriteStream & { fd: any }, + ] as const; +} diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts new file mode 100644 index 0000000000..27ee14ea2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { frontendPlugin } from './frontendPlugin'; + +const appTsxContent = ` +import { createApp } from '@backstage/app-defaults'; + +const router = ( + + } /> + +) +`; + +describe('frontendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a frontend plugin', async () => { + mockFs({ + '/root': { + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test', + 'Checking Prerequisites:', + `availability plugins${sep}test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.tsx.hbs', + 'templating index.ts.hbs', + 'templating plugin.test.ts.hbs', + 'templating plugin.ts.hbs', + 'templating routes.ts.hbs', + 'copying setupTests.ts', + 'templating ExampleComponent.test.tsx.hbs', + 'templating ExampleComponent.tsx.hbs', + 'copying index.ts', + 'templating ExampleFetchComponent.test.tsx.hbs', + 'templating ExampleFetchComponent.tsx.hbs', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}test`, + 'app adding dependency', + 'app adding import', + ]); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from 'backstage-plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); + + it('should create a frontend plugin with more options and codeowners', async () => { + mockFs({ + '/root': { + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + owner: '@test-user', + }); + + const [, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + scope: 'internal', + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => {}, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + '@internal/plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from '@internal/plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts new file mode 100644 index 0000000000..3862ae4883 --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const frontendPlugin = createFactory({ + name: 'plugin', + description: 'A new frontend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + const extensionName = `${upperFirst(camelCase(id))}Page`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-plugin', + values: { + id, + name, + extensionName, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) { + await Task.forItem('app', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/app/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + + await Task.forItem('app', 'adding import', async () => { + const pluginsFilePath = paths.resolveTargetRoot( + 'packages/app/src/App.tsx', + ); + if (!(await fs.pathExists(pluginsFilePath))) { + return; + } + + const content = await fs.readFile(pluginsFilePath, 'utf8'); + const revLines = content.split('\n').reverse(); + + const lastImportIndex = revLines.findIndex(line => + line.match(/ from ("|').*("|')/), + ); + const lastRouteIndex = revLines.findIndex(line => + line.match(/<\/FlatRoutes/), + ); + + if (lastImportIndex !== -1 && lastRouteIndex !== -1) { + const importLine = `import { ${extensionName} } from '${name}';`; + if (!content.includes(importLine)) { + revLines.splice(lastImportIndex, 0, importLine); + } + + const componentLine = `} />`; + if (!content.includes(componentLine)) { + const [indentation] = + revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; + revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine); + } + + const newContent = revLines.reverse().join('\n'); + await fs.writeFile(pluginsFilePath, newContent, 'utf8'); + } + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts new file mode 100644 index 0000000000..0764d33e2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { frontendPlugin } from './frontendPlugin'; +export { backendPlugin } from './backendPlugin'; +export { pluginCommon } from './pluginCommon'; +export { scaffolderModule } from './scaffolderModule'; diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts new file mode 100644 index 0000000000..602fce7bbd --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { pluginCommon } from './pluginCommon'; + +describe('pluginCommon factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a common plugin package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(pluginCommon, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await pluginCommon.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-common', + 'Checking Prerequisites:', + `availability plugins${sep}test-common`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying setupTests.ts', + 'Installing:', + `moving plugins${sep}test-common`, + ]); + + await expect( + fs.readJson('/root/plugins/test-common/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-test-common', + description: 'Common functionalities for the test plugin', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts new file mode 100644 index 0000000000..1bcca2fb6a --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const pluginCommon = createFactory({ + name: 'plugin-common', + description: 'A new isomorphic common plugin package', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const suffix = `${id}-common`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${suffix}` + : `backstage-plugin-${suffix}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', suffix) + : paths.resolveTargetRoot(`backstage-plugin-${suffix}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-common-plugin-package', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${suffix}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts new file mode 100644 index 0000000000..c60c58e619 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { scaffolderModule } from './scaffolderModule'; + +describe('scaffolderModule factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a scaffolder backend module package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(scaffolderModule, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await scaffolderModule.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating module backstage-plugin-scaffolder-backend-module-test', + 'Checking Prerequisites:', + `availability plugins${sep}scaffolder-backend-module-test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying index.ts', + 'copying example.test.ts', + 'copying example.ts', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}scaffolder-backend-module-test`, + ]); + + await expect( + fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-scaffolder-backend-module-test', + description: 'The test module for @backstage/plugin-scaffolder-backend', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.ts b/packages/cli/src/lib/create/factories/scaffolderModule.ts new file mode 100644 index 0000000000..b89f0fc691 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const scaffolderModule = createFactory({ + name: 'scaffolder-module', + description: + 'An module exporting custom actions for @backstage/plugin-scaffolder-backend', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [ + { + type: 'input', + name: 'id', + message: 'Enter the name of the module [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the name of the module'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Module names must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }, + ownerPrompt(), + ], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const slug = `scaffolder-backend-module-${id}`; + + let name = `backstage-plugin-${slug}`; + if (ctx.scope) { + if (ctx.scope === 'backstage') { + name = `@backstage/plugin-${slug}`; + } else { + name = `@${ctx.scope}/backstage-plugin-${slug}`; + } + } + + Task.log(); + Task.log(`Creating module ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', slug) + : paths.resolveTargetRoot(`backstage-plugin-${slug}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'scaffolder-module', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${slug}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts new file mode 100644 index 0000000000..5a2460d0df --- /dev/null +++ b/packages/cli/src/lib/create/types.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DistinctQuestion } from 'inquirer'; + +export interface CreateContext { + /** The package scope to use for new packages */ + scope?: string; + /** The NPM registry to use for new packages */ + npmRegistry?: string; + /** Whether new packages should be marked as private */ + private: boolean; + /** Whether we are creating something in a monorepo or not */ + isMonoRepo: boolean; + /** The default version to use for new packages */ + defaultVersion: string; + + /** Creates a temporary directory. This will always be deleted after creation is done. */ + createTemporaryDirectory(name: string): Promise; + + /** Signal that the creation process got to a point where permanent modifications were made */ + markAsModified(): void; +} + +export type AnyOptions = Record; + +export type Prompt = DistinctQuestion & { name: string }; + +export interface Factory { + /** + * The name used for this factory. + */ + name: string; + + /** + * A description that describes what this factory creates to the user. + */ + description: string; + + /** + * An optional options discovery step that is run + * before the prompts to potentially fill in some of the options. + */ + optionsDiscovery?(): Promise>; + + /** + * Inquirer prompts that will be filled in either interactively or + * through command line arguments. + */ + optionsPrompts?: ReadonlyArray>; + + /** + * The main method of the factory that handles creation. + */ + create(options: TOptions, context?: CreateContext): Promise; +} + +export type AnyFactory = Factory; + +export function createFactory( + config: Factory, +): AnyFactory { + return config as AnyFactory; +} diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index c0b4c45b6c..dd3955a10a 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -26,11 +26,11 @@ 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}`); - } + super( + command + ? `Command '${command}' exited with code ${code}` + : `Child exited with code ${code}`, + ); this.code = code; } } diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index dba42e6013..4faf904177 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -106,18 +106,19 @@ export async function createDistWorkspace( if (options.buildDependencies) { const exclude = options.buildExcludes ?? []; - const scopeArgs = targets - .filter(target => !exclude.includes(target.name)) - .flatMap(target => ['--scope', target.name]); - const lernaArgs = - options.parallel && Number.isInteger(options.parallel) - ? ['--concurrency', options.parallel.toString()] - : []; + const toBuild = targets.filter(target => !exclude.includes(target.name)); + if (toBuild.length > 0) { + const scopeArgs = toBuild.flatMap(target => ['--scope', target.name]); + const lernaArgs = + options.parallel && Number.isInteger(options.parallel) + ? ['--concurrency', options.parallel.toString()] + : []; - await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { - cwd: paths.targetRoot, - }); + await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { + cwd: paths.targetRoot, + }); + } } await moveToDistWorkspace(targetDir, targets); diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index e17015d6cf..c84eed435e 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -18,35 +18,40 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; +import { promisify } from 'util'; import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { exec as execCb } from 'child_process'; import { paths } from './paths'; +import { assertError } from '@backstage/errors'; + +const exec = promisify(execCb); const TASK_NAME_MAX_LENGTH = 14; export class Task { static log(name: string = '') { - process.stdout.write(`${chalk.green(name)}\n`); + process.stderr.write(`${chalk.green(name)}\n`); } static error(message: string = '') { - process.stdout.write(`\n${chalk.red(message)}\n\n`); + process.stderr.write(`\n${chalk.red(message)}\n\n`); } static section(name: string) { const title = chalk.green(`${name}:`); - process.stdout.write(`\n ${title}\n`); + process.stderr.write(`\n ${title}\n`); } static exit(code: number = 0) { process.exit(code); } - static async forItem( + static async forItem( task: string, item: string, - taskFunc: () => Promise, - ): Promise { + taskFunc: () => Promise, + ): Promise { const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH)); const spinner = ora({ @@ -56,13 +61,40 @@ export class Task { }).start(); try { - await taskFunc(); + const result = await taskFunc(); spinner.succeed(); + return result; } catch (error) { spinner.fail(); throw error; } } + + static async forCommand( + command: string, + options?: { cwd?: string; optional?: boolean }, + ) { + try { + await Task.forItem('executing', command, async () => { + await exec(command, { cwd: options?.cwd }); + }); + } catch (error) { + assertError(error); + if (error.stderr) { + process.stderr.write(error.stderr as Buffer); + } + if (error.stdout) { + process.stdout.write(error.stdout as Buffer); + } + if (options?.optional) { + Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`); + } else { + throw new Error( + `Failed to execute command '${chalk.cyan(command)}', ${error}`, + ); + } + } + } } export async function templatingTask( @@ -85,7 +117,9 @@ export async function templatingTask( const destination = destinationFile.replace(/\.hbs$/, ''); const template = await fs.readFile(file); - const compiled = handlebars.compile(template.toString()); + const compiled = handlebars.compile(template.toString(), { + strict: true, + }); const contents = compiled( { name: basename(destination), ...context }, { @@ -122,3 +156,44 @@ export async function templatingTask( } } } + +export async function addPackageDependency( + path: string, + options: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, +) { + try { + const pkgJson = await fs.readJson(path); + + const normalize = (obj: Record) => { + if (Object.keys(obj).length === 0) { + return undefined; + } + return Object.fromEntries( + Object.keys(obj) + .sort() + .map(key => [key, obj[key]]), + ); + }; + + pkgJson.dependencies = normalize({ + ...pkgJson.dependencies, + ...options.dependencies, + }); + pkgJson.devDependencies = normalize({ + ...pkgJson.devDependencies, + ...options.devDependencies, + }); + pkgJson.peerDependencies = normalize({ + ...pkgJson.peerDependencies, + ...options.peerDependencies, + }); + + await fs.writeJson(path, pkgJson, { spaces: 2 }); + } catch (error) { + throw new Error(`Failed to add package dependencies, ${error}`); + } +} diff --git a/packages/cli/src/lib/urls.test.ts b/packages/cli/src/lib/urls.test.ts new file mode 100644 index 0000000000..c2a67fb849 --- /dev/null +++ b/packages/cli/src/lib/urls.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { isValidUrl } from './urls'; + +describe('isValidUrl', () => { + it('should return true for url', () => { + const validUrl = isValidUrl('http://some.valid.url'); + expect(validUrl).toBe(true); + }); + + it('should return false for absolute path', () => { + const validUrl = isValidUrl('/some/absolute/path'); + expect(validUrl).toBe(false); + }); + + it('should return false for relative path', () => { + const validUrl = isValidUrl('../some/relative/path'); + expect(validUrl).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/urls.ts b/packages/cli/src/lib/urls.ts new file mode 100644 index 0000000000..848cea25d9 --- /dev/null +++ b/packages/cli/src/lib/urls.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index c69072c64d..3b512b22c3 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso import { version as devUtils } from '@backstage/dev-utils/package.json'; import { version as testUtils } from '@backstage/test-utils/package.json'; import { version as theme } from '@backstage/theme/package.json'; +import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; export const packageVersions: Record = { '@backstage/backend-common': backendCommon, @@ -53,6 +54,7 @@ export const packageVersions: Record = { '@backstage/dev-utils': devUtils, '@backstage/test-utils': testUtils, '@backstage/theme': theme, + '@backstage/plugin-scaffolder-backend': scaffolderBackend, }; export function findVersion() { diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 5cca7dcb07..37bcd94186 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -4,41 +4,43 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - {{#if privatePackage}} "private": {{privatePackage}}, - {{/if}} +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} "publishConfig": { - {{#if npmRegistry}} "registry": "{{npmRegistry}}", - {{/if}} - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "scripts": { - "start": "backstage-cli backend:dev", - "build": "backstage-cli backend:build", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", - "@backstage/config": "{{versionQuery '@backstage/config'}}", - "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", - "express": "{{versionQuery 'express' '4.17.1'}}", - "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", - "winston": "{{versionQuery 'winston' '3.2.1'}}", - "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", - "yn": "{{versionQuery 'yn' '4.0.0'}}" - }, - "devDependencies": { - "@backstage/cli": "{{versionQuery '@backstage/cli'}}", - "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", - "supertest": "{{versionQuery 'supertest' '4.0.2'}}", - "msw": "{{versionQuery 'msw' '0.35.0'}}" - }, - "files": [ - "dist" - ] - } +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/config": "{{versionQuery '@backstage/config'}}", + "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", + "express": "{{versionQuery 'express' '4.17.1'}}", + "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", + "winston": "{{versionQuery 'winston' '3.2.1'}}", + "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", + "yn": "{{versionQuery 'yn' '4.0.0'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", + "supertest": "{{versionQuery 'supertest' '4.0.2'}}", + "msw": "{{versionQuery 'msw' '0.35.0'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs index 54d2716290..0a3ed2b7f0 100644 --- a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs +++ b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/packages/cli/templates/default-common-plugin-package/.eslintrc.js b/packages/cli/templates/default-common-plugin-package/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/cli/templates/default-common-plugin-package/README.md.hbs b/packages/cli/templates/default-common-plugin-package/README.md.hbs new file mode 100644 index 0000000000..917e18d4b9 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +Welcome to the common package for the {{id}} plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/default-common-plugin-package/package.json.hbs b/packages/cli/templates/default-common-plugin-package/package.json.hbs new file mode 100644 index 0000000000..efaee496e7 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -0,0 +1,34 @@ +{ + "name": "{{name}}", + "description": "Common functionalities for the {{id}} plugin", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs new file mode 100644 index 0000000000..2e1150d74e --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs @@ -0,0 +1,19 @@ +/***/ +/** + * Common functionalities for the {{id}} plugin. + * + * @packageDocumentation + */ + +/** + * In this package you might for example declare types that are common + * between the frontend and backend plugin packages. + */ +export type CommonType = { + field: string +} + +/** + * Or you might declare some common constants. + */ +export const COMMON_CONSTANT = 1 diff --git a/packages/cli/templates/default-common-plugin-package/src/setupTests.ts b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli/templates/default-common-plugin-package/tsconfig.json b/packages/cli/templates/default-common-plugin-package/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 41376821ba..624302da93 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -4,10 +4,12 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", -{{#if privatePackage}} "private": {{privatePackage}}, +{{#if privatePackage}} + "private": {{privatePackage}}, {{/if}} "publishConfig": { -{{#if npmRegistry}} "registry": "{{npmRegistry}}", +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", {{/if}} "access": "public", "main": "dist/index.esm.js", diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs index a5a278e8a7..7e5d8f85f0 100644 --- a/packages/cli/templates/default-plugin/src/routes.ts.hbs +++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs @@ -1,5 +1,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: '{{ id }}', + id: '{{ id }}', }); diff --git a/packages/cli/templates/scaffolder-module/.eslintrc.js b/packages/cli/templates/scaffolder-module/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/cli/templates/scaffolder-module/README.md.hbs b/packages/cli/templates/scaffolder-module/README.md.hbs new file mode 100644 index 0000000000..8ee653a4ac --- /dev/null +++ b/packages/cli/templates/scaffolder-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend). + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/scaffolder-module/package.json.hbs b/packages/cli/templates/scaffolder-module/package.json.hbs new file mode 100644 index 0000000000..de6ffd9e3a --- /dev/null +++ b/packages/cli/templates/scaffolder-module/package.json.hbs @@ -0,0 +1,37 @@ +{ + "name": "{{name}}", + "description": "The {{id}} module for @backstage/plugin-scaffolder-backend", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build --output cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}" + }, + "devDependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts new file mode 100644 index 0000000000..e427b2c603 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PassThrough } from 'stream'; +import { createAcmeExampleAction } from './example'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('acme:example', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should call action', async () => { + const action = createAcmeExampleAction(); + + const logger = getVoidLogger(); + jest.spyOn(logger, 'info'); + + await action.handler({ + input: { + myParameter: 'test', + }, + workspacePath: '/tmp', + logger, + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory() { + // Usage of mock-fs is recommended for testing of filesystem operations + throw new Error('Not implemented'); + }, + }); + + expect(logger.info).toHaveBeenCalledWith( + 'Running example template with parameters: test', + ); + }); +}); diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts new file mode 100644 index 0000000000..c20a4bdf25 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; + +/** + * Creates an `acme:example` Scaffolder action. + * + * @remarks + * + * See {@link https://example.com} for more information. + * + * @public + */ +export function createAcmeExampleAction() { + // For more information on how to define custom actions, see + // https://backstage.io/docs/features/software-templates/writing-custom-actions + return createTemplateAction<{ + myParameter: string; + }>({ + id: 'acme:example', + description: 'Runs Yeoman on an installed Yeoman generator', + schema: { + input: { + type: 'object', + required: ['myParameter'], + properties: { + myParameter: { + title: 'An example parameter', + description: 'This is the schema for our example parameter', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + ctx.logger.info( + `Running example template with parameters: ${ctx.input.myParameter}`, + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + }, + }); +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/index.ts b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts new file mode 100644 index 0000000000..e81099f333 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts @@ -0,0 +1 @@ +export { createAcmeExampleAction } from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/actions/index.ts b/packages/cli/templates/scaffolder-module/src/actions/index.ts new file mode 100644 index 0000000000..ab6642ebb0 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/index.ts @@ -0,0 +1 @@ +export * from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/index.ts.hbs b/packages/cli/templates/scaffolder-module/src/index.ts.hbs new file mode 100644 index 0000000000..3690e43b8e --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/index.ts.hbs @@ -0,0 +1,8 @@ +/***/ +/** + * The {{id}} module for @backstage/plugin-scaffolder-backend. + * + * @packageDocumentation + */ + +export * from './actions'; diff --git a/packages/cli/templates/scaffolder-module/tsconfig.json b/packages/cli/templates/scaffolder-module/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/scaffolder-module/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index fbb2076167..bf0711ad6f 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/codemods +## 0.1.22 + +### Patch Changes + +- Updated dependencies + - @backstage/cli-common@0.1.6 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + +## 0.1.21 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/core-plugin-api@0.1.13 + - @backstage/core-app-api@0.1.20 + ## 0.1.20 ### Patch Changes diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 0dc0e9a0d5..bfb8d4d4b3 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/codemods", "description": "A collection of codemods for Backstage projects", - "version": "0.1.20", + "version": "0.1.22", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "backstage-codemods": "bin/backstage-codemods" }, "dependencies": { - "@backstage/cli-common": "0.1.5", + "@backstage/cli-common": "0.1.6", "@backstage/core-app-api": "*", "@backstage/core-components": "*", "@backstage/core-plugin-api": "*", diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index dcaec00816..00ace6e704 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/config-loader +## 0.8.0 + +### Minor Changes + +- 1e99c73c75: Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`. + + This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users. + + If you use `loadConfig` directly you will need to update your usage from: + + ```diff + - const appConfigs = await loadConfig(options) + + const { appConfigs } = await loadConfig(options) + ``` + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. +- Updated dependencies + - @backstage/cli-common@0.1.6 + +## 0.7.2 + +### Patch Changes + +- 0611f3b3e2: Reading app config from a remote server +- 26c5659c97: Bump msw to the same version as the rest + ## 0.7.1 ### Patch Changes diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 19b5ee9e27..e0fab50e5b 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -23,22 +23,54 @@ export type ConfigSchemaProcessingOptions = { withFilteredKeys?: boolean; }; +// Warning: (ae-missing-release-tag) "ConfigTarget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ConfigTarget = + | { + path: string; + } + | { + url: string; + }; + // @public export type ConfigVisibility = 'frontend' | 'backend' | 'secret'; // @public -export function loadConfig(options: LoadConfigOptions): Promise; +export function loadConfig( + options: LoadConfigOptions, +): Promise; // @public export type LoadConfigOptions = { configRoot: string; configPaths: string[]; + configTargets: ConfigTarget[]; env?: string; experimentalEnvFunc?: (name: string) => Promise; - watch?: { - onChange: (configs: AppConfig[]) => void; - stopSignal?: Promise; - }; + remote?: LoadConfigOptionsRemote; + watch?: LoadConfigOptionsWatch; +}; + +// Warning: (ae-missing-release-tag) "LoadConfigOptionsRemote" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LoadConfigOptionsRemote = { + reloadIntervalSeconds: number; +}; + +// Warning: (ae-missing-release-tag) "LoadConfigOptionsWatch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LoadConfigOptionsWatch = { + onChange: (configs: AppConfig[]) => void; + stopSignal?: Promise; +}; + +// @public +export type LoadConfigResult = { + appConfigs: AppConfig[]; }; // @public @@ -71,4 +103,8 @@ export type TransformFunc = ( visibility: ConfigVisibility; }, ) => T | undefined; + +// Warnings were encountered during analysis: +// +// src/loader.d.ts:33:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/config-loader" does not have an export "configTargets" ``` diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 604caa4008..b4c0350def 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.7.1", + "version": "0.8.0", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/types": "^0.1.1", @@ -38,12 +38,13 @@ "ajv": "^7.0.3", "chokidar": "^3.5.2", "fs-extra": "9.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", "typescript-json-schema": "^0.51.0", "yaml": "^1.9.2", - "yup": "^0.32.9" + "yup": "^0.32.9", + "node-fetch": "2.6.5" }, "devDependencies": { "@types/jest": "^26.0.7", @@ -51,7 +52,8 @@ "@types/mock-fs": "^4.10.0", "@types/node": "^14.14.32", "@types/yup": "^0.29.13", - "mock-fs": "^5.1.0" + "mock-fs": "^5.1.0", + "msw": "^0.35.0" }, "files": [ "dist" diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 888c68ef70..2699b3571f 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -29,4 +29,10 @@ export type { TransformFunc, } from './lib'; export { loadConfig } from './loader'; -export type { LoadConfigOptions } from './loader'; +export type { + ConfigTarget, + LoadConfigOptions, + LoadConfigOptionsWatch, + LoadConfigOptionsRemote, + LoadConfigResult, +} from './loader'; diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 32a0191cae..ca88b771ba 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -17,3 +17,4 @@ export { readEnvConfig } from './env'; export * from './transform'; export * from './schema'; +export { isValidUrl } from './urls'; diff --git a/packages/config-loader/src/lib/urls.test.ts b/packages/config-loader/src/lib/urls.test.ts new file mode 100644 index 0000000000..c2a67fb849 --- /dev/null +++ b/packages/config-loader/src/lib/urls.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { isValidUrl } from './urls'; + +describe('isValidUrl', () => { + it('should return true for url', () => { + const validUrl = isValidUrl('http://some.valid.url'); + expect(validUrl).toBe(true); + }); + + it('should return false for absolute path', () => { + const validUrl = isValidUrl('/some/absolute/path'); + expect(validUrl).toBe(false); + }); + + it('should return false for relative path', () => { + const validUrl = isValidUrl('../some/relative/path'); + expect(validUrl).toBe(false); + }); +}); diff --git a/packages/config-loader/src/lib/urls.ts b/packages/config-loader/src/lib/urls.ts new file mode 100644 index 0000000000..848cea25d9 --- /dev/null +++ b/packages/config-loader/src/lib/urls.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 3f51e76936..b5c986eeb9 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -18,8 +18,43 @@ import { AppConfig } from '@backstage/config'; import { loadConfig } from './loader'; import mockFs from 'mock-fs'; import fs from 'fs-extra'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; describe('loadConfig', () => { + const server = setupServer(); + const initialLoaderHandler = rest.get( + `https://some.domain.io/app-config.yaml`, + (_req, res, ctx) => { + return res( + ctx.body( + `app: + title: Remote Example App + sessionKey: 'abc123' + escaped: \$\${Escaped} + `, + ), + ); + }, + ); + + const reloadHandler = rest.get( + `https://some.domain.io/app-config.yaml`, + (_req, res, ctx) => { + return res( + ctx.body( + `app: + title: NEW ReMOTe ExaMPLe App + sessionKey: 'abc123' + escaped: \$\${Escaped} + `, + ), + ); + }, + ); + + beforeAll(() => server.listen()); + beforeEach(() => { process.env.MY_SECRET = 'is-secret'; process.env.SUBSTITUTE_ME = 'substituted'; @@ -32,6 +67,13 @@ describe('loadConfig', () => { $file: secrets/session-key.txt escaped: \$\${Escaped} `, + '/root/app-config2.yaml': ` + app: + title: Example App 2 + sessionKey: + $file: secrets/session-key.txt + escaped: \$\${Escaped} + `, '/root/app-config.development.yaml': ` app: sessionKey: development-key @@ -67,110 +109,190 @@ describe('loadConfig', () => { afterEach(() => { mockFs.restore(); + server.resetHandlers(); }); + afterAll(() => server.close()); + it('load config from default path', async () => { await expect( loadConfig({ configRoot: '/root', configPaths: [], + configTargets: [], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); - it('loads config with secrets', async () => { + it('load config from remote path', async () => { + server.use(initialLoaderHandler); + + const configUrl = 'https://some.domain.io/app-config.yaml'; + + await expect( + loadConfig({ + configRoot: '/root', + configPaths: [], + configTargets: [{ url: configUrl }], + env: 'production', + remote: { + reloadIntervalSeconds: 30, + }, + }), + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, + }, + }, + ], + }); + }); + + it('loads config with secrets from two different files', async () => { + await expect( + loadConfig({ + configRoot: '/root', + configPaths: ['/root/app-config2.yaml'], + configTargets: [{ path: '/root/app-config.yaml' }], + env: 'production', + }), + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, + }, + }, + { + context: 'app-config2.yaml', + data: { + app: { + title: 'Example App 2', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, + }, + }, + ], + }); + }); + + it('loads config with secrets from single file', async () => { await expect( loadConfig({ configRoot: '/root', configPaths: ['/root/app-config.yaml'], + configTargets: [{ path: '/root/app-config.yaml' }], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads development config with secrets', async () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [ - '/root/app-config.yaml', - '/root/app-config.development.yaml', + configPaths: [], + configTargets: [ + { path: '/root/app-config.yaml' }, + { path: '/root/app-config.development.yaml' }, ], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', - }, - }, - }, - { - context: 'app-config.development.yaml', - data: { - app: { - sessionKey: 'development-key', - }, - backend: { - foo: { - bar: 'token is-secret', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', }, }, - other: { - secret: 'abc123', + }, + { + context: 'app-config.development.yaml', + data: { + app: { + sessionKey: 'development-key', + }, + backend: { + foo: { + bar: 'token is-secret', + }, + }, + other: { + secret: 'abc123', + }, }, }, - }, - ]); + ], + }); }); it('loads deep substituted config', async () => { await expect( loadConfig({ configRoot: '/root', - configPaths: ['/root/app-config.substitute.yaml'], + configPaths: [], + configTargets: [{ path: '/root/app-config.substitute.yaml' }], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.substitute.yaml', - data: { - app: { - someConfig: { - secret: '123abc', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.substitute.yaml', + data: { + app: { + someConfig: { + secret: '123abc', + }, + noSubstitute: 'notSubstituted', }, - noSubstitute: 'notSubstituted', }, }, - }, - ]); + ], + }); }); it('watches config files', async () => { @@ -181,23 +303,26 @@ describe('loadConfig', () => { loadConfig({ configRoot: '/root', configPaths: [], + configTargets: [], watch: { onChange: onChange.resolve, stopSignal: stopSignal.promise, }, }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); await fs.writeJson('/root/app-config.yaml', { app: { @@ -218,12 +343,66 @@ describe('loadConfig', () => { stopSignal.resolve(); }); + it('watches remote config urls', async () => { + server.use(initialLoaderHandler); + + const onChange = defer(); + const stopSignal = defer(); + + const configUrl = 'https://some.domain.io/app-config.yaml'; + await expect( + loadConfig({ + configRoot: '/root', + configPaths: [], + configTargets: [{ url: configUrl }], + watch: { + onChange: onChange.resolve, + stopSignal: stopSignal.promise, + }, + remote: { + reloadIntervalSeconds: 1, + }, + }), + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, + }, + }, + ], + }); + + server.use(reloadHandler); + + await expect(onChange.promise).resolves.toEqual([ + { + context: configUrl, + data: { + app: { + title: 'NEW ReMOTe ExaMPLe App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, + }, + }, + ]); + + stopSignal.resolve(); + }); + it('stops watching config files', async () => { const stopSignal = defer(); await loadConfig({ configRoot: '/root', configPaths: [], + configTargets: [], watch: { onChange: () => { expect('not').toBe('called'); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 6803d61786..6a92ed519d 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -17,15 +17,38 @@ import fs from 'fs-extra'; import yaml from 'yaml'; import chokidar from 'chokidar'; -import { resolve as resolvePath, dirname, isAbsolute, basename } from 'path'; +import { basename, dirname, isAbsolute, resolve as resolvePath } from 'path'; import { AppConfig } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import { applyConfigTransforms, - readEnvConfig, createIncludeTransform, createSubstitutionTransform, + isValidUrl, + readEnvConfig, } from './lib'; +import fetch from 'node-fetch'; + +export type ConfigTarget = { path: string } | { url: string }; + +export type LoadConfigOptionsWatch = { + /** + * A listener that is called when a config file is changed. + */ + onChange: (configs: AppConfig[]) => void; + + /** + * An optional signal that stops the watcher once the promise resolves. + */ + stopSignal?: Promise; +}; + +export type LoadConfigOptionsRemote = { + /** + * An optional remote config reloading period, in seconds + */ + reloadIntervalSeconds: number; +}; /** * Options that control the loading of configuration files in the backend. @@ -36,9 +59,14 @@ export type LoadConfigOptions = { // The root directory of the config loading context. Used to find default configs. configRoot: string; - // Absolute paths to load config files from. Configs from earlier paths have lower priority. + /** Absolute paths to load config files from. Configs from earlier paths have lower priority. + * @deprecated Use {@link configTargets} instead. + */ configPaths: string[]; + // Paths to load config files from. Configs from earlier paths have lower priority. + configTargets: ConfigTarget[]; + /** @deprecated This option has been removed */ env?: string; @@ -49,20 +77,26 @@ export type LoadConfigOptions = { */ experimentalEnvFunc?: (name: string) => Promise; + /** + * An optional remote config + */ + remote?: LoadConfigOptionsRemote; + /** * An optional configuration that enables watching of config files. */ - watch?: { - /** - * A listener that is called when a config file is changed. - */ - onChange: (configs: AppConfig[]) => void; + watch?: LoadConfigOptionsWatch; +}; - /** - * An optional signal that stops the watcher once the promise resolves. - */ - stopSignal?: Promise; - }; +/** + * Results of loading configuration files. + * @public + */ +export type LoadConfigResult = { + /** + * Array of all loaded configs. + */ + appConfigs: AppConfig[]; }; /** @@ -72,13 +106,33 @@ export type LoadConfigOptions = { */ export async function loadConfig( options: LoadConfigOptions, -): Promise { - const { configRoot, experimentalEnvFunc: envFunc, watch } = options; - const configPaths = options.configPaths.slice(); +): Promise { + const { configRoot, experimentalEnvFunc: envFunc, watch, remote } = options; + + const configPaths: string[] = options.configTargets + .slice() + .filter((e): e is { path: string } => e.hasOwnProperty('path')) + .map(configTarget => configTarget.path); + + // Append deprecated configPaths to the absolute config paths received via configTargets. + options.configPaths.forEach(cp => { + if (!configPaths.includes(cp)) { + configPaths.push(cp); + } + }); + + const configUrls: string[] = options.configTargets + .slice() + .filter((e): e is { url: string } => e.hasOwnProperty('url')) + .map(configTarget => configTarget.url); + + if (remote === undefined && configUrls.length > 0) { + throw new Error(`Remote config detected but this feature is turned off`); + } // If no paths are provided, we default to reading // `app-config.yaml` and, if it exists, `app-config.local.yaml` - if (configPaths.length === 0) { + if (configPaths.length === 0 && configUrls.length === 0) { configPaths.push(resolvePath(configRoot, 'app-config.yaml')); const localConfig = resolvePath(configRoot, 'app-config.local.yaml'); @@ -114,22 +168,67 @@ export async function loadConfig( return configs; }; - let fileConfigs; + const loadRemoteConfigFiles = async () => { + const configs: AppConfig[] = []; + + const readConfigFromUrl = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Could not read config file at ${url}`); + } + + return await response.text(); + }; + + for (let i = 0; i < configUrls.length; i++) { + const configUrl = configUrls[i]; + if (!isValidUrl(configUrl)) { + throw new Error(`Config load path is not valid: '${configUrl}'`); + } + + const remoteConfigContent = await readConfigFromUrl(configUrl); + if (!remoteConfigContent) { + throw new Error(`Config is not valid`); + } + const configYaml = yaml.parse(remoteConfigContent); + const substitutionTransform = createSubstitutionTransform(env); + const data = await applyConfigTransforms(configRoot, configYaml, [ + substitutionTransform, + ]); + + configs.push({ data, context: configUrl }); + } + + return configs; + }; + + let fileConfigs: AppConfig[]; try { fileConfigs = await loadConfigFiles(); } catch (error) { throw new ForwardedError('Failed to read static configuration file', error); } + let remoteConfigs: AppConfig[] = []; + if (remote) { + try { + remoteConfigs = await loadRemoteConfigFiles(); + } catch (error) { + throw new ForwardedError( + `Failed to read remote configuration file`, + error, + ); + } + } + const envConfigs = await readEnvConfig(process.env); - // Set up config file watching if requested by the caller - if (watch) { - let currentSerializedConfig = JSON.stringify(fileConfigs); - + const watchConfigFile = (watchProp: LoadConfigOptionsWatch) => { const watcher = chokidar.watch(configPaths, { usePolling: process.env.NODE_ENV === 'test', }); + + let currentSerializedConfig = JSON.stringify(fileConfigs); watcher.on('change', async () => { try { const newConfigs = await loadConfigFiles(); @@ -140,18 +239,71 @@ export async function loadConfig( } currentSerializedConfig = newSerializedConfig; - watch.onChange([...newConfigs, ...envConfigs]); + watchProp.onChange([...remoteConfigs, ...newConfigs, ...envConfigs]); } catch (error) { console.error(`Failed to reload configuration files, ${error}`); } }); - if (watch.stopSignal) { - watch.stopSignal.then(() => { + if (watchProp.stopSignal) { + watchProp.stopSignal.then(() => { watcher.close(); }); } + }; + + const watchRemoteConfig = ( + watchProp: LoadConfigOptionsWatch, + remoteProp: LoadConfigOptionsRemote, + ) => { + const hasConfigChanged = async ( + oldRemoteConfigs: AppConfig[], + newRemoteConfigs: AppConfig[], + ) => { + return ( + JSON.stringify(oldRemoteConfigs) !== JSON.stringify(newRemoteConfigs) + ); + }; + + let handle: NodeJS.Timeout | undefined; + try { + handle = setInterval(async () => { + console.info(`Checking for config update`); + const newRemoteConfigs = await loadRemoteConfigFiles(); + if (await hasConfigChanged(remoteConfigs, newRemoteConfigs)) { + remoteConfigs = newRemoteConfigs; + console.info(`Remote config change, reloading config ...`); + watchProp.onChange([...remoteConfigs, ...fileConfigs, ...envConfigs]); + console.info(`Remote config reloaded`); + } + }, remoteProp.reloadIntervalSeconds * 1000); + } catch (error) { + console.error(`Failed to reload configuration files, ${error}`); + } + + if (watchProp.stopSignal) { + watchProp.stopSignal.then(() => { + if (handle !== undefined) { + console.info(`Stopping remote config watch`); + clearInterval(handle); + handle = undefined; + } + }); + } + }; + + // Set up config file watching if requested by the caller + if (watch) { + watchConfigFile(watch); } - return [...fileConfigs, ...envConfigs]; + if (watch && remote) { + watchRemoteConfig(watch, remote); + } + + return { + appConfigs: remote + ? [...remoteConfigs, ...fileConfigs, ...envConfigs] + : [...fileConfigs, ...envConfigs], + }; } diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index ff75f5bf81..b1d590a7bd 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/core-app-api +## 0.1.22 + +### Patch Changes + +- Reverted the `createApp` TypeScript type to match the one before version `0.1.21`, as it was an accidental breaking change. + +## 0.1.21 + +### Patch Changes + +- 0b1de52732: Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. +- ecd1fcb80a: Deprecated the `BackstagePluginWithAnyOutput` type. +- 32bfbafb0f: Start exporting and marking several types as public to address errors in the API report. +- 014cbf8cb9: The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement. + + The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`. + + The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely. + +- 475edb5bc5: move the BehaviorSubject init into the constructor +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/app-defaults@0.1.1 + +## 0.1.20 + +### Patch Changes + +- 78c512ce8f: I have added default icons for the catalog, scaffolder, techdocs, and search. +- 8b4284cd5c: Improve API documentation for @backstage/core-plugin-api +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + ## 0.1.19 ### Patch Changes diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 09d3313c84..e69ee4f325 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -27,9 +27,11 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; +import { createApp as createApp_2 } from '@backstage/app-defaults'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } 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'; @@ -93,7 +95,6 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { | undefined; // (undocumented) getAllApis(): Set; - // Warning: (ae-forgotten-export) The symbol "ApiFactoryScope" needs to be exported by the entry point index.d.ts register< Api, Impl extends Api, @@ -103,6 +104,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { >(scope: ApiFactoryScope, factory: ApiFactory): boolean; } +// @public +export type ApiFactoryScope = 'default' | 'app' | 'static'; + // @public export const ApiProvider: { (props: PropsWithChildren): JSX.Element; @@ -117,6 +121,12 @@ export const ApiProvider: { }; // @public +export type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + +// @public @deprecated export class ApiRegistry implements ApiHolder { constructor(apis: Map); // Warning: (ae-forgotten-export) The symbol "ApiRegistryBuilder" needs to be exported by the entry point index.d.ts @@ -149,7 +159,7 @@ export type AppComponents = { Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; - ThemeProvider: ComponentType<{}>; + ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType; }; @@ -163,23 +173,52 @@ export type AppContext = { getComponents(): AppComponents; }; +// @public +export type AppIcons = { + 'kind:api': IconComponent; + 'kind:component': IconComponent; + 'kind:domain': IconComponent; + 'kind:group': IconComponent; + 'kind:location': IconComponent; + 'kind:system': IconComponent; + 'kind:user': IconComponent; + brokenImage: IconComponent; + catalog: IconComponent; + chat: IconComponent; + dashboard: IconComponent; + docs: IconComponent; + email: IconComponent; + github: IconComponent; + group: IconComponent; + help: IconComponent; + scaffolder: IconComponent; + search: IconComponent; + techdocs: IconComponent; + user: IconComponent; + warning: IconComponent; +}; + // @public export type AppOptions = { apis?: Iterable; - icons?: Partial & { + defaultApis?: Iterable; + icons: AppIcons & { [key in string]: IconComponent; }; - plugins?: BackstagePluginWithAnyOutput[]; - components?: Partial; - themes?: AppTheme[]; + plugins?: (Omit, 'output'> & { + output(): ( + | PluginOutput + | { + type: string; + } + )[]; + })[]; + components: AppComponents; + themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; bindRoutes?(context: { bind: AppRouteBinder }): void; }; -// Warning: (ae-forgotten-export) The symbol "PartialKeys" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "TargetRouteMap" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "KeysWithType" needs to be exported by the entry point index.d.ts -// // @public export type AppRouteBinder = < ExternalRoutes extends { @@ -210,8 +249,6 @@ export class AppThemeSelector implements AppThemeApi { // @public export class AtlassianAuth { - // Warning: (ae-forgotten-export) The symbol "OAuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -233,6 +270,15 @@ export class Auth0Auth { }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; } +// @public +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export type BackstageApp = { getPlugins(): BackstagePlugin[]; @@ -241,7 +287,7 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public +// @public @deprecated export type BackstagePluginWithAnyOutput = Omit< BackstagePlugin, 'output' @@ -285,10 +331,13 @@ export type BootErrorPageProps = { export { ConfigReader }; -// Warning: (ae-forgotten-export) The symbol "PrivateAppImpl" needs to be exported by the entry point index.d.ts -// +// @public @deprecated +export function createApp( + options?: Parameters[0], +): BackstageApp & AppContext; + // @public -export function createApp(options?: AppOptions): PrivateAppImpl; +export function createSpecializedApp(options: AppOptions): BackstageApp; // @public export const defaultConfigLoader: AppConfigLoader; @@ -298,15 +347,11 @@ export class ErrorAlerter implements ErrorApi { constructor(alertApi: AlertApi, errorApi: ErrorApi); // (undocumented) error$(): Observable<{ - error: { - name: string; - message: string; - stack?: string | undefined; - }; - context?: ErrorContext | undefined; + error: ErrorApiError; + context?: ErrorApiErrorContext | undefined; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -314,10 +359,10 @@ export class ErrorApiForwarder implements ErrorApi { // (undocumented) error$(): Observable<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -353,6 +398,8 @@ export type FlatRoutesProps = { // @public export class GithubAuth implements OAuthApi, SessionApi { // Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts + // + // @deprecated constructor(sessionManager: SessionManager); // (undocumented) static create({ @@ -454,12 +501,11 @@ export class OAuth2 BackstageIdentityApi, SessionApi { + // @deprecated constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }); - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -468,7 +514,7 @@ export class OAuth2 oauthRequestApi, defaultScopes, scopeTransform, - }: CreateOptions): OAuth2; + }: OAuth2CreateOptions): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -490,6 +536,11 @@ export class OAuth2 signOut(): Promise; } +// @public +export type OAuth2CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + // @public export type OAuth2Session = { providerInfo: { @@ -502,6 +553,12 @@ export type OAuth2Session = { backstageIdentity: BackstageIdentity; }; +// @public +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + // @public export class OAuthRequestManager implements OAuthRequestApi { // (undocumented) @@ -524,25 +581,31 @@ export class OktaAuth { // @public export class OneLoginAuth { - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, environment, provider, oauthRequestApi, - }: CreateOptions_2): typeof oneloginAuthApiRef.T; + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T; } +// @public +export type OneLoginAuthCreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - // Warning: (ae-forgotten-export) The symbol "SamlSession" needs to be exported by the entry point index.d.ts + // @deprecated constructor(sessionManager: SessionManager); - // Warning: (ae-forgotten-export) The symbol "AuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -563,6 +626,13 @@ export class SamlAuth signOut(): Promise; } +// @public +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // @public export type SignInPageProps = { onResult(result: SignInResult): void; @@ -578,7 +648,7 @@ export type SignInResult = { // @public export class UnhandledErrorForwarder { - static forward(errorApi: ErrorApi, errorContext: ErrorContext): void; + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; } // @public @@ -607,9 +677,4 @@ export class WebStorage implements StorageApi { // (undocumented) set(key: string, data: T): Promise; } - -// Warnings were encountered during analysis: -// -// src/apis/system/ApiProvider.d.ts:15:5 - (ae-forgotten-export) The symbol "ApiProviderProps" needs to be exported by the entry point index.d.ts -// src/app/types.d.ts:152:5 - (ae-forgotten-export) The symbol "AppIcons" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index f40e3d5eb7..96c021b1ee 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-app-api", "description": "Core app API used by Backstage apps", - "version": "0.1.19", + "version": "0.1.22", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", + "@backstage/app-defaults": "^0.1.1", + "@backstage/core-components": "^0.7.4", "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -46,8 +47,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", 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 index 7fcbde97df..5e404a35d7 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -26,10 +26,10 @@ describe('UrlPatternDiscovery', () => { it('should use a plain pattern', async () => { const discoveryApi = UrlPatternDiscovery.compile( - 'http://localhost:7000/{{ pluginId }}', + 'http://localhost:7007/{{ pluginId }}', ); await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( - 'http://localhost:7000/my-plugin', + 'http://localhost:7007/my-plugin', ); }); diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts index aa921c2595..cba8344977 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -30,7 +30,7 @@ export class UrlPatternDiscovery implements DiscoveryApi { * 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 }}` + * Example pattern: `http://localhost:7007/api/{{ pluginId }}` */ static compile(pattern: string): UrlPatternDiscovery { const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts index 2111798d6a..350213d938 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, + AlertApi, +} from '@backstage/core-plugin-api'; /** * Decorates an ErrorApi by also forwarding error messages @@ -27,7 +32,7 @@ export class ErrorAlerter implements ErrorApi { private readonly errorApi: ErrorApi, ) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (!context?.hidden) { this.alertApi.post({ message: error.message, severity: 'error' }); } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index cd4564a050..f67c00d991 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { PublishSubject } from '../../../lib/subjects'; @@ -26,14 +30,14 @@ import { PublishSubject } from '../../../lib/subjects'; export class ErrorApiForwarder implements ErrorApi { private readonly subject = new PublishSubject<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>(); - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { this.subject.next({ error, context }); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ error: Error; context?: ErrorApiErrorContext }> { return this.subject; } } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts index 16e473fb3a..8e697f12b2 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts @@ -1,4 +1,8 @@ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; /* * Copyright 2020 Spotify AB @@ -25,11 +29,11 @@ export class UnhandledErrorForwarder { /** * Add event listener, such that unhandled errors can be forwarded using an given `ErrorApi` instance */ - static forward(errorApi: ErrorApi, errorContext: ErrorContext) { + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext) { window.addEventListener( 'unhandledrejection', (e: PromiseRejectionEvent) => { - errorApi.post(e.reason as Error, errorContext); + errorApi.post(e.reason as ErrorApiError, errorContext); }, ); } 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 index d0f137f0be..76cf01d20a 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts @@ -15,7 +15,6 @@ */ import MockOAuthApi from './MockOAuthApi'; -import PowerIcon from '@material-ui/icons/Power'; describe('MockOAuthApi', () => { it('should trigger all requests', async () => { @@ -24,13 +23,13 @@ describe('MockOAuthApi', () => { const authHandler1 = jest.fn().mockImplementation(() => authResult); const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler1, }); const authHandler2 = jest.fn().mockResolvedValue('other'); const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler2, }); @@ -67,13 +66,13 @@ describe('MockOAuthApi', () => { const authHandler1 = jest.fn(); const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler1, }); const authHandler2 = jest.fn(); const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler2, }); 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 index d8faf90229..3110197ad9 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import ProviderIcon from '@material-ui/icons/AcUnit'; import { OAuthRequestManager } from './OAuthRequestManager'; describe('OAuthRequestManager', () => { @@ -27,7 +26,7 @@ describe('OAuthRequestManager', () => { const requester = manager.createAuthRequester({ provider: { title: 'My Provider', - icon: ProviderIcon, + icon: () => null, }, onAuthRequest: async () => 'hello', }); diff --git a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts index 227d70e494..caad40ddbf 100644 --- a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import AtlassianIcon from '@material-ui/icons/AcUnit'; import { atlassianAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'atlassian', title: 'Atlassian', - icon: AtlassianIcon, + icon: () => null, }; /** 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 index 69462942e4..0a158ccb9e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -14,7 +14,6 @@ * 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'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'auth0', title: 'Auth0', - icon: Auth0Icon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts index c9db93906a..e488580c4d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import BitbucketIcon from '@material-ui/icons/FormatBold'; import { BackstageIdentity, bitbucketAuthApiRef, @@ -37,7 +36,7 @@ export type BitbucketAuthResponse = { const DEFAULT_PROVIDER = { id: 'bitbucket', title: 'Bitbucket', - icon: BitbucketIcon, + icon: () => null, }; /** 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 index 04bfff028d..8bcd4cb7a5 100644 --- 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 @@ -21,7 +21,7 @@ describe('GithubAuth', () => { const getSession = jest .fn() .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const githubAuth = new GithubAuth({ getSession } as any); + const githubAuth = new (GithubAuth as any)({ getSession }) as GithubAuth; 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 index 01e7e77b8e..3e9c899346 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import GithubIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; import { @@ -48,7 +47,7 @@ export type GithubAuthResponse = { const DEFAULT_PROVIDER = { id: 'github', title: 'GitHub', - icon: GithubIcon, + icon: () => null, }; /** @@ -117,6 +116,9 @@ export default class GithubAuth implements OAuthApi, SessionApi { return new GithubAuth(sessionManagerMux); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { 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 index af581d0018..f46f2ef72e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -14,7 +14,6 @@ * 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'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'gitlab', title: 'GitLab', - icon: GitlabIcon, + icon: () => null, }; /** 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 index 916c52382e..a6f37b250f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -14,7 +14,6 @@ * 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'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'google', title: 'Google', - icon: GoogleIcon, + icon: () => null, }; const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index bbc9d23ccc..50333f07a0 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -25,3 +25,4 @@ export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; export * from './atlassian'; +export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; 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 index 09bd7f67a2..148be66873 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -14,7 +14,6 @@ * 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'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'microsoft', title: 'Microsoft', - icon: MicrosoftIcon, + icon: () => null, }; /** 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 index 6c0759d967..403e8445d8 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -14,7 +14,6 @@ * 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'; @@ -33,7 +32,11 @@ import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; -type CreateOptions = OAuthApiCreateOptions & { +/** + * OAuth2 create options. + * @public + */ +export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; }; @@ -51,7 +54,7 @@ export type OAuth2Response = { const DEFAULT_PROVIDER = { id: 'oauth2', title: 'Your Identity Provider', - icon: OAuth2Icon, + icon: () => null, }; /** @@ -74,7 +77,7 @@ export default class OAuth2 oauthRequestApi, defaultScopes = [], scopeTransform = x => x, - }: CreateOptions) { + }: OAuth2CreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -115,6 +118,9 @@ export default class OAuth2 private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; 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 index 4ada35846a..fb8b0e6c64 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -16,6 +16,7 @@ import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; +export type { OAuth2CreateOptions } from './OAuth2'; /** * Session information for generic OAuth2 auth. * 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 index 294fb496af..465a124051 100644 --- a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -14,7 +14,6 @@ * 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'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'okta', title: 'Okta', - icon: OktaIcon, + icon: () => null, }; const OKTA_OIDC_SCOPES: Set = new Set([ 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 index f49ea4cde8..93b9f6634c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import OneLoginIcon from '@material-ui/icons/AcUnit'; import { oneloginAuthApiRef, OAuthRequestApi, @@ -23,7 +22,11 @@ import { } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; -type CreateOptions = { +/** + * OneLogin auth provider create options. + * @public + */ +export type OneLoginAuthCreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; @@ -33,7 +36,7 @@ type CreateOptions = { const DEFAULT_PROVIDER = { id: 'onelogin', title: 'onelogin', - icon: OneLoginIcon, + icon: () => null, }; const OIDC_SCOPES: Set = new Set([ @@ -59,7 +62,7 @@ export default class OneLoginAuth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof oneloginAuthApiRef.T { + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, 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 index e1826f17dd..8504d95611 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts @@ -15,3 +15,4 @@ */ export { default as OneLoginAuth } from './OneLoginAuth'; +export type { OneLoginAuthCreateOptions } 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 index f2668dedd2..c1b70e963d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import SamlIcon from '@material-ui/icons/AcUnit'; import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { @@ -42,7 +41,7 @@ export type SamlAuthResponse = { const DEFAULT_PROVIDER = { id: 'saml', title: 'SAML', - icon: SamlIcon, + icon: () => null, }; /** @@ -80,6 +79,9 @@ export default class SamlAuth return this.sessionManager.sessionState$(); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { 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 index 930e6cb115..f9dc2895af 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { default as SamlAuth } from './SamlAuth'; +export type { SamlSession } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts index 89343e9e06..825f433cec 100644 --- a/packages/core-app-api/src/apis/implementations/auth/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/types.ts @@ -20,11 +20,19 @@ import { OAuthRequestApi, } from '@backstage/core-plugin-api'; +/** + * Create options for OAuth APIs. + * @public + */ export type OAuthApiCreateOptions = AuthApiCreateOptions & { oauthRequestApi: OAuthRequestApi; defaultScopes?: string[]; }; +/** + * Generic create options for auth APIs. + * @public + */ export type AuthApiCreateOptions = { discoveryApi: DiscoveryApi; environment?: string; diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts index 880f075930..5f56793cae 100644 --- a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts @@ -22,7 +22,11 @@ import { AnyApiFactory, } from '@backstage/core-plugin-api'; -type ApiFactoryScope = +/** + * Scope type when registering API factories. + * @public + */ +export 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 diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx index 73bbdd6f77..c75f883a51 100644 --- a/packages/core-app-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -23,7 +23,11 @@ import { createVersionedContext, } from '@backstage/version-bridge'; -type ApiProviderProps = { +/** + * Prop types for the ApiProvider component. + * @public + */ +export type ApiProviderProps = { apis: ApiHolder; children: ReactNode; }; diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts index 29c8b1cfe4..e433381dd1 100644 --- a/packages/core-app-api/src/apis/system/ApiRegistry.ts +++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts @@ -36,6 +36,7 @@ class ApiRegistryBuilder { * A registry for utility APIs. * * @public + * @deprecated Will be removed, use {@link @backstage/test-utils#TestApiProvider} or {@link @backstage/test-utils#TestApiRegistry} instead. */ export class ApiRegistry implements ApiHolder { static builder() { diff --git a/packages/core-app-api/src/apis/system/index.ts b/packages/core-app-api/src/apis/system/index.ts index 23e1a9a4b8..56c42f1e2a 100644 --- a/packages/core-app-api/src/apis/system/index.ts +++ b/packages/core-app-api/src/apis/system/index.ts @@ -15,7 +15,9 @@ */ export { ApiProvider } from './ApiProvider'; +export type { ApiProviderProps } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; export { ApiResolver } from './ApiResolver'; export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export type { ApiFactoryScope } from './ApiFactoryRegistry'; export * from './types'; diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx similarity index 87% rename from packages/core-app-api/src/app/App.test.tsx rename to packages/core-app-api/src/app/AppManager.test.tsx index ab287a3ece..07401660e8 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -20,11 +20,9 @@ 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, Navigate, Route, Routes } from 'react-router-dom'; -import { defaultAppIcons } from './icons'; import { configApiRef, createApiFactory, @@ -37,8 +35,8 @@ import { createRoutableExtension, analyticsApiRef, } from '@backstage/core-plugin-api'; -import { generateBoundRoutes, PrivateAppImpl } from './App'; -import { AppThemeProvider } from './AppThemeProvider'; +import { generateBoundRoutes, AppManager } from './AppManager'; +import { AppComponents, AppIcons } from './types'; describe('generateBoundRoutes', () => { it('runs happy path', () => { @@ -128,7 +126,7 @@ describe('Integration Test', () => { const HiddenComponent = plugin2.provide( createRoutableExtension({ name: 'HiddenComponent', - component: () => Promise.resolve((_: { path?: string }) =>
), + component: () => Promise.resolve(() =>
), mountPoint: plugin2RouteRef, }), ); @@ -137,7 +135,7 @@ describe('Integration Test', () => { createRoutableExtension({ name: 'ExposedComponent', component: () => - Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + Promise.resolve(() => { const link1 = useRouteRef(plugin1RouteRef); const link2 = useRouteRef(plugin2RouteRef); const subLink1 = useRouteRef(subRouteRef1); @@ -169,6 +167,7 @@ describe('Integration Test', () => { const NavigateComponent = plugin1.provide( createRoutableExtension({ + name: 'NavigateComponent', component: () => Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { return ; @@ -177,17 +176,19 @@ describe('Integration Test', () => { }), ); - const components = { + const components: AppComponents = { NotFoundErrorPage: () => null, BootErrorPage: () => null, Progress: () => null, Router: BrowserRouter, ErrorBoundaryFallback: () => null, - ThemeProvider: AppThemeProvider, + ThemeProvider: ({ children }) => <>{children}, }; + const icons = {} as AppIcons; + it('runs happy paths', async () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [noOpAnalyticsApi], defaultApis: [], themes: [ @@ -195,12 +196,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -218,8 +220,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -241,7 +243,7 @@ describe('Integration Test', () => { }); it('runs happy paths without optional routes', async () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [noOpAnalyticsApi], defaultApis: [], themes: [ @@ -249,12 +251,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -270,8 +273,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -298,7 +301,7 @@ describe('Integration Test', () => { }), ]; - const app = new PrivateAppImpl({ + const app = new AppManager({ apis, defaultApis: [], themes: [ @@ -306,10 +309,10 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [ createPlugin({ id: 'test', @@ -317,6 +320,7 @@ describe('Integration Test', () => { }), ], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -332,8 +336,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -348,7 +352,7 @@ describe('Integration Test', () => { it('should track route changes via analytics api', async () => { const mockAnalyticsApi = new MockAnalyticsApi(); const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; - const app = new PrivateAppImpl({ + const app = new AppManager({ apis, defaultApis: [], themes: [ @@ -356,12 +360,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -378,7 +383,7 @@ describe('Integration Test', () => { } /> - } /> + } /> , @@ -408,7 +413,7 @@ describe('Integration Test', () => { }); it('should throw some error when the route has duplicate params', () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [], defaultApis: [], themes: [ @@ -416,12 +421,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -438,9 +444,9 @@ describe('Integration Test', () => { - - - + }> + } /> + , diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/AppManager.tsx similarity index 94% rename from packages/core-app-api/src/app/App.tsx rename to packages/core-app-api/src/app/AppManager.tsx index 8cb3f167fb..950f57bb7d 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -77,6 +77,8 @@ import { SignInPageProps, SignInResult, } from './types'; +import { AppThemeProvider } from './AppThemeProvider'; +import { defaultConfigLoader } from './defaultConfigLoader'; export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) { const result = new Map(); @@ -121,17 +123,6 @@ function getBasePath(configApi: Config) { return pathname; } -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, @@ -151,7 +142,7 @@ function useConfigLoader( noConfigNode = ; } - const { ThemeProvider } = components; + const { ThemeProvider = AppThemeProvider } = components; // Before the config is loaded we can't use a router, so exit early if (noConfigNode) { @@ -170,7 +161,7 @@ function useConfigLoader( } class AppContextImpl implements AppContext { - constructor(private readonly app: PrivateAppImpl) {} + constructor(private readonly app: AppManager) {} getPlugins(): BackstagePlugin[] { return this.app.getPlugins(); @@ -185,7 +176,7 @@ class AppContextImpl implements AppContext { } } -export class PrivateAppImpl implements BackstageApp { +export class AppManager implements BackstageApp { private apiHolder?: ApiHolder; private configApi?: ConfigApi; @@ -201,14 +192,16 @@ export class PrivateAppImpl implements BackstageApp { private readonly identityApi = new AppIdentity(); private readonly apiFactoryRegistry: ApiFactoryRegistry; - constructor(options: FullAppOptions) { - this.apis = options.apis; + constructor(options: AppOptions) { + this.apis = options.apis ?? []; this.icons = options.icons; - this.plugins = new Set(options.plugins); + this.plugins = new Set( + (options.plugins as BackstagePlugin[]) ?? [], + ); this.components = options.components; - this.themes = options.themes; - this.configLoader = options.configLoader; - this.defaultApis = options.defaultApis; + this.themes = options.themes as AppTheme[]; + this.configLoader = options.configLoader ?? defaultConfigLoader; + this.defaultApis = options.defaultApis ?? []; this.bindRoutes = options.bindRoutes; this.apiFactoryRegistry = new ApiFactoryRegistry(); } @@ -307,7 +300,7 @@ export class PrivateAppImpl implements BackstageApp { return loadedConfig.node; } - const { ThemeProvider } = this.components; + const { ThemeProvider = AppThemeProvider } = this.components; return ( diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx index 4e3a8487a6..2e145025d5 100644 --- a/packages/core-app-api/src/app/AppThemeProvider.tsx +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -90,6 +90,17 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) { throw new Error('App has no themes'); } + if (appTheme.Provider) { + return ; + } + + // eslint-disable-next-line no-console + console.warn( + "DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " + + 'and should be migrated to use a Provider instead. ' + + 'See https://backstage.io/docs/api/deprecations#app-theme for more info.', + ); + return ( {children} diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx index 65aa15b50c..b2a21634d3 100644 --- a/packages/core-app-api/src/app/createApp.tsx +++ b/packages/core-app-api/src/app/createApp.tsx @@ -14,171 +14,25 @@ * limitations under the License. */ -import { AppConfig } from '@backstage/config'; -import { JsonObject } from '@backstage/types'; -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 { AppThemeProvider } from './AppThemeProvider'; -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. - * - * @public - */ -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; -}; - -export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) { - if (useInRouterContext()) { - return <>{children}; - } - return {children}; -} +import { createApp as createDefaultApp } from '@backstage/app-defaults'; +import { AppContext, BackstageApp } from './types'; /** * Creates a new Backstage App. * + * @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead + * @param options - A set of options for creating the app * @public */ -export function createApp(options?: AppOptions) { - const DefaultNotFoundPage = () => ( - +export function createApp( + options?: Parameters[0], +): BackstageApp & AppContext { + // eslint-disable-next-line no-console + console.warn( + 'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' + + 'migrate to importing createApp from the @backstage/app-defaults package instead. ' + + 'If you do not wish to use a standard app configuration but instead supply all options yourself ' + + ' you can use createSpecializedApp from @backstage/core-app-api instead.', ); - 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, - ThemeProvider: AppThemeProvider, - ...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, - }); + return createDefaultApp(options) as BackstageApp & AppContext; } diff --git a/packages/core-app-api/src/app/createSpecializedApp.tsx b/packages/core-app-api/src/app/createSpecializedApp.tsx new file mode 100644 index 0000000000..9bda223722 --- /dev/null +++ b/packages/core-app-api/src/app/createSpecializedApp.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AppManager } from './AppManager'; +import { AppOptions, BackstageApp } from './types'; + +/** + * Creates a new Backstage App where the full set of options are required. + * + * @public + * @param options - A set of options for creating the app + * @returns + */ +export function createSpecializedApp(options: AppOptions): BackstageApp { + return new AppManager(options); +} diff --git a/packages/core-app-api/src/app/createApp.test.tsx b/packages/core-app-api/src/app/defaultConfigLoader.test.ts similarity index 66% rename from packages/core-app-api/src/app/createApp.test.tsx rename to packages/core-app-api/src/app/defaultConfigLoader.test.ts index 0c3f62b14d..aebba92c43 100644 --- a/packages/core-app-api/src/app/createApp.test.tsx +++ b/packages/core-app-api/src/app/defaultConfigLoader.test.ts @@ -14,15 +14,7 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; -import { renderWithEffects } from '@backstage/test-utils'; -import React, { PropsWithChildren } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { - defaultConfigLoader, - OptionallyWrapInRouter, - createApp, -} from './createApp'; +import { defaultConfigLoader } from './defaultConfigLoader'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; @@ -100,44 +92,3 @@ describe('defaultConfigLoader', () => { ]); }); }); - -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(); - }); -}); - -describe('Optional ThemeProvider', () => { - it('should render app with user-provided ThemeProvider', async () => { - const components = { - NotFoundErrorPage: () => null, - BootErrorPage: () => null, - Progress: () => null, - Router: MemoryRouter, - ErrorBoundaryFallback: () => null, - ThemeProvider: ({ children }: PropsWithChildren<{}>) => ( -
{children}
- ), - }; - - const App = createApp({ components }).getProvider(); - - await renderWithEffects(); - - expect(screen.getByRole('main')).toBeInTheDocument(); - }); -}); diff --git a/packages/core-app-api/src/app/defaultConfigLoader.ts b/packages/core-app-api/src/app/defaultConfigLoader.ts new file mode 100644 index 0000000000..00aee2d367 --- /dev/null +++ b/packages/core-app-api/src/app/defaultConfigLoader.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { AppConfigLoader } from './types'; + +/** + * 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. + * + * @public + */ +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; +}; diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index ab82774cad..5ea5405632 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,5 +14,7 @@ * limitations under the License. */ -export { createApp, defaultConfigLoader } from './createApp'; +export { createApp } from './createApp'; +export { createSpecializedApp } from './createSpecializedApp'; +export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 407018111b..f16c8ac656 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -27,7 +27,6 @@ import { PluginOutput, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; -import { AppIcons } from './icons'; /** * Props for the `BootErrorPage` component of {@link AppComponents}. @@ -97,7 +96,7 @@ export type AppComponents = { Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; - ThemeProvider: ComponentType<{}>; + ThemeProvider?: ComponentType<{}>; /** * An optional sign-in page that will be rendered instead of the AppRouter at startup. @@ -111,6 +110,36 @@ export type AppComponents = { SignInPage?: ComponentType; }; +/** + * A set of well-known icons that should be available within an app. + * + * @public + */ +export type AppIcons = { + 'kind:api': IconComponent; + 'kind:component': IconComponent; + 'kind:domain': IconComponent; + 'kind:group': IconComponent; + 'kind:location': IconComponent; + 'kind:system': IconComponent; + 'kind:user': IconComponent; + + brokenImage: IconComponent; + catalog: IconComponent; + chat: IconComponent; + dashboard: IconComponent; + docs: IconComponent; + email: IconComponent; + github: IconComponent; + group: IconComponent; + help: IconComponent; + scaffolder: IconComponent; + search: IconComponent; + techdocs: IconComponent; + user: IconComponent; + warning: IconComponent; +}; + /** * A function that loads in the App config that will be accessible via the ConfigApi. * @@ -123,6 +152,8 @@ export type AppConfigLoader = () => Promise; /** * Extracts a union of the keys in a map whose value extends the given type + * + * @ignore */ type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; @@ -130,6 +161,8 @@ type KeysWithType = { /** * Takes a map Map required values and makes all keys matching Keys optional + * + * @ignore */ type PartialKeys< Map extends { [name in string]: any }, @@ -138,6 +171,8 @@ type PartialKeys< /** * Creates a map of target routes with matching parameters based on a map of external routes. + * + * @ignore */ type TargetRouteMap< ExternalRoutes extends { [name: string]: ExternalRouteRef }, @@ -171,6 +206,7 @@ export type AppRouteBinder = < * * @public * @remarks + * @deprecated Will be removed * * The `type: string` type is there to handle output from newer or older plugin * API versions that might not be supported by this version of the app API, but @@ -196,24 +232,35 @@ export type BackstagePluginWithAnyOutput = Omit< 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. + * add new ones, or override factories provided by default or by plugins. */ apis?: Iterable; + /** + * A collection of ApiFactories to register in the application as default APIs. + * Theses APIs can not be overridden by plugin factories, but can be overridden + * by plugin APIs provided through the + * A collection of ApiFactories to register in the application to either + * add new ones, or override factories provided by default or by plugins. + */ + defaultApis?: Iterable; + /** * Supply icons to override the default ones. */ - icons?: Partial & { [key in string]: IconComponent }; + icons: AppIcons & { [key in string]: IconComponent }; /** * A list of all plugins to include in the app. */ - plugins?: BackstagePluginWithAnyOutput[]; + plugins?: (Omit, 'output'> & { + output(): (PluginOutput | { type: string })[]; + })[]; /** * Supply components to the app to override the default ones. */ - components?: Partial; + components: AppComponents; /** * Themes provided as a part of the app. By default two themes are included, one @@ -226,18 +273,26 @@ export type AppOptions = { * id: 'light', * title: 'Light Theme', * variant: 'light', - * theme: lightTheme, * icon: , + * Provider: ({ children }) => ( + * + * {children} + * + * ), * }, { * id: 'dark', * title: 'Dark Theme', * variant: 'dark', - * theme: darkTheme, * icon: , + * Provider: ({ children }) => ( + * + * {children} + * + * ), * }] * ``` */ - themes?: AppTheme[]; + themes: (Partial & Omit)[]; /** * A function that loads in App configuration that will be accessible via diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 7e448586e3..baf89a59d8 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -14,7 +14,6 @@ * 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'; @@ -29,7 +28,7 @@ const defaultOptions = { provider: { id: 'my-provider', title: 'My Provider', - icon: ProviderIcon, + icon: () => null, }, oauthRequestApi: new MockOAuthApi(), sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ diff --git a/packages/core-app-api/src/lib/subjects.ts b/packages/core-app-api/src/lib/subjects.ts index 79c2c25675..e0789da92c 100644 --- a/packages/core-app-api/src/lib/subjects.ts +++ b/packages/core-app-api/src/lib/subjects.ts @@ -121,34 +121,37 @@ export class PublishSubject * * See http://reactivex.io/documentation/subject.html */ + export class BehaviorSubject implements Observable, ZenObservable.SubscriptionObserver { - private isClosed = false; + private isClosed: boolean; private currentValue: T; - private terminatingError?: Error; + private terminatingError: Error | undefined; + private readonly observable: Observable; constructor(value: T) { + this.isClosed = false; this.currentValue = value; - } - - private readonly observable = new ObservableImpl(subscriber => { - if (this.isClosed) { - if (this.terminatingError) { - subscriber.error(this.terminatingError); - } else { - subscriber.complete(); + this.terminatingError = undefined; + this.observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; } - return () => {}; - } - subscriber.next(this.currentValue); + subscriber.next(this.currentValue); - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + } private readonly subscribers = new Set< ZenObservable.SubscriptionObserver diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx index 2b05c8f61d..d1b45c39d1 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx @@ -16,14 +16,15 @@ import React from 'react'; import { FeatureFlagged } from './FeatureFlagged'; import { render } from '@testing-library/react'; -import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { LocalStorageFeatureFlags } from '../apis'; +import { TestApiProvider } from '@backstage/test-utils'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); describe('FeatureFlagged', () => { diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx index 0df296cc59..ca247d46cd 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -17,17 +17,18 @@ import { render, RenderResult } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; -import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { LocalStorageFeatureFlags } from '../apis'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; import { AppContext } from '../app'; import { AppContextProvider } from '../app/AppContext'; import { FlatRoutes } from './FlatRoutes'; +import { TestApiProvider } from '@backstage/test-utils'; const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); function makeRouteRenderer(node: ReactNode) { diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index af03762094..c62ba9e90d 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,49 @@ # @backstage/core-components +## 0.7.4 + +### Patch Changes + +- 274a4fc633: Add Props Icon for Sidebar Item SidebarSearchField and Settings +- 682945e233: Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window. +- 892c1d9202: Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken +- Updated dependencies + - @backstage/core-plugin-api@0.2.0 + +## 0.7.3 + +### Patch Changes + +- c11a37710a: Added a warning variant to `DismissableBanner` component. If you are using a + custom theme, you will need to add the optional `palette.banner.warning` color, + otherwise this variant will fall back to the `palette.banner.error` color. +- 5826c17b7d: Allow for `cellStyle` property on `TableColumn` to be a function as well as `React.CSSProperties` as per the Material UI Table component +- e0861b92ff: Add new way to override color selection to progress bar/gauge components. + + `Gauge`, `LinearGauge` and `GaugeCard` all accept a `getColor` prop, + which is a function of the type: + + ```ts + export type GaugePropsGetColor = (args: { + palette: Palette; + value: number; + inverse?: boolean; + max?: number; + }) => string; + ``` + + Return a standard CSS color string (e.g. "red", "#f02020"), and the gauge will + be set to that color. + + If the prop is omitted, the default implementation is unchanged from previous + versions. + +- 021986e8a3: fixed route resolving (issue #7741) when user cannot select a tab in any of the tabbed pages (like the Catalog page) if it shares the same initial letters as a preceding tab. (i.e. where tab with a path of /ci is followed by a path of /ci-2, user cannot select /ci-2 as /ci will always be selected first). +- a39a2105ef: Add Theme Overrides for Sidebar +- Updated dependencies + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + ## 0.7.2 ### Patch Changes diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index cc884c9e8f..0f1e3510ae 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -7,6 +7,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; +import { BackstagePalette } from '@backstage/theme'; import { BackstageTheme } from '@backstage/theme'; import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button'; import { CardHeaderProps } from '@material-ui/core/CardHeader'; @@ -100,7 +101,7 @@ export type BottomLinkProps = { // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export function Breadcrumbs(props: Props_21): JSX.Element; +export function Breadcrumbs(props: Props_20): JSX.Element; // @public (undocumented) export type BreadcrumbsClickableTextClassKey = 'root'; @@ -157,7 +158,7 @@ export interface CodeSnippetProps { // Warning: (ae-missing-release-tag) "Content" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Content(props: PropsWithChildren): JSX.Element; +export function Content(props: PropsWithChildren): JSX.Element; // Warning: (ae-forgotten-export) The symbol "ContentHeaderProps" needs to be exported by the entry point index.d.ts // @@ -368,10 +369,8 @@ export function FeatureCalloutCircular( // @public (undocumented) export type FiltersContainerClassKey = 'root' | 'title'; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export function Gauge(props: Props_11): JSX.Element; +export function Gauge(props: GaugeProps): JSX.Element; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // @@ -384,6 +383,27 @@ export type GaugeCardClassKey = 'root'; // @public (undocumented) export type GaugeClassKey = 'root' | 'overlay' | 'circle' | 'colorUnknown'; +// @public (undocumented) +export type GaugeProps = { + value: number; + fractional?: boolean; + inverse?: boolean; + unit?: string; + max?: number; + getColor?: GaugePropsGetColor; +}; + +// @public (undocumented) +export type GaugePropsGetColor = (args: GaugePropsGetColorOptions) => string; + +// @public (undocumented) +export type GaugePropsGetColorOptions = { + palette: BackstagePalette; + value: number; + inverse?: boolean; + max?: number; +}; + // @public (undocumented) export function GitHubIcon(props: IconComponentProps): JSX.Element; @@ -393,7 +413,7 @@ export function GroupIcon(props: IconComponentProps): JSX.Element; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export function Header(props: PropsWithChildren): JSX.Element; +export function Header(props: PropsWithChildren): JSX.Element; // @public (undocumented) export type HeaderClassKey = @@ -484,7 +504,7 @@ export type IconLinkVerticalProps = { // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export function InfoCard(props: Props_16): JSX.Element; +export function InfoCard(props: Props_15): JSX.Element; // @public (undocumented) export type InfoCardClassKey = @@ -565,7 +585,7 @@ export type LifecycleClassKey = 'alpha' | 'beta'; // Warning: (ae-missing-release-tag) "LinearGauge" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function LinearGauge(props: Props_12): JSX.Element | null; +export function LinearGauge(props: Props_11): JSX.Element | null; // Warning: (ae-missing-release-tag) "LinkType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -666,7 +686,7 @@ export type OverflowTooltipClassKey = 'container'; // Warning: (ae-missing-release-tag) "Page" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Page(props: PropsWithChildren): JSX.Element; +export function Page(props: PropsWithChildren): JSX.Element; // Warning: (ae-missing-release-tag) "PageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -771,7 +791,7 @@ export type SelectInputBaseClassKey = 'root' | 'input'; // Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Sidebar(props: PropsWithChildren): JSX.Element; +export function Sidebar(props: PropsWithChildren): JSX.Element; // Warning: (ae-missing-release-tag) "SIDEBAR_INTRO_LOCAL_STORAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1961,7 +1981,7 @@ export const SidebarSpacer: React_2.ComponentType< // Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function SignInPage(props: Props_19): JSX.Element; +export function SignInPage(props: Props_18): JSX.Element; // Warning: (ae-missing-release-tag) "SignInPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2050,7 +2070,7 @@ export function StatusWarning(props: PropsWithChildren<{}>): JSX.Element; // Warning: (ae-missing-release-tag) "StructuredMetadataTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function StructuredMetadataTable(props: Props_13): JSX.Element; +export function StructuredMetadataTable(props: Props_12): JSX.Element; // Warning: (ae-missing-release-tag) "StructuredMetadataTableListClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2132,7 +2152,7 @@ export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; // Warning: (ae-missing-release-tag) "TabbedCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function TabbedCard(props: PropsWithChildren): JSX.Element; +export function TabbedCard(props: PropsWithChildren): JSX.Element; // Warning: (ae-missing-release-tag) "TabbedCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 7f50af7488..afa9af0341 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-components", "description": "Core components used by Backstage plugins and apps", - "version": "0.7.2", + "version": "0.7.4", "private": false, "publishConfig": { "access": "public", @@ -30,9 +30,9 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.4", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@material-table/core": "^3.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -67,9 +67,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/core-app-api": "^0.1.19", - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/core-app-api": "^0.1.21", + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx index d6bf990f8a..477057f31b 100644 --- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -17,78 +17,66 @@ 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 { AlertApiForwarder } from '@backstage/core-app-api'; import Observable from 'zen-observable'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } 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' }, - ); - }, + const apis = [ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of( + { message: 'message one' }, + { message: 'message two' }, + { message: 'message three' }, + ); }, - ], - ]); - }); + }, + ] as const, + ] as const; it('renders first message', async () => { const { queryByText } = await renderInTestApp( - + - , + , ); expect(queryByText('message one')).toBeInTheDocument(); @@ -96,9 +84,9 @@ describe('', () => { 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/CopyTextButton/CopyTextButton.test.tsx b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx index b84d3919e8..a9532dd79b 100644 --- a/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx +++ b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx @@ -17,10 +17,9 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { CopyTextButton } from './CopyTextButton'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { errorApiRef, ErrorApi } from '@backstage/core-plugin-api'; +import { errorApiRef } from '@backstage/core-plugin-api'; import { useCopyToClipboard } from 'react-use'; jest.mock('popper.js', () => { @@ -51,22 +50,18 @@ const props = { tooltipText: 'mockTooltip', }; -const apiRegistry = ApiRegistry.from([ - [ - errorApiRef, - { - post: jest.fn(), - error$: jest.fn(), - } as ErrorApi, - ], -]); +const mockErrorApi = { + post: jest.fn(), + error$: jest.fn(), +}; +const apis = [[errorApiRef, mockErrorApi] as const] as const; describe('', () => { it('renders without exploding', async () => { const { getByTitle, queryByText } = await renderInTestApp( - + - , + , ); expect(getByTitle('mockTooltip')).toBeInTheDocument(); expect(queryByText('mockTooltip')).not.toBeInTheDocument(); @@ -80,9 +75,9 @@ describe('', () => { spy.mockReturnValue([{}, copy]); const rendered = await renderInTestApp( - + - , + , ); const button = rendered.getByTitle('mockTooltip'); fireEvent.click(button); @@ -101,10 +96,10 @@ describe('', () => { spy.mockReturnValue([{ error }, jest.fn()]); await renderInTestApp( - + - , + , ); - expect(apiRegistry.get(errorApiRef)?.post).toHaveBeenCalledWith(error); + expect(mockErrorApi.post).toHaveBeenCalledWith(error); }); }); diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx index e0e9a89c9f..5487d2e7b5 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -18,12 +18,13 @@ import React from 'react'; import { DismissableBanner } from './DismissableBanner'; import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; -import { ApiProvider, ApiRegistry, WebStorage } from '@backstage/core-app-api'; +import { WebStorage } from '@backstage/core-app-api'; import { ErrorApi, storageApiRef, StorageApi, } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; export default { title: 'Feedback/DismissableBanner', @@ -37,47 +38,47 @@ const createWebStorage = (): StorageApi => { return WebStorage.create({ errorApi }); }; -const apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]); +const apis = [[storageApiRef, createWebStorage()] as const]; export const Default = () => (
- + - +
); export const Error = () => (
- + - +
); export const EmojisIncluded = () => (
- + - +
); export const WithLink = () => (
- + @@ -90,29 +91,29 @@ export const WithLink = () => ( variant="info" id="linked_dismissable" /> - +
); export const Fixed = () => (
- + - +
); export const Warning = () => (
- + - +
); diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx index ddc119369b..e62b3fa82d 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx @@ -16,13 +16,17 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { DismissableBanner } from './DismissableBanner'; -import { ApiRegistry, ApiProvider, WebStorage } from '@backstage/core-app-api'; +import { ApiProvider, WebStorage } from '@backstage/core-app-api'; import { storageApiRef, StorageApi } from '@backstage/core-plugin-api'; describe('', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; const createWebStorage = (): StorageApi => { return WebStorage.create({ @@ -31,7 +35,7 @@ describe('', () => { }; beforeEach(() => { - apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]); + apis = TestApiRegistry.from([storageApiRef, createWebStorage()]); }); it('renders the message and the popover', async () => { diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx index 40082925c6..92b449f705 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx @@ -80,7 +80,8 @@ const useStyles = makeStyles( backgroundColor: theme.palette.banner.error, }, warning: { - backgroundColor: theme.palette.banner.warning, + backgroundColor: + theme.palette.banner.warning ?? theme.palette.banner.error, }, }), { name: 'BackstageDismissableBanner' }, diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index df1bcfac03..2d00aad827 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -16,8 +16,11 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { isExternalUri, Link } from './Link'; import { Route, Routes } from 'react-router'; @@ -48,11 +51,11 @@ describe('', () => { const { getByText } = render( wrapInTestApp( - + {linkText} - , + , ), ); diff --git a/packages/core-components/src/components/ProgressBars/Gauge.stories.tsx b/packages/core-components/src/components/ProgressBars/Gauge.stories.tsx index 3481c48cef..401f502697 100644 --- a/packages/core-components/src/components/ProgressBars/Gauge.stories.tsx +++ b/packages/core-components/src/components/ProgressBars/Gauge.stories.tsx @@ -53,3 +53,9 @@ export const AbsoluteProgress = () => (
); + +export const StaticColor = () => ( +
+ '#f0f'} value={0.5} /> +
+); diff --git a/packages/core-components/src/components/ProgressBars/Gauge.test.tsx b/packages/core-components/src/components/ProgressBars/Gauge.test.tsx index 066e1233a0..2d9e852e6c 100644 --- a/packages/core-components/src/components/ProgressBars/Gauge.test.tsx +++ b/packages/core-components/src/components/ProgressBars/Gauge.test.tsx @@ -56,16 +56,26 @@ describe('', () => { }; it('colors the progress correctly', () => { - expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd'); - expect(getProgressColor(palette, 10)).toBe(error); - expect(getProgressColor(palette, 50)).toBe(warning); - expect(getProgressColor(palette, 90)).toBe(ok); + expect(getProgressColor({ palette, value: 'Not a Number' as any })).toBe( + '#ddd', + ); + expect(getProgressColor({ palette, value: 10 })).toBe(error); + expect(getProgressColor({ palette, value: 50 })).toBe(warning); + expect(getProgressColor({ palette, value: 90 })).toBe(ok); }); it('colors the inverse progress correctly', () => { - expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd'); - expect(getProgressColor(palette, 10, true)).toBe(ok); - expect(getProgressColor(palette, 50, true)).toBe(warning); - expect(getProgressColor(palette, 90, true)).toBe(error); + expect( + getProgressColor({ + palette, + value: 'Not a Number' as any, + inverse: true, + }), + ).toBe('#ddd'); + expect(getProgressColor({ palette, value: 10, inverse: true })).toBe(ok); + expect(getProgressColor({ palette, value: 50, inverse: true })).toBe( + warning, + ); + expect(getProgressColor({ palette, value: 90, inverse: true })).toBe(error); }); }); diff --git a/packages/core-components/src/components/ProgressBars/Gauge.tsx b/packages/core-components/src/components/ProgressBars/Gauge.tsx index 83e8d05b16..00e7b3257b 100644 --- a/packages/core-components/src/components/ProgressBars/Gauge.tsx +++ b/packages/core-components/src/components/ProgressBars/Gauge.tsx @@ -15,7 +15,7 @@ */ import { makeStyles, useTheme } from '@material-ui/core/styles'; -import { BackstageTheme } from '@backstage/theme'; +import { BackstagePalette, BackstageTheme } from '@backstage/theme'; import { Circle } from 'rc-progress'; import React from 'react'; @@ -46,32 +46,45 @@ const useStyles = makeStyles( { name: 'BackstageGauge' }, ); -type Props = { +/** @public */ +export type GaugeProps = { value: number; fractional?: boolean; inverse?: boolean; unit?: string; max?: number; + getColor?: GaugePropsGetColor; }; -const defaultProps = { +/** @public */ +export type GaugePropsGetColorOptions = { + palette: BackstagePalette; + value: number; + inverse?: boolean; + max?: number; +}; + +/** @public */ +export type GaugePropsGetColor = (args: GaugePropsGetColorOptions) => string; + +const defaultGaugeProps = { fractional: true, inverse: false, unit: '%', max: 100, }; -export function getProgressColor( - palette: BackstageTheme['palette'], - value: number, - inverse?: boolean, - max?: number, -) { +export const getProgressColor: GaugePropsGetColor = ({ + palette, + value, + inverse, + max, +}) => { if (isNaN(value)) { return '#ddd'; } - const actualMax = max ? max : defaultProps.max; + const actualMax = max ? max : defaultGaugeProps.max; const actualValue = inverse ? actualMax - value : value; if (actualValue < actualMax / 3) { @@ -81,14 +94,15 @@ export function getProgressColor( } return palette.status.ok; -} +}; /** @public */ -export function Gauge(props: Props) { +export function Gauge(props: GaugeProps) { + const { getColor = getProgressColor } = props; const classes = useStyles(props); - const theme = useTheme(); + const { palette } = useTheme(); const { value, fractional, inverse, unit, max } = { - ...defaultProps, + ...defaultGaugeProps, ...props, }; @@ -102,7 +116,7 @@ export function Gauge(props: Props) { percent={asPercentage} strokeWidth={12} trailWidth={12} - strokeColor={getProgressColor(theme.palette, asActual, inverse, max)} + strokeColor={getColor({ palette, value: asActual, inverse, max })} className={classes.circle} />
diff --git a/packages/core-components/src/components/ProgressBars/GaugeCard.stories.tsx b/packages/core-components/src/components/ProgressBars/GaugeCard.stories.tsx index ad3323f8d3..64a6cc1b16 100644 --- a/packages/core-components/src/components/ProgressBars/GaugeCard.stories.tsx +++ b/packages/core-components/src/components/ProgressBars/GaugeCard.stories.tsx @@ -97,3 +97,24 @@ export const LinkInFooter = () => ( ); + +export const StaticColor = () => ( + + + '#f00'} title="Red" progress={0.5} /> + + + '#0f0'} title="Green" progress={0.5} /> + + + '#00f'} title="Blue" progress={0.5} /> + + + palette.status.error} + title="palette.status.error" + progress={0.5} + /> + + +); diff --git a/packages/core-components/src/components/ProgressBars/GaugeCard.tsx b/packages/core-components/src/components/ProgressBars/GaugeCard.tsx index fe17de30cd..0fa51b3919 100644 --- a/packages/core-components/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core-components/src/components/ProgressBars/GaugeCard.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { InfoCard, InfoCardVariants } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; -import { Gauge } from './Gauge'; +import { Gauge, GaugePropsGetColor } from './Gauge'; type Props = { title: string; @@ -28,6 +28,7 @@ type Props = { progress: number; inverse?: boolean; deepLink?: BottomLinkProps; + getColor?: GaugePropsGetColor; }; /** @public */ @@ -46,7 +47,14 @@ const useStyles = makeStyles( /** @public */ export function GaugeCard(props: Props) { const classes = useStyles(props); - const { title, subheader, progress, inverse, deepLink, variant } = props; + const { title, subheader, progress, inverse, deepLink, variant, getColor } = + props; + + const gaugeProps = { + inverse, + getColor, + value: progress, + }; return (
@@ -56,7 +64,7 @@ export function GaugeCard(props: Props) { deepLink={deepLink} variant={variant} > - +
); diff --git a/packages/core-components/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/core-components/src/components/ProgressBars/LinearGauge.stories.tsx index ee2ed2eae6..5d36ea9178 100644 --- a/packages/core-components/src/components/ProgressBars/LinearGauge.stories.tsx +++ b/packages/core-components/src/components/ProgressBars/LinearGauge.stories.tsx @@ -41,3 +41,9 @@ export const LowProgress = () => (
); + +export const StaticColor = () => ( +
+ '#f0f'} value={0.5} /> +
+); diff --git a/packages/core-components/src/components/ProgressBars/LinearGauge.tsx b/packages/core-components/src/components/ProgressBars/LinearGauge.tsx index afc93fd337..f1e4fb3334 100644 --- a/packages/core-components/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core-components/src/components/ProgressBars/LinearGauge.tsx @@ -20,18 +20,19 @@ import Tooltip from '@material-ui/core/Tooltip'; // @ts-ignore import { Line } from 'rc-progress'; import { BackstageTheme } from '@backstage/theme'; -import { getProgressColor } from './Gauge'; +import { getProgressColor, GaugePropsGetColor } from './Gauge'; type Props = { /** * Progress value between 0.0 - 1.0. */ value: number; + getColor?: GaugePropsGetColor; }; export function LinearGauge(props: Props) { - const { value } = props; - const theme = useTheme(); + const { value, getColor = getProgressColor } = props; + const { palette } = useTheme(); if (isNaN(value)) { return null; } @@ -39,7 +40,12 @@ export function LinearGauge(props: Props) { if (percent > 100) { percent = 100; } - const strokeColor = getProgressColor(theme.palette, percent, false, 100); + const strokeColor = getColor({ + palette, + value: percent, + inverse: false, + max: 100, + }); return ( diff --git a/packages/core-components/src/components/ProgressBars/index.ts b/packages/core-components/src/components/ProgressBars/index.ts index 3d821eaac2..5e588ea696 100644 --- a/packages/core-components/src/components/ProgressBars/index.ts +++ b/packages/core-components/src/components/ProgressBars/index.ts @@ -17,5 +17,10 @@ export { GaugeCard } from './GaugeCard'; export type { GaugeCardClassKey } from './GaugeCard'; export { Gauge } from './Gauge'; +export type { + GaugeProps, + GaugePropsGetColor, + GaugePropsGetColorOptions, +} from './Gauge'; export type { GaugeClassKey } from './Gauge'; export { LinearGauge } from './LinearGauge'; diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx index 9b33304a95..bbd48c127c 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -31,6 +31,12 @@ const testRoute2 = { children:
tabbed-test-content-2
, }; +const testRoute3 = { + title: 'tabbed-test-title-3', + path: '/some-other-path-similar', + children:
tabbed-test-content-3
, +}; + describe('RoutedTabs', () => { it('renders simplest case', async () => { const rendered = await renderInTestApp( @@ -46,7 +52,7 @@ describe('RoutedTabs', () => { } + element={} /> , ); @@ -61,6 +67,13 @@ describe('RoutedTabs', () => { expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + + const thirdTab = rendered.queryAllByRole('tab')[2]; + act(() => { + fireEvent.click(thirdTab); + }); + expect(rendered.getByText('tabbed-test-title-3')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-3')).toBeInTheDocument(); }); describe('correctly delegates nested links', () => { diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx index 2a65c8ffc8..5debafd3f8 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx @@ -33,9 +33,15 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { element: children, })); - const element = useRoutes(routes) ?? subRoutes[0].children; + // TODO: remove once react-router updated + const sortedRoutes = routes.sort((a, b) => + // remove "/*" symbols from path end before comparing + b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')), + ); - const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? []; + const element = useRoutes(sortedRoutes) ?? subRoutes[0].children; + + const [matchedRoute] = matchRoutes(sortedRoutes, `/${params['*']}`) ?? []; const foundIndex = matchedRoute ? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path) : 0; diff --git a/packages/core-components/src/components/Table/Table.stories.tsx b/packages/core-components/src/components/Table/Table.stories.tsx index 7785764b22..b057d0a12c 100644 --- a/packages/core-components/src/components/Table/Table.stories.tsx +++ b/packages/core-components/src/components/Table/Table.stories.tsx @@ -326,3 +326,49 @@ export const FilterTable = () => {
); }; + +export const StyledTable = () => { + const classes = useStyles(); + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + cellStyle: (_, rowData: any & { tableData: { id: number } }) => { + return rowData.tableData.id % 2 === 0 + ? { + color: '#6CD75F', + } + : { + color: '#DC3D5A', + }; + }, + }, + { + title: 'Column 2', + field: 'col2', + cellStyle: { color: '#2FA5DC' }, + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
+ + + ); +}; diff --git a/packages/core-components/src/components/Table/Table.test.tsx b/packages/core-components/src/components/Table/Table.test.tsx index fb231fde31..12431dea59 100644 --- a/packages/core-components/src/components/Table/Table.test.tsx +++ b/packages/core-components/src/components/Table/Table.test.tsx @@ -18,17 +18,18 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { Table } from './Table'; +const column1 = { + title: 'Column 1', + field: 'col1', +}; + +const column2 = { + title: 'Column 2', + field: 'col2', +}; + const minProps = { - columns: [ - { - title: 'Column 1', - field: 'col1', - }, - { - title: 'Column 2', - field: 'col2', - }, - ], + columns: [column1, column2], data: [ { col1: 'first value, first row', @@ -47,6 +48,100 @@ describe('
', () => { expect(rendered.getByText('second value, second row')).toBeInTheDocument(); }); + describe('with style rows', () => { + describe('with CSS Properties object', () => { + const styledColumn2 = { + ...column2, + cellStyle: { + color: 'blue', + }, + }; + + it('renders non-highlighted correctly', async () => { + const columns = [column1, styledColumn2]; + + const rendered = await renderInTestApp( +
, + ); + expect(rendered.getByText('second value, first row')).toHaveStyle({ + color: 'blue', + }); + }); + + it('renders highlighted column correctly', async () => { + const columns = [ + column1, + { + ...styledColumn2, + highlight: true, + }, + ]; + + const rendered = await renderInTestApp( +
, + ); + expect(rendered.getByText('second value, first row')).toHaveStyle({ + color: 'blue', + 'font-weight': 700, + }); + }); + }); + + describe('with CSS Properties function', () => { + const styledColumn2 = { + ...column2, + cellStyle: ( + _data: any, + rowData: any & { tableData: { id: number } }, + ) => { + return rowData.tableData.id % 2 === 0 + ? { + color: 'green', + } + : { + color: 'red', + }; + }, + }; + + it('renders non-highlighted columns correctly', async () => { + const columns = [column1, styledColumn2]; + + const rendered = await renderInTestApp( +
, + ); + expect(rendered.getByText('second value, first row')).toHaveStyle({ + color: 'green', + }); + expect(rendered.getByText('second value, second row')).toHaveStyle({ + color: 'red', + }); + }); + + it('renders highlighted columns correctly', async () => { + const columns = [ + column1, + { + ...styledColumn2, + highlight: true, + }, + ]; + + const rendered = await renderInTestApp( +
, + ); + expect(rendered.getByText('second value, first row')).toHaveStyle({ + color: 'green', + 'font-weight': 700, + }); + expect(rendered.getByText('second value, second row')).toHaveStyle({ + color: 'red', + 'font-weight': 700, + }); + }); + }); + }); + it('renders with subtitle', async () => { const rendered = await renderInTestApp(
, diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index f433e7cdc5..e90ed04871 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -169,12 +169,26 @@ function convertColumns( ): TableColumn[] { return columns.map(column => { const headerStyle: React.CSSProperties = {}; - const cellStyle: React.CSSProperties = - typeof column.cellStyle === 'object' ? column.cellStyle : {}; + + let cellStyle = column.cellStyle || {}; if (column.highlight) { headerStyle.color = theme.palette.textContrast; - cellStyle.fontWeight = theme.typography.fontWeightBold; + + if (typeof cellStyle === 'object') { + (cellStyle as React.CSSProperties).fontWeight = + theme.typography.fontWeightBold; + } else { + const cellStyleFn = cellStyle as ( + data: any, + rowData: T, + column?: Column, + ) => React.CSSProperties; + cellStyle = (data, rowData, rowColumn) => { + const style = cellStyleFn(data, rowData, rowColumn); + return { ...style, fontWeight: theme.typography.fontWeightBold }; + }; + } } return { diff --git a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx index a1c45bff4d..361ad123bf 100644 --- a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx +++ b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx @@ -20,9 +20,9 @@ import { ErrorBoundary } from './ErrorBoundary'; import { MockErrorApi, renderInTestApp, + TestApiProvider, withLogCollector, } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; type BombProps = { @@ -41,25 +41,25 @@ const Bomb = ({ shouldThrow }: BombProps) => { describe('', () => { it('should render error boundary with and without error', async () => { const { error } = await withLogCollector(['error'], async () => { - const apis = ApiRegistry.with(errorApiRef, new MockErrorApi()); + const errorApi = new MockErrorApi(); const { rerender, queryByRole, getByRole, getByText } = await renderInTestApp( - + - , + , ); expect(queryByRole('alert')).not.toBeInTheDocument(); expect(getByText(/working component/i)).toBeInTheDocument(); rerender( - + - , + , ); expect(getByRole('alert')).toBeInTheDocument(); diff --git a/packages/core-components/src/layout/Header/Header.test.tsx b/packages/core-components/src/layout/Header/Header.test.tsx index 69d46cd102..776e39045c 100644 --- a/packages/core-components/src/layout/Header/Header.test.tsx +++ b/packages/core-components/src/layout/Header/Header.test.tsx @@ -15,13 +15,9 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { Header } from './Header'; -import { - ApiRegistry, - ConfigReader, - ApiProvider, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; jest.mock('react-helmet', () => { @@ -72,14 +68,12 @@ describe('
', () => { }); it('should use app.title', async () => { - const apiRegistry = ApiRegistry.with( - configApiRef, - new ConfigReader({ app: { title: 'Blah' } }), - ); const rendered = await renderInTestApp( - +
, - , + , ); rendered.getAllByText(/Title | Blah/); }); diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 40495ca0e0..76d346031e 100644 --- a/packages/core-components/src/layout/Header/Header.tsx +++ b/packages/core-components/src/layout/Header/Header.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles( gridArea: 'pageHeader', padding: theme.spacing(3), width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + boxShadow: theme.shadows[4], position: 'relative', zIndex: 100, display: 'flex', @@ -65,12 +65,12 @@ const useStyles = makeStyles( title: { color: theme.palette.bursts.fontColor, wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + fontSize: theme.typography.h3.fontSize, marginBottom: 0, }, subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', + color: theme.palette.common.white, + opacity: 0.8, display: 'inline-block', // prevents margin collapse of adjacent siblings marginTop: theme.spacing(1), }, @@ -82,7 +82,6 @@ const useStyles = makeStyles( color: theme.palette.bursts.fontColor, }, breadcrumb: { - fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', color: theme.palette.bursts.fontColor, }, breadcrumbType: { diff --git a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx index 1d4bf6954a..3d9dfe70a1 100644 --- a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx +++ b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx @@ -14,16 +14,12 @@ * limitations under the License. */ -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { HomepageTimer } from './HomepageTimer'; import React from 'react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core/styles'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef } from '@backstage/core-plugin-api'; it('changes default timezone to GMT', async () => { @@ -41,9 +37,9 @@ it('changes default timezone to GMT', async () => { const rendered = await renderWithEffects( - + - + , ); diff --git a/packages/core-components/src/layout/Page/Page.stories.tsx b/packages/core-components/src/layout/Page/Page.stories.tsx index 7386d198a3..12ddd51d04 100644 --- a/packages/core-components/src/layout/Page/Page.stories.tsx +++ b/packages/core-components/src/layout/Page/Page.stories.tsx @@ -20,8 +20,7 @@ import Grid from '@material-ui/core/Grid'; import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import React, { useState } from 'react'; -import { MemoryRouter } from 'react-router'; -import { createApp } from '@backstage/core-app-api'; +import { wrapInTestApp } from '@backstage/test-utils'; import { Content, ContentHeader, @@ -197,53 +196,44 @@ const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => ( ); -const app = createApp({ configLoader: async () => [] }); -const AppProvider = app.getProvider(); - export const PluginWithData = () => { const [selectedTab, setSelectedTab] = useState(2); - return ( - - -
- - - setSelectedTab(index)} - tabs={tabs.map(({ label }, index) => ({ - id: index.toString(), - label, - }))} - /> - - - - - -
-
-
- ); + return wrapInTestApp(() => ( +
+ + + setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + + + + +
+ )); }; export const PluginWithTable = () => { - return ( - -
- - - - -
- - - - - ); + return wrapInTestApp(() => ( +
+ + + + +
+ + + + )); }; diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 883e0395cd..3f15ae4ea6 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -293,11 +293,13 @@ export const SidebarItem = forwardRef((props, ref) => { type SidebarSearchFieldProps = { onSearch: (input: string) => void; to?: string; + icon?: IconComponent; }; export function SidebarSearchField(props: SidebarSearchFieldProps) { const [input, setInput] = useState(''); const classes = useStyles(); + const Icon = props.icon ? props.icon : SearchIcon; const search = () => { props.onSearch(input); @@ -329,7 +331,7 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) { return (
- + ({ overflowY: 'auto', diff --git a/packages/core-components/src/layout/Sidebar/Page.tsx b/packages/core-components/src/layout/Sidebar/Page.tsx index cc50c5af6b..6248c237b2 100644 --- a/packages/core-components/src/layout/Sidebar/Page.tsx +++ b/packages/core-components/src/layout/Sidebar/Page.tsx @@ -49,7 +49,7 @@ export type SidebarPinStateContextType = { export const SidebarPinStateContext = createContext( { - isPinned: false, + isPinned: true, toggleSidebarPinState: () => {}, }, ); diff --git a/packages/core-components/src/layout/Sidebar/localStorage.ts b/packages/core-components/src/layout/Sidebar/localStorage.ts index 0e904ee319..78ec355306 100644 --- a/packages/core-components/src/layout/Sidebar/localStorage.ts +++ b/packages/core-components/src/layout/Sidebar/localStorage.ts @@ -24,10 +24,10 @@ export const LocalStorage = { try { value = JSON.parse( window.localStorage.getItem(LocalStorageKeys.SIDEBAR_PIN_STATE) || - 'false', + 'true', ); } catch { - return false; + return true; } return !!value; }, diff --git a/packages/core-components/src/layout/SignInPage/commonProvider.tsx b/packages/core-components/src/layout/SignInPage/commonProvider.tsx index 103050a2d9..515ae01e4d 100644 --- a/packages/core-components/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/commonProvider.tsx @@ -49,7 +49,9 @@ const Component: ProviderComponent = ({ config, onResult }) => { userId: identity!.id, profile: profile!, getIdToken: () => { - return authApi.getBackstageIdentity().then(i => i!.idToken); + return authApi + .getBackstageIdentity() + .then(i => i!.token ?? i!.idToken); }, signOut: async () => { await authApi.signOut(); diff --git a/packages/core-plugin-api/CHANGELOG.md b/packages/core-plugin-api/CHANGELOG.md index 9d8246f931..a8d35e5a14 100644 --- a/packages/core-plugin-api/CHANGELOG.md +++ b/packages/core-plugin-api/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/core-plugin-api +## 0.2.0 + +### Minor Changes + +- 7e18ed7f29: Removed the unused `UserFlags` type. +- 7df99cdb77: Remove exports of unused types(`RouteOptions` and `RoutePath`). + +### Patch Changes + +- 37ebea2d68: Add deprecation warnings around `title` `icon` and `path` as they are no longer controlled when creating `routeRefs` +- 2dd2a7b2cc: Deprecated the `theme` property on `AppTheme`, replacing it with `Provider`. See https://backstage.io/docs/api/deprecations#app-theme for more details. +- b6a4bacdc4: Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types. + +## 0.1.13 + +### Patch Changes + +- 4a336fd292: Deprecate use of extensions without name. Adds a warning to the developer console to prompt integrators to provide names for extensions. +- 8b4284cd5c: Improve API documentation for @backstage/core-plugin-api +- e059aea7b9: Deprecate unused ApiRef types +- Updated dependencies + - @backstage/theme@0.2.13 + ## 0.1.12 ### Patch Changes diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 88afc0e8e6..27f3480930 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -5,52 +5,43 @@ ```ts /// +import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; +import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api'; import { Observable as Observable_2 } from '@backstage/types'; import { Observer as Observer_2 } from '@backstage/types'; +import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { Subscription as Subscription_2 } from '@backstage/types'; import { SvgIconProps } from '@material-ui/core'; -// Warning: (ae-missing-release-tag) "AlertApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AlertApi = { post(alert: AlertMessage): void; alert$(): Observable_2; }; -// Warning: (ae-missing-release-tag) "alertApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const alertApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "AlertMessage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AlertMessage = { message: string; severity?: 'success' | 'info' | 'warning' | 'error'; }; -// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnalyticsApi = { captureEvent(event: AnalyticsEvent): void; }; -// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const analyticsApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "AnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const AnalyticsContext: ({ attributes, @@ -60,14 +51,10 @@ export const AnalyticsContext: ({ children: ReactNode; }) => JSX.Element; -// Warning: (ae-missing-release-tag) "AnalyticsContextValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnalyticsContextValue = CommonAnalyticsContext & AnyAnalyticsContext; -// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnalyticsEvent = { action: string; @@ -77,15 +64,11 @@ export type AnalyticsEvent = { context: AnalyticsContextValue; }; -// Warning: (ae-missing-release-tag) "AnalyticsEventAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; }; -// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnalyticsTracker = { captureEvent: ( @@ -98,16 +81,12 @@ export type AnalyticsTracker = { ) => void; }; -// Warning: (ae-missing-release-tag) "AnyAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; }; -// Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AnyApiFactory = ApiFactory< unknown, unknown, @@ -116,14 +95,27 @@ export type AnyApiFactory = ApiFactory< } >; -// Warning: (ae-missing-release-tag) "AnyApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AnyApiRef = ApiRef; -// Warning: (ae-missing-release-tag) "ApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type AnyExternalRoutes = { + [name: string]: ExternalRouteRef; +}; + +// @public +export type AnyParams = + | { + [param in string]: string; + } + | undefined; + +// @public +export type AnyRoutes = { + [name: string]: RouteRef | SubRouteRef; +}; + +// @public export type ApiFactory< Api, Impl extends Api, @@ -136,24 +128,24 @@ export type ApiFactory< factory(deps: Deps): Impl; }; -// Warning: (ae-missing-release-tag) "ApiHolder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type ApiHolder = { get(api: ApiRef): T | undefined; }; -// Warning: (ae-missing-release-tag) "ApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type ApiRef = { id: string; T: T; }; -// Warning: (ae-missing-release-tag) "ApiRefsToTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type ApiRefConfig = { + id: string; + description?: string; +}; + +// @public @deprecated export type ApiRefsToTypes< T extends { [key in string]: ApiRef; @@ -162,34 +154,27 @@ export type ApiRefsToTypes< [key in keyof T]: ApiRefType; }; -// Warning: (ae-missing-release-tag) "ApiRefType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated export type ApiRefType = T extends ApiRef ? U : never; -// Warning: (ae-missing-release-tag) "AppComponents" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; + ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType; }; -// Warning: (ae-missing-release-tag) "AppContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AppContext = { - getPlugins(): BackstagePlugin[]; - getSystemIcon(key: string): IconComponent | undefined; + getPlugins(): BackstagePlugin_2[]; + getSystemIcon(key: string): IconComponent_2 | undefined; getComponents(): AppComponents; }; -// Warning: (ae-missing-release-tag) "AppTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AppTheme = { id: string; @@ -197,10 +182,9 @@ export type AppTheme = { variant: 'light' | 'dark'; theme: BackstageTheme; icon?: React.ReactElement; + Provider?(props: { children: ReactNode }): JSX.Element | null; }; -// Warning: (ae-missing-release-tag) "AppThemeApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AppThemeApi = { getInstalledThemes(): AppTheme[]; @@ -209,77 +193,56 @@ export type AppThemeApi = { setActiveThemeId(themeId?: string): void; }; -// Warning: (ae-missing-release-tag) "appThemeApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const appThemeApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "atlassianAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const atlassianAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "attachComponentData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function attachComponentData

( component: ComponentType

, type: string, data: unknown, ): void; -// Warning: (ae-missing-release-tag) "auth0AuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "AuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AuthProvider = { title: string; icon: IconComponent; }; -// Warning: (ae-missing-release-tag) "AuthRequester" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AuthRequester = ( scopes: Set, ) => Promise; -// Warning: (ae-missing-release-tag) "AuthRequesterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AuthRequesterOptions = { provider: AuthProvider; onAuthRequest(scopes: Set): Promise; }; -// Warning: (ae-missing-release-tag) "AuthRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AuthRequestOptions = { optional?: boolean; instantPopup?: boolean; }; -// Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type BackstageIdentity = { id: string; idToken: string; token: string; }; -// Warning: (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration -// Warning: (ae-missing-release-tag) "BackstageIdentityApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type BackstageIdentityApi = { getBackstageIdentity( @@ -287,11 +250,7 @@ export type BackstageIdentityApi = { ): Promise; }; -// Warning: (ae-forgotten-export) The symbol "AnyRoutes" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "AnyExternalRoutes" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "BackstagePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type BackstagePlugin< Routes extends AnyRoutes = {}, ExternalRoutes extends AnyExternalRoutes = {}, @@ -304,23 +263,17 @@ export type BackstagePlugin< externalRoutes: ExternalRoutes; }; -// Warning: (ae-missing-release-tag) "bitbucketAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "BootErrorPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; error: Error; }; -// Warning: (ae-missing-release-tag) "CommonAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type CommonAnalyticsContext = { pluginId: string; @@ -328,19 +281,21 @@ export type CommonAnalyticsContext = { extension: string; }; -// Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + // @public export type ConfigApi = Config; -// Warning: (ae-missing-release-tag) "configApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const configApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "createApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "createApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function createApiFactory< Api, @@ -350,29 +305,21 @@ export function createApiFactory< }, >(factory: ApiFactory): ApiFactory; -// @public (undocumented) +// @public export function createApiFactory( api: ApiRef, instance: Impl, ): ApiFactory; -// Warning: (ae-forgotten-export) The symbol "ApiRefConfig" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createApiRef(config: ApiRefConfig): ApiRef; -// Warning: (ae-missing-release-tag) "createComponentExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createComponentExtension< T extends (props: any) => JSX.Element | null, >(options: { component: ComponentLoader; name?: string }): Extension; -// Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createExternalRouteRef< Params extends { [param in ParamKey]: string; @@ -385,9 +332,7 @@ export function createExternalRouteRef< optional?: Optional; }): ExternalRouteRef, Optional>; -// Warning: (ae-missing-release-tag) "createPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createPlugin< Routes extends AnyRoutes = {}, ExternalRoutes extends AnyExternalRoutes = {}, @@ -395,9 +340,7 @@ export function createPlugin< config: PluginConfig, ): BackstagePlugin; -// Warning: (ae-missing-release-tag) "createReactExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createReactExtension< T extends (props: any) => JSX.Element | null, >(options: { @@ -406,9 +349,7 @@ export function createReactExtension< name?: string; }): Extension; -// Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createRoutableExtension< T extends (props: any) => JSX.Element | null, >(options: { @@ -417,9 +358,7 @@ export function createRoutableExtension< name?: string; }): Extension; -// Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createRouteRef< Params extends { [param in ParamKey]: string; @@ -433,12 +372,7 @@ export function createRouteRef< title?: string; }): RouteRef>; -// Warning: (ae-forgotten-export) The symbol "AnyParams" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "MakeSubRouteRef" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "PathParams" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createSubRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createSubRouteRef< Path extends string, ParentParams extends AnyParams = never, @@ -448,20 +382,14 @@ export function createSubRouteRef< parent: RouteRef; }): MakeSubRouteRef, ParentParams>; -// Warning: (ae-missing-release-tag) "DiscoveryApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type DiscoveryApi = { getBaseUrl(pluginId: string): Promise; }; -// Warning: (ae-missing-release-tag) "discoveryApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const discoveryApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "ElementCollection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface ElementCollection { findComponentData(query: { key: string }): T[]; @@ -476,48 +404,50 @@ export interface ElementCollection { }): ElementCollection; } -// Warning: (ae-missing-release-tag) "ErrorApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public @deprecated (undocumented) +type Error_2 = ErrorApiError; +export { Error_2 as Error }; + // @public export type ErrorApi = { - post(error: Error_2, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; error$(): Observable_2<{ - error: Error_2; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; }; -// Warning: (ae-missing-release-tag) "errorApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type ErrorApiError = { + name: string; + message: string; + stack?: string; +}; + +// @public +export type ErrorApiErrorContext = { + hidden?: boolean; +}; + +// @public export const errorApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "ErrorBoundaryFallbackProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type ErrorBoundaryFallbackProps = { - plugin?: BackstagePlugin; + plugin?: BackstagePlugin_2; error: Error; resetError: () => void; }; -// Warning: (ae-missing-release-tag) "ErrorContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export type ErrorContext = { - hidden?: boolean; -}; +// @public @deprecated (undocumented) +export type ErrorContext = ErrorApiErrorContext; -// Warning: (ae-missing-release-tag) "Extension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type Extension = { expose(plugin: BackstagePlugin): T; }; -// Warning: (ae-missing-release-tag) "ExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type ExternalRouteRef< Params extends AnyParams = any, Optional extends boolean = any, @@ -527,25 +457,19 @@ export type ExternalRouteRef< optional?: Optional; }; -// Warning: (ae-missing-release-tag) "FeatureFlag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type FeatureFlag = { name: string; pluginId: string; }; -// Warning: (ae-missing-release-tag) "FeatureFlagOutput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type FeatureFlagOutput = { type: 'feature-flag'; name: string; }; -// Warning: (ae-missing-release-tag) "FeatureFlagsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface FeatureFlagsApi { getRegisteredFlags(): FeatureFlag[]; isActive(name: string): boolean; @@ -553,60 +477,42 @@ export interface FeatureFlagsApi { save(options: FeatureFlagsSaveOptions): void; } -// Warning: (ae-missing-release-tag) "featureFlagsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const featureFlagsApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "FeatureFlagsHooks" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type FeatureFlagsHooks = { register(name: string): void; }; -// Warning: (ae-missing-release-tag) "FeatureFlagsSaveOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type FeatureFlagsSaveOptions = { states: Record; merge?: boolean; }; -// Warning: (ae-missing-release-tag) "FeatureFlagState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export enum FeatureFlagState { - // (undocumented) Active = 1, - // (undocumented) None = 0, } -// Warning: (ae-missing-release-tag) "getComponentData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function getComponentData( node: ReactNode, type: string, ): T | undefined; -// Warning: (ae-missing-release-tag) "githubAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "gitlabAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "googleAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const googleAuthApiRef: ApiRef< OAuthApi & @@ -616,15 +522,11 @@ export const googleAuthApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "IconComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type IconComponent = ComponentType<{ fontSize?: 'default' | 'small' | 'large'; }>; -// Warning: (ae-missing-release-tag) "IdentityApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type IdentityApi = { getUserId(): string; @@ -633,13 +535,27 @@ export type IdentityApi = { signOut(): Promise; }; -// Warning: (ae-missing-release-tag) "identityApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const identityApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "microsoftAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export type MakeSubRouteRef< + Params extends { + [param in string]: string; + }, + ParentParams extends AnyParams, +> = keyof Params & keyof ParentParams extends never + ? SubRouteRef>> + : never; + +// @public +export type MergeParams< + P1 extends { + [param in string]: string; + }, + P2 extends AnyParams, +> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); + // @public export const microsoftAuthApiRef: ApiRef< OAuthApi & @@ -649,8 +565,6 @@ export const microsoftAuthApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "oauth2ApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const oauth2ApiRef: ApiRef< OAuthApi & @@ -660,8 +574,6 @@ export const oauth2ApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "OAuthApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type OAuthApi = { getAccessToken( @@ -670,8 +582,6 @@ export type OAuthApi = { ): Promise; }; -// Warning: (ae-missing-release-tag) "OAuthRequestApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type OAuthRequestApi = { createAuthRequester( @@ -680,13 +590,9 @@ export type OAuthRequestApi = { authRequest$(): Observable_2; }; -// Warning: (ae-missing-release-tag) "oauthRequestApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const oauthRequestApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "OAuthScope" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type OAuthScope = string | string[]; @@ -696,8 +602,6 @@ export type Observable = Observable_2; // @public @deprecated export type Observer = Observer_2; -// Warning: (ae-missing-release-tag) "oidcAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const oidcAuthApiRef: ApiRef< OAuthApi & @@ -707,8 +611,6 @@ export const oidcAuthApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "oktaAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const oktaAuthApiRef: ApiRef< OAuthApi & @@ -718,9 +620,10 @@ export const oktaAuthApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "oneloginAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type OldIconComponent = ComponentType; + +// @public export const oneloginAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -729,15 +632,39 @@ export const oneloginAuthApiRef: ApiRef< SessionApi >; -// Warning: (ae-missing-release-tag) "OpenIdConnectApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type OpenIdConnectApi = { getIdToken(options?: AuthRequestOptions): Promise; }; -// Warning: (ae-missing-release-tag) "PendingAuthRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export type OptionalParams< + Params extends { + [param in string]: string; + }, +> = Params[keyof Params] extends never ? undefined : Params; + +// @public +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; + +// @public +export type ParamNames = + S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; + +// @public +export type ParamPart = S extends `:${infer Param}` + ? Param + : never; + +// @public +export type PathParams = { + [name in ParamNames]: string; +}; + // @public export type PendingAuthRequest = { provider: AuthProvider; @@ -745,9 +672,7 @@ export type PendingAuthRequest = { trigger(): Promise; }; -// Warning: (ae-missing-release-tag) "PluginConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type PluginConfig< Routes extends AnyRoutes, ExternalRoutes extends AnyExternalRoutes, @@ -759,20 +684,14 @@ export type PluginConfig< externalRoutes?: ExternalRoutes; }; -// Warning: (ae-missing-release-tag) "PluginHooks" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type PluginHooks = { featureFlags: FeatureFlagsHooks; }; -// Warning: (ae-missing-release-tag) "PluginOutput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type PluginOutput = FeatureFlagOutput; -// Warning: (ae-missing-release-tag) "ProfileInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ProfileInfo = { email?: string; @@ -780,28 +699,17 @@ export type ProfileInfo = { picture?: string; }; -// Warning: (ae-missing-release-tag) "ProfileInfoApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ProfileInfoApi = { getProfile(options?: AuthRequestOptions): Promise; }; -// Warning: (ae-missing-release-tag) "RouteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type RouteOptions = { - exact?: boolean; -}; +// @public +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; -// Warning: (ae-missing-release-tag) "RoutePath" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type RoutePath = string; - -// Warning: (ae-missing-release-tag) "RouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type RouteRef = { $$routeRefType: 'absolute'; params: ParamKeys; @@ -810,15 +718,11 @@ export type RouteRef = { title?: string; }; -// Warning: (ae-missing-release-tag) "samlAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi >; -// Warning: (ae-missing-release-tag) "SessionApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type SessionApi = { signIn(): Promise; @@ -826,73 +730,44 @@ export type SessionApi = { sessionState$(): Observable_2; }; -// Warning: (ae-missing-release-tag) "SessionState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export enum SessionState { - // (undocumented) SignedIn = 'SignedIn', - // (undocumented) SignedOut = 'SignedOut', } -// Warning: (ae-missing-release-tag) "SignInPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SignInPageProps = { onResult(result: SignInResult): void; }; -// Warning: (ae-missing-release-tag) "SignInResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SignInResult = { userId: string; - profile: ProfileInfo; + profile: ProfileInfo_2; getIdToken?: () => Promise; signOut?: () => Promise; }; -// Warning: (ae-missing-release-tag) "StorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface StorageApi { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' forBucket(name: string): StorageApi; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag - // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration get(key: string): T | undefined; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' observe$(key: string): Observable_2>; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' remove(key: string): Promise; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' set(key: string, data: any): Promise; } -// Warning: (ae-missing-release-tag) "storageApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const storageApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "StorageValueChange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type StorageValueChange = { key: string; newValue?: T; }; -// Warning: (ae-missing-release-tag) "SubRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SubRouteRef = { $$routeRefType: 'sub'; parent: RouteRef; @@ -903,35 +778,23 @@ export type SubRouteRef = { // @public @deprecated export type Subscription = Subscription_2; -// Warning: (ae-missing-release-tag) "TypesToApiRefs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type TypesToApiRefs = { [key in keyof T]: ApiRef; }; -// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function useAnalytics(): AnalyticsTracker; -// Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function useApi(apiRef: ApiRef): T; -// Warning: (ae-missing-release-tag) "useApiHolder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function useApiHolder(): ApiHolder; -// Warning: (ae-missing-release-tag) "useApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const useApp: () => AppContext; -// Warning: (ae-missing-release-tag) "useElementFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function useElementFilter( node: ReactNode, @@ -939,51 +802,26 @@ export function useElementFilter( dependencies?: any[], ): T; -// Warning: (ae-missing-release-tag) "UserFlags" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type UserFlags = {}; - -// Warning: (ae-forgotten-export) The symbol "RouteFunc" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "useRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "useRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function useRouteRef( routeRef: ExternalRouteRef, ): Optional extends true ? RouteFunc | undefined : RouteFunc; -// @public (undocumented) +// @public export function useRouteRef( routeRef: RouteRef | SubRouteRef, ): RouteFunc; -// Warning: (ae-missing-release-tag) "useRouteRefParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; -// Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function withApis(apis: TypesToApiRefs):

( WrappedComponent: React_2.ComponentType

, ) => { (props: React_2.PropsWithChildren>): JSX.Element; displayName: string; }; - -// Warnings were encountered during analysis: -// -// src/apis/definitions/ErrorApi.d.ts:37:5 - (ae-forgotten-export) The symbol "Error" needs to be exported by the entry point index.d.ts -// src/apis/definitions/auth.d.ts:29:8 - (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration -// src/apis/definitions/auth.d.ts:38:8 - (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration -// src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration -// src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration -// src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration -// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts -// src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts -// src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 23bf0fc8b0..e5b4572dff 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-plugin-api", "description": "Core API used by Backstage plugins", - "version": "0.1.12", + "version": "0.2.0", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -43,9 +43,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.21", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index adbd9be6f3..bb2141d47f 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -27,7 +27,8 @@ const AnalyticsReactContext = /** * A "private" (to this package) hook that enables context inheritance and a * way to read Analytics Context values at event capture-time. - * @private + * + * @internal */ export const useAnalyticsContext = (): AnalyticsContextValue => { const theContext = useContext(AnalyticsReactContext); @@ -54,8 +55,12 @@ export const useAnalyticsContext = (): AnalyticsContextValue => { * Provides components in the child react tree an Analytics Context, ensuring * all analytics events captured within the context have relevant attributes. * + * @remarks + * * Analytics contexts are additive, meaning the context ultimately emitted with * an event is the combination of all contexts in the parent tree. + * + * @public */ export const AnalyticsContext = ({ attributes, @@ -84,6 +89,7 @@ export const AnalyticsContext = ({ * * @param Component - Component to be wrapped with analytics context attributes * @param values - Analytics context key/value pairs. + * @internal */ export function withAnalyticsContext

( Component: React.ComponentType

, diff --git a/packages/core-plugin-api/src/analytics/types.ts b/packages/core-plugin-api/src/analytics/types.ts index ea6c3b030b..d001c38d3e 100644 --- a/packages/core-plugin-api/src/analytics/types.ts +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -16,6 +16,8 @@ /** * Common analytics context attributes. + * + * @public */ export type CommonAnalyticsContext = { /** @@ -35,7 +37,9 @@ export type CommonAnalyticsContext = { }; /** - * Allow arbitrary scalar values as context attributes too. + * Allows arbitrary scalar values as context attributes too. + * + * @public */ export type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; @@ -43,6 +47,8 @@ export type AnyAnalyticsContext = { /** * Analytics context envelope. + * + * @public */ export type AnalyticsContextValue = CommonAnalyticsContext & AnyAnalyticsContext; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index 02d1ecc3c3..5a047e5a63 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -33,7 +33,9 @@ function useAnalyticsApi(): AnalyticsApi { } /** - * Get a pre-configured analytics tracker. + * Gets a pre-configured analytics tracker. + * + * @public */ export function useAnalytics(): AnalyticsTracker { const trackerRef = useRef(null); diff --git a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts index 7110c1d9f8..0c84f7c975 100644 --- a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts @@ -17,6 +17,11 @@ import { createApiRef, ApiRef } from '../system'; import { Observable } from '@backstage/types'; +/** + * Message handled by the {@link AlertApi}. + * + * @public + */ export type AlertMessage = { message: string; // Severity will default to success since that is what material ui defaults the value to. @@ -25,8 +30,9 @@ export type AlertMessage = { /** * The alert API is used to report alerts to the app, and display them to the user. + * + * @public */ - export type AlertApi = { /** * Post an alert for handling by the application. @@ -39,6 +45,11 @@ export type AlertApi = { alert$(): Observable; }; +/** + * The {@link ApiRef} of {@link AlertApi}. + * + * @public + */ export const alertApiRef: ApiRef = createApiRef({ id: 'core.alert', }); diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts index 43e35607a3..ef190d6c21 100644 --- a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -20,6 +20,8 @@ import { AnalyticsContextValue } from '../../analytics/types'; /** * Represents an event worth tracking in an analytics system that could inform * how users of a Backstage instance are using its features. + * + * @public */ export type AnalyticsEvent = { /** @@ -76,6 +78,8 @@ export type AnalyticsEvent = { /** * A structure allowing other arbitrary metadata to be provided by analytics * event emitters. + * + * @public */ export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; @@ -84,6 +88,8 @@ export type AnalyticsEventAttributes = { /** * Represents a tracker with methods that can be called to track events in a * configured analytics service. + * + * @public */ export type AnalyticsTracker = { captureEvent: ( @@ -99,9 +105,13 @@ export type AnalyticsTracker = { /** * The Analytics API is used to track user behavior in a Backstage instance. * + * @remarks + * * To instrument your App or Plugin, retrieve an analytics tracker using the * useAnalytics() hook. This will return a pre-configured AnalyticsTracker * with relevant methods for instrumentation. + * + * @public */ export type AnalyticsApi = { /** @@ -111,6 +121,11 @@ export type AnalyticsApi = { captureEvent(event: AnalyticsEvent): void; }; +/** + * The {@link ApiRef} of {@link AnalyticsApi}. + * + * @public + */ export const analyticsApiRef: ApiRef = createApiRef({ id: 'core.analytics', }); diff --git a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts index 5ac16f83fd..8053c449a1 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -14,12 +14,15 @@ * limitations under the License. */ +import { ReactNode } from 'react'; import { ApiRef, createApiRef } from '../system'; import { BackstageTheme } from '@backstage/theme'; import { Observable } from '@backstage/types'; /** * Describes a theme provided by the app. + * + * @public */ export type AppTheme = { /** @@ -39,6 +42,7 @@ export type AppTheme = { /** * The specialized MaterialUI theme instance. + * @deprecated use Provider instead, see https://backstage.io/docs/api/deprecations#app-theme */ theme: BackstageTheme; @@ -46,11 +50,15 @@ export type AppTheme = { * An Icon for the theme mode setting. */ icon?: React.ReactElement; + + Provider?(props: { children: ReactNode }): JSX.Element | null; }; /** * The AppThemeApi gives access to the current app theme, and allows switching * to other options that have been registered as a part of the App. + * + * @public */ export type AppThemeApi = { /** @@ -76,6 +84,11 @@ export type AppThemeApi = { setActiveThemeId(themeId?: string): void; }; +/** + * The {@link ApiRef} of {@link AppThemeApi}. + * + * @public + */ export const appThemeApiRef: ApiRef = createApiRef({ id: 'core.apptheme', }); diff --git a/packages/core-plugin-api/src/apis/definitions/ConfigApi.ts b/packages/core-plugin-api/src/apis/definitions/ConfigApi.ts index 08b9f91f57..d3bada9e46 100644 --- a/packages/core-plugin-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ConfigApi.ts @@ -19,9 +19,16 @@ import { Config } from '@backstage/config'; /** * The Config API is used to provide a mechanism to access the * runtime configuration of the system. + * + * @public */ export type ConfigApi = Config; +/** + * The {@link ApiRef} of {@link ConfigApi}. + * + * @public + */ export const configApiRef: ApiRef = createApiRef({ id: 'core.config', }); diff --git a/packages/core-plugin-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-plugin-api/src/apis/definitions/DiscoveryApi.ts index 7082cda9dc..d23fe3db6c 100644 --- a/packages/core-plugin-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/DiscoveryApi.ts @@ -19,6 +19,8 @@ import { ApiRef, createApiRef } from '../system'; * The discovery API is used to provide a mechanism for plugins to * discover the endpoint to use to talk to their backend counterpart. * + * @remarks + * * The purpose of the discovery API is to allow for many different deployment * setups and routing methods through a central configuration, instead * of letting each individual plugin manage that configuration. @@ -26,6 +28,8 @@ import { ApiRef, createApiRef } from '../system'; * Implementations of the discovery API can be a simple as a URL pattern * using the pluginId, but could also have overrides for individual plugins, * or query a separate discovery service. + * + * @public */ export type DiscoveryApi = { /** @@ -41,6 +45,11 @@ export type DiscoveryApi = { getBaseUrl(pluginId: string): Promise; }; +/** + * The {@link ApiRef} of {@link DiscoveryApi}. + * + * @public + */ export const discoveryApiRef: ApiRef = createApiRef({ id: 'core.discovery', }); diff --git a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts index 69c331bc06..45ee0901c8 100644 --- a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts @@ -20,24 +20,42 @@ import { Observable } from '@backstage/types'; /** * Mirrors the JavaScript Error class, for the purpose of * providing documentation and optional fields. + * + * @public */ -type Error = { +export type ErrorApiError = { name: string; message: string; stack?: string; }; /** - * Provides additional information about an error that was posted to the application. + * @public + * @deprecated Use ErrorApiError instead */ -export type ErrorContext = { +export type Error = ErrorApiError; + +/** + * Provides additional information about an error that was posted to the application. + * + * @public + */ +export type ErrorApiErrorContext = { // If set to true, this error should not be displayed to the user. Defaults to false. hidden?: boolean; }; +/** + * @public + * @deprecated Use ErrorApiErrorContext instead + */ +export type ErrorContext = ErrorApiErrorContext; + /** * The error API is used to report errors to the app, and display them to the user. * + * @remarks + * * Plugins can use this API as a method of displaying errors to the user, but also * to report errors for collection by error reporting services. * @@ -49,19 +67,29 @@ export type ErrorContext = { * if it would be useful to collect or log it for debugging purposes, but with * the hidden flag set. For example, an error arising from form field validation * should probably not be reported, while a failed REST call would be useful to report. + * + * @public */ export type ErrorApi = { /** * Post an error for handling by the application. */ - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; /** * Observe errors posted by other parts of the application. */ - error$(): Observable<{ error: Error; context?: ErrorContext }>; + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }>; }; +/** + * The {@link ApiRef} of {@link ErrorApi}. + * + * @public + */ export const errorApiRef: ApiRef = createApiRef({ id: 'core.error', }); diff --git a/packages/core-plugin-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-plugin-api/src/apis/definitions/FeatureFlagsApi.ts index 07797bf3f1..ac43bbebe8 100644 --- a/packages/core-plugin-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/FeatureFlagsApi.ts @@ -17,29 +17,35 @@ import { ApiRef, createApiRef } from '../system'; /** - * The feature flags API is used to toggle functionality to users across plugins and Backstage. + * Fetaure flag descriptor. * - * Plugins can use this API to register feature flags that they have available - * for users to enable/disable, and this API will centralize the current user's - * state of which feature flags they would like to enable. - * - * This is ideal for Backstage plugins, as well as your own App, to trial incomplete - * or unstable upcoming features. Although there will be a common interface for users - * to enable and disable feature flags, this API acts as another way to enable/disable. + * @public */ - export type FeatureFlag = { name: string; pluginId: string; }; +/** + * Enum representing the state of a feature flag (inactive/active). + * + * @public + */ export enum FeatureFlagState { + /** + * Feature flag inactive (disabled). + */ None = 0, + /** + * Feature flag active (enabled). + */ Active = 1, } /** * Options to use when saving feature flags. + * + * @public */ export type FeatureFlagsSaveOptions = { /** @@ -55,8 +61,21 @@ export type FeatureFlagsSaveOptions = { merge?: boolean; }; -export type UserFlags = {}; - +/** + * The feature flags API is used to toggle functionality to users across plugins and Backstage. + * + * @remarks + * + * Plugins can use this API to register feature flags that they have available + * for users to enable/disable, and this API will centralize the current user's + * state of which feature flags they would like to enable. + * + * This is ideal for Backstage plugins, as well as your own App, to trial incomplete + * or unstable upcoming features. Although there will be a common interface for users + * to enable and disable feature flags, this API acts as another way to enable/disable. + * + * @public + */ export interface FeatureFlagsApi { /** * Registers a new feature flag. Once a feature flag has been registered it @@ -80,6 +99,11 @@ export interface FeatureFlagsApi { save(options: FeatureFlagsSaveOptions): void; } +/** + * The {@link ApiRef} of {@link FeatureFlagsApi}. + * + * @public + */ export const featureFlagsApiRef: ApiRef = createApiRef({ id: 'core.featureflags', }); diff --git a/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts b/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts index 9d7131fed6..9f68a8b2cc 100644 --- a/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/IdentityApi.ts @@ -18,6 +18,8 @@ import { ProfileInfo } from './auth'; /** * The Identity API used to identify and get information about the signed in user. + * + * @public */ export type IdentityApi = { /** @@ -49,6 +51,11 @@ export type IdentityApi = { signOut(): Promise; }; +/** + * The {@link ApiRef} of {@link IdentityApi}. + * + * @public + */ export const identityApiRef: ApiRef = createApiRef({ id: 'core.identity', }); diff --git a/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts index 14609f5b3a..03e129ef6a 100644 --- a/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -21,8 +21,12 @@ import { ApiRef, createApiRef } from '../system'; /** * Information about the auth provider that we're requesting a login towards. * + * @remarks + * * This should be shown to the user so that they can be informed about what login is being requested * before a popup is shown. + * + * @public */ export type AuthProvider = { /** @@ -39,6 +43,8 @@ export type AuthProvider = { /** * Describes how to handle auth requests. Both how to show them to the user, and what to do when * the user accesses the auth request. + * + * @public */ export type AuthRequesterOptions = { /** @@ -56,12 +62,16 @@ export type AuthRequesterOptions = { /** * Function used to trigger new auth requests for a set of scopes. * + * @remarks + * * The returned promise will resolve to the same value returned by the onAuthRequest in the - * AuthRequesterOptions. Or rejected, if the request is rejected. + * {@link AuthRequesterOptions}. Or rejected, if the request is rejected. * * This function can be called multiple times before the promise resolves. All calls * will be merged into one request, and the scopes forwarded to the onAuthRequest will be the * union of all requested scopes. + * + * @public */ export type AuthRequester = ( scopes: Set, @@ -71,8 +81,12 @@ export type AuthRequester = ( * An pending auth request for a single auth provider. The request will remain in this pending * state until either reject() or trigger() is called. * + * @remarks + * * Any new requests for the same provider are merged into the existing pending request, meaning * there will only ever be a single pending request for a given provider. + * + * @public */ export type PendingAuthRequest = { /** @@ -95,6 +109,8 @@ export type PendingAuthRequest = { /** * Provides helpers for implemented OAuth login flows within Backstage. + * + * @public */ export type OAuthRequestApi = { /** @@ -127,6 +143,11 @@ export type OAuthRequestApi = { authRequest$(): Observable; }; +/** + * The {@link ApiRef} of {@link OAuthRequestApi}. + * + * @public + */ export const oauthRequestApiRef: ApiRef = createApiRef({ id: 'core.oauthrequest', }); diff --git a/packages/core-plugin-api/src/apis/definitions/StorageApi.ts b/packages/core-plugin-api/src/apis/definitions/StorageApi.ts index f9a52a0bf4..73a19a1ceb 100644 --- a/packages/core-plugin-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/StorageApi.ts @@ -17,48 +17,60 @@ import { ApiRef, createApiRef } from '../system'; import { Observable } from '@backstage/types'; +/** + * Describes a value change event. + * + * @public + */ export type StorageValueChange = { key: string; newValue?: T; }; +/** + * Provides key-value persistence API. + * + * @public + */ export interface StorageApi { /** * Create a bucket to store data in. - * @param {String} name Namespace for the storage to be stored under, + * @param name - Namespace for the storage to be stored under, * will inherit previous namespaces too */ forBucket(name: string): StorageApi; /** * Get the current value for persistent data, use observe$ to be notified of updates. - * - * @param {String} key Unique key associated with the data. - * @return {Object} data The data that should is stored. + * @param key - Unique key associated with the data. */ get(key: string): T | undefined; /** * Remove persistent data. - * - * @param {String} key Unique key associated with the data. + * @param key - Unique key associated with the data. */ remove(key: string): Promise; /** * Save persistent data, and emit messages to anyone that is using observe$ for this key - * - * @param {String} key Unique key associated with the data. + * @param key - Unique key associated with the data. + * @param data - The data to be stored under the key. */ set(key: string, data: any): Promise; /** * Observe changes on a particular key in the bucket - * @param {String} key Unique key associated with the data + * @param key - Unique key associated with the data */ observe$(key: string): Observable>; } +/** + * The {@link ApiRef} of {@link StorageApi}. + * + * @public + */ export const storageApiRef: ApiRef = createApiRef({ id: 'core.storage', }); diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index 41c918fa6c..8fe532f3a6 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -32,11 +32,20 @@ import { Observable } from '@backstage/types'; * An array of scopes, or a scope string formatted according to the * auth provider, which is typically a space separated list. * + * @remarks + * * See the documentation for each auth provider for the list of scopes * supported by each provider. + * + * @public */ export type OAuthScope = string | string[]; +/** + * Configuration of an authentication request. + * + * @public + */ export type AuthRequestOptions = { /** * If this is set to true, the user will not be prompted to log in, @@ -45,7 +54,7 @@ export type AuthRequestOptions = { * This can be used to perform a check whether the user is logged in, or if you don't * want to force a user to be logged in, but provide functionality if they already are. * - * @default false + * @defaultValue false */ optional?: boolean; @@ -55,7 +64,7 @@ export type AuthRequestOptions = { * * The method must be called synchronously from a user action for this to work in all browsers. * - * @default false + * @defaultValue false */ instantPopup?: boolean; }; @@ -63,6 +72,8 @@ export type AuthRequestOptions = { /** * This API provides access to OAuth 2 credentials. It lets you request access tokens, * which can be used to act on behalf of the user when talking to APIs. + * + * @public */ export type OAuthApi = { /** @@ -95,6 +106,8 @@ export type OAuthApi = { /** * This API provides access to OpenID Connect credentials. It lets you request ID tokens, * which can be passed to backend services to prove the user's identity. + * + * @public */ export type OpenIdConnectApi = { /** @@ -113,13 +126,15 @@ export type OpenIdConnectApi = { /** * This API provides access to profile information of the user from an auth provider. + * + * @public */ export type ProfileInfoApi = { /** * Get profile information for the user as supplied by this auth provider. * * If the optional flag is not set, a session is guaranteed to be returned, while if - * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + * the optional flag is set, the session may be undefined. See {@link AuthRequestOptions} for more details. */ getProfile(options?: AuthRequestOptions): Promise; }; @@ -127,23 +142,32 @@ export type ProfileInfoApi = { /** * This API provides access to the user's identity within Backstage. * + * @remarks + * * An auth provider that implements this interface can be used to sign-in to backstage. It is * not intended to be used directly from a plugin, but instead serves as a connection between - * this authentication method and the app's @IdentityApi + * this authentication method and the app's {@link IdentityApi} + * + * @public */ export type BackstageIdentityApi = { /** * Get the user's identity within Backstage. This should normally not be called directly, - * use the @IdentityApi instead. + * use the {@link IdentityApi} instead. * * If the optional flag is not set, a session is guaranteed to be returned, while if - * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + * the optional flag is set, the session may be undefined. See {@link AuthRequestOptions} for more details. */ getBackstageIdentity( options?: AuthRequestOptions, ): Promise; }; +/** + * A (user id, token) pair. + * + * @public + */ export type BackstageIdentity = { /** * The backstage user ID. @@ -163,6 +187,8 @@ export type BackstageIdentity = { /** * Profile information of the user. + * + * @public */ export type ProfileInfo = { /** @@ -183,14 +209,24 @@ export type ProfileInfo = { /** * Session state values passed to subscribers of the SessionApi. + * + * @public */ export enum SessionState { + /** + * User signed in. + */ SignedIn = 'SignedIn', + /** + * User not signed in. + */ SignedOut = 'SignedOut', } /** * The SessionApi provides basic controls for any auth provider that is tied to a persistent session. + * + * @public */ export type SessionApi = { /** @@ -212,10 +248,14 @@ export type SessionApi = { /** * Provides authentication towards Google APIs and identities. * - * See https://developers.google.com/identity/protocols/googlescopes for a full list of supported scopes. + * @remarks + * + * See {@link https://developers.google.com/identity/protocols/googlescopes} for a full list of supported scopes. * * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. + * + * @public */ export const googleAuthApiRef: ApiRef< OAuthApi & @@ -230,8 +270,12 @@ export const googleAuthApiRef: ApiRef< /** * Provides authentication towards GitHub APIs. * - * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ + * @remarks + * + * See {@link https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/} * for a full list of supported scopes. + * + * @public */ export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -242,8 +286,12 @@ export const githubAuthApiRef: ApiRef< /** * Provides authentication towards Okta APIs. * - * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ + * @remarks + * + * See {@link https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/} * for a full list of supported scopes. + * + * @public */ export const oktaAuthApiRef: ApiRef< OAuthApi & @@ -258,8 +306,12 @@ export const oktaAuthApiRef: ApiRef< /** * Provides authentication towards GitLab APIs. * - * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token + * @remarks + * + * See {@link https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token} * for a full list of supported scopes. + * + * @public */ export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -270,8 +322,12 @@ export const gitlabAuthApiRef: ApiRef< /** * Provides authentication towards Auth0 APIs. * - * See https://auth0.com/docs/scopes/current/oidc-scopes + * @remarks + * + * See {@link https://auth0.com/docs/scopes/current/oidc-scopes} * for a full list of supported scopes. + * + * @public */ export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -282,9 +338,13 @@ export const auth0AuthApiRef: ApiRef< /** * Provides authentication towards Microsoft APIs and identities. * + * @remarks + * * For more info and a full list of supported scopes, see: - * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent - * - https://docs.microsoft.com/en-us/graph/permissions-reference + * - {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent} + * - {@link https://docs.microsoft.com/en-us/graph/permissions-reference} + * + * @public */ export const microsoftAuthApiRef: ApiRef< OAuthApi & @@ -298,6 +358,8 @@ export const microsoftAuthApiRef: ApiRef< /** * Provides authentication for custom identity providers. + * + * @public */ export const oauth2ApiRef: ApiRef< OAuthApi & @@ -311,6 +373,8 @@ export const oauth2ApiRef: ApiRef< /** * Provides authentication for custom OpenID Connect identity providers. + * + * @public */ export const oidcAuthApiRef: ApiRef< OAuthApi & @@ -323,7 +387,9 @@ export const oidcAuthApiRef: ApiRef< }); /** - * Provides authentication for saml based identity providers + * Provides authentication for SAML-based identity providers. + * + * @public */ export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -331,6 +397,11 @@ export const samlAuthApiRef: ApiRef< id: 'core.auth.saml', }); +/** + * Provides authentication towards OneLogin APIs. + * + * @public + */ export const oneloginAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & @@ -344,8 +415,12 @@ export const oneloginAuthApiRef: ApiRef< /** * Provides authentication towards Bitbucket APIs. * - * See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ + * @remarks + * + * See {@link https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/} * for a full list of supported scopes. + * + * @public */ export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi @@ -356,8 +431,12 @@ export const bitbucketAuthApiRef: ApiRef< /** * Provides authentication towards Atlassian APIs. * - * See https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/ + * @remarks + * + * See {@link https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/} * for a full list of supported scopes. + * + * @public */ export const atlassianAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts index 37678ed079..7e13a1963f 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts @@ -16,6 +16,11 @@ import type { ApiRef } from './types'; +/** + * API reference configuration - holds an ID of the referenced API. + * + * @public + */ export type ApiRefConfig = { id: string; /** @@ -57,6 +62,13 @@ class ApiRefImpl implements ApiRef { } } +/** + * Creates a reference to an API. + * + * @param config - The descriptor of the API to reference. + * @returns An API reference. + * @public + */ export function createApiRef(config: ApiRefConfig): ApiRef { return new ApiRefImpl(config); } diff --git a/packages/core-plugin-api/src/apis/system/helpers.ts b/packages/core-plugin-api/src/apis/system/helpers.ts index 547d583482..eaffed3003 100644 --- a/packages/core-plugin-api/src/apis/system/helpers.ts +++ b/packages/core-plugin-api/src/apis/system/helpers.ts @@ -17,19 +17,44 @@ import { ApiRef, ApiFactory, TypesToApiRefs } from './types'; /** - * Used to infer types for a standalone ApiFactory that isn't immediately passed + * Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed * to another function. + * + * @remarks + * * This function doesn't actually do anything, it's only used to infer types. + * + * @public */ export function createApiFactory< Api, Impl extends Api, Deps extends { [name in string]: unknown }, >(factory: ApiFactory): ApiFactory; +/** + * Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed + * to another function. + * + * @param api - Ref of the API that will be produced by the factory. + * @param instance - Implementation of the API to use. + * @public + */ export function createApiFactory( api: ApiRef, instance: Impl, ): ApiFactory; +/** + * Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed + * to another function. + * + * @remarks + * + * Creates factory from {@link ApiRef} or returns the factory itself if provided. + * + * @param factory - Existing factory or {@link ApiRef}. + * @param instance - The instance to be returned by the factory. + * @public + */ export function createApiFactory< Api, Impl extends Api, diff --git a/packages/core-plugin-api/src/apis/system/index.ts b/packages/core-plugin-api/src/apis/system/index.ts index ceada1ebd7..964750474e 100644 --- a/packages/core-plugin-api/src/apis/system/index.ts +++ b/packages/core-plugin-api/src/apis/system/index.ts @@ -16,5 +16,6 @@ export { useApi, useApiHolder, withApis } from './useApi'; export { createApiRef } from './ApiRef'; +export type { ApiRefConfig } from './ApiRef'; export * from './types'; export * from './helpers'; diff --git a/packages/core-plugin-api/src/apis/system/types.ts b/packages/core-plugin-api/src/apis/system/types.ts index a97d29fdaa..a449e83450 100644 --- a/packages/core-plugin-api/src/apis/system/types.ts +++ b/packages/core-plugin-api/src/apis/system/types.ts @@ -14,25 +14,64 @@ * limitations under the License. */ +/** + * API reference. + * + * @public + */ export type ApiRef = { id: string; T: T; }; +/** + * Catch-all {@link ApiRef} type. + * + * @public + */ export type AnyApiRef = ApiRef; +/** + * Transforms ApiRef type into its inner API type. + * + * @public + * @deprecated unused type. + */ export type ApiRefType = T extends ApiRef ? U : never; +/** + * Wraps a type with API properties into a type holding their respective {@link ApiRef}s. + * Reverse type transform of {@link ApiRefsToTypes}. + * + * @public + */ export type TypesToApiRefs = { [key in keyof T]: ApiRef }; +/** + * Unwraps type with {@link ApiRef} properties into a type holding their respective API types. + * Reverse type transform of {@link TypesToApiRefs}. + * + * @public + * @deprecated unused type. + */ export type ApiRefsToTypes }> = { [key in keyof T]: ApiRefType; }; +/** + * Provides lookup of APIs through their {@link ApiRef}s. + * + * @public + */ export type ApiHolder = { get(api: ApiRef): T | undefined; }; +/** + * Describes type returning API implementations. + * + * @public + */ export type ApiFactory< Api, Impl extends Api, @@ -43,6 +82,11 @@ export type ApiFactory< factory(deps: Deps): Impl; }; +/** + * Catch-all {@link ApiFactory} type. + * + * @public + */ export type AnyApiFactory = ApiFactory< unknown, unknown, diff --git a/packages/core-plugin-api/src/apis/system/useApi.tsx b/packages/core-plugin-api/src/apis/system/useApi.tsx index ef598a4c0e..465a39cff4 100644 --- a/packages/core-plugin-api/src/apis/system/useApi.tsx +++ b/packages/core-plugin-api/src/apis/system/useApi.tsx @@ -18,6 +18,11 @@ import React, { PropsWithChildren } from 'react'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { useVersionedContext } from '@backstage/version-bridge'; +/** + * React hook for retrieving {@link ApiHolder}, an API catalog. + * + * @public + */ export function useApiHolder(): ApiHolder { const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context'); if (!versionedHolder) { @@ -31,6 +36,12 @@ export function useApiHolder(): ApiHolder { return apiHolder; } +/** + * React hook for retrieving APIs. + * + * @param apiRef - Reference of the API to use. + * @public + */ export function useApi(apiRef: ApiRef): T { const apiHolder = useApiHolder(); @@ -41,6 +52,12 @@ export function useApi(apiRef: ApiRef): T { return api; } +/** + * Wrapper for giving component an API context. + * + * @param apis - APIs for the context. + * @public + */ export function withApis(apis: TypesToApiRefs) { return function withApisWrapper

( WrappedComponent: React.ComponentType

, diff --git a/packages/core-plugin-api/src/app/types.ts b/packages/core-plugin-api/src/app/types.ts index 8b07d699ee..df78728a21 100644 --- a/packages/core-plugin-api/src/app/types.ts +++ b/packages/core-plugin-api/src/app/types.ts @@ -14,80 +14,15 @@ * limitations under the License. */ -import { ComponentType } from 'react'; -import { ProfileInfo } from '../apis/definitions'; -import { IconComponent } from '../icons'; -import { BackstagePlugin } from '../plugin/types'; - -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; -}; - -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; -}; +// This is a bit of a hack that we use to avoid having to redeclare these types +// within this package or have an explicit dependency on core-app-api. +// These types end up being inlined and duplicated into this package at build time. +// eslint-disable-next-line no-restricted-imports +export type { + BootErrorPageProps, + SignInResult, + SignInPageProps, + ErrorBoundaryFallbackProps, + AppComponents, + AppContext, +} from '../../../core-app-api/src/app/types'; diff --git a/packages/core-plugin-api/src/app/useApp.tsx b/packages/core-plugin-api/src/app/useApp.tsx index 05d2e1c5c0..f2175a0969 100644 --- a/packages/core-plugin-api/src/app/useApp.tsx +++ b/packages/core-plugin-api/src/app/useApp.tsx @@ -17,6 +17,11 @@ import { useVersionedContext } from '@backstage/version-bridge'; import { AppContext as AppContextV1 } from './types'; +/** + * React hook providing {@link AppContext}. + * + * @public + */ export const useApp = (): AppContextV1 => { const versionedContext = useVersionedContext<{ 1: AppContextV1 }>('app-context'); diff --git a/packages/core-plugin-api/src/deprecatedTypes.ts b/packages/core-plugin-api/src/deprecatedTypes.ts index 4b24614e17..5596ff9f46 100644 --- a/packages/core-plugin-api/src/deprecatedTypes.ts +++ b/packages/core-plugin-api/src/deprecatedTypes.ts @@ -39,8 +39,9 @@ export type Subscription = CoreSubscription; /** * Observable sequence of values and errors, see TC39. * - * https://github.com/tc39/proposal-observable + * @remarks * + * {@link https://github.com/tc39/proposal-observable} * This is used as a common return type for observable values and can be created * using many different observable implementations, such as zen-observable or RxJS 5. * diff --git a/packages/core-plugin-api/src/extensions/componentData.tsx b/packages/core-plugin-api/src/extensions/componentData.tsx index 97a0b74e5b..1f7ef7f06a 100644 --- a/packages/core-plugin-api/src/extensions/componentData.tsx +++ b/packages/core-plugin-api/src/extensions/componentData.tsx @@ -41,6 +41,18 @@ type MaybeComponentNode = ReactNode & { type?: ComponentWithData; }; +/** + * Stores data related to a component in a global store. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#component-data}. + * + * @param component - The component to attach the data to. + * @param type - The key under which the data will be stored. + * @param data - Arbitrary value. + * @public + */ export function attachComponentData

( component: ComponentType

, type: string, @@ -70,6 +82,18 @@ export function attachComponentData

( container.map.set(type, data); } +/** + * Retrieves data attached to a component. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#component-data}. + * + * @param node - React component to look up. + * @param type - Key of the data to retrieve. + * @returns Data stored using {@link attachComponentData}. + * @public + */ export function getComponentData( node: ReactNode, type: string, diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index 29c696c71f..dcc92d9d47 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -41,6 +41,7 @@ describe('extensions', () => { const Component = () =>

; const extension = createReactExtension({ + name: 'Extension', component: { sync: Component, }, @@ -67,6 +68,7 @@ describe('extensions', () => { }); const extension2 = createRoutableExtension({ + name: 'Extension2', component: () => Promise.resolve(Component), mountPoint: routeRef, }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 2eb5275c66..ca5ada8478 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -22,7 +22,12 @@ import { attachComponentData } from './componentData'; import { Extension, BackstagePlugin } from '../plugin/types'; import { PluginErrorBoundary } from './PluginErrorBoundary'; -type ComponentLoader = +/** + * Lazy or synchronous retrieving of extension components. + * + * @public + */ +export type ComponentLoader = | { lazy: () => Promise; } @@ -30,9 +35,19 @@ type ComponentLoader = sync: T; }; -// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. -// ComponentType inserts children as an optional prop whether the inner component accepts it or not, -// making it impossible to make the usage of children type safe. +/** + * Extension for components that can have its own URL route (top-level pages, tabs etc.). + * + * @remarks + * + * We do not use ComponentType as the return type, since it doesn't let us convey the children prop. + * ComponentType inserts children as an optional prop whether the inner component accepts it or not, + * making it impossible to make the usage of children type safe. + * + * See {@link https://backstage.io/docs/plugins/composability#extensions}. + * + * @public + */ export function createRoutableExtension< T extends (props: any) => JSX.Element | null, >(options: { @@ -40,7 +55,7 @@ export function createRoutableExtension< mountPoint: RouteRef; name?: string; }): Extension { - const { component, mountPoint } = options; + const { component, mountPoint, name } = options; return createReactExtension({ component: { lazy: () => @@ -70,7 +85,7 @@ export function createRoutableExtension< }; const componentName = - options.name || + name || (InnerComponent as { displayName?: string }).displayName || InnerComponent.name || 'LazyComponent'; @@ -93,13 +108,23 @@ export function createRoutableExtension< data: { 'core.mountPoint': mountPoint, }, - name: options.name, + name, }); } -// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. -// ComponentType inserts children as an optional prop whether the inner component accepts it or not, -// making it impossible to make the usage of children type safe. +/** + * Plain React component extension. + * + * @remarks + * + * We do not use ComponentType as the return type, since it doesn't let us convey the children prop. + * ComponentType inserts children as an optional prop whether the inner component accepts it or not, + * making it impossible to make the usage of children type safe. + * + * See {@link https://backstage.io/docs/plugins/composability#extensions}. + * + * @public + */ export function createComponentExtension< T extends (props: any) => JSX.Element | null, >(options: { component: ComponentLoader; name?: string }): Extension { @@ -107,9 +132,19 @@ export function createComponentExtension< return createReactExtension({ component, name }); } -// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. -// ComponentType inserts children as an optional prop whether the inner component accepts it or not, -// making it impossible to make the usage of children type safe. +/** + * Used by {@link createComponentExtension} and {@link createRoutableExtension}. + * + * @remarks + * + * We do not use ComponentType as the return type, since it doesn't let us convey the children prop. + * ComponentType inserts children as an optional prop whether the inner component accepts it or not, + * making it impossible to make the usage of children type safe. + * + * See {@link https://backstage.io/docs/plugins/composability#extensions}. + * + * @public + */ export function createReactExtension< T extends (props: any) => JSX.Element | null, >(options: { @@ -117,7 +152,14 @@ export function createReactExtension< data?: Record; name?: string; }): Extension { - const { data = {} } = options; + const { data = {}, name } = options; + if (!name) { + // eslint-disable-next-line no-console + console.warn( + 'Declaring extensions without name is DEPRECATED. ' + + 'Make sure that all usages of createReactExtension, createComponentExtension and createRoutableExtension provide a name.', + ); + } let Component: T; if ('lazy' in options.component) { @@ -129,7 +171,7 @@ export function createReactExtension< Component = options.component.sync; } const componentName = - options.name || + name || (Component as { displayName?: string }).displayName || Component.name || 'Component'; @@ -151,7 +193,7 @@ export function createReactExtension< diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 39db17311a..35803bdeee 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -20,5 +20,6 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; +export type { ComponentLoader } from './extensions'; export { useElementFilter } from './useElementFilter'; export type { ElementCollection } from './useElementFilter'; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 4d42a0c969..8795ecdded 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -18,11 +18,8 @@ import { useElementFilter } from './useElementFilter'; import { renderHook } from '@testing-library/react-hooks'; import { attachComponentData } from './componentData'; import { featureFlagsApiRef } from '../apis'; -import { - ApiProvider, - ApiRegistry, - LocalStorageFeatureFlags, -} from '@backstage/core-app-api'; +import { LocalStorageFeatureFlags } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; const WRAPPING_COMPONENT_KEY = 'core.blob.testing'; const INNER_COMPONENT_KEY = 'core.blob2.testing'; @@ -45,9 +42,9 @@ const FeatureFlagComponent = (_props: { attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); describe('useElementFilter', () => { diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx index 7efdd949b2..fa04e7c2c6 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -82,17 +82,23 @@ function selectChildren( * A querying interface tailored to traversing a set of selected React elements * and extracting data. * + * @remarks + * * Methods prefixed with `selectBy` are used to narrow the set of selected elements. * * Methods prefixed with `find` return concrete data using a deep traversal of the set. * * Methods prefixed with `get` return concrete data using a shallow traversal of the set. + * + * @public */ export interface ElementCollection { /** * Narrows the set of selected components by doing a deep traversal and * only including those that have defined component data for the given `key`. * + * @remarks + * * Whether an element in the tree has component data set for the given key * is determined by whether `getComponentData` returns undefined. * @@ -104,6 +110,8 @@ export interface ElementCollection { * If `withStrictError` is set, the resulting selection must be a full match, meaning * there may be no elements that were excluded in the selection. If the selection * is not a clean match, an error will be throw with `withStrictError` as the message. + * + * @param query - Filtering query. */ selectByComponentData(query: { key: string; @@ -113,6 +121,8 @@ export interface ElementCollection { /** * Finds all elements using the same criteria as `selectByComponentData`, but * returns the actual component data of each of those elements instead. + * + * @param query - Lookup query. */ findComponentData(query: { key: string }): T[]; @@ -163,7 +173,11 @@ class Collection implements ElementCollection { /** * useElementFilter is a utility that helps you narrow down and retrieve data * from a React element tree, typically operating on the `children` property - * passed in to a component. A common use-case is to construct declarative APIs + * passed in to a component. + * + * @remarks + * + * A common use-case is to construct declarative APIs * where a React component defines its behavior based on its children, such as * the relationship between `Routes` and `Route` in `react-router`. * @@ -175,6 +189,8 @@ class Collection implements ElementCollection { * with added memoization based on the input `node`. If further memoization * dependencies are used in the filter function, they should be added to the * third `dependencies` argument, just like `useMemo`, `useEffect`, etc. + * + * @public */ export function useElementFilter( node: ReactNode, diff --git a/packages/core-plugin-api/src/icons/index.ts b/packages/core-plugin-api/src/icons/index.ts index 9c9e45e54c..de79dd08d3 100644 --- a/packages/core-plugin-api/src/icons/index.ts +++ b/packages/core-plugin-api/src/icons/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export type { IconComponent } from './types'; +export type { IconComponent, OldIconComponent } from './types'; diff --git a/packages/core-plugin-api/src/icons/types.ts b/packages/core-plugin-api/src/icons/types.ts index ccc1c337e7..5648261f3c 100644 --- a/packages/core-plugin-api/src/icons/types.ts +++ b/packages/core-plugin-api/src/icons/types.ts @@ -21,6 +21,8 @@ import { SvgIconProps } from '@material-ui/core'; * IconComponent is the common icon type used throughout Backstage when * working with and rendering generic icons, including the app system icons. * + * @remarks + * * The type is based on SvgIcon from MUI, but both do not what the plugin-api * package to have a dependency on MUI, nor do we want the props to be as broad * as the SvgIconProps interface. @@ -28,6 +30,8 @@ import { SvgIconProps } from '@material-ui/core'; * If you have the need to forward additional props from SvgIconProps, you can * open an issue or submit a PR to the main Backstage repo. When doing so please * also describe your use-case and reasoning of the addition. + * + * @public */ export type IconComponent = ComponentType<{ fontSize?: 'default' | 'small' | 'large'; @@ -37,5 +41,7 @@ export type IconComponent = ComponentType<{ * This exists for backwards compatibility with the old core package. * It's used in some parts of this package in order to smooth out the * migration, but it is not exported. + * + * @public */ export type OldIconComponent = ComponentType; diff --git a/packages/core-plugin-api/src/plugin/Plugin.tsx b/packages/core-plugin-api/src/plugin/Plugin.tsx index 53a53b76d0..52ca0e76e3 100644 --- a/packages/core-plugin-api/src/plugin/Plugin.tsx +++ b/packages/core-plugin-api/src/plugin/Plugin.tsx @@ -24,6 +24,9 @@ import { } from './types'; import { AnyApiFactory } from '../apis'; +/** + * @internal + */ export class PluginImpl< Routes extends AnyRoutes, ExternalRoutes extends AnyExternalRoutes, @@ -80,6 +83,12 @@ export class PluginImpl< } } +/** + * Creates Backstage Plugin from config. + * + * @param config - Plugin configuration. + * @public + */ export function createPlugin< Routes extends AnyRoutes = {}, ExternalRoutes extends AnyExternalRoutes = {}, diff --git a/packages/core-plugin-api/src/plugin/index.ts b/packages/core-plugin-api/src/plugin/index.ts index 321ae6998a..d3272607ef 100644 --- a/packages/core-plugin-api/src/plugin/index.ts +++ b/packages/core-plugin-api/src/plugin/index.ts @@ -16,6 +16,8 @@ export { createPlugin } from './Plugin'; export type { + AnyExternalRoutes, + AnyRoutes, BackstagePlugin, Extension, FeatureFlagOutput, @@ -23,6 +25,4 @@ export type { PluginConfig, PluginHooks, PluginOutput, - RouteOptions, - RoutePath, } from './types'; diff --git a/packages/core-plugin-api/src/plugin/types.ts b/packages/core-plugin-api/src/plugin/types.ts index b3e1043efd..aeb7037c51 100644 --- a/packages/core-plugin-api/src/plugin/types.ts +++ b/packages/core-plugin-api/src/plugin/types.ts @@ -17,29 +17,55 @@ import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; -export type RouteOptions = { - // Whether the route path must match exactly, defaults to true. - exact?: boolean; -}; - -export type RoutePath = string; - -// Replace with using RouteRefs +/** + * Replace with using {@link RouteRef}s. + * + * @public + */ export type FeatureFlagOutput = { type: 'feature-flag'; name: string; }; +/** + * {@link FeatureFlagOutput} type. + * + * @public + */ export type PluginOutput = FeatureFlagOutput; +/** + * Plugin extension type. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#extensions}. + * + * @public + */ export type Extension = { expose(plugin: BackstagePlugin): T; }; +/** + * Catch-all route type. + * + * @public + */ export type AnyRoutes = { [name: string]: RouteRef | SubRouteRef }; +/** + * Catch-all type for {@link ExternalRouteRef}s. + * + * @public + */ export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; +/** + * Plugin type. + * + * @public + */ export type BackstagePlugin< Routes extends AnyRoutes = {}, ExternalRoutes extends AnyExternalRoutes = {}, @@ -52,6 +78,11 @@ export type BackstagePlugin< externalRoutes: ExternalRoutes; }; +/** + * Plugin descriptor type. + * + * @public + */ export type PluginConfig< Routes extends AnyRoutes, ExternalRoutes extends AnyExternalRoutes, @@ -63,10 +94,20 @@ export type PluginConfig< externalRoutes?: ExternalRoutes; }; +/** + * Holds hooks registered by the plugin. + * + * @public + */ export type PluginHooks = { featureFlags: FeatureFlagsHooks; }; +/** + * Interface for registering feature flags hooks. + * + * @public + */ export type FeatureFlagsHooks = { register(name: string): void; }; diff --git a/packages/core-plugin-api/src/routing/ExternalRouteRef.ts b/packages/core-plugin-api/src/routing/ExternalRouteRef.ts index c1c17c86df..2ebb81dbfb 100644 --- a/packages/core-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/core-plugin-api/src/routing/ExternalRouteRef.ts @@ -22,6 +22,9 @@ import { OptionalParams, } from './types'; +/** + * @internal + */ export class ExternalRouteRefImpl< Params extends AnyParams, Optional extends boolean, @@ -42,6 +45,16 @@ export class ExternalRouteRefImpl< } } +/** + * Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @param options - Description of the route reference to be created. + * @public + */ export function createExternalRouteRef< Params extends { [param in ParamKey]: string }, Optional extends boolean = false, diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index 78211e29c6..26e63c8f3a 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -23,7 +23,10 @@ import { } from './types'; import { OldIconComponent } from '../icons/types'; -// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused +/** + * @deprecated + * @internal + */ export type RouteRefConfig = { params?: ParamKeys; path?: string; @@ -31,6 +34,9 @@ export type RouteRefConfig = { title: string; }; +/** + * @internal + */ export class RouteRefImpl implements RouteRef { @@ -42,21 +48,45 @@ export class RouteRefImpl private readonly id: string, readonly params: ParamKeys, private readonly config: { + /** @deprecated */ path?: string; + /** @deprecated */ icon?: OldIconComponent; + /** @deprecated */ title?: string; }, - ) {} + ) { + if (config.path) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own path, please remove the path for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + + if (config.icon) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own icon, please remove the icon for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + + if (config.title) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own title, please remove the title for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + } + + /** @deprecated use `useRouteRef` instead */ + get path() { + return this.config.path ?? ''; + } 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; } @@ -66,6 +96,12 @@ export class RouteRefImpl } } +/** + * Create a {@link RouteRef} from a route descriptor. + * + * @param config - Description of the route reference to be created. + * @public + */ 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} diff --git a/packages/core-plugin-api/src/routing/SubRouteRef.ts b/packages/core-plugin-api/src/routing/SubRouteRef.ts index be30d56096..61d89fcbdb 100644 --- a/packages/core-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/core-plugin-api/src/routing/SubRouteRef.ts @@ -26,6 +26,9 @@ import { // Should match the pattern in react-router const PARAM_PATTERN = /^\w+$/; +/** + * @internal + */ export class SubRouteRefImpl implements SubRouteRef { @@ -45,18 +48,34 @@ export class SubRouteRefImpl } } -// 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 }; +/** + * Used in {@link PathParams} type declaration. + * @public + */ +export type ParamPart = S extends `:${infer Param}` + ? Param + : never; /** - * Merges a param object type with with an optional params type into a params object + * Used in {@link PathParams} type declaration. + * @public */ -type MergeParams< +export type ParamNames = + S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; +/** + * This utility type helps us infer a Param object type from a string path + * For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` + * @public + */ +export type PathParams = { [name in ParamNames]: string }; + +/** + * Merges a param object type with with an optional params type into a params object. + * @public + */ +export type MergeParams< P1 extends { [param in string]: string }, P2 extends AnyParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); @@ -64,14 +83,22 @@ type MergeParams< /** * 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. + * + * @public */ -type MakeSubRouteRef< +export type MakeSubRouteRef< Params extends { [param in string]: string }, ParentParams extends AnyParams, > = keyof Params & keyof ParentParams extends never ? SubRouteRef>> : never; +/** + * Create a {@link SubRouteRef} from a route descriptor. + * + * @param config - Description of the route reference to be created. + * @public + */ export function createSubRouteRef< Path extends string, ParentParams extends AnyParams = never, diff --git a/packages/core-plugin-api/src/routing/index.ts b/packages/core-plugin-api/src/routing/index.ts index 79158d2cdc..01d69cd4b0 100644 --- a/packages/core-plugin-api/src/routing/index.ts +++ b/packages/core-plugin-api/src/routing/index.ts @@ -14,9 +14,24 @@ * limitations under the License. */ -export type { RouteRef, SubRouteRef, ExternalRouteRef } from './types'; +export type { + AnyParams, + RouteRef, + SubRouteRef, + ExternalRouteRef, + OptionalParams, + ParamKeys, + RouteFunc, +} from './types'; export { createRouteRef } from './RouteRef'; export { createSubRouteRef } from './SubRouteRef'; +export type { + MakeSubRouteRef, + MergeParams, + ParamNames, + ParamPart, + PathParams, +} from './SubRouteRef'; export { createExternalRouteRef } from './ExternalRouteRef'; export { useRouteRef } from './useRouteRef'; export { useRouteRefParams } from './useRouteRefParams'; diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index 7e20d1ff67..bd419abb76 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -17,32 +17,70 @@ import { OldIconComponent } from '../icons/types'; import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; +/** + * Catch-all type for route params. + * + * @public + */ export type AnyParams = { [param in string]: string } | undefined; + +/** + * Type describing the key type of a route parameter mapping. + * + * @public + */ export type ParamKeys = keyof Params extends never ? [] : (keyof Params)[]; + +/** + * Optional route params. + * + * @public + */ export type OptionalParams = Params[keyof Params] extends never ? undefined : Params; -// 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. +/** + * TS magic for handling route parameters. + * + * @remarks + * + * 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. + * + * @public + */ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; -// This symbol is what we use at runtime to determine whether a given object -// is a type of RouteRef or not. It doesn't work well in TypeScript though since -// the `unique symbol` will refer to different values between package versions. -// For that reason we use the marker $$routeRefType to represent the symbol at -// compile-time instead of using the symbol directly. +/** + * This symbol is what we use at runtime to determine whether a given object + * is a type of RouteRef or not. It doesn't work well in TypeScript though since + * the `unique symbol` will refer to different values between package versions. + * For that reason we use the marker $$routeRefType to represent the symbol at + * compile-time instead of using the symbol directly. + * + * @internal + */ export const routeRefType: unique symbol = getOrCreateGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), ); +/** + * Absolute route reference. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ export type RouteRef = { $$routeRefType: 'absolute'; // See routeRefType above @@ -57,6 +95,15 @@ export type RouteRef = { title?: string; }; +/** + * Descriptor of a route relative to an absolute {@link RouteRef}. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ export type SubRouteRef = { $$routeRefType: 'sub'; // See routeRefType above @@ -67,6 +114,15 @@ export type SubRouteRef = { params: ParamKeys; }; +/** + * Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ export type ExternalRouteRef< Params extends AnyParams = any, Optional extends boolean = any, @@ -78,20 +134,35 @@ export type ExternalRouteRef< optional?: Optional; }; +/** + * @internal + */ export type AnyRouteRef = | RouteRef | SubRouteRef | ExternalRouteRef; // TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release -/** @deprecated */ +/** + * @deprecated + * @internal + */ export type ConcreteRoute = {}; -/** @deprecated */ +/** + * @deprecated + * @internal + */ export type AbsoluteRouteRef = RouteRef<{}>; -/** @deprecated */ +/** + * @deprecated + * @internal + */ export type MutableRouteRef = RouteRef<{}>; -// A duplicate of the react-router RouteObject, but with routeRef added +/** + * A duplicate of the react-router RouteObject, but with routeRef added + * @internal + */ export interface BackstageRouteObject { caseSensitive: boolean; children?: BackstageRouteObject[]; diff --git a/packages/core-plugin-api/src/routing/useRouteRef.tsx b/packages/core-plugin-api/src/routing/useRouteRef.tsx index ea05cc14fd..50cfb3bdfb 100644 --- a/packages/core-plugin-api/src/routing/useRouteRef.tsx +++ b/packages/core-plugin-api/src/routing/useRouteRef.tsx @@ -25,6 +25,9 @@ import { SubRouteRef, } from './types'; +/** + * @internal + */ export interface RouteResolver { resolve( anyRouteRef: @@ -35,12 +38,47 @@ export interface RouteResolver { ): RouteFunc | undefined; } +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ export function useRouteRef( routeRef: ExternalRouteRef, ): Optional extends true ? RouteFunc | undefined : RouteFunc; + +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ export function useRouteRef( routeRef: RouteRef | SubRouteRef, ): RouteFunc; + +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ export function useRouteRef( routeRef: | RouteRef diff --git a/packages/core-plugin-api/src/routing/useRouteRefParams.ts b/packages/core-plugin-api/src/routing/useRouteRefParams.ts index a61df97cf5..e24576372b 100644 --- a/packages/core-plugin-api/src/routing/useRouteRefParams.ts +++ b/packages/core-plugin-api/src/routing/useRouteRefParams.ts @@ -17,6 +17,11 @@ import { useParams } from 'react-router-dom'; import { RouteRef, AnyParams, SubRouteRef } from './types'; +/** + * React hook for retrieving dynamic params from the current URL. + * @param _routeRef - Ref of the current route. + * @public + */ export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params { diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 95d99d0532..af1fd58ead 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,83 @@ # @backstage/create-app +## 0.4.4 + +### Patch Changes + +- 4ebc9fd277: Create backstage.json file + + `@backstage/create-app` will create a new `backstage.json` file. At this point, the file will contain a `version` property, representing the version of `@backstage/create-app` used for creating the application. If the backstage's application has been bootstrapped using an older version of `@backstage/create-app`, the `backstage.json` file can be created and kept in sync, together with all the changes of the latest version of backstage, by running the following script: + + ```bash + yarn backstage-cli versions:bump + ``` + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 014cbf8cb9: Migrated the app template use the new `@backstage/app-defaults` for the `createApp` import, since the `createApp` exported by `@backstage/app-core-api` will be removed in the future. + + To migrate an existing application, add the latest version of `@backstage/app-defaults` as a dependency in `packages/app/package.json`, and make the following change to `packages/app/src/App.tsx`: + + ```diff + -import { createApp, FlatRoutes } from '@backstage/core-app-api'; + +import { createApp } from '@backstage/app-defaults'; + +import { FlatRoutes } from '@backstage/core-app-api'; + ``` + +- 2163e83fa2: Refactor and add regression tests for create-app tasks +- Updated dependencies + - @backstage/cli-common@0.1.6 + +## 0.4.3 + +### Patch Changes + +- 5dcea2586c: Integrated `SidebarSearchModal` component into default-app to use the `SearchModal`. + + The `SidebarSearchModal` component can also be used in other generated apps: + + ```diff + import { + - SidebarSearch, + + SidebarSearchModal + } from '@backstage/plugin-search'; + ... + + + + - + + + + ... + ``` + + If you only want to use the `SearchModal` you can import it from `'@backstage/plugin-search'`: + + ```js + import { SearchModal } from '@backstage/plugin-search'; + ``` + +- 5725f87e4c: Updated the app template to no longer include the `--no-private` flag for the `create-plugin` command. + + To apply this change to an existing application, remove the `--no-private` flag from the `create-plugin` command in the root `package.json`: + + ```diff + "prettier:check": "prettier --check .", + - "create-plugin": "backstage-cli create-plugin --scope internal --no-private", + + "create-plugin": "backstage-cli create-plugin --scope internal", + "remove-plugin": "backstage-cli remove-plugin" + ``` + +- 1921f70aa7: Removed the version pinning of the packages `graphql-language-service-interface` and `graphql-language-service-parser`. This should no longer be necessary. + + You can apply the same change in your repository by ensuring that the following does _NOT_ appear in your root `package.json`. + + ```json + "resolutions": { + "graphql-language-service-interface": "2.8.2", + "graphql-language-service-parser": "1.9.0" + }, + ``` + ## 0.4.2 ## 0.4.1 diff --git a/packages/create-app/package.json b/packages/create-app/package.json index ad4ab8dabd..d5ee616ff5 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "A CLI that helps you create your own Backstage app", - "version": "0.4.2", + "version": "0.4.4", "private": false, "publishConfig": { "access": "public" @@ -23,11 +23,12 @@ "scripts": { "build": "backstage-cli build --outputs cjs", "lint": "backstage-cli lint", + "test": "backstage-cli test", "clean": "backstage-cli clean", "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "chalk": "^4.0.0", "commander": "^6.1.0", "fs-extra": "9.1.0", @@ -40,6 +41,7 @@ "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", "@types/recursive-readdir": "^2.2.0", + "mock-fs": "^5.1.1", "ts-node": "^10.0.0" }, "peerDependencies": { diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts new file mode 100644 index 0000000000..ee01e8fd42 --- /dev/null +++ b/packages/create-app/src/createApp.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 inquirer from 'inquirer'; +import mockFs from 'mock-fs'; +import path from 'path'; +import { Command } from 'commander'; +import * as tasks from './lib/tasks'; +import createApp from './createApp'; + +jest.mock('./lib/tasks'); + +beforeAll(() => { + mockFs({ + [`${__dirname}/package.json`]: '', // required by `findPaths(__dirname)` + 'templates/': mockFs.load(path.resolve(__dirname, '../templates/')), + }); +}); + +afterAll(() => { + mockFs.restore(); +}); + +const promptMock = jest.spyOn(inquirer, 'prompt'); +const checkPathExistsMock = jest.spyOn(tasks, 'checkPathExistsTask'); +const templatingMock = jest.spyOn(tasks, 'templatingTask'); +const checkAppExistsMock = jest.spyOn(tasks, 'checkAppExistsTask'); +const createTemporaryAppFolderMock = jest.spyOn( + tasks, + 'createTemporaryAppFolderTask', +); +const moveAppMock = jest.spyOn(tasks, 'moveAppTask'); +const buildAppMock = jest.spyOn(tasks, 'buildAppTask'); + +describe('command entrypoint', () => { + beforeEach(() => { + promptMock.mockResolvedValueOnce({ + name: 'MyApp', + dbType: 'PostgreSQL', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should call expected tasks with no path option', async () => { + const cmd = {} as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(checkAppExistsMock).toHaveBeenCalled(); + expect(createTemporaryAppFolderMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(moveAppMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should call expected tasks with path option', async () => { + const cmd = { path: 'myDirectory' } as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(checkPathExistsMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should not call `buildAppTask` when `skipInstall` is supplied', async () => { + const cmd = { skipInstall: true } as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(buildAppMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index d9719742fb..d301792380 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -14,87 +14,23 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { promisify } from 'util'; import chalk from 'chalk'; import { Command } from 'commander'; import inquirer, { Answers, Question } from 'inquirer'; -import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import { findPaths } from '@backstage/cli-common'; import os from 'os'; -import { Task, templatingTask } from './lib/tasks'; +import { + Task, + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './lib/tasks'; -const exec = promisify(execCb); - -async function checkAppExists(rootDir: string, name: string) { - await Task.forItem('checking', name, async () => { - const destination = resolvePath(rootDir, name); - - if (await fs.pathExists(destination)) { - const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); - throw new Error( - `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, - ); - } - }); -} - -async function checkPathExists(path: string) { - await Task.forItem('checking', path, async () => { - try { - await fs.mkdirs(path); - } catch (error) { - // will fail if a file already exists at given `path` - throw new Error(`Failed to create app directory: ${error.message}`); - } - }); -} - -async function createTemporaryAppFolder(tempDir: string) { - await Task.forItem('creating', 'temporary directory', async () => { - try { - await fs.mkdir(tempDir); - } catch (error) { - throw new Error(`Failed to create temporary app directory, ${error}`); - } - }); -} - -async function buildApp(appDir: string) { - const runCmd = async (cmd: string) => { - await Task.forItem('executing', cmd, async () => { - process.chdir(appDir); - - await exec(cmd).catch(error => { - process.stdout.write(error.stderr); - process.stdout.write(error.stdout); - throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); - }); - }); - }; - - await runCmd('yarn install'); - await runCmd('yarn tsc'); -} - -async function moveApp(tempDir: string, destination: string, id: string) { - await Task.forItem('moving', id, async () => { - await fs - .move(tempDir, destination) - .catch(error => { - throw new Error( - `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, - ); - }) - .finally(() => { - // remove temporary files on both success and failure - fs.removeSync(tempDir); - }); - }); -} - -export default async (cmd: Command): Promise => { +export default async (cmd: Command, version: string): Promise => { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); @@ -143,29 +79,29 @@ export default async (cmd: Command): Promise => { // Template directly to specified path Task.section('Checking that supplied path exists'); - await checkPathExists(appDir); + await checkPathExistsTask(appDir); Task.section('Preparing files'); - await templatingTask(templateDir, cmd.path, answers); + await templatingTask(templateDir, cmd.path, answers, version); } else { // Template to temporary location, and then move files Task.section('Checking if the directory is available'); - await checkAppExists(paths.targetDir, answers.name); + await checkAppExistsTask(paths.targetDir, answers.name); Task.section('Creating a temporary app directory'); - await createTemporaryAppFolder(tempDir); + await createTemporaryAppFolderTask(tempDir); Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, answers); + await templatingTask(templateDir, tempDir, answers, version); Task.section('Moving to final location'); - await moveApp(tempDir, appDir, answers.name); + await moveAppTask(tempDir, appDir, answers.name); } if (!cmd.skipInstall) { Task.section('Building the app'); - await buildApp(appDir); + await buildAppTask(appDir); } Task.log(); diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 1f37be56d2..8a2da2fad8 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -38,7 +38,7 @@ const main = (argv: string[]) => { '--skip-install', 'Skip the install and builds steps after creating the app', ) - .action(createApp); + .action(cmd => createApp(cmd, version)); program.parse(argv); }; diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts new file mode 100644 index 0000000000..24df97c694 --- /dev/null +++ b/packages/create-app/src/lib/tasks.test.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import child_process from 'child_process'; +import path from 'path'; +import { + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './tasks'; + +jest.mock('child_process'); + +beforeEach(() => { + mockFs({ + 'projects/my-module.ts': '', + 'projects/dir/my-file.txt': '', + 'tmp/mockApp/.gitignore': '', + 'tmp/mockApp/package.json': '', + 'tmp/mockApp/packages/app/package.json': '', + // load templates into mock filesystem + 'templates/': mockFs.load(path.resolve(__dirname, '../../templates/')), + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe('checkAppExistsTask', () => { + it('should do nothing if the directory does not exist', async () => { + const dir = 'projects/'; + const name = 'MyNewApp'; + await expect(checkAppExistsTask(dir, name)).resolves.not.toThrow(); + }); + + it('should throw an error when a file of the same name exists', async () => { + const dir = 'projects/'; + const name = 'my-module.ts'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); + + it('should throw an error when a directory of the same name exists', async () => { + const dir = 'projects/'; + const name = 'dir'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('checkPathExistsTask', () => { + it('should create a directory at the given path', async () => { + const appDir = 'projects/newProject'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should do nothing if a directory of the same name exists', async () => { + const appDir = 'projects/dir'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should fail if a file of the same name exists', async () => { + await expect(checkPathExistsTask('projects/my-module.ts')).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('createTemporaryAppFolderTask', () => { + it('should create a directory at a given path', async () => { + const tempDir = 'projects/tmpFolder'; + await expect(createTemporaryAppFolderTask(tempDir)).resolves.not.toThrow(); + expect(fs.existsSync(tempDir)).toBe(true); + }); + + it('should fail if a directory of the same name exists', async () => { + const tempDir = 'projects/dir'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); + + it('should fail if a file of the same name exists', async () => { + const tempDir = 'projects/dir/my-file.txt'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); +}); + +describe('buildAppTask', () => { + it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => { + const mockChdir = jest.spyOn(process, 'chdir'); + const mockExec = child_process.exec as unknown as jest.MockedFunction< + ( + command: string, + callback: (error: null, stdout: string, stderr: string) => void, + ) => void + >; + + // requires callback implementation to support `promisify` wrapper + // https://stackoverflow.com/a/60579617/10044859 + mockExec.mockImplementation((_command, callback) => { + callback(null, 'standard out', 'standard error'); + }); + + const appDir = 'projects/dir'; + await expect(buildAppTask(appDir)).resolves.not.toThrow(); + expect(mockChdir).toBeCalledTimes(2); + expect(mockChdir).toHaveBeenNthCalledWith(1, appDir); + expect(mockChdir).toHaveBeenNthCalledWith(2, appDir); + expect(mockExec).toBeCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + 'yarn install', + expect.any(Function), + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + 'yarn tsc', + expect.any(Function), + ); + }); + + it('should fail if project directory does not exist', async () => { + const appDir = 'projects/missingProject'; + await expect(buildAppTask(appDir)).rejects.toThrow( + 'no such file or directory', + ); + }); +}); + +describe('moveAppTask', () => { + const tempDir = 'tmp/mockApp/'; + const id = 'myApp'; + + it('should move all files in the temp dir to the target dir', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('projects/mockApp/.gitignore')).toBe(true); + expect(fs.existsSync('projects/mockApp/package.json')).toBe(true); + expect(fs.existsSync('projects/mockApp/packages/app/package.json')).toBe( + true, + ); + }); + + it('should fail to move files if destination already exists', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow( + 'dest already exists', + ); + }); + + it('should remove temporary files if move succeeded', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); + + it('should remove temporary files if move failed', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow(); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); +}); + +describe('templatingTask', () => { + it('should generate a project populating context parameters', async () => { + const templateDir = 'templates/default-app'; + const destinationDir = 'templatedApp'; + const context = { + name: 'SuperCoolBackstageInstance', + dbTypeSqlite: true, + }; + await templatingTask(templateDir, destinationDir, context, '1.0.0'); + expect(fs.existsSync('templatedApp/package.json')).toBe(true); + expect(fs.existsSync('templatedApp/.dockerignore')).toBe(true); + await expect(fs.readJson('templatedApp/backstage.json')).resolves.toEqual({ + version: '1.0.0', + }); + // catalog was populated with `context.name` + expect( + fs.readFileSync('templatedApp/catalog-info.yaml', 'utf-8'), + ).toContain('name: SuperCoolBackstageInstance'); + // backend dependencies include `sqlite3` from `context.SQLite` + expect( + fs.readFileSync('templatedApp/packages/backend/package.json', 'utf-8'), + ).toContain('"sqlite3"'); + }); +}); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 19f0ed66b8..2793088892 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -14,15 +14,25 @@ * limitations under the License. */ +import { BACKSTAGE_JSON } from '@backstage/cli-common'; import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; -import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { + basename, + dirname, + join, + resolve as resolvePath, + relative as relativePath, +} from 'path'; +import { exec as execCb } from 'child_process'; import { packageVersions } from './versions'; +import { promisify } from 'util'; const TASK_NAME_MAX_LENGTH = 14; +const exec = promisify(execCb); export class Task { static log(name: string = '') { @@ -65,17 +75,28 @@ export class Task { } } +/** + * Generate a templated backstage project + * + * @param templateDir - location containing template files + * @param destinationDir - location to save templated project + * @param context - template parameters + */ export async function templatingTask( templateDir: string, destinationDir: string, context: any, + version: string, ) { const files = await recursive(templateDir).catch(error => { throw new Error(`Failed to read template directory: ${error.message}`); }); for (const file of files) { - const destinationFile = file.replace(templateDir, destinationDir); + const destinationFile = resolvePath( + destinationDir, + relativePath(templateDir, file), + ); await fs.ensureDir(dirname(destinationFile)); if (file.endsWith('.hbs')) { @@ -115,4 +136,112 @@ export async function templatingTask( }); } } + await Task.forItem('creating', BACKSTAGE_JSON, () => + fs.writeFile( + join(destinationDir, BACKSTAGE_JSON), + `{\n "version": ${JSON.stringify(version)}\n}\n`, + ), + ); +} + +/** + * Verify that application target does not already exist + * + * @param rootDir - The directory to create application folder `name` + * @param name - The specified name of the application + * @Throws Error - If directory with name of `destination` already exists + */ +export async function checkAppExistsTask(rootDir: string, name: string) { + await Task.forItem('checking', name, async () => { + const destination = resolvePath(rootDir, name); + + if (await fs.pathExists(destination)) { + const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); + throw new Error( + `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, + ); + } + }); +} + +/** + * Verify that application `path` exists, otherwise create the directory + * + * @param {string} path - target to create directory + * @throws {Error} if `path` is a file, or `fs.mkdir` fails + */ +export async function checkPathExistsTask(path: string) { + await Task.forItem('checking', path, async () => { + try { + await fs.mkdirs(path); + } catch (error) { + // will fail if a file already exists at given `path` + throw new Error(`Failed to create app directory: ${error.message}`); + } + }); +} + +/** + * Create a folder to store templated files + * + * @param {string} tempDir - target temporary directory + * @throws {Error} if `fs.mkdir` fails + */ +export async function createTemporaryAppFolderTask(tempDir: string) { + await Task.forItem('creating', 'temporary directory', async () => { + try { + await fs.mkdir(tempDir); + } catch (error) { + throw new Error(`Failed to create temporary app directory, ${error}`); + } + }); +} + +/** + * Run `yarn install` and `run tsc` in application directory + * + * @param {string} appDir - location of application to build + */ +export async function buildAppTask(appDir: string) { + const runCmd = async (cmd: string) => { + await Task.forItem('executing', cmd, async () => { + process.chdir(appDir); + await exec(cmd).catch(error => { + process.stdout.write(error.stderr); + process.stdout.write(error.stdout); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + }); + }; + + await runCmd('yarn install'); + await runCmd('yarn tsc'); +} + +/** + * Move temporary directory to destination application folder + * + * @param {string} tempDir source path to copy files from + * @param {string} destination target path to copy files + * @param {string} id + * @throws {Error} if `fs.move` fails + */ +export async function moveAppTask( + tempDir: string, + destination: string, + id: string, +) { + await Task.forItem('moving', id, async () => { + await fs + .move(tempDir, destination) + .catch(error => { + throw new Error( + `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, + ); + }) + .finally(() => { + // remove temporary files on both success and failure + fs.removeSync(tempDir); + }); + }); } diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 97904cc15b..6c85652c64 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -30,6 +30,7 @@ Rollup will extract the value of the version field in each package at build time leaving any imports in place. */ +import { version as appDefaults } from '../../../app-defaults/package.json'; import { version as backendCommon } from '../../../backend-common/package.json'; import { version as catalogClient } from '../../../catalog-client/package.json'; import { version as catalogModel } from '../../../catalog-model/package.json'; @@ -68,6 +69,7 @@ import { version as pluginTechdocsBackend } from '../../../../plugins/techdocs-b import { version as pluginUserSettings } from '../../../../plugins/user-settings/package.json'; export const packageVersions = { + '@backstage/app-defaults': appDefaults, '@backstage/backend-common': backendCommon, '@backstage/catalog-client': catalogClient, '@backstage/catalog-model': catalogModel, diff --git a/packages/create-app/templates/default-app/app-config.production.yaml b/packages/create-app/templates/default-app/app-config.production.yaml index 92f4574fd1..5e36c2319f 100644 --- a/packages/create-app/templates/default-app/app-config.production.yaml +++ b/packages/create-app/templates/default-app/app-config.production.yaml @@ -1,8 +1,8 @@ app: # Should be the same as backend.baseUrl when using the `app-backend` plugin - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 backend: - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 9c35c58c33..ab4858edd6 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -6,9 +6,9 @@ organization: name: My Company backend: - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 csp: connect-src: ["'self'", 'http:', 'https:'] # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference @@ -32,7 +32,9 @@ backend: user: ${POSTGRES_USER} password: ${POSTGRES_PASSWORD} # https://node-postgres.com/features/ssl - # ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + # you can set the sslmode configuration option via the `PGSSLMODE` environment variable + # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + # ssl: # ca: # if you have a CA file and want to verify it you can uncomment this section # $file: /ca/server.crt {{/if}} diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 5ed066a30b..bdae71d9e3 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -20,13 +20,9 @@ "lint": "lerna run lint --since origin/master --", "lint:all": "lerna run lint --", "prettier:check": "prettier --check .", - "create-plugin": "backstage-cli create-plugin --scope internal --no-private", + "create-plugin": "backstage-cli create-plugin --scope internal", "remove-plugin": "backstage-cli remove-plugin" }, - "resolutions": { - "graphql-language-service-interface": "2.8.2", - "graphql-language-service-parser": "1.9.0" - }, "workspaces": { "packages": [ "packages/*", diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index c443b45dc7..9e15608a01 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -4,6 +4,7 @@ "private": true, "bundled": true, "dependencies": { + "@backstage/app-defaults": "^{{version '@backstage/app-defaults'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/cli": "^{{version '@backstage/cli'}}", "@backstage/core-app-api": "^{{version '@backstage/core-app-api'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx index 82bc479858..b94cac73b3 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx @@ -10,9 +10,9 @@ describe('App', () => { { data: { app: { title: 'Test' }, - backend: { baseUrl: 'http://localhost:7000' }, + backend: { baseUrl: 'http://localhost:7007' }, techdocs: { - storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 4cd83685a6..8a535835b4 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -26,7 +26,8 @@ import { searchPage } from './components/search/SearchPage'; import { Root } from './components/Root'; import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; const app = createApp({ apis, diff --git a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx index 198e7ec2b1..ec59b0b116 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx @@ -25,7 +25,7 @@ import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { NavLink } from 'react-router-dom'; import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; -import { SidebarSearch } from '@backstage/plugin-search'; +import { SidebarSearchModal } from '@backstage/plugin-search'; import { Sidebar, SidebarPage, @@ -74,7 +74,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - + {/* Global nav, not org-specific */} diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx index 7b3c2b29d4..50ffbadb76 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx @@ -2,10 +2,13 @@ import React from 'react'; import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; +import { DocsResultListItem } from '@backstage/plugin-techdocs'; + import { SearchBar, SearchFilter, SearchResult, + SearchType, DefaultResultListItem, } from '@backstage/plugin-search'; import { Content, Header, Page } from '@backstage/core-components'; @@ -39,6 +42,11 @@ const SearchPage = () => { + { result={document} /> ); + case 'techdocs': + return ( + + ); default: return ( { + let appThemeApi: jest.Mocked; + + beforeEach(() => { + appThemeApi = { + activeThemeId$: jest.fn(), + getActiveThemeId: jest.fn(), + getInstalledThemes: jest.fn(), + setActiveThemeId: jest.fn(), + }; + + appThemeApi.activeThemeId$.mockReturnValue( + ObservableImpl.of('dark'), + ); + appThemeApi.getInstalledThemes.mockReturnValue([ + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: {} as unknown as BackstageTheme, + }, + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: {} as unknown as BackstageTheme, + }, + ]); + }); + + it('should display current theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByText('Dark Theme')).toBeInTheDocument(); + expect( + getByText('Dark Theme').parentElement?.parentElement, + ).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select different theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + + userEvent.click(getByText('Light Theme')); + + expect(appThemeApi.setActiveThemeId).toHaveBeenCalledWith('light'); + }); +}); diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx new file mode 100644 index 0000000000..e13554bbf7 --- /dev/null +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SidebarItem } from '@backstage/core-components'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; +import { ListItemIcon, ListItemText, Menu, MenuItem } from '@material-ui/core'; +import AutoIcon from '@material-ui/icons/BrightnessAuto'; +import React, { cloneElement, useCallback, useState } from 'react'; +import { useObservable } from 'react-use'; + +type ThemeIconProps = { + active?: boolean; + icon: JSX.Element | undefined; +}; + +const ThemeIcon = ({ active, icon }: ThemeIconProps) => + icon ? ( + cloneElement(icon, { + color: active ? 'primary' : undefined, + }) + ) : ( + + ); + +export const SidebarThemeSwitcher = () => { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + const themeIds = appThemeApi.getInstalledThemes(); + const activeTheme = themeIds.find(t => t.id === themeId); + + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleSelectTheme = (newThemeId: string | undefined) => { + if (themeIds.some(t => t.id === newThemeId)) { + appThemeApi.setActiveThemeId(newThemeId); + } else { + appThemeApi.setActiveThemeId(undefined); + } + + setAnchorEl(undefined); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const ActiveIcon = useCallback( + () => , + [activeTheme], + ); + + return ( + <> + + + + Choose a theme + handleSelectTheme(undefined)} + > + + + + Auto + + + {themeIds.map(theme => { + const active = theme.id === themeId; + return ( + handleSelectTheme(theme.id)} + > + + + + {theme.title} + + ); + })} + + + ); +}; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 755289a060..adea9e14f2 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,6 +14,30 @@ * limitations under the License. */ +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { + AlertDisplay, + OAuthRequestDialog, + Sidebar, + SidebarDivider, + SidebarItem, + SidebarPage, + SidebarSpace, + SidebarSpacer, +} from '@backstage/core-components'; +import { + AnyApiFactory, + ApiFactory, + AppTheme, + attachComponentData, + BackstagePlugin, + configApiRef, + createApiFactory, + createRouteRef, + IconComponent, + RouteRef, +} from '@backstage/core-plugin-api'; import { ScmIntegrationsApi, scmIntegrationsApiRef, @@ -24,30 +48,7 @@ import React, { ComponentType, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import { hot } from 'react-hot-loader'; import { Route } from 'react-router'; - -import { - AlertDisplay, - OAuthRequestDialog, - Sidebar, - SidebarItem, - SidebarPage, - SidebarSpacer, -} from '@backstage/core-components'; - -import { - AnyApiFactory, - ApiFactory, - AppTheme, - attachComponentData, - configApiRef, - createApiFactory, - createRouteRef, - IconComponent, - RouteRef, - BackstagePlugin, -} from '@backstage/core-plugin-api'; - -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; const GatheringRoute: (props: { path: string; @@ -202,6 +203,9 @@ export class DevAppBuilder { {this.sidebarItems} + + + {this.routes} diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 1b8e321edb..6c941cda58 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -25,7 +25,7 @@ }, "devDependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/errors": "^0.1.2", + "@backstage/errors": "^0.1.4", "@types/fs-extra": "^9.0.1", "@types/node": "^14.14.32", "@types/puppeteer": "^5.4.4", diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index e1d8473121..a7456f91dd 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -471,7 +471,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) { print('Try to fetch entities from the backend'); // Try fetch entities, should be ok - await fetch('http://localhost:7000/api/catalog/entities').then(res => + await fetch('http://localhost:7007/api/catalog/entities').then(res => res.json(), ); print('Entities fetched successfully'); diff --git a/packages/embedded-techdocs-app/.eslintrc.js b/packages/embedded-techdocs-app/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/embedded-techdocs-app/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/embedded-techdocs-app/CHANGELOG.md b/packages/embedded-techdocs-app/CHANGELOG.md new file mode 100644 index 0000000000..6a9583c2dc --- /dev/null +++ b/packages/embedded-techdocs-app/CHANGELOG.md @@ -0,0 +1,17 @@ +# embedded-techdocs-app + +## 0.2.53 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/cli@0.9.0 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-techdocs@0.12.6 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + - @backstage/test-utils@0.1.22 + - @backstage/app-defaults@0.1.1 + - @backstage/integration-react@0.1.14 diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml new file mode 100644 index 0000000000..02d68c940e --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -0,0 +1,12 @@ +# NOTE: This file is used for testing techdocs-cli locally + +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7007 + +techdocs: + builder: 'external' + requestUrl: http://localhost:7007/api diff --git a/packages/embedded-techdocs-app/app-config.yaml b/packages/embedded-techdocs-app/app-config.yaml new file mode 100644 index 0000000000..56e63ff97c --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.yaml @@ -0,0 +1,10 @@ +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:3000 + +techdocs: + builder: 'external' + requestUrl: http://localhost:3000/api diff --git a/packages/embedded-techdocs-app/cypress.json b/packages/embedded-techdocs-app/cypress.json new file mode 100644 index 0000000000..5de7ebffea --- /dev/null +++ b/packages/embedded-techdocs-app/cypress.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:3001", + "fixturesFolder": false, + "pluginsFile": false +} diff --git a/packages/embedded-techdocs-app/cypress/.eslintrc.json b/packages/embedded-techdocs-app/cypress/.eslintrc.json new file mode 100644 index 0000000000..2b3a458b95 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "plugins": ["cypress"], + "extends": ["plugin:cypress/recommended"], + "rules": { + "jest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "cy.contains"] + } + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true, + "optionalDependencies": true, + "peerDependencies": true, + "bundledDependencies": true + } + ] + } +} diff --git a/packages/embedded-techdocs-app/cypress/integration/app.js b/packages/embedded-techdocs-app/cypress/integration/app.js new file mode 100644 index 0000000000..d31f7a7964 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/integration/app.js @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('App', () => { + it('should render the catalog', () => { + cy.visit('/'); + cy.contains('My Company Service Catalog'); + }); +}); diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json new file mode 100644 index 0000000000..b2d7882f8f --- /dev/null +++ b/packages/embedded-techdocs-app/package.json @@ -0,0 +1,66 @@ +{ + "name": "embedded-techdocs-app", + "version": "0.2.53", + "private": true, + "bundled": true, + "dependencies": { + "@backstage/app-defaults": "^0.1.1", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.9.0", + "@backstage/config": "^0.1.10", + "@backstage/core-app-api": "^0.1.21", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-techdocs": "^0.12.6", + "@backstage/test-utils": "^0.1.22", + "@backstage/theme": "^0.2.11", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "history": "^5.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dom": "*", + "cross-env": "^7.0.0", + "cypress": "^7.3.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli app:serve --config ./app-config.yaml --config ./app-config.dev.yaml", + "build": "backstage-cli app:build --config ./app-config.yaml", + "build:dev": "backstage-cli app:build --config ./app-config.dev.yaml", + "clean": "backstage-cli clean", + "test": "backstage-cli test", + "lint": "backstage-cli lint", + "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", + "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "prettier": "@spotify/prettier-config", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/packages/embedded-techdocs-app/public/android-chrome-192x192.png b/packages/embedded-techdocs-app/public/android-chrome-192x192.png new file mode 100644 index 0000000000..eec0ae25b9 Binary files /dev/null and b/packages/embedded-techdocs-app/public/android-chrome-192x192.png differ diff --git a/packages/embedded-techdocs-app/public/apple-touch-icon.png b/packages/embedded-techdocs-app/public/apple-touch-icon.png new file mode 100644 index 0000000000..3158830ac7 Binary files /dev/null and b/packages/embedded-techdocs-app/public/apple-touch-icon.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-16x16.png b/packages/embedded-techdocs-app/public/favicon-16x16.png new file mode 100644 index 0000000000..58cf61a35e Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-16x16.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-32x32.png b/packages/embedded-techdocs-app/public/favicon-32x32.png new file mode 100644 index 0000000000..c0915ece75 Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-32x32.png differ diff --git a/packages/embedded-techdocs-app/public/favicon.ico b/packages/embedded-techdocs-app/public/favicon.ico new file mode 100644 index 0000000000..5e45e5dfbd Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon.ico differ diff --git a/packages/embedded-techdocs-app/public/index.html b/packages/embedded-techdocs-app/public/index.html new file mode 100644 index 0000000000..a6102f010d --- /dev/null +++ b/packages/embedded-techdocs-app/public/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + <%= app.title %> + + + +
+ + + diff --git a/packages/embedded-techdocs-app/public/manifest.json b/packages/embedded-techdocs-app/public/manifest.json new file mode 100644 index 0000000000..4a7c1b4ec4 --- /dev/null +++ b/packages/embedded-techdocs-app/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Backstage", + "name": "Backstage", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/embedded-techdocs-app/public/robots.txt b/packages/embedded-techdocs-app/public/robots.txt new file mode 100644 index 0000000000..01b0f9a107 --- /dev/null +++ b/packages/embedded-techdocs-app/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/embedded-techdocs-app/public/safari-pinned-tab.svg b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg new file mode 100644 index 0000000000..0f500b3002 --- /dev/null +++ b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg @@ -0,0 +1 @@ +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/embedded-techdocs-app/src/App.test.tsx b/packages/embedded-techdocs-app/src/App.test.tsx new file mode 100644 index 0000000000..f177433d95 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { renderWithEffects } from '@backstage/test-utils'; +import App from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7007' }, + techdocs: { + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = await renderWithEffects(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/packages/embedded-techdocs-app/src/App.tsx b/packages/embedded-techdocs-app/src/App.tsx new file mode 100644 index 0000000000..00b232d8e1 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Navigate, Route } from 'react-router'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + +import { + DefaultTechDocsHome, + TechDocsIndexPage, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { apis } from './apis'; +import { Root } from './components/Root'; +import { techDocsPage } from './components/TechDocsPage'; +import * as plugins from './plugins'; + +const app = createApp({ + apis, + plugins: Object.values(plugins), +}); + +const AppProvider = app.getProvider(); +const AppRouter = app.getRouter(); + +const routes = ( + + + {/* we need this route as TechDocs header links relies on it */} + } + /> + }> + + + } + > + {techDocsPage} + + +); + +const App = () => ( + + + {routes} + + +); + +export default App; diff --git a/packages/embedded-techdocs-app/src/apis.ts b/packages/embedded-techdocs-app/src/apis.ts new file mode 100644 index 0000000000..6234e3b719 --- /dev/null +++ b/packages/embedded-techdocs-app/src/apis.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + scmIntegrationsApiRef, + ScmIntegrationsApi, +} from '@backstage/integration-react'; +import { + AnyApiFactory, + configApiRef, + createApiFactory, + DiscoveryApi, + discoveryApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + SyncResult, + TechDocsApi, + techdocsApiRef, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs'; + +// TODO: Export type from plugin-techdocs and import this here +// import { ParsedEntityId } from '@backstage/plugin-techdocs' + +/** + * Note: Override TechDocs API to use local mkdocs server instead of techdocs-backend. + */ + +class TechDocsDevStorageApi implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + + async getEntityDocs(_entityId: EntityName, path: string) { + const apiOrigin = await this.getApiOrigin(); + // Irrespective of the entity, use mkdocs server to find the file for the path. + const url = `${apiOrigin}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + async syncEntityDocs(_: EntityName): Promise { + // this is just stub of this function as we don't need to check if docs are up to date, + // we always want to retrigger a new build + return 'cached'; + } + + // Used by transformer to modify the request to assets (CSS, Image) from inside the HTML. + async getBaseUrl( + oldBaseUrl: string, + _entityId: EntityName, + path: string, + ): Promise { + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${path}`).toString(); + } +} + +class TechDocsDevApi implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getEntityMetadata(_entityId: any) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'local', + }, + spec: { + owner: 'test', + lifecycle: 'experimental', + }, + }; + } + + async getTechDocsMetadata(_entityId: EntityName) { + return { + site_name: 'Live preview environment', + site_description: '', + }; + } +} + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevStorageApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: techdocsApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +]; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx new file mode 100644 index 0000000000..c7b1c846c4 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx new file mode 100644 index 0000000000..073cf6edad --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/embedded-techdocs-app/src/components/Root/Root.tsx b/packages/embedded-techdocs-app/src/components/Root/Root.tsx new file mode 100644 index 0000000000..65e75a488a --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/Root.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; +import { + Sidebar, + SidebarPage, + sidebarConfig, + SidebarContext, + SidebarItem, + SidebarDivider, +} from '@backstage/core-components'; +import { NavLink } from 'react-router-dom'; + +const useSidebarLogoStyles = makeStyles({ + root: { + width: sidebarConfig.drawerWidthClosed, + height: 3 * sidebarConfig.logoHeight, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: -14, + }, + link: { + width: sidebarConfig.drawerWidthClosed, + marginLeft: 24, + }, +}); + +const SidebarLogo = () => { + const classes = useSidebarLogoStyles(); + const { isOpen } = useContext(SidebarContext); + + return ( +
+ + {isOpen ? : } + +
+ ); +}; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + {/* Global nav, not org-specific */} + + {/* End global nav */} + + {children} + +); diff --git a/packages/embedded-techdocs-app/src/components/Root/index.ts b/packages/embedded-techdocs-app/src/components/Root/index.ts new file mode 100644 index 0000000000..dff706f08f --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Root } from './Root'; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx new file mode 100644 index 0000000000..37e5f233d7 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -0,0 +1,54 @@ +/* + * 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 { Content } from '@backstage/core-components'; + +import { + Reader, + TechDocsPage, + TechDocsPageHeader, +} from '@backstage/plugin-techdocs'; + +const DefaultTechDocsPage = () => { + const techDocsMetadata = { + site_name: 'Live preview environment', + site_description: '', + }; + + return ( + + {({ entityRef, onReady }) => ( + <> + + + + + + )} + + ); +}; + +export const techDocsPage = ; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts new file mode 100644 index 0000000000..421e244613 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './TechDocsPage'; diff --git a/packages/embedded-techdocs-app/src/index.tsx b/packages/embedded-techdocs-app/src/index.tsx new file mode 100644 index 0000000000..b15bc4c102 --- /dev/null +++ b/packages/embedded-techdocs-app/src/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/embedded-techdocs-app/src/plugins.ts b/packages/embedded-techdocs-app/src/plugins.ts new file mode 100644 index 0000000000..42fc16b339 --- /dev/null +++ b/packages/embedded-techdocs-app/src/plugins.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; diff --git a/packages/embedded-techdocs-app/src/setupTests.ts b/packages/embedded-techdocs-app/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/embedded-techdocs-app/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 4984919e6a..97b011bad6 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -33,8 +33,11 @@ export type ErrorLike = { [unknownKeys: string]: unknown; }; +// @public @deprecated +export type ErrorResponse = ErrorResponseBody; + // @public -export type ErrorResponse = { +export type ErrorResponseBody = { error: SerializedError; request?: { method: string; @@ -65,19 +68,27 @@ export class NotFoundError extends CustomErrorBase {} // @public export class NotModifiedError extends CustomErrorBase {} -// @public +// @public @deprecated export function parseErrorResponse(response: Response): Promise; +// @public +export function parseErrorResponseBody( + response: Response, +): Promise; + // @public export class ResponseError extends Error { + // @deprecated constructor(props: { message: string; response: Response; - data: ErrorResponse; + data: ErrorResponseBody; cause: Error; }); + readonly body: ErrorResponseBody; readonly cause: Error; - readonly data: ErrorResponse; + // @deprecated + get data(): ErrorResponseBody; static fromResponse(response: Response): Promise; readonly response: Response; } diff --git a/packages/errors/package.json b/packages/errors/package.json index fe62bb3bba..38fe6c634e 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -35,7 +35,7 @@ "serialize-error": "^8.0.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/jest": "^26.0.7" }, "files": [ diff --git a/packages/errors/src/errors/ResponseError.ts b/packages/errors/src/errors/ResponseError.ts index f74ad2fa0c..a20035cd8c 100644 --- a/packages/errors/src/errors/ResponseError.ts +++ b/packages/errors/src/errors/ResponseError.ts @@ -14,16 +14,16 @@ * limitations under the License. */ +import { deserializeError } from '../serialization'; import { - parseErrorResponse, - ErrorResponse, - deserializeError, -} from '../serialization'; + ErrorResponseBody, + parseErrorResponseBody, +} from '../serialization/response'; /** * An error thrown as the result of a failed server request. * - * The server is expected to respond on the ErrorResponse format. + * The server is expected to respond on the ErrorResponseBody format. * * @public */ @@ -39,7 +39,7 @@ export class ResponseError extends Error { /** * The parsed JSON error body, as sent by the server. */ - readonly data: ErrorResponse; + readonly body: ErrorResponseBody; /** * The Error cause, as seen by the remote server. This is parsed out of the @@ -60,7 +60,7 @@ export class ResponseError extends Error { * been consumed before. */ static async fromResponse(response: Response): Promise { - const data = await parseErrorResponse(response); + const data = await parseErrorResponseBody(response); const status = data.response.statusCode || response.status; const statusText = data.error.name || response.statusText; @@ -75,16 +75,28 @@ export class ResponseError extends Error { }); } + /** + * @deprecated will be removed. + **/ constructor(props: { message: string; response: Response; - data: ErrorResponse; + data: ErrorResponseBody; cause: Error; }) { super(props.message); this.name = 'ResponseError'; this.response = props.response; - this.data = props.data; + this.body = props.data; this.cause = props.cause; } + /** + * The parsed JSON error body, as sent by the server. + * @deprecated use body instead. + */ + get data(): ErrorResponseBody { + // eslint-disable-next-line no-console + console.warn('ErrorResponse.data is deprecated, use .body instead.'); + return this.body; + } } diff --git a/packages/errors/src/serialization/index.ts b/packages/errors/src/serialization/index.ts index d37b04297a..b86661d496 100644 --- a/packages/errors/src/serialization/index.ts +++ b/packages/errors/src/serialization/index.ts @@ -16,5 +16,5 @@ export { deserializeError, serializeError, stringifyError } from './error'; export type { SerializedError } from './error'; -export { parseErrorResponse } from './response'; -export type { ErrorResponse } from './response'; +export { parseErrorResponse, parseErrorResponseBody } from './response'; +export type { ErrorResponse, ErrorResponseBody } from './response'; diff --git a/packages/errors/src/serialization/response.test.ts b/packages/errors/src/serialization/response.test.ts index 54a3641657..fe1cc1e9c4 100644 --- a/packages/errors/src/serialization/response.test.ts +++ b/packages/errors/src/serialization/response.test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { parseErrorResponse, ErrorResponse } from './response'; +import { parseErrorResponseBody, ErrorResponseBody } from './response'; -describe('parseErrorResponse', () => { +describe('parseErrorResponseBody', () => { it('handles the happy path', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Fours', message: 'Expected fives', stack: 'lines' }, request: { method: 'GET', url: '/' }, response: { statusCode: 444 }, @@ -31,13 +31,13 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual( + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( body, ); }); it('uses request header and text body when wrong content type, even if parsable', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Threes', message: 'Expected twos' }, request: { method: 'GET', url: '/' }, response: { statusCode: 333 }, @@ -50,19 +50,21 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'not-application/not-json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours, ${JSON.stringify( - body, - )}`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours, ${JSON.stringify( + body, + )}`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); it('uses request header and text body when not parsable', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Threes', message: 'Expected twos' }, request: { method: 'GET', url: '/' }, response: { statusCode: 333 }, @@ -75,15 +77,17 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours, ${JSON.stringify( - body, - ).substring(1)}`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours, ${JSON.stringify( + body, + ).substring(1)}`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); it('uses request header when failing to get body', async () => { @@ -96,12 +100,14 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); }); diff --git a/packages/errors/src/serialization/response.ts b/packages/errors/src/serialization/response.ts index 50bf8f7ab1..4a1731646d 100644 --- a/packages/errors/src/serialization/response.ts +++ b/packages/errors/src/serialization/response.ts @@ -20,8 +20,16 @@ import { SerializedError } from './error'; * A standard shape of JSON data returned as the body of backend errors. * * @public + * @deprecated - Use {@link ErrorResponseBody} instead. */ -export type ErrorResponse = { +export type ErrorResponse = ErrorResponseBody; + +/** + * A standard shape of JSON data returned as the body of backend errors. + * + * @public + */ +export type ErrorResponseBody = { /** Details of the error that was caught */ error: SerializedError; @@ -51,10 +59,29 @@ export type ErrorResponse = { * * @public * @param response - The response of a failed request + * @deprecated - Use {@link parseErrorResponseBody} instead. */ export async function parseErrorResponse( response: Response, ): Promise { + return parseErrorResponseBody(response); +} + +/** + * Attempts to construct an ErrorResponseBody out of a failed server request. + * Assumes that the response has already been checked to be not ok. This + * function consumes the body of the response, and assumes that it hasn't + * been consumed before. + * + * The code is forgiving, and constructs a useful synthetic body as best it can + * if the response body wasn't on the expected form. + * + * @public + * @param response - The response of a failed request + */ +export async function parseErrorResponseBody( + response: Response, +): Promise { try { const text = await response.text(); if (text) { @@ -73,7 +100,7 @@ export async function parseErrorResponse( return { error: { - name: 'Unknown', + name: 'Error', message: `Request failed with status ${response.status} ${response.statusText}, ${text}`, }, response: { @@ -87,7 +114,7 @@ export async function parseErrorResponse( return { error: { - name: 'Unknown', + name: 'Error', message: `Request failed with status ${response.status} ${response.statusText}`, }, response: { diff --git a/packages/integration-react/CHANGELOG.md b/packages/integration-react/CHANGELOG.md index a2a0d0c368..e972b4d4e8 100644 --- a/packages/integration-react/CHANGELOG.md +++ b/packages/integration-react/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/integration-react +## 0.1.14 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.13 ### Patch Changes diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index b703203ab2..f3c79cc56c 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration-react", "description": "Frontend package for managing integrations towards external systems", - "version": "0.1.13", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,8 +22,8 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/integration": "^0.6.9", "@backstage/theme": "^0.2.12", "@material-ui/core": "^4.12.2", @@ -34,9 +34,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/integration/package.json b/packages/integration/package.json index bd49ecc419..4ccde52a70 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -39,9 +39,9 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/config-loader": "^0.7.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/config-loader": "^0.8.0", + "@backstage/test-utils": "^0.1.22", "@types/jest": "^26.0.7", "@types/luxon": "^2.0.4", "msw": "^0.35.0" diff --git a/packages/search-common/package.json b/packages/search-common/package.json index e913ef66ff..d65e9bcbdc 100644 --- a/packages/search-common/package.json +++ b/packages/search-common/package.json @@ -39,7 +39,7 @@ "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.9.0" }, "jest": { "roots": [ diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 41b8433594..2750324c17 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -1,6 +1,5 @@ import { AlertApiForwarder, - ApiRegistry, ErrorAlerter, ErrorApiForwarder, GithubAuth, @@ -27,78 +26,57 @@ import { configApiRef, } from '@backstage/core-plugin-api'; -const builder = ApiRegistry.builder(); - -builder.add(configApiRef, new ConfigReader({})); - -const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - -builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); - -builder.add(identityApiRef, { +const configApi = new ConfigReader({}); +const alertApi = new AlertApiForwarder(); +const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); +const identityApi = { getUserId: () => 'guest', getProfile: () => ({ email: 'guest@example.com' }), getIdToken: () => undefined, signOut: async () => {}, +}; +const oauthRequestApi = new OAuthRequestManager(); +const googleAuthApi = GoogleAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const githubAuthApi = GithubAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const gitlabAuthApi = GitlabAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const oktaAuthApi = OktaAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const auth0AuthApi = Auth0Auth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const oauth2Api = OAuth2.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, }); -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); - -builder.add( - googleAuthApiRef, - GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - githubAuthApiRef, - GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - gitlabAuthApiRef, - GitlabAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - oktaAuthApiRef, - OktaAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - auth0AuthApiRef, - Auth0Auth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - oauth2ApiRef, - OAuth2.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -export const apis = builder.build(); +export const apis = [ + [configApiRef, configApi], + [alertApiRef, alertApi], + [errorApiRef, errorApi], + [identityApiRef, identityApi], + [oauthRequestApiRef, oauthRequestApi], + [googleAuthApiRef, googleAuthApi], + [githubAuthApiRef, githubAuthApi], + [gitlabAuthApiRef, gitlabAuthApi], + [oktaAuthApiRef, oktaAuthApi], + [auth0AuthApiRef, auth0AuthApi], + [oauth2ApiRef, oauth2Api], +]; diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 5bdc6b3a2f..72ff42311d 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -1,23 +1,30 @@ const path = require('path'); const WebpackPluginFailBuildOnWarning = require('./webpack-plugin-fail-build-on-warning'); +/** + * This set of stories are the ones that we publish to backstage.io. + */ +const BACKSTAGE_CORE_STORIES = [ + 'packages/core-components', + 'plugins/org', + 'plugins/search', +]; + module.exports = ({ args }) => { // Calling storybook with no args causes our default list of stories to be used. // This set of stories are the ones that we publish to backstage.io // // If it's called with args, each arg should be the path to a package that we will // show the stories from, for example `yarn storybook plugins/catalog`. - let stories; - if (args.length === 0) { - stories = [ - '../../core-components/src/**/*.stories.tsx', - '../../../plugins/org/src/**/*.stories.tsx', - '../../../plugins/search/src/**/*.stories.tsx', - ]; - } else { - const rootDir = path.resolve(__dirname, '../../..'); - stories = args.map(arg => path.join(rootDir, arg, 'src/**/*.stories.tsx')); - } + + const rootPath = '../../..'; + const storiesSrcGlob = 'src/**/*.stories.tsx'; + + const getStoriesPath = package => + path.posix.join(rootPath, package, storiesSrcGlob); + + const packages = args.length === 0 ? BACKSTAGE_CORE_STORIES : args; + const stories = packages.map(getStoriesPath); return { stories, diff --git a/packages/storybook/.storybook/preview.js b/packages/storybook/.storybook/preview.js index 6186b82ca5..2b1a4af329 100644 --- a/packages/storybook/.storybook/preview.js +++ b/packages/storybook/.storybook/preview.js @@ -6,17 +6,17 @@ import { useDarkMode } from 'storybook-dark-mode'; import { apis } from './apis'; import { Content, AlertDisplay } from '@backstage/core-components'; -import { ApiProvider } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; addDecorator(story => ( - + {story()} - + )); addParameters({ diff --git a/packages/techdocs-cli/.eslintrc.js b/packages/techdocs-cli/.eslintrc.js new file mode 100644 index 0000000000..884f559c0f --- /dev/null +++ b/packages/techdocs-cli/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + 'no-restricted-imports': 0, + }, + }, + ], +}; diff --git a/packages/techdocs-cli/.snyk b/packages/techdocs-cli/.snyk new file mode 100644 index 0000000000..cfb30e5aa7 --- /dev/null +++ b/packages/techdocs-cli/.snyk @@ -0,0 +1,17 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-BROWSERSLIST-1090194: + - '*': + reason: Developer tools are not a valid target for ReDoS attacks + expires: 2022-05-20T00:00:00.000Z + created: 2021-11-20T00:00:00.000Z + + SNYK-JS-IMMER-1540542: + - '*': + reason: Prototype pollution is not an effective attack against a CLI as it already executes arbitrary code + expires: 2022-05-20T00:00:00.000Z + created: 2021-11-20T00:00:00.000Z + +patch: {} diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md new file mode 100644 index 0000000000..6d69e452ad --- /dev/null +++ b/packages/techdocs-cli/CHANGELOG.md @@ -0,0 +1,108 @@ +# @techdocs/cli + +## 0.8.6 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 1578ad341b: Add support for specifying bucketRootPath for AWS and GCS publishers +- f2694e3750: Adds ability to use encrypted S3 buckets by utilizing the SSE option in the AWS SDK +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/techdocs-common@0.10.7 + +## 0.8.5 + +### Patch Changes + +- Reunified the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo code back into the main [backstage](https://github.com/backstage/backstage) repo + + See [7288](https://github.com/backstage/backstage/issues/7288)). The changes include some internal refactoring that do not affect functionality beyond the local development setup. + +## 0.8.4 + +### Patch Changes + +- 8333394: The [change](https://github.com/backstage/techdocs-cli/commit/b25014cec313d46ce1c9b4f324cc09047a00fc1f) updated the `@backstage/techdocs-common` from version `0.9.0` to `0.10.2` and one of the intermediate versions, the [0.10.0](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#patch-changes-2), introduced the use of search in context that requires an implementation for the Search API. + + Created a custom techdocs page to disable search in the Reader component, preventing it from using the Search API, as we don't want to provide search in preview mode. + +## 0.8.3 + +### Patch Changes + +- edbb988: Upgrades the techdocs common page to the latest version 0.10.2. + + See [@backstage/techdocs-common changelog](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#L3). + +- db4ebfc: Add an `etag` flag to the `generate` command that is stored in the `techdocs_metadata.json` file. + +## 0.8.2 + +### Patch Changes + +- 8fc7384: Allow to execute techdocs-cli serve using docker techdocs-container on Windows + +## 0.8.1 + +### Patch Changes + +- 0187424: Separate build and publish release steps + +## 0.8.0 + +### Minor Changes + +- c6f437a: OpenStack Swift configuration changed due to OSS SDK Client change in @backstage/techdocs-common, it was a breaking change. + PR Reference: https://github.com/backstage/backstage/pull/6839 + +### Patch Changes + +- 05f0409: Merge Jobs for Release Pull Requests and Package Publishes + +## 0.7.0 + +### Minor Changes + +- 9d1f8d8: The `techdocs-cli publish` command will now publish TechDocs content to remote + storage using the lowercase'd entity triplet as the storage path. This is in + line with the beta release of the TechDocs plugin (`v0.11.0`). + + If you have been running `techdocs-cli` prior to this version, you will need to + follow this [migration guide](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). + +## 0.6.2 + +### Patch Changes + +- f1bcf1a: Changelog (from v0.6.1 to v0.6.2) + + #### :bug: Bug Fix + + - `techdocs-cli` + - [#105](https://github.com/backstage/techdocs-cli/pull/105) Add azureAccountKey parameter back to the publish command ([@emmaindal](https://github.com/emmaindal)) + + #### :house: Internal + + - `embedded-techdocs-app` + - [#122](https://github.com/backstage/techdocs-cli/pull/122) chore(deps-dev): bump @types/node from 12.20.20 to 16.7.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#120](https://github.com/backstage/techdocs-cli/pull/120) chore(deps-dev): bump @types/react-dom from 16.9.14 to 17.0.9 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#119](https://github.com/backstage/techdocs-cli/pull/119) chore(deps-dev): bump @testing-library/user-event from 12.8.3 to 13.2.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#118](https://github.com/backstage/techdocs-cli/pull/118) chore(deps-dev): bump @testing-library/react from 10.4.9 to 12.0.0 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - Other + - [#117](https://github.com/backstage/techdocs-cli/pull/117) chore(deps): bump @backstage/plugin-catalog from 0.6.11 to 0.6.12 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#124](https://github.com/backstage/techdocs-cli/pull/124) Update release process docs ([@emmaindal](https://github.com/emmaindal)) + - [#116](https://github.com/backstage/techdocs-cli/pull/116) ignore dependabot branches for project board workflow ([@emmaindal](https://github.com/emmaindal)) + - [#106](https://github.com/backstage/techdocs-cli/pull/106) Configure dependabot for all packages ([@emmaindal](https://github.com/emmaindal)) + - [#102](https://github.com/backstage/techdocs-cli/pull/102) readme: add information about running techdocs-common locally ([@vcapretz](https://github.com/vcapretz)) + - [#103](https://github.com/backstage/techdocs-cli/pull/103) Introduce changesets and improve the publish workflow ([@minkimcello](https://github.com/minkimcello)) + - [#101](https://github.com/backstage/techdocs-cli/pull/101) update yarn lockfile to get rid of old version of node-forge ([@emmaindal](https://github.com/emmaindal)) + + #### Committers: 3 + + Thank you for contributing โค๏ธ + + - `Emma Indal` ([@emmaindal](https://github.com/emmaindal)) + - `Min Kim` ([@minkimcello](https://github.com/minkimcello)) + - `Vitor Capretz` ([@vcapretz](https://github.com/vcapretz)) diff --git a/packages/techdocs-cli/README.md b/packages/techdocs-cli/README.md new file mode 100644 index 0000000000..6f070147eb --- /dev/null +++ b/packages/techdocs-cli/README.md @@ -0,0 +1,68 @@ +# techdocs-cli + +[![NPM Version badge](https://img.shields.io/npm/v/@techdocs/cli)](https://www.npmjs.com/package/@techdocs/cli) + +## Usage + +See [techdocs-cli usage docs](https://backstage.io/docs/features/techdocs/cli). + +## Development + +NOTE: When we build `techdocs-cli` it copies the output `embedded-techdocs-app` +bundle into the `packages/techdocs-cli/dist` which is then published with the +`@techdocs/cli` npm package. + +### Running + +```sh +# From the root of this repository run +# NOTE: This will build the embedded-techdocs-app and copy the output into the cli dist directory +yarn build --scope @techdocs/cli + +# Now execute the binary +packages/techdocs-cli/bin/techdocs-cli + +# ... or as a shell alias in ~/.zshrc or ~/.zprofile or ~/.bashrc or similar +export PATH=/path/to/backstage/packages/techdocs-cli/bin:$PATH +``` + +If you want to test live test changes to the `packages/embedded-techdocs-app` +you can serve the app and run the CLI using the following commands: + +```sh +# Open a shell to the embedded-techdocs-app directory +cd packages/embedded-techdocs-app + +# Run the embedded-techdocs-app using dev mode +yarn start + +# In another shell use the techdocs-cli from the root of this repo +yarn techdocs-cli:dev [...options] +``` + +### Testing + +Running unit tests requires mkdocs to be installed locally: + +```sh +pip install mkdocs +pip install mkdocs-techdocs-core +``` + +Then run `yarn test`. + +### Use an example docs project + +We have created an [example documentation project](https://github.com/backstage/techdocs-container/tree/main/mock-docs) and it's shipped with [techdocs-container](https://github.com/backstage/techdocs-container) repository, for the purpose of local development. But you are free to create your own local test site. All it takes is a `docs/index.md` and `mkdocs.yml` in a directory. + +```sh +git clone https://github.com/backstage/techdocs-container.git + +cd techdocs-container/mock-docs + +# To get a view of your docs in Backstage, use: +techdocs-cli serve + +# To view the raw mkdocs site (without Backstage), use: +techdocs-cli serve:mkdocs +``` diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli new file mode 100755 index 0000000000..6c0bbd37b5 --- /dev/null +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal) { + require('..'); +} else { + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json new file mode 100644 index 0000000000..70ca0a9668 --- /dev/null +++ b/packages/techdocs-cli/package.json @@ -0,0 +1,72 @@ +{ + "name": "@techdocs/cli", + "description": "Utility CLI for managing TechDocs sites in Backstage.", + "version": "0.8.6", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-cli" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "types": "", + "scripts": { + "start": "nodemon --", + "build": "./scripts/build.sh", + "clean": "backstage-cli clean", + "lint": "backstage-cli lint", + "test": "backstage-cli test --testPathIgnorePatterns=src/e2e.test.ts", + "test:e2e": "backstage-cli test src/e2e.test.ts", + "test:e2e:ci": "backstage-cli test --watchAll=false --ci src/e2e.test.ts" + }, + "bin": { + "techdocs-cli": "bin/techdocs-cli" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@types/commander": "^2.12.2", + "@types/fs-extra": "^9.0.6", + "@types/http-proxy": "^1.17.4", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dev-utils": "^9.0.4", + "@types/serve-handler": "^6.1.0", + "@types/webpack-env": "^1.15.3", + "embedded-techdocs-app": "0.2.53", + "find-process": "^1.4.5", + "nodemon": "^2.0.2", + "ts-node": "^10.0.0" + }, + "files": [ + "bin", + "dist" + ], + "nodemonConfig": { + "watch": "./src", + "exec": "bin/techdocs-cli", + "ext": "ts" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-model": "^0.9.7", + "@backstage/config": "^0.1.10", + "@backstage/techdocs-common": "^0.10.7", + "@types/dockerode": "^3.3.0", + "commander": "^6.1.0", + "dockerode": "^3.3.1", + "fs-extra": "^9.0.1", + "http-proxy": "^1.18.1", + "react-dev-utils": "^11.0.4", + "serve-handler": "^6.1.3", + "winston": "^3.2.1" + } +} diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh new file mode 100755 index 0000000000..630fef42f0 --- /dev/null +++ b/packages/techdocs-cli/scripts/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Copyright 2021 The Backstage Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +SCRIPT_DIR=$(dirname $0) +TECHDOCS_CLI_DIR="$SCRIPT_DIR"/.. +TECHDOCS_CLI_EMBEDDED_APP_DIR="$TECHDOCS_CLI_DIR"/../embedded-techdocs-app + +compile_and_build_cli() { + echo "๐Ÿ“„ Compiling..." + yarn workspace @techdocs/cli tsc > /dev/null + echo "๐Ÿ“ฆ๏ธ Building..." + pushd $TECHDOCS_CLI_DIR > /dev/null + npx backstage-cli build --outputs cjs > /dev/null + popd > /dev/null +} + +build_and_embed_app() { + echo "๐Ÿšš Embedding app..." + if [ "$TECHDOCS_CLI_DEV_MODE" = "true" ] ; then + yarn workspace embedded-techdocs-app build:dev > /dev/null + else + yarn workspace embedded-techdocs-app build > /dev/null + fi + cp -r "$TECHDOCS_CLI_EMBEDDED_APP_DIR"/dist "$TECHDOCS_CLI_DIR"/dist/techdocs-preview-bundle > /dev/null +} + +compile_and_build_cli +build_and_embed_app +echo "๐Ÿ Ready!" diff --git a/packages/techdocs-cli/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts new file mode 100644 index 0000000000..a255805139 --- /dev/null +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { resolve } from 'path'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import Docker from 'dockerode'; +import { + TechdocsGenerator, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import { DockerContainerRunner } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { + convertTechDocsRefToLocationAnnotation, + createLogger, +} from '../../lib/utility'; +import { stdout } from 'process'; + +export default async function generate(cmd: Command) { + // Use techdocs-common package to generate docs. Keep consistency between Backstage and CI generating docs. + // Docs can be prepared using actions/checkout or git clone, or similar paradigms on CI. The TechDocs CI workflow + // will run on the CI pipeline containing the documentation files. + + const logger = createLogger({ verbose: cmd.verbose }); + + const sourceDir = resolve(cmd.sourceDir); + const outputDir = resolve(cmd.outputDir); + const dockerImage = cmd.dockerImage; + const pullImage = cmd.pull; + + logger.info(`Using source dir ${sourceDir}`); + logger.info(`Will output generated files in ${outputDir}`); + + logger.verbose('Creating output directory if it does not exist.'); + + await fs.ensureDir(outputDir); + + const config = new ConfigReader({ + techdocs: { + generator: { + runIn: cmd.docker ? 'docker' : 'local', + dockerImage, + pullImage, + }, + }, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + let parsedLocationAnnotation = {} as ParsedLocationAnnotation; + if (cmd.techdocsRef) { + try { + parsedLocationAnnotation = convertTechDocsRefToLocationAnnotation( + cmd.techdocsRef, + ); + } catch (err) { + logger.error(err.message); + } + } + + // Generate docs using @backstage/techdocs-common + const techdocsGenerator = await TechdocsGenerator.fromConfig(config, { + logger, + containerRunner, + }); + + logger.info('Generating documentation...'); + + await techdocsGenerator.run({ + inputDir: sourceDir, + outputDir, + ...(cmd.techdocsRef + ? { + parsedLocationAnnotation, + } + : {}), + logger, + etag: cmd.etag, + ...(process.env.LOG_LEVEL === 'debug' ? { logStream: stdout } : {}), + }); + + logger.info('Done!'); +} diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts new file mode 100644 index 0000000000..d4f41cf7a3 --- /dev/null +++ b/packages/techdocs-cli/src/commands/index.ts @@ -0,0 +1,247 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CommanderStatic } from 'commander'; +import { TechdocsGenerator } from '@backstage/techdocs-common'; + +const defaultDockerImage = TechdocsGenerator.defaultDockerImage; + +export function registerCommands(program: CommanderStatic) { + program + .command('generate') + .description('Generate TechDocs documentation site using MkDocs.') + .option( + '--source-dir ', + 'Source directory containing mkdocs.yml and docs/ directory.', + '.', + ) + .option( + '--output-dir ', + 'Output directory containing generated TechDocs site.', + './site/', + ) + .option( + '--docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option('--no-pull', 'Do not pull the latest docker image', false) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable and plugins in current user environment.', + ) + .option( + '--techdocs-ref ', + 'The repository hosting documentation source files e.g. github:https://ghe.mycompany.net.com/org/repo.' + + '\nThis value is same as the backstage.io/techdocs-ref annotation of the corresponding Backstage entity.' + + '\nIt is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml if not found.\n', + ) + .option( + '--etag ', + 'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .alias('build') + .action(lazy(() => import('./generate/generate').then(m => m.default))); + + program + .command('migrate') + .description( + 'Migrate objects with case-sensitive entity triplets to lower-case versions.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--awsBucketRootPath', + 'Optional sub-directory to store files in Amazon S3', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--removeOriginal', + 'Optional Files are copied by default. If flag is set, files are renamed/moved instead.', + false, + ) + .option( + '--concurrency ', + 'Optional Controls the number of API requests allowed to be performed simultaneously.', + '25', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./migrate/migrate').then(m => m.default))); + + program + .command('publish') + .description( + 'Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .requiredOption( + '--entity ', + '(Required always) Entity uid separated by / in namespace/kind/name order (case-sensitive). Example: default/Component/myEntity ', + ) + .option( + '--legacyUseCaseSensitiveTripletPaths', + 'Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). Only use if your TechDocs backend is configured the same way.', + false, + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option('--awsS3sse ', 'Optional AWS S3 Server Side Encryption.') + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--gcsBucketRootPath', + 'Optional sub-directory to store files in Google cloud storage', + ) + .option( + '--directory ', + 'Path of the directory containing generated files to publish', + './site/', + ) + .action(lazy(() => import('./publish/publish').then(m => m.default))); + + program + .command('serve:mkdocs') + .description('Serve a documentation project locally using MkDocs serve.') + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, run `mkdocs serve` in current user environment.', + ) + .option('-p, --port ', 'Port to serve documentation locally', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/mkdocs').then(m => m.default))); + + program + .command('serve') + .description( + 'Serve a documentation project locally in a Backstage app-like environment', + ) + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable in current user environment.', + ) + .option('--mkdocs-port ', 'Port for MkDocs server to use', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/serve').then(m => m.default))); +} + +// Wraps an action function so that it always exits and handles errors +// Humbly taken from backstage-cli's registerCommands +function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + process.exit(0); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error.message); + process.exit(1); + } + }; +} diff --git a/packages/techdocs-cli/src/commands/migrate/migrate.ts b/packages/techdocs-cli/src/commands/migrate/migrate.ts new file mode 100644 index 0000000000..1cd8fca65b --- /dev/null +++ b/packages/techdocs-cli/src/commands/migrate/migrate.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function migrate(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + if (!publisher.migrateDocsCase) { + throw new Error(`Migration not implemented for ${cmd.publisherType}`); + } + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + throw new Error(''); + } + + // Validate and parse migration arguments. + const removeOriginal = cmd.removeOriginal; + const numericConcurrency = parseInt(cmd.concurrency, 10); + + if (!Number.isInteger(numericConcurrency) || numericConcurrency <= 0) { + throw new Error( + `Concurrency must be a number greater than 1. ${cmd.concurrency} provided.`, + ); + } + + await publisher.migrateDocsCase({ + concurrency: numericConcurrency, + removeOriginal, + }); +} diff --git a/packages/techdocs-cli/src/commands/publish/publish.ts b/packages/techdocs-cli/src/commands/publish/publish.ts new file mode 100644 index 0000000000..11a47d4a71 --- /dev/null +++ b/packages/techdocs-cli/src/commands/publish/publish.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { resolve } from 'path'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Entity } from '@backstage/catalog-model'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function publish(cmd: Command): Promise { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + return Promise.reject(new Error('')); + } + + const [namespace, kind, name] = cmd.entity.split('/'); + const entity = { + kind, + metadata: { + namespace, + name, + }, + } as Entity; + + const directory = resolve(cmd.directory); + await publisher.publish({ entity, directory }); + + return true; +} diff --git a/packages/techdocs-cli/src/commands/serve/mkdocs.ts b/packages/techdocs-cli/src/commands/serve/mkdocs.ts new file mode 100644 index 0000000000..f6779c5146 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/mkdocs.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Command } from 'commander'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { createLogger } from '../../lib/utility'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; + +export default async function serveMkdocs(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const dockerAddr = `http://0.0.0.0:${cmd.port}`; + const localAddr = `http://127.0.0.1:${cmd.port}`; + const expectedDevAddr = cmd.docker ? dockerAddr : localAddr; + // We want to open browser only once based on a log. + let boolOpenBrowserTriggered = false; + + const logFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line in between + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + // Logs from container is verbose. + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !boolOpenBrowserTriggered && + line.includes(`Serving on ${expectedDevAddr}`) + ) { + // Always open the local address, since 0.0.0.0 belongs to docker + logger.info(`\nStarting mkdocs server on ${localAddr}\n`); + openBrowser(localAddr); + boolOpenBrowserTriggered = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + + // Commander stores --no-docker in cmd.docker variable + const childProcess = await runMkdocsServer({ + port: cmd.port, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: logFunc, + stderrLogFunc: logFunc, + }); + + // Keep waiting for user to cancel the process + await waitForSignal([childProcess]); +} diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts new file mode 100644 index 0000000000..4d71de3e52 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -0,0 +1,125 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Command } from 'commander'; +import path from 'path'; +import openBrowser from 'react-dev-utils/openBrowser'; +import HTTPServer from '../../lib/httpServer'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; +import { createLogger } from '../../lib/utility'; + +export default async function serve(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + // Determine if we want to run in local dev mode or not + // This will run the backstage http server on a different port and only used + // for proxying mkdocs to the backstage app running locally (e.g. with webpack-dev-server) + const isDevMode = Object.keys(process.env).includes('TECHDOCS_CLI_DEV_MODE') + ? true + : false; + + // TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle + // a backstage app, we define app.baseUrl in the app-config.yaml. + // Hence, it is complicated to make this configurable. + const backstagePort = 3000; + const backstageBackendPort = 7007; + + const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`; + const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`; + const mkdocsExpectedDevAddr = cmd.docker ? mkdocsDockerAddr : mkdocsLocalAddr; + + let mkdocsServerHasStarted = false; + const mkdocsLogFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !mkdocsServerHasStarted && + line.includes(`Serving on ${mkdocsExpectedDevAddr}`) + ) { + mkdocsServerHasStarted = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + logger.info('Starting mkdocs server.'); + const mkdocsChildProcess = await runMkdocsServer({ + port: cmd.mkdocsPort, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: mkdocsLogFunc, + stderrLogFunc: mkdocsLogFunc, + }); + + // Wait until mkdocs server has started so that Backstage starts with docs loaded + // Takes 1-5 seconds + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 3000)); + if (mkdocsServerHasStarted) { + break; + } + logger.info('Waiting for mkdocs server to start...'); + } + + if (!mkdocsServerHasStarted) { + logger.error( + 'mkdocs server did not start. Exiting. Try re-running command with -v option for more details.', + ); + } + + // Run the embedded-techdocs Backstage app + const techdocsPreviewBundlePath = path.join( + path.dirname(require.resolve('@techdocs/cli/package.json')), + 'dist', + 'techdocs-preview-bundle', + ); + + const port = isDevMode ? backstageBackendPort : backstagePort; + const httpServer = new HTTPServer( + techdocsPreviewBundlePath, + port, + cmd.mkdocsPort, + cmd.verbose, + ); + + httpServer + .serve() + .catch(err => { + logger.error(err); + mkdocsChildProcess.kill(); + process.exit(1); + }) + .then(() => { + // The last three things default/component/local/ don't matter. They can be anything. + openBrowser(`http://localhost:${port}/docs/default/component/local/`); + logger.info( + `Serving docs in Backstage at http://localhost:${port}/docs/default/component/local/\nOpening browser.`, + ); + }); + + await waitForSignal([mkdocsChildProcess]); +} diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts new file mode 100644 index 0000000000..088dbc44b8 --- /dev/null +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { execSync, spawn, SpawnOptionsWithoutStdio } from 'child_process'; +import path from 'path'; + +import findProcess from 'find-process'; + +const executeCommand = ( + command: string, + args: string[], + options?: SpawnOptionsWithoutStdio, +): Promise<{ + exit: number; + stdout: string; + stderr: string; +}> => { + return new Promise(resolve => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + const shell = process.platform === 'win32'; + const proc = spawn(command, args, { ...options, shell }); + + proc.stdout?.on('data', data => { + stdout.push(Buffer.from(data)); + }); + + proc.stderr?.on('data', data => { + stderr.push(Buffer.from(data)); + }); + + proc.on('exit', code => { + resolve({ + exit: code ?? 0, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +}; + +const timeout = 25000; + +jest.setTimeout(timeout * 2); + +describe('end-to-end', () => { + const cwd = path.resolve(__dirname, 'fixture'); + + afterEach(async () => { + // On Windows the pid of a spawned process may be wrong + // Because of this, we should be kill the MKDocs after the test + // (e.g. https://github.com/nodejs/node/issues/4289#issuecomment-854270414) + if (process.platform === 'win32') { + const procs = await findProcess('name', 'mkdocs', true); + procs.forEach((proc: { pid: number }) => { + process.kill(proc.pid); + }); + } + }); + + beforeAll(() => { + execSync('yarn workspace @techdocs/cli link', { stdio: 'ignore' }); + }); + + afterAll(() => { + execSync('yarn workspace @techdocs/cli unlink', { stdio: 'ignore' }); + }); + + it('shows help text', async () => { + const proc = await executeCommand('techdocs-cli', ['--help']); + expect(proc.stdout).toContain('Usage: techdocs-cli [options]'); + expect(proc.exit).toEqual(0); + }); + + it('can generate', async () => { + const proc = await executeCommand( + 'techdocs-cli', + ['generate', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Successfully generated docs'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in mkdocs', async () => { + const proc = await executeCommand( + 'techdocs-cli', + ['serve:mkdocs', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Starting mkdocs server'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in backstage', async () => { + jest.setTimeout(30000); + const proc = await executeCommand( + 'techdocs-cli', + ['serve', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Starting mkdocs server'); + expect(proc.stdout).toContain('Serving docs in Backstage at'); + expect(proc.exit).toEqual(0); + }); +}); diff --git a/packages/techdocs-cli/src/fixture/docs/README.md b/packages/techdocs-cli/src/fixture/docs/README.md new file mode 100644 index 0000000000..c32f73f6f4 --- /dev/null +++ b/packages/techdocs-cli/src/fixture/docs/README.md @@ -0,0 +1 @@ +# Test Fixture diff --git a/packages/techdocs-cli/src/fixture/mkdocs.yml b/packages/techdocs-cli/src/fixture/mkdocs.yml new file mode 100644 index 0000000000..5d5c2a02ae --- /dev/null +++ b/packages/techdocs-cli/src/fixture/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: docs-test-fixture +site_description: Documentation site test fixture + +nav: + - HOME: README.md + +plugins: + - techdocs-core diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts new file mode 100644 index 0000000000..6f4437cbce --- /dev/null +++ b/packages/techdocs-cli/src/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { registerCommands } from './commands'; +import { version } from '../package.json'; + +const main = (argv: string[]) => { + program.name('techdocs-cli').version(version); + + registerCommands(program); + + program.parse(argv); +}; + +main(process.argv); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts new file mode 100644 index 0000000000..88f542c049 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Command } from 'commander'; +import { PublisherConfig } from './PublisherConfig'; + +describe('getValidPublisherConfig', () => { + it('should not allow unknown publisher types', () => { + const invalidConfig = { + publisherType: 'unknown publisher', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(invalidConfig)).toThrowError( + `Unknown publisher type ${invalidConfig.publisherType}`, + ); + }); + + describe('for azureBlobStorage', () => { + it('should require --azureAccountName', () => { + const config = { + publisherType: 'azureBlobStorage', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + 'azureBlobStorage requires --azureAccountName to be specified', + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'azureBlobStorage', + azureAccountName: 'someAccountName', + storageName: 'someContainer', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'azureBlobStorage', + ); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ), + ).toBe('someContainer'); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ), + ).toBe('someAccountName'); + }); + }); + + describe('for awsS3', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + awsBucketRootPath: 'backstage-data/techdocs', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketName'), + ).toBe('someStorageName'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketRootPath'), + ).toBe('backstage-data/techdocs'); + }); + + it('should return valid ConfigReader with SSE option', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + awsS3sse: 'aws:kms', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect(actualConfig.getString('techdocs.publisher.awsS3.sse')).toBe( + 'aws:kms', + ); + }); + }); + + describe('for openStackSwift', () => { + it('should throw error on missing parameters', () => { + const config = { + publisherType: 'openStackSwift', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + `openStackSwift requires the following params to be specified: ${[ + 'osAuthUrl', + 'osSwiftUrl', + ].join(', ')}`, + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'openStackSwift', + storageName: 'someStorageName', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + osAuthUrl: 'someAuthUrl', + osSwiftUrl: 'someSwiftUrl', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'openStackSwift', + ); + expect( + actualConfig.getConfig('techdocs.publisher.openStackSwift').get(), + ).toMatchObject({ + containerName: 'someStorageName', + credentials: { + id: 'someCredentialId', + secret: 'someSecret', + }, + authUrl: 'someAuthUrl', + swiftUrl: 'someSwiftUrl', + }); + }); + }); + + describe('for googleGcs', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'googleGcs', + storageName: 'someStorageName', + gcsBucketRootPath: 'backstage-data/techdocs', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'googleGcs', + ); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketName'), + ).toBe('someStorageName'); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketRootPath'), + ).toBe('backstage-data/techdocs'); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.ts b/packages/techdocs-cli/src/lib/PublisherConfig.ts new file mode 100644 index 0000000000..c18b9ff588 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Command } from 'commander'; + +type Publisher = keyof typeof PublisherConfig['configFactories']; +type PublisherConfiguration = { + [p in Publisher]?: any; +} & { + type: Publisher; +}; + +/** + * Helper when working with publisher-related configurations. + */ +export class PublisherConfig { + /** + * Maps publisher-specific config keys to config getters. + */ + private static configFactories = { + awsS3: PublisherConfig.getValidAwsS3Config, + azureBlobStorage: PublisherConfig.getValidAzureConfig, + googleGcs: PublisherConfig.getValidGoogleGcsConfig, + openStackSwift: PublisherConfig.getValidOpenStackSwiftConfig, + }; + + /** + * Returns Backstage config suitable for use when instantiating a Publisher. If + * there are any missing or invalid options provided, an error is thrown. + * + * Note: This assumes that proper credentials are set in Environment + * variables for the respective GCS/AWS clients to work. + */ + static getValidConfig(cmd: Command): ConfigReader { + const publisherType = cmd.publisherType; + + if (!PublisherConfig.isKnownPublisher(publisherType)) { + throw new Error(`Unknown publisher type ${cmd.publisherType}`); + } + + return new ConfigReader({ + // This backend config is not used at all. Just something needed a create a mock discovery instance. + backend: { + baseUrl: 'http://localhost:7007', + listen: { + port: 7007, + }, + }, + techdocs: { + publisher: PublisherConfig.configFactories[publisherType](cmd), + legacyUseCaseSensitiveTripletPaths: + cmd.legacyUseCaseSensitiveTripletPaths, + }, + }); + } + + /** + * Typeguard to ensure the publisher has a known config getter. + */ + private static isKnownPublisher( + type: string, + ): type is keyof typeof PublisherConfig['configFactories'] { + return PublisherConfig.configFactories.hasOwnProperty(type); + } + + /** + * Retrieve valid AWS S3 configuration based on the command. + */ + private static getValidAwsS3Config(cmd: Command): PublisherConfiguration { + return { + type: 'awsS3', + awsS3: { + bucketName: cmd.storageName, + ...(cmd.awsBucketRootPath && { bucketRootPath: cmd.awsBucketRootPath }), + ...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }), + ...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }), + ...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }), + ...(cmd.awsS3sse && { sse: cmd.awsS3sse }), + }, + }; + } + + /** + * Retrieve valid Azure Blob Storage configuration based on the command. + */ + private static getValidAzureConfig(cmd: Command): PublisherConfiguration { + if (!cmd.azureAccountName) { + throw new Error( + `azureBlobStorage requires --azureAccountName to be specified`, + ); + } + + return { + type: 'azureBlobStorage', + azureBlobStorage: { + containerName: cmd.storageName, + credentials: { + accountName: cmd.azureAccountName, + accountKey: cmd.azureAccountKey, + }, + }, + }; + } + + /** + * Retrieve valid GCS configuration based on the command. + */ + private static getValidGoogleGcsConfig(cmd: Command): PublisherConfiguration { + return { + type: 'googleGcs', + googleGcs: { + bucketName: cmd.storageName, + ...(cmd.gcsBucketRootPath && { bucketRootPath: cmd.gcsBucketRootPath }), + }, + }; + } + + /** + * Retrieves valid OpenStack Swift configuration based on the command. + */ + private static getValidOpenStackSwiftConfig( + cmd: Command, + ): PublisherConfiguration { + const missingParams = [ + 'osCredentialId', + 'osSecret', + 'osAuthUrl', + 'osSwiftUrl', + ].filter((param: string) => !cmd[param]); + + if (missingParams.length) { + throw new Error( + `openStackSwift requires the following params to be specified: ${missingParams.join( + ', ', + )}`, + ); + } + + return { + type: 'openStackSwift', + openStackSwift: { + containerName: cmd.storageName, + credentials: { + id: cmd.osCredentialId, + secret: cmd.osSecret, + }, + authUrl: cmd.osAuthUrl, + swiftUrl: cmd.osSwiftUrl, + }, + }; + } +} diff --git a/packages/techdocs-cli/src/lib/httpServer.ts b/packages/techdocs-cli/src/lib/httpServer.ts new file mode 100644 index 0000000000..0402ce4e69 --- /dev/null +++ b/packages/techdocs-cli/src/lib/httpServer.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 serveHandler from 'serve-handler'; +import http from 'http'; +import httpProxy from 'http-proxy'; +import { createLogger } from './utility'; + +export default class HTTPServer { + private readonly proxyEndpoint: string; + private readonly backstageBundleDir: string; + private readonly backstagePort: number; + private readonly mkdocsPort: number; + private readonly verbose: boolean; + + constructor( + backstageBundleDir: string, + backstagePort: number, + mkdocsPort: number, + verbose: boolean, + ) { + this.proxyEndpoint = '/api/'; + this.backstageBundleDir = backstageBundleDir; + this.backstagePort = backstagePort; + this.mkdocsPort = mkdocsPort; + this.verbose = verbose; + } + + // Create a Proxy for mkdocs server + private createProxy() { + const proxy = httpProxy.createProxyServer({ + target: `http://localhost:${this.mkdocsPort}`, + }); + + return (request: http.IncomingMessage): [httpProxy, string] => { + // If the request goes to /api/ we want to remove /api/ from the prefix of the request URL. + // e.g. ['/', 'api', pathChunks] + const [, , ...pathChunks] = request.url?.split('/') ?? []; + const forwardPath = pathChunks.join('/'); + + return [proxy, forwardPath]; + }; + } + + public async serve(): Promise { + return new Promise((resolve, reject) => { + const proxyHandler = this.createProxy(); + const server = http.createServer( + (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.url?.startsWith(this.proxyEndpoint)) { + const [proxy, forwardPath] = proxyHandler(request); + + proxy.on('error', (error: Error) => { + reject(error); + }); + + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + + request.url = forwardPath; + return proxy.web(request, response); + } + + return serveHandler(request, response, { + public: this.backstageBundleDir, + trailingSlash: true, + rewrites: [{ source: '**', destination: 'index.html' }], + }); + }, + ); + + const logger = createLogger({ verbose: false }); + server.listen(this.backstagePort, () => { + if (this.verbose) { + logger.info( + `[techdocs-preview-bundle] Running local version of Backstage at http://localhost:${this.backstagePort}`, + ); + } + resolve(server); + }); + + server.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts new file mode 100644 index 0000000000..0167dec12b --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { runMkdocsServer } from './mkdocsServer'; +import { run } from './run'; + +jest.mock('./run', () => { + return { + run: jest.fn(), + }; +}); + +describe('runMkdocsServer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('docker', () => { + it('should run docker directly by default', async () => { + await runMkdocsServer({}); + + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + `${process.cwd()}:/content`, + '8000:8000', + 'serve', + '--dev-addr', + '0.0.0.0:8000', + 'spotify/techdocs', + ]), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['5678:5678', '0.0.0.0:5678']), + expect.objectContaining({}), + ); + }); + + it('should accept custom docker image', async () => { + await runMkdocsServer({ dockerImage: 'my-org/techdocs' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['my-org/techdocs']), + expect.objectContaining({}), + ); + }); + }); + + describe('mkdocs', () => { + it('should run mkdocs if specified', async () => { + await runMkdocsServer({ useDocker: false }); + + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['serve', '--dev-addr', '127.0.0.1:8000']), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ useDocker: false, port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['127.0.0.1:5678']), + expect.objectContaining({}), + ); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts new file mode 100644 index 0000000000..60efcfaa89 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ChildProcess } from 'child_process'; +import { run, LogFunc } from './run'; + +export const runMkdocsServer = async (options: { + port?: string; + useDocker?: boolean; + dockerImage?: string; + stdoutLogFunc?: LogFunc; + stderrLogFunc?: LogFunc; +}): Promise => { + const port = options.port ?? '8000'; + const useDocker = options.useDocker ?? true; + const dockerImage = options.dockerImage ?? 'spotify/techdocs'; + + if (useDocker) { + return await run( + 'docker', + [ + 'run', + '--rm', + '-w', + '/content', + '-v', + `${process.cwd()}:/content`, + '-p', + `${port}:${port}`, + '-it', + dockerImage, + 'serve', + '--dev-addr', + `0.0.0.0:${port}`, + ], + { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }, + ); + } + + return await run('mkdocs', ['serve', '--dev-addr', `127.0.0.1:${port}`], { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }); +}; diff --git a/packages/techdocs-cli/src/lib/run.ts b/packages/techdocs-cli/src/lib/run.ts new file mode 100644 index 0000000000..9e2abeb617 --- /dev/null +++ b/packages/techdocs-cli/src/lib/run.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { spawn, SpawnOptions, ChildProcess } from 'child_process'; + +export type LogFunc = (data: Buffer | string) => void; +type SpawnOptionsPartialEnv = Omit & { + env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; +}; + +// TODO: Accept log functions to pipe logs with. +// Runs a child command, returning the child process instance. +// Use it along with waitForSignal to run a long running process e.g. mkdocs serve +export const run = async ( + name: string, + args: string[] = [], + options: SpawnOptionsPartialEnv = {}, +): Promise => { + const { stdoutLogFunc, stderrLogFunc } = options; + + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: 'true', + ...(options.env ?? {}), + }; + + // Refer: https://nodejs.org/api/child_process.html#child_process_subprocess_stdio + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + + const childProcess = spawn(name, args, { + stdio: stdio, + ...options, + env, + }); + + if (stdoutLogFunc && childProcess.stdout) { + childProcess.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && childProcess.stderr) { + childProcess.stderr.on('data', stderrLogFunc); + } + + return childProcess; +}; + +// Block indefinitely and wait for a signal to kill the child process(es) +// Throw error if any child process errors +// Resolves only when all processes exit with status code 0 +export async function waitForSignal( + childProcesses: Array, +): Promise { + const promises: Array> = []; + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + childProcesses.forEach(childProcess => { + childProcess.kill(); + }); + }); + } + + childProcesses.forEach(childProcess => { + if (typeof childProcess.exitCode === 'number') { + if (childProcess.exitCode) { + throw new Error(`Non zero exit code from child process`); + } + return; + } + + promises.push( + new Promise((resolve, reject) => { + childProcess.once('error', reject); + childProcess.once('exit', resolve); + }), + ); + }); + + await Promise.all(promises); +} diff --git a/packages/techdocs-cli/src/lib/utility.ts b/packages/techdocs-cli/src/lib/utility.ts new file mode 100644 index 0000000000..87dbbfd08d --- /dev/null +++ b/packages/techdocs-cli/src/lib/utility.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + RemoteProtocol, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import * as winston from 'winston'; + +export const convertTechDocsRefToLocationAnnotation = ( + techdocsRef: string, +): ParsedLocationAnnotation => { + // Split on the first colon for the protocol and the rest after the first split + // is the location. + const [type, target] = techdocsRef.split(/:(.+)/) as [ + RemoteProtocol?, + string?, + ]; + + if (!type || !target) { + throw new Error( + `Can not parse --techdocs-ref ${techdocsRef}. Should be of type HOST:URL.`, + ); + } + + return { type, target }; +}; + +export const createLogger = ({ + verbose = false, +}: { + verbose: boolean; +}): winston.Logger => { + const logger = winston.createLogger({ + level: verbose ? 'verbose' : 'info', + transports: [ + new winston.transports.Console({ format: winston.format.simple() }), + ], + }); + + return logger; +}; diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index b540f74549..0207ea409d 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/techdocs-common +## 0.10.7 + +### Patch Changes + +- 0b60a051c9: Added OpenStack Swift case migration support. +- 9e64a7ac1e: Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + +## 0.10.6 + +### Patch Changes + +- a2d4389587: 1. Techdocs publisher constructors now use parameter objects when being + instantiated + + 2. Internal refactor of `LocalPublish` publisher to use `fromConfig` for + creation to be aligned with other publishers; this does not impact + `LocalPublish` usage. + + ```diff + - const publisher = new LocalPublish(config, logger, discovery); + + const publisher = LocalPublish.fromConfig(config, logger, discovery); + ``` + +- 6129c89a47: Default TechDocs container used at docs generation-time is now [v0.3.5](https://github.com/backstage/techdocs-container/releases/tag/v0.3.5). +- f3c7eec64b: Updated to properly join URL segments under any OS for both AWS S3 and GCP +- Updated dependencies + - @backstage/backend-common@0.9.9 + ## 0.10.5 ### Patch Changes @@ -834,7 +865,7 @@ Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. - 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7007/api/techdocs/static/docs` in most setups. 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 152b9b04f1..b13b94a1a7 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.10.5", + "version": "0.10.7", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,14 +38,14 @@ "dependencies": { "@azure/identity": "^1.5.0", "@azure/storage-blob": "^12.5.0", - "@backstage/backend-common": "^0.9.8", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/search-common": "^0.2.1", "@backstage/integration": "^0.6.9", "@google-cloud/storage": "^5.6.0", - "@trendyol-js/openstack-swift-sdk": "^0.0.4", + "@trendyol-js/openstack-swift-sdk": "^0.0.5", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "express": "^4.17.1", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/fs-extra": "^9.0.5", "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 1f7afa0855..1f49668d35 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -45,14 +45,16 @@ const createPublisherFromConfig = ({ bucketName = 'bucketName', bucketRootPath = '/', legacyUseCaseSensitiveTripletPaths = false, + sse, }: { bucketName?: string; bucketRootPath?: string; legacyUseCaseSensitiveTripletPaths?: boolean; + sse?: string; } = {}) => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'awsS3', awsS3: { @@ -62,6 +64,7 @@ const createPublisherFromConfig = ({ }, bucketName, bucketRootPath, + sse, }, }, legacyUseCaseSensitiveTripletPaths, @@ -171,6 +174,13 @@ describe('AwsS3Publish', () => { expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); + it('should publish a directory when sse is specified', async () => { + const publisher = createPublisherFromConfig({ + sse: 'aws:kms', + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); + it('should fail to publish a directory', async () => { const wrongPathToGeneratedDirectory = path.join( rootDir, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 29eecb8d03..abfa8ddd24 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -57,6 +57,29 @@ const streamToBuffer = (stream: Readable): Promise => { }; export class AwsS3Publish implements PublisherBase { + private readonly storageClient: aws.S3; + private readonly bucketName: string; + private readonly legacyPathCasing: boolean; + private readonly logger: Logger; + private readonly bucketRootPath: string; + private readonly sse?: 'aws:kms' | 'AES256'; + + constructor(options: { + storageClient: aws.S3; + bucketName: string; + legacyPathCasing: boolean; + logger: Logger; + bucketRootPath: string; + sse?: 'aws:kms' | 'AES256'; + }) { + this.storageClient = options.storageClient; + this.bucketName = options.bucketName; + this.legacyPathCasing = options.legacyPathCasing; + this.logger = options.logger; + this.bucketRootPath = options.bucketRootPath; + this.sse = options.sse; + } + static fromConfig(config: Config, logger: Logger): PublisherBase { let bucketName = ''; try { @@ -72,6 +95,11 @@ export class AwsS3Publish implements PublisherBase { config.getOptionalString('techdocs.publisher.awsS3.bucketRootPath') || '', ); + const sse = config.getOptionalString('techdocs.publisher.awsS3.sse') as + | 'aws:kms' + | 'AES256' + | undefined; + // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. // 1. AWS environment variables // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html @@ -112,13 +140,14 @@ export class AwsS3Publish implements PublisherBase { 'techdocs.legacyUseCaseSensitiveTripletPaths', ) || false; - return new AwsS3Publish( + return new AwsS3Publish({ storageClient, bucketName, + bucketRootPath, legacyPathCasing, logger, - bucketRootPath, - ); + sse, + }); } private static buildCredentials( @@ -152,20 +181,6 @@ export class AwsS3Publish implements PublisherBase { return explicitCredentials; } - constructor( - private readonly storageClient: aws.S3, - private readonly bucketName: string, - private readonly legacyPathCasing: boolean, - private readonly logger: Logger, - private readonly bucketRootPath: string, - ) { - this.storageClient = storageClient; - this.bucketName = bucketName; - this.legacyPathCasing = legacyPathCasing; - this.logger = logger; - this.bucketRootPath = bucketRootPath; - } - /** * Check if the defined bucket exists. Being able to connect means the configuration is good * and the storage client will work. @@ -202,6 +217,7 @@ export class AwsS3Publish implements PublisherBase { async publish({ entity, directory }: PublishRequest): Promise { const useLegacyPathCasing = this.legacyPathCasing; const bucketRootPath = this.bucketRootPath; + const sse = this.sse; // First, try to retrieve a list of all individual files currently existing let existingFiles: string[] = []; @@ -244,7 +260,8 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, ), Body: fileStream, - }; + ...(sse && { ServerSideEncryption: sse }), + } as aws.S3.PutObjectRequest; return this.storageClient.upload(params).promise(); }, @@ -306,7 +323,7 @@ export class AwsS3Publish implements PublisherBase { ? entityTriplet : lowerCaseEntityTriplet(entityTriplet); - const entityRootDir = path.join(this.bucketRootPath, entityDir); + const entityRootDir = path.posix.join(this.bucketRootPath, entityDir); const stream = this.storageClient .getObject({ @@ -357,7 +374,7 @@ export class AwsS3Publish implements PublisherBase { : lowerCaseEntityTripletInStoragePath(decodedUriNoRoot); // Re-prepend the root path to the relative file path - const filePath = path.join(this.bucketRootPath, filePathNoRoot); + const filePath = path.posix.join(this.bucketRootPath, filePathNoRoot); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); @@ -396,7 +413,7 @@ export class AwsS3Publish implements PublisherBase { ? entityTriplet : lowerCaseEntityTriplet(entityTriplet); - const entityRootDir = path.join(this.bucketRootPath, entityDir); + const entityRootDir = path.posix.join(this.bucketRootPath, entityDir); await this.storageClient .headObject({ diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 1bf3586266..a2503bd7b2 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -51,7 +51,7 @@ const createPublisherFromConfig = ({ } = {}) => { const config = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index f1d60f709a..7a58d92095 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -47,6 +47,23 @@ import { const BATCH_CONCURRENCY = 3; export class AzureBlobStoragePublish implements PublisherBase { + private readonly storageClient: BlobServiceClient; + private readonly containerName: string; + private readonly legacyPathCasing: boolean; + private readonly logger: Logger; + + constructor(options: { + storageClient: BlobServiceClient; + containerName: string; + legacyPathCasing: boolean; + logger: Logger; + }) { + this.storageClient = options.storageClient; + this.containerName = options.containerName; + this.legacyPathCasing = options.legacyPathCasing; + this.logger = options.logger; + } + static fromConfig(config: Config, logger: Logger): PublisherBase { let containerName = ''; try { @@ -95,24 +112,12 @@ export class AzureBlobStoragePublish implements PublisherBase { 'techdocs.legacyUseCaseSensitiveTripletPaths', ) || false; - return new AzureBlobStoragePublish( - storageClient, - containerName, - legacyPathCasing, - logger, - ); - } - - constructor( - private readonly storageClient: BlobServiceClient, - private readonly containerName: string, - private readonly legacyPathCasing: boolean, - private readonly logger: Logger, - ) { - this.storageClient = storageClient; - this.containerName = containerName; - this.legacyPathCasing = legacyPathCasing; - this.logger = logger; + return new AzureBlobStoragePublish({ + storageClient: storageClient, + containerName: containerName, + legacyPathCasing: legacyPathCasing, + logger: logger, + }); } async getReadiness(): Promise { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index c085594325..a52fd0f0bd 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -52,7 +52,7 @@ const createPublisherFromConfig = ({ } = {}) => { const config = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'googleGcs', googleGcs: { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 5a142e800a..f072412c20 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -41,6 +41,26 @@ import { } from './types'; export class GoogleGCSPublish implements PublisherBase { + private readonly storageClient: Storage; + private readonly bucketName: string; + private readonly legacyPathCasing: boolean; + private readonly logger: Logger; + private readonly bucketRootPath: string; + + constructor(options: { + storageClient: Storage; + bucketName: string; + legacyPathCasing: boolean; + logger: Logger; + bucketRootPath: string; + }) { + this.storageClient = options.storageClient; + this.bucketName = options.bucketName; + this.legacyPathCasing = options.legacyPathCasing; + this.logger = options.logger; + this.bucketRootPath = options.bucketRootPath; + } + static fromConfig(config: Config, logger: Logger): PublisherBase { let bucketName = ''; try { @@ -84,27 +104,13 @@ export class GoogleGCSPublish implements PublisherBase { 'techdocs.legacyUseCaseSensitiveTripletPaths', ) || false; - return new GoogleGCSPublish( + return new GoogleGCSPublish({ storageClient, bucketName, legacyPathCasing, logger, bucketRootPath, - ); - } - - constructor( - private readonly storageClient: Storage, - private readonly bucketName: string, - private readonly legacyPathCasing: boolean, - private readonly logger: Logger, - private readonly bucketRootPath: string, - ) { - this.storageClient = storageClient; - this.bucketName = bucketName; - this.legacyPathCasing = legacyPathCasing; - this.logger = logger; - this.bucketRootPath = bucketRootPath; + }); } /** @@ -231,7 +237,7 @@ export class GoogleGCSPublish implements PublisherBase { ? entityTriplet : lowerCaseEntityTriplet(entityTriplet); - const entityRootDir = path.join(this.bucketRootPath, entityDir); + const entityRootDir = path.posix.join(this.bucketRootPath, entityDir); const fileStreamChunks: Array = []; this.storageClient @@ -270,7 +276,7 @@ export class GoogleGCSPublish implements PublisherBase { : lowerCaseEntityTripletInStoragePath(decodedUriNoRoot); // Re-prepend the root path to the relative file path - const filePath = path.join(this.bucketRootPath, filePathNoRoot); + const filePath = path.posix.join(this.bucketRootPath, filePathNoRoot); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); @@ -310,7 +316,7 @@ export class GoogleGCSPublish implements PublisherBase { ? entityTriplet : lowerCaseEntityTriplet(entityTriplet); - const entityRootDir = path.join(this.bucketRootPath, entityDir); + const entityRootDir = path.posix.join(this.bucketRootPath, entityDir); this.storageClient .bucket(this.bucketName) diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index 38ead236bd..761de03c5a 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -39,7 +39,7 @@ const createMockEntity = (annotations = {}, lowerCase = false) => { }; const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/techdocs'), + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/techdocs'), getExternalBaseUrl: jest.fn(), }; @@ -63,7 +63,11 @@ describe('local publisher', () => { const mockConfig = new ConfigReader({}); - const publisher = new LocalPublish(mockConfig, logger, testDiscovery); + const publisher = LocalPublish.fromConfig( + mockConfig, + logger, + testDiscovery, + ); const mockEntity = createMockEntity(); const lowerMockEntity = createMockEntity(undefined, true); @@ -90,7 +94,11 @@ describe('local publisher', () => { }, }); - const publisher = new LocalPublish(mockConfig, logger, testDiscovery); + const publisher = LocalPublish.fromConfig( + mockConfig, + logger, + testDiscovery, + ); const mockEntity = createMockEntity(); const lowerMockEntity = createMockEntity(undefined, true); @@ -106,7 +114,11 @@ describe('local publisher', () => { describe('docsRouter', () => { const mockConfig = new ConfigReader({}); - const publisher = new LocalPublish(mockConfig, logger, testDiscovery); + const publisher = LocalPublish.fromConfig( + mockConfig, + logger, + testDiscovery, + ); let app: express.Express; beforeEach(() => { @@ -166,7 +178,7 @@ describe('local publisher', () => { legacyUseCaseSensitiveTripletPaths: true, }, }); - const legacyPublisher = new LocalPublish( + const legacyPublisher = LocalPublish.fromConfig( legacyConfig, logger, testDiscovery, diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index de0072de7a..70b4eb3ff2 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -59,24 +59,37 @@ try { * called "static" at the root of techdocs-backend plugin. */ export class LocalPublish implements PublisherBase { - private legacyPathCasing: boolean; + private readonly legacyPathCasing: boolean; + private readonly logger: Logger; + private readonly discovery: PluginEndpointDiscovery; - // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. - // Move the logic of setting staticDocsDir based on config over to fromConfig, - // and set the value as a class parameter. - constructor( - // @ts-ignore - private readonly config: Config, - private readonly logger: Logger, - private readonly discovery: PluginEndpointDiscovery, - ) { - this.config = config; - this.logger = logger; - this.discovery = discovery; - this.legacyPathCasing = + // TODO: Move the logic of setting staticDocsDir based on config over to + // fromConfig, and set the value as a class parameter. + constructor(options: { + logger: Logger; + discovery: PluginEndpointDiscovery; + legacyPathCasing: boolean; + }) { + this.logger = options.logger; + this.discovery = options.discovery; + this.legacyPathCasing = options.legacyPathCasing; + } + + static fromConfig( + config: Config, + logger: Logger, + discovery: PluginEndpointDiscovery, + ): PublisherBase { + const legacyPathCasing = config.getOptionalBoolean( 'techdocs.legacyUseCaseSensitiveTripletPaths', ) || false; + + return new LocalPublish({ + logger, + discovery, + legacyPathCasing, + }); } async getReadiness(): Promise { diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index 8dde687997..c2236fb880 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -84,7 +84,7 @@ beforeEach(() => { mockFs.restore(); const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { @@ -114,7 +114,7 @@ describe('OpenStackSwiftPublish', () => { it('should reject incorrect config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 51e7b76406..7b623933e3 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -24,7 +24,11 @@ import { SwiftClient } from '@trendyol-js/openstack-swift-sdk'; import { NotFound } from '@trendyol-js/openstack-swift-sdk/lib/types'; import { Stream, Readable } from 'stream'; import { Logger } from 'winston'; -import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { + getFileTreeRecursively, + getHeadersForFileExtension, + lowerCaseEntityTripletInStoragePath, +} from './helpers'; import { PublisherBase, PublishRequest, @@ -54,6 +58,20 @@ const bufferToStream = (buffer: Buffer): Readable => { }; export class OpenStackSwiftPublish implements PublisherBase { + private readonly storageClient: SwiftClient; + private readonly containerName: string; + private readonly logger: Logger; + + constructor(options: { + storageClient: SwiftClient; + containerName: string; + logger: Logger; + }) { + this.storageClient = options.storageClient; + this.containerName = options.containerName; + this.logger = options.logger; + } + static fromConfig(config: Config, logger: Logger): PublisherBase { let containerName = ''; try { @@ -78,17 +96,7 @@ export class OpenStackSwiftPublish implements PublisherBase { secret: openStackSwiftConfig.getString('credentials.secret'), }); - return new OpenStackSwiftPublish(storageClient, containerName, logger); - } - - constructor( - private readonly storageClient: SwiftClient, - private readonly containerName: string, - private readonly logger: Logger, - ) { - this.storageClient = storageClient; - this.containerName = containerName; - this.logger = logger; + return new OpenStackSwiftPublish({ storageClient, containerName, logger }); } /* @@ -285,4 +293,67 @@ export class OpenStackSwiftPublish implements PublisherBase { return false; } } + + async migrateDocsCase({ + removeOriginal = false, + concurrency = 25, + }): Promise { + // Iterate through every file in the root of the publisher. + const allObjects = await this.getAllObjectsFromContainer(); + const limiter = createLimiter(concurrency); + await Promise.all( + allObjects.map(f => + limiter(async file => { + let newPath; + try { + newPath = lowerCaseEntityTripletInStoragePath(file); + } catch (e) { + assertError(e); + this.logger.warn(e.message); + return; + } + + // If all parts are already lowercase, ignore. + if (file === newPath) { + return; + } + + try { + this.logger.verbose(`Migrating ${file} to ${newPath}`); + await this.storageClient.copy( + this.containerName, + file, + this.containerName, + newPath, + ); + if (removeOriginal) { + await this.storageClient.delete(this.containerName, file); + } + } catch (e) { + assertError(e); + this.logger.warn(`Unable to migrate ${file}: ${e.message}`); + } + }, f), + ), + ); + } + + /** + * Returns a list of all object keys from the configured container. + */ + protected async getAllObjectsFromContainer( + { prefix } = { prefix: '' }, + ): Promise { + let objects: string[] = []; + const OSS_MAX_LIMIT = Math.pow(2, 31) - 1; + + const allObjects = await this.storageClient.list( + this.containerName, + prefix, + OSS_MAX_LIMIT, + ); + objects = allObjects.map((object: any) => object.name); + + return objects; + } } diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 2671ef7b47..a47da828a7 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -27,7 +27,7 @@ import { OpenStackSwiftPublish } from './openStackSwift'; const logger = getVoidLogger(); const discovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7007'), getExternalBaseUrl: jest.fn(), }; @@ -39,7 +39,7 @@ describe('Publisher', () => { it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', }, }); @@ -53,7 +53,7 @@ describe('Publisher', () => { it('should create local publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'local', }, @@ -70,7 +70,7 @@ describe('Publisher', () => { it('should create google gcs publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'googleGcs', googleGcs: { @@ -91,7 +91,7 @@ describe('Publisher', () => { it('should create AWS S3 publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'awsS3', awsS3: { @@ -115,7 +115,7 @@ describe('Publisher', () => { it('should create Azure Blob Storage publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { @@ -143,7 +143,7 @@ describe('Publisher', () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { @@ -166,7 +166,7 @@ describe('Publisher', () => { it('should create Open Stack Swift publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index c7c6154114..eb9e3104bf 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -61,10 +61,10 @@ export class Publisher { return OpenStackSwiftPublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); - return new LocalPublish(config, logger, discovery); + return LocalPublish.fromConfig(config, logger, discovery); default: logger.info('Creating Local publisher for TechDocs'); - return new LocalPublish(config, logger, discovery); + return LocalPublish.fromConfig(config, logger, discovery); } } } diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index a1fa9d3b6b..3460e6c714 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/test-utils +## 0.1.22 + +### Patch Changes + +- 0b1de52732: Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. +- 2dd2a7b2cc: Migrated to using `createSpecializedApp`. +- Updated dependencies + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + +## 0.1.21 + +### Patch Changes + +- 71fd5cd735: Update Keyboard deprecation with a link to the recommended successor +- Updated dependencies + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/core-app-api@0.1.20 + ## 0.1.20 ### Patch Changes diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index c4936f6d96..83c209122d 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -5,9 +5,12 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { ReactElement } from 'react'; @@ -27,8 +30,8 @@ export type CollectedLogs = { // @public export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; // @public @deprecated (undocumented) @@ -109,13 +112,13 @@ export class MockErrorApi implements ErrorApi { constructor(options?: MockErrorApiOptions); // (undocumented) error$(): Observable<{ - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; // (undocumented) getErrors(): ErrorWithContext[]; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; // (undocumented) waitForError(pattern: RegExp, timeoutMs?: number): Promise; } @@ -174,6 +177,26 @@ export function setupRequestMockHandlers(worker: { // @public export type SyncLogCollector = () => void; +// @public +export const TestApiProvider: ({ + apis, + children, +}: TestApiProviderProps) => JSX.Element; + +// @public +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +// @public +export class TestApiRegistry implements ApiHolder { + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ): TestApiRegistry; + get(api: ApiRef): T | undefined; +} + // @public export type TestAppOptions = { routeEntries?: string[]; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 269b7b2d38..79667c236a 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.20", + "version": "0.1.22", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-app-api": "^0.1.19", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-app-api": "^0.1.21", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.11.2", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", @@ -45,7 +46,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/TestApiProvider.test.tsx b/packages/test-utils/src/testUtils/TestApiProvider.test.tsx new file mode 100644 index 0000000000..1b981b15ec --- /dev/null +++ b/packages/test-utils/src/testUtils/TestApiProvider.test.tsx @@ -0,0 +1,135 @@ +/* + * 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 { createApiRef, useApiHolder } from '@backstage/core-plugin-api'; +import { TestApiProvider, TestApiRegistry } from './TestApiProvider'; +import { render, screen } from '@testing-library/react'; + +const xApiRef = createApiRef<{ a: string; b: number }>({ + id: 'x', +}); +const yApiRef = createApiRef({ + id: 'y', +}); + +function Verifier() { + const holder = useApiHolder(); + const x = holder.get(xApiRef); + const y = holder.get(yApiRef); + + return ( +
+ {x ? ( + + x={x.a},{x.b} + + ) : ( + no x + )} + {y ? y={y} : no y} +
+ ); +} + +describe('TestApiProvider', () => { + it('should provide APIs', () => { + render( + + + , + ); + expect(screen.getByText('x=a,3')).toBeInTheDocument(); + expect(screen.getByText('y=y')).toBeInTheDocument(); + }); + + it('should provide partial APIs', () => { + render( + + + , + ); + expect(screen.getByText('x=a,')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); + + it('should require partial implementations to still match types', () => { + render( + // @ts-expect-error + + + , + ); + expect(screen.getByText('x=3,')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); + + it('should allow empty APIs', () => { + render( + + + , + ); + expect(screen.getByText('no x')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); +}); + +describe('TestApiRegistry', () => { + it('should be created with APIs', () => { + const x = { a: 'a', b: 3 }; + const y = 'y'; + const registry = TestApiRegistry.from([xApiRef, x], [yApiRef, y]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBe(y); + }); + + it('should allow partial implementations', () => { + const x = { a: 'a' }; + const registry = TestApiRegistry.from([xApiRef, x]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBeUndefined(); + }); + + it('should require partial implementations to match types', () => { + const x = { a: 2 }; + // @ts-expect-error + const registry = TestApiRegistry.from([xApiRef, x]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBeUndefined(); + }); + + it('should prefer last duplicate API that was provided', () => { + const x1 = { a: 'a' }; + const x2 = { a: 's' }; + const x3 = { a: 'd' }; + const registry = TestApiRegistry.from( + [xApiRef, x1], + [xApiRef, x2], + [xApiRef, x3], + ); + + expect(registry.get(xApiRef)).toBe(x3); + }); +}); diff --git a/packages/test-utils/src/testUtils/TestApiProvider.tsx b/packages/test-utils/src/testUtils/TestApiProvider.tsx new file mode 100644 index 0000000000..b1499b0cde --- /dev/null +++ b/packages/test-utils/src/testUtils/TestApiProvider.tsx @@ -0,0 +1,130 @@ +/* + * 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 } from 'react'; +import { ApiProvider } from '@backstage/core-app-api'; +import { ApiRef, ApiHolder } from '@backstage/core-plugin-api'; + +/** @ignore */ +type TestApiProviderPropsApiPair = TApi extends infer TImpl + ? readonly [ApiRef, Partial] + : never; + +/** @ignore */ +type TestApiProviderPropsApiPairs = { + [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; +}; + +/** + * Properties for the {@link TestApiProvider} component. + * + * @public + */ +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +/** + * The `TestApiRegistry` is an {@link @backstage/core-plugin-api#ApiHolder} implementation + * that is particularly well suited for development and test environments such as + * unit tests, storybooks, and isolated plugin development setups. + * + * @public + */ +export class TestApiRegistry implements ApiHolder { + /** + * Creates a new {@link TestApiRegistry} with a list of API implementation pairs. + * + * Similar to the {@link TestApiProvider}, there is no need to provide a full + * implementation of each API, it's enough to implement the methods that are tested. + * + * @example + * ```ts + * const apis = TestApiRegistry.from( + * [configApiRef, new ConfigReader({})], + * [identityApiRef, { getUserId: () => 'tester' }], + * ); + * ``` + * + * @public + * @param apis - A list of pairs mapping an ApiRef to its respective implementation. + */ + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ) { + return new TestApiRegistry( + new Map(apis.map(([api, impl]) => [api.id, impl])), + ); + } + + private constructor(private readonly apis: Map) {} + + /** + * Returns an implementation of the API. + * + * @public + */ + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} + +/** + * The `TestApiProvider` is a Utility API context provider that is particularly + * well suited for development and test environments such as unit tests, storybooks, + * and isolated plugin development setups. + * + * It lets you provide any number of API implementations, without necessarily + * having to fully implement each of the APIs. + * + * A migration from `ApiRegistry` and `ApiProvider` might look like this, from: + * + * ```tsx + * renderInTestApp( + * + * {...} + * + * ) + * ``` + * + * To the following: + * + * ```tsx + * renderInTestApp( + * + * {...} + * + * ) + * ``` + * + * Note that the cast to `IdentityApi` is no longer needed as long as the mock API + * implements a subset of the `IdentityApi`. + * + * @public + **/ +export const TestApiProvider = ({ + apis, + children, +}: TestApiProviderProps) => { + return ( + + ); +}; diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 87601e29e2..96918b4e47 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; /** @@ -27,12 +31,12 @@ export type MockErrorApiOptions = { }; /** - * ErrorWithContext contains error and ErrorContext + * ErrorWithContext contains error and ErrorApiErrorContext * @public */ export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; type Waiter = { @@ -59,7 +63,7 @@ export class MockErrorApi implements ErrorApi { constructor(private readonly options: MockErrorApiOptions = {}) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (this.options.collect) { this.errors.push({ error, context }); @@ -76,7 +80,10 @@ export class MockErrorApi implements ErrorApi { throw new Error(`MockErrorApi received unexpected error, ${error}`); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }> { return nullObservable; } diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 25809912ec..39e5da3df2 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { createExternalRouteRef, createRouteRef, @@ -29,6 +28,7 @@ import React, { useEffect } from 'react'; import { Route, Routes } from 'react-router'; import { MockErrorApi } from './apis'; import { renderInTestApp, wrapInTestApp } from './appWrappers'; +import { TestApiProvider } from './TestApiProvider'; describe('wrapInTestApp', () => { it('should provide routing and warn about missing act()', async () => { @@ -111,9 +111,9 @@ describe('wrapInTestApp', () => { }; const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('foo')).toBeInTheDocument(); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 6f55cc6190..cad1df4ecc 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -18,7 +18,10 @@ import React, { ComponentType, ReactNode, ReactElement } from 'react'; import { MemoryRouter } from 'react-router'; import { Route } from 'react-router-dom'; import { lightTheme } from '@backstage/theme'; -import { createApp } from '@backstage/core-app-api'; +import { ThemeProvider } from '@material-ui/core/styles'; +import { CssBaseline } from '@material-ui/core'; +import MockIcon from '@material-ui/icons/AcUnit'; +import { createSpecializedApp } from '@backstage/core-app-api'; import { BootErrorPageProps, RouteRef, @@ -28,8 +31,34 @@ import { } from '@backstage/core-plugin-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from './testingLibrary'; +import { defaultApis } from './defaultApis'; import { mockApis } from './mockApis'; +const mockIcons = { + 'kind:api': MockIcon, + 'kind:component': MockIcon, + 'kind:domain': MockIcon, + 'kind:group': MockIcon, + 'kind:location': MockIcon, + 'kind:system': MockIcon, + 'kind:user': MockIcon, + + brokenImage: MockIcon, + catalog: MockIcon, + scaffolder: MockIcon, + techdocs: MockIcon, + search: MockIcon, + chat: MockIcon, + dashboard: MockIcon, + docs: MockIcon, + email: MockIcon, + github: MockIcon, + group: MockIcon, + help: MockIcon, + user: MockIcon, + warning: MockIcon, +}; + const ErrorBoundaryFallback = ({ error }: { error: Error }) => { throw new Error(`Reached ErrorBoundaryFallback Page with error, ${error}`); }; @@ -90,8 +119,9 @@ export function wrapInTestApp( const { routeEntries = ['/'] } = options; const boundRoutes = new Map(); - const app = createApp({ + const app = createSpecializedApp({ apis: mockApis, + defaultApis, // Bit of a hack to make sure that the default config loader isn't used // as that would force every single test to wait for config loading. configLoader: false as unknown as undefined, @@ -104,13 +134,18 @@ export function wrapInTestApp( ), }, + icons: mockIcons, plugins: [], themes: [ { id: 'light', - theme: lightTheme, title: 'Test App Theme', variant: 'light', + Provider: ({ children }) => ( + + {children} + + ), }, ], bindRoutes: ({ bind }) => { diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/test-utils/src/testUtils/defaultApis.ts similarity index 96% rename from packages/core-app-api/src/app/defaultApis.ts rename to packages/test-utils/src/testUtils/defaultApis.ts index fb02274cf0..f06e1ba6b7 100644 --- a/packages/core-app-api/src/app/defaultApis.ts +++ b/packages/test-utils/src/testUtils/defaultApis.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ import { OneLoginAuth, UnhandledErrorForwarder, AtlassianAuth, -} from '../apis'; +} from '@backstage/core-app-api'; import { createApiFactory, @@ -59,8 +59,8 @@ import { atlassianAuthApiRef, } from '@backstage/core-plugin-api'; -import OAuth2Icon from '@material-ui/icons/AcUnit'; - +// TODO(Rugvip): This is just a copy of the createApp default APIs for now, but +// we should clean up this list a bit move more things over to mocks. export const defaultApis = [ createApiFactory({ api: discoveryApiRef, @@ -226,7 +226,7 @@ export const defaultApis = [ provider: { id: 'oidc', title: 'Your Identity Provider', - icon: OAuth2Icon, + icon: () => null, }, environment: configApi.getOptionalString('auth.environment'), }), diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index 7d93d606cc..c778c5c837 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -22,3 +22,5 @@ export * from './msw'; export * from './Keyboard'; export * from './logCollector'; export * from './testingLibrary'; +export { TestApiProvider, TestApiRegistry } from './TestApiProvider'; +export type { TestApiProviderProps } from './TestApiProvider'; diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 8709484c79..1c8661d7cc 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/theme +## 0.2.13 + +### Patch Changes + +- c11a37710a: Added a warning variant to `DismissableBanner` component. If you are using a + custom theme, you will need to add the optional `palette.banner.warning` color, + otherwise this variant will fall back to the `palette.banner.error` color. + ## 0.2.12 ### Patch Changes diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 30dcb7b040..2dc6ab7b55 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -64,7 +64,7 @@ export type BackstagePaletteAdditions = { error: string; text: string; link: string; - warning: string; + warning?: string; }; }; diff --git a/packages/theme/package.json b/packages/theme/package.json index 1acc91de79..28a05af98d 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.12", + "version": "0.2.13", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.12.2" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.9.0" }, "files": [ "dist" diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 40f45ee739..e1acba3a01 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -76,7 +76,7 @@ export type BackstagePaletteAdditions = { error: string; text: string; link: string; - warning: string; + warning?: string; }; }; diff --git a/packages/types/package.json b/packages/types/package.json index 8632422a25..197d51b061 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -31,7 +31,7 @@ }, "dependencies": {}, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/zen-observable": "^0.8.0", "zen-observable": "^0.8.15" }, diff --git a/packages/version-bridge/package.json b/packages/version-bridge/package.json index 7cc0b56a34..35a6e8f71d 100644 --- a/packages/version-bridge/package.json +++ b/packages/version-bridge/package.json @@ -33,7 +33,7 @@ "react": "^16.12.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2" diff --git a/plugins/allure/CHANGELOG.md b/plugins/allure/CHANGELOG.md index 3de49b9b74..f769b6e133 100644 --- a/plugins/allure/CHANGELOG.md +++ b/plugins/allure/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-allure +## 0.1.7 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.6 ### Patch Changes diff --git a/plugins/allure/package.json b/plugins/allure/package.json index 63864a7239..9efe6132e8 100644 --- a/plugins/allure/package.json +++ b/plugins/allure/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-allure", "description": "A Backstage plugin that integrates with Allure", - "version": "0.1.6", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,11 +22,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -36,10 +36,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/allure/src/plugin.ts b/plugins/allure/src/plugin.ts index f5ca7beeb3..a2eaa06a38 100644 --- a/plugins/allure/src/plugin.ts +++ b/plugins/allure/src/plugin.ts @@ -23,7 +23,7 @@ import { import { AllureApiClient, allureApiRef } from './api'; export const allureRouteRef = createRouteRef({ - title: 'allure-report', + id: 'allure', }); export const allurePlugin = createPlugin({ diff --git a/plugins/analytics-module-ga/CHANGELOG.md b/plugins/analytics-module-ga/CHANGELOG.md index a59bcb6351..acc5c235f9 100644 --- a/plugins/analytics-module-ga/CHANGELOG.md +++ b/plugins/analytics-module-ga/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-analytics-module-ga +## 0.1.3 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.2 ### Patch Changes diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 25534e9577..e0a32960c5 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-analytics-module-ga", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,9 +22,9 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -34,10 +34,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 188d81fcd1..0b0bd4890c 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-api-docs +## 0.6.14 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.6.13 + +### Patch Changes + +- 044c38e739: Lazy load all API definition widgets. The widgets use libraries like + `swagger-ui`, `graphiql`, and `@asyncapi/react-component` which are quite heavy + weight. To improve initial load times, the widgets are only loaded once used. +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.6.12 ### Patch Changes diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md index de36857945..111660ae07 100644 --- a/plugins/api-docs/api-report.md +++ b/plugins/api-docs/api-report.md @@ -84,11 +84,19 @@ export const ApiTypeTitle: ({ apiEntity: ApiEntity; }) => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "AsyncApiDefinitionWidget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const AsyncApiDefinitionWidget: ({ definition }: Props_5) => JSX.Element; +export const AsyncApiDefinitionWidget: ( + props: AsyncApiDefinitionWidgetProps, +) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AsyncApiDefinitionWidgetProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AsyncApiDefinitionWidgetProps = { + definition: string; +}; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ConsumedApisCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -100,7 +108,7 @@ export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element; // Warning: (ae-missing-release-tag) "ConsumingComponentsCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const ConsumingComponentsCard: ({ variant }: Props_6) => JSX.Element; +export const ConsumingComponentsCard: ({ variant }: Props_5) => JSX.Element; // Warning: (ae-missing-release-tag) "defaultDefinitionWidgets" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -169,20 +177,34 @@ export const EntityProvidingComponentsCard: ({ // @public (undocumented) export const HasApisCard: ({ variant }: Props_3) => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "OpenApiDefinitionWidget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const OpenApiDefinitionWidget: ({ definition }: Props_8) => JSX.Element; +export const OpenApiDefinitionWidget: ( + props: OpenApiDefinitionWidgetProps, +) => JSX.Element; + +// Warning: (ae-missing-release-tag) "OpenApiDefinitionWidgetProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type OpenApiDefinitionWidgetProps = { + definition: string; +}; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "PlainApiDefinitionWidget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const PlainApiDefinitionWidget: ({ - definition, - language, -}: Props_9) => JSX.Element; +export const PlainApiDefinitionWidget: ( + props: PlainApiDefinitionWidgetProps, +) => JSX.Element; + +// Warning: (ae-missing-release-tag) "PlainApiDefinitionWidgetProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PlainApiDefinitionWidgetProps = { + definition: any; + language: string; +}; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ProvidedApisCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -194,5 +216,5 @@ export const ProvidedApisCard: ({ variant }: Props_4) => JSX.Element; // Warning: (ae-missing-release-tag) "ProvidingComponentsCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const ProvidingComponentsCard: ({ variant }: Props_7) => JSX.Element; +export const ProvidingComponentsCard: ({ variant }: Props_6) => JSX.Element; ``` diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 67045c2043..a4aa15c419 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-api-docs", "description": "A Backstage plugin that helps represent API entities in the frontend", - "version": "0.6.12", + "version": "0.6.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,12 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -53,10 +53,10 @@ "swagger-ui-react": "^4.0.0-rc.3" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index 7b0ffac58e..32a6ec0f77 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -16,13 +16,12 @@ import { ApiEntity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; import { ApiDefinitionCard } from './ApiDefinitionCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -31,10 +30,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx index 450e9e57ac..5476857a06 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx @@ -15,11 +15,10 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ApiTypeTitle } from './ApiTypeTitle'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -28,10 +27,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index cf40e5160c..3629110d47 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -15,11 +15,7 @@ */ import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { TableColumn, TableProps } from '@backstage/core-components'; import { ConfigApi, @@ -34,7 +30,11 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import DashboardIcon from '@material-ui/icons/Dashboard'; import { render } from '@testing-library/react'; import React from 'react'; @@ -88,8 +88,8 @@ describe('ApiCatalogPage', () => { const renderWrapped = (children: React.ReactNode) => render( wrapInTestApp( - { new DefaultStarredEntitiesApi({ storageApi }), ], [apiDocsConfigRef, apiDocsConfig], - ])} + ]} > {children} - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index ac09b717f7..5e7b22524a 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ConsumedApisCard } from './ConsumedApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx index ac833251e3..4102bebf0e 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { HasApisCard } from './HasApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index f2719d0f4b..b29075aee9 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ProvidedApisCard } from './ProvidedApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.test.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx similarity index 87% rename from plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.test.tsx rename to plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx index 0170ada180..cb89476c03 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.test.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx @@ -16,9 +16,9 @@ import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; +import { AsyncApiDefinition } from './AsyncApiDefinition'; -describe('', () => { +describe('', () => { it('renders asyncapi spec', async () => { const definition = ` asyncapi: 2.0.0 @@ -40,7 +40,7 @@ components: type: string `; const { getByText, getAllByText } = await renderInTestApp( - , + , ); expect(getByText(/Account Service/i)).toBeInTheDocument(); @@ -51,7 +51,7 @@ components: it('renders error if definition is missing', async () => { const { getByText } = await renderInTestApp( - , + , ); expect(getByText(/Error/i)).toBeInTheDocument(); expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument(); diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx new file mode 100644 index 0000000000..1c993f67d7 --- /dev/null +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 AsyncApi from '@asyncapi/react-component'; +import '@asyncapi/react-component/lib/styles/fiori.css'; +import { alpha, makeStyles } from '@material-ui/core/styles'; +import React from 'react'; + +const useStyles = makeStyles(theme => ({ + root: { + '& .asyncapi': { + 'font-family': 'inherit', + background: 'none', + }, + '& h2': { + ...theme.typography.h6, + }, + '& .text-teal': { + color: theme.palette.primary.main, + }, + '& button': { + ...theme.typography.button, + background: 'none', + boxSizing: 'border-box', + minWidth: 64, + borderRadius: theme.shape.borderRadius, + transition: theme.transitions.create( + ['background-color', 'box-shadow', 'border'], + { + duration: theme.transitions.duration.short, + }, + ), + padding: '5px 15px', + color: theme.palette.primary.main, + border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`, + '&:hover': { + textDecoration: 'none', + '&.Mui-disabled': { + backgroundColor: 'transparent', + }, + border: `1px solid ${theme.palette.primary.main}`, + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.action.hoverOpacity, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + '&.Mui-disabled': { + color: theme.palette.action.disabled, + }, + }, + '& .asyncapi__collapse-button:hover': { + color: theme.palette.primary.main, + }, + '& button.asyncapi__toggle-button': { + 'min-width': 'inherit', + }, + '& .asyncapi__info-list li': { + 'border-color': theme.palette.primary.main, + '&:hover': { + color: theme.palette.text.primary, + 'border-color': theme.palette.primary.main, + 'background-color': theme.palette.primary.main, + }, + }, + '& .asyncapi__info-list li a': { + color: theme.palette.primary.main, + '&:hover': { + color: theme.palette.getContrastText(theme.palette.primary.main), + }, + }, + '& .asyncapi__enum': { + color: theme.palette.secondary.main, + }, + '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': + { + 'background-color': 'inherit', + }, + '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': + { + 'background-color': 'inherit', + color: theme.palette.text.primary, + }, + '& .asyncapi__additional-properties-notice': { + color: theme.palette.text.hint, + }, + '& .asyncapi__code, .asyncapi__code-pre': { + background: theme.palette.background.default, + }, + '& .asyncapi__schema-example-header-title': { + color: theme.palette.text.secondary, + }, + '& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': + { + 'background-color': 'inherit', + color: theme.palette.text.secondary, + }, + '& .asyncapi__table-header': { + background: theme.palette.background.default, + }, + '& .asyncapi__table-body': { + color: theme.palette.text.primary, + }, + '& .asyncapi__server-security-flow': { + background: theme.palette.background.default, + border: 'none', + }, + '& .asyncapi__server-security-flows-list a': { + color: theme.palette.primary.main, + }, + '& .asyncapi__table-row--nested': { + color: theme.palette.text.secondary, + }, + }, +})); + +type Props = { + definition: string; +}; + +export const AsyncApiDefinition = ({ definition }: Props) => { + const classes = useStyles(); + + return ( +
+ +
+ ); +}; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index f32b5d01b5..03bc6fa8a6 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -14,132 +14,27 @@ * limitations under the License. */ -import AsyncApi from '@asyncapi/react-component'; -import '@asyncapi/react-component/lib/styles/fiori.css'; -import { fade, makeStyles } from '@material-ui/core/styles'; -import React from 'react'; +import { Progress } from '@backstage/core-components'; +import React, { Suspense } from 'react'; -const useStyles = makeStyles(theme => ({ - root: { - '& .asyncapi': { - 'font-family': 'inherit', - background: 'none', - }, - '& h2': { - ...theme.typography.h6, - }, - '& .text-teal': { - color: theme.palette.primary.main, - }, - '& button': { - ...theme.typography.button, - background: 'none', - boxSizing: 'border-box', - minWidth: 64, - borderRadius: theme.shape.borderRadius, - transition: theme.transitions.create( - ['background-color', 'box-shadow', 'border'], - { - duration: theme.transitions.duration.short, - }, - ), - padding: '5px 15px', - color: theme.palette.primary.main, - border: `1px solid ${fade(theme.palette.primary.main, 0.5)}`, - '&:hover': { - textDecoration: 'none', - '&.Mui-disabled': { - backgroundColor: 'transparent', - }, - border: `1px solid ${theme.palette.primary.main}`, - backgroundColor: fade( - theme.palette.primary.main, - theme.palette.action.hoverOpacity, - ), - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - '&.Mui-disabled': { - color: theme.palette.action.disabled, - }, - }, - '& .asyncapi__collapse-button:hover': { - color: theme.palette.primary.main, - }, - '& button.asyncapi__toggle-button': { - 'min-width': 'inherit', - }, - '& .asyncapi__info-list li': { - 'border-color': theme.palette.primary.main, - '&:hover': { - color: theme.palette.text.primary, - 'border-color': theme.palette.primary.main, - 'background-color': theme.palette.primary.main, - }, - }, - '& .asyncapi__info-list li a': { - color: theme.palette.primary.main, - '&:hover': { - color: theme.palette.getContrastText(theme.palette.primary.main), - }, - }, - '& .asyncapi__enum': { - color: theme.palette.secondary.main, - }, - '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': - { - 'background-color': 'inherit', - }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': - { - 'background-color': 'inherit', - color: theme.palette.text.primary, - }, - '& .asyncapi__additional-properties-notice': { - color: theme.palette.text.hint, - }, - '& .asyncapi__code, .asyncapi__code-pre': { - background: theme.palette.background.default, - }, - '& .asyncapi__schema-example-header-title': { - color: theme.palette.text.secondary, - }, - '& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': - { - 'background-color': 'inherit', - color: theme.palette.text.secondary, - }, - '& .asyncapi__table-header': { - background: theme.palette.background.default, - }, - '& .asyncapi__table-body': { - color: theme.palette.text.primary, - }, - '& .asyncapi__server-security-flow': { - background: theme.palette.background.default, - border: 'none', - }, - '& .asyncapi__server-security-flows-list a': { - color: theme.palette.primary.main, - }, - '& .asyncapi__table-row--nested': { - color: theme.palette.text.secondary, - }, - }, -})); +// The asyncapi component and related CSS has a significant size, only load it +// if the element is actually used. +const LazyAsyncApiDefinition = React.lazy(() => + import('./AsyncApiDefinition').then(m => ({ + default: m.AsyncApiDefinition, + })), +); -type Props = { +export type AsyncApiDefinitionWidgetProps = { definition: string; }; -export const AsyncApiDefinitionWidget = ({ definition }: Props) => { - const classes = useStyles(); - +export const AsyncApiDefinitionWidget = ( + props: AsyncApiDefinitionWidgetProps, +) => { return ( -
- -
+ }> + + ); }; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts index dcca901be2..7cc49e3138 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts @@ -15,3 +15,4 @@ */ export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; +export type { AsyncApiDefinitionWidgetProps } from './AsyncApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 274962c346..06022eec69 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ConsumingComponentsCard } from './ConsumingComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -39,10 +38,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 879944749a..275338d2f9 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ProvidingComponentsCard } from './ProvidingComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -39,10 +38,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.test.tsx b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.test.tsx similarity index 89% rename from plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.test.tsx rename to plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.test.tsx index 63e5a73692..fd78fa9204 100644 --- a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.test.tsx +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.test.tsx @@ -16,9 +16,9 @@ import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget'; +import { GraphQlDefinition } from './GraphQlDefinition'; -describe('', () => { +describe('', () => { it('renders graphql schema', async () => { const definition = ` """Hello World!""" @@ -53,7 +53,7 @@ type Film { }; const { getByText } = await renderInTestApp( - , + , ); expect(getByText(/Film/i)).toBeInTheDocument(); diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.tsx b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.tsx new file mode 100644 index 0000000000..d3fddf70cf --- /dev/null +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinition.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core/styles'; +import GraphiQL from 'graphiql'; +import 'graphiql/graphiql.css'; +import { buildSchema } from 'graphql'; +import React from 'react'; + +const useStyles = makeStyles(() => ({ + root: { + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + }, + graphiQlWrapper: { + flex: 1, + '@global': { + '.graphiql-container': { + boxSizing: 'initial', + height: '100%', + minHeight: '600px', + flex: '1 1 auto', + }, + }, + }, +})); + +type Props = { + definition: string; +}; + +export const GraphQlDefinition = ({ definition }: Props) => { + const classes = useStyles(); + const schema = buildSchema(definition); + + return ( +
+
+ Promise.resolve(null) as any} + schema={schema} + docExplorerOpen + defaultSecondaryEditorOpen={false} + /> +
+
+ ); +}; diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx index 8d15ba08a2..1f8a796249 100644 --- a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx @@ -14,54 +14,27 @@ * limitations under the License. */ -import { BackstageTheme } from '@backstage/theme'; -import { makeStyles } from '@material-ui/core/styles'; -import 'graphiql/graphiql.css'; -import { buildSchema } from 'graphql'; -import React, { Suspense } from 'react'; import { Progress } from '@backstage/core-components'; +import React, { Suspense } from 'react'; -const GraphiQL = React.lazy(() => import('graphiql')); +// The graphql component, graphql and related CSS has a significant size, only +// load it if the element is actually used. +const LazyGraphQlDefinition = React.lazy(() => + import('./GraphQlDefinition').then(m => ({ + default: m.GraphQlDefinition, + })), +); -const useStyles = makeStyles(() => ({ - root: { - height: '100%', - display: 'flex', - flexFlow: 'column nowrap', - }, - graphiQlWrapper: { - flex: 1, - '@global': { - '.graphiql-container': { - boxSizing: 'initial', - height: '100%', - minHeight: '600px', - flex: '1 1 auto', - }, - }, - }, -})); - -type Props = { - definition: any; +export type GraphQlDefinitionWidgetProps = { + definition: string; }; -export const GraphQlDefinitionWidget = ({ definition }: Props) => { - const classes = useStyles(); - const schema = buildSchema(definition); - +export const GraphQlDefinitionWidget = ( + props: GraphQlDefinitionWidgetProps, +) => { return ( }> -
-
- Promise.resolve(null) as any} - schema={schema} - docExplorerOpen - defaultSecondaryEditorOpen={false} - /> -
-
+
); }; diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts index 9173f492b0..8645b3c372 100644 --- a/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts @@ -15,3 +15,4 @@ */ export { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget'; +export type { GraphQlDefinitionWidgetProps } from './GraphQlDefinitionWidget'; diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.test.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.test.tsx similarity index 87% rename from plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.test.tsx rename to plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.test.tsx index 080aa11a3a..735fba86f7 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.test.tsx +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.test.tsx @@ -17,9 +17,9 @@ import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; -import { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; +import { OpenApiDefinition } from './OpenApiDefinition'; -describe('', () => { +describe('', () => { it('renders openapi spec', async () => { const definition = ` openapi: "3.0.0" @@ -39,7 +39,7 @@ paths: description: Success `; const { getByText } = await renderInTestApp( - , + , ); // swagger-ui loads the documentation asynchronously @@ -51,7 +51,7 @@ paths: it('renders error if definition is missing', async () => { const { getByText } = await renderInTestApp( - , + , ); expect(getByText(/No API definition provided/i)).toBeInTheDocument(); }); diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx new file mode 100644 index 0000000000..4a74b238f7 --- /dev/null +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { makeStyles } from '@material-ui/core/styles'; +import React, { useEffect, useState } from 'react'; +import SwaggerUI from 'swagger-ui-react'; +import 'swagger-ui-react/swagger-ui.css'; + +const useStyles = makeStyles(theme => ({ + root: { + '& .swagger-ui': { + fontFamily: 'inherit', + color: theme.palette.text.primary, + + [`& .info h1, + .info h2, + .info h3, + .info h4, + .info h5, + .info h6`]: { + fontFamily: 'inherit', + color: theme.palette.text.primary, + }, + [`& .scheme-container`]: { + backgroundColor: theme.palette.background.default, + }, + [`& .opblock-tag, + .opblock-tag small, + table thead tr td, + table thead tr th`]: { + fontFamily: 'inherit', + color: theme.palette.text.primary, + borderColor: theme.palette.divider, + }, + [`& section.models, + section.models.is-open h4`]: { + borderColor: theme.palette.divider, + }, + [`& .opblock .opblock-summary-description, + .parameter__type, + table.headers td, + .model-title, + .model .property.primitive, + section h3`]: { + fontFamily: 'inherit', + color: theme.palette.text.secondary, + }, + [`& .opblock .opblock-summary-operation-id, + .opblock .opblock-summary-path, + .opblock .opblock-summary-path__deprecated, + .opblock .opblock-section-header h4, + .parameter__name, + .response-col_status, + .response-col_links, + .responses-inner h4, + .responses-inner h5, + .opblock-section-header .btn, + .tab li, + .info li, + .info p, + .info table, + section.models h4, + .info .title, + table.model tr.description, + .property-row`]: { + fontFamily: 'inherit', + color: theme.palette.text.primary, + }, + [`& .opblock .opblock-section-header, + .model-box, + section.models .model-container`]: { + background: theme.palette.background.default, + }, + [`& .prop-format, + .parameter__in`]: { + color: theme.palette.text.disabled, + }, + [`& .opblock-description-wrapper p, + .opblock-external-docs-wrapper p, + .opblock-title_normal p, + .response-control-media-type__accept-message, + .opblock .opblock-section-header>label, + .scheme-container .schemes>label, + .info .base-url, + .model`]: { + color: theme.palette.text.hint, + }, + [`& .parameter__name.required:after`]: { + color: theme.palette.warning.dark, + }, + [`& .prop-type`]: { + color: theme.palette.primary.main, + }, + }, + }, +})); + +export type OpenApiDefinitionProps = { + definition: string; +}; + +export const OpenApiDefinition = ({ definition }: OpenApiDefinitionProps) => { + const classes = useStyles(); + + // Due to a bug in the swagger-ui-react component, the component needs + // to be created without content first. + const [def, setDef] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => setDef(definition), 0); + return () => clearTimeout(timer); + }, [definition, setDef]); + + return ( +
+ +
+ ); +}; diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx index 4c22d29bc2..606e476454 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx @@ -14,81 +14,27 @@ * limitations under the License. */ -import { makeStyles } from '@material-ui/core/styles'; -import React, { useEffect, useState } from 'react'; -import SwaggerUI from 'swagger-ui-react'; -import 'swagger-ui-react/swagger-ui.css'; +import { Progress } from '@backstage/core-components'; +import React, { Suspense } from 'react'; -// TODO: Schemas +// The swagger-ui component and related CSS has a significant size, only load it +// if the element is actually used. +const LazyOpenApiDefinition = React.lazy(() => + import('./OpenApiDefinition').then(m => ({ + default: m.OpenApiDefinition, + })), +); -const useStyles = makeStyles(theme => ({ - root: { - '& .swagger-ui, .info h1, .info h2, .info h3, .info h4, .info h': { - 'font-family': 'inherit', - color: theme.palette.text.primary, - }, - '& .scheme-container': { - 'background-color': theme.palette.background.default, - }, - '& .opblock-tag, .opblock-tag small, table thead tr td, table thead tr th': - { - color: theme.palette.text.primary, - 'border-color': theme.palette.divider, - }, - '& section.models, section.models.is-open h4': { - 'border-color': theme.palette.divider, - }, - '& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3': - { - color: theme.palette.text.secondary, - }, - '& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row': - { - color: theme.palette.text.primary, - }, - '& .opblock .opblock-section-header, .model-box, section.models .model-container': - { - background: theme.palette.background.default, - }, - '& .prop-format, .parameter__in': { - color: theme.palette.text.disabled, - }, - '& ': { - color: theme.palette.text.primary, - 'border-color': theme.palette.divider, - }, - '& .opblock-description-wrapper p, .opblock-external-docs-wrapper p, .opblock-title_normal p, .response-control-media-type__accept-message, .opblock .opblock-section-header>label, .scheme-container .schemes>label, .info .base-url, .model': - { - color: theme.palette.text.hint, - }, - '& .parameter__name.required:after': { - color: theme.palette.warning.dark, - }, - '& .prop-type': { - color: theme.palette.primary.main, - }, - }, -})); - -type Props = { +export type OpenApiDefinitionWidgetProps = { definition: string; }; -export const OpenApiDefinitionWidget = ({ definition }: Props) => { - const classes = useStyles(); - - // Due to a bug in the swagger-ui-react component, the component needs - // to be created without content first. - const [def, setDef] = useState(''); - - useEffect(() => { - const timer = setTimeout(() => setDef(definition), 0); - return () => clearTimeout(timer); - }, [definition, setDef]); - +export const OpenApiDefinitionWidget = ( + props: OpenApiDefinitionWidgetProps, +) => { return ( -
- -
+ }> + + ); }; diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts index 94401c92a1..e317d42a59 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts @@ -15,3 +15,4 @@ */ export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; +export type { OpenApiDefinitionWidgetProps } from './OpenApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx b/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx index efdd882564..b7f1e8afc0 100644 --- a/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx @@ -17,13 +17,19 @@ import React from 'react'; import { CodeSnippet } from '@backstage/core-components'; -type Props = { +export type PlainApiDefinitionWidgetProps = { definition: any; language: string; }; -export const PlainApiDefinitionWidget = ({ definition, language }: Props) => { +export const PlainApiDefinitionWidget = ( + props: PlainApiDefinitionWidgetProps, +) => { return ( - + ); }; diff --git a/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts index c233652981..562a3e67c1 100644 --- a/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts +++ b/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts @@ -15,3 +15,4 @@ */ export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget'; +export type { PlainApiDefinitionWidgetProps } from './PlainApiDefinitionWidget'; diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index ab976dbf18..b7c152889e 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -19,12 +19,8 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const rootRoute = createRouteRef({ - icon: NoIcon, - path: '/api-docs', - title: 'APIs', + id: 'api-docs', }); export const createComponentRouteRef = createExternalRouteRef({ diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 0658b10ebb..a58b028049 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-app-backend +## 0.3.19 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/backend-common@0.9.10 + ## 0.3.18 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index d989457c79..ebb0393192 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-app-backend", "description": "A Backstage backend plugin that serves the Backstage frontend app", - "version": "0.3.18", + "version": "0.3.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/config-loader": "^0.7.1", + "@backstage/backend-common": "^0.9.10", + "@backstage/config-loader": "^0.8.0", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@backstage/types": "^0.1.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index c1ba2038aa..5dc0cdb861 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-auth-backend +## 0.4.8 + +### Patch Changes + +- 892c1d9202: Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/test-utils@0.1.22 + +## 0.4.7 + +### Patch Changes + +- 5ee31f860b: Only use settings that have a value when creating a new FirestoreKeyStore instance +- 3e0e2f09d5: Added forwarding of the `audience` option for the SAML provider, making it possible to enable `audience` verification. +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/test-utils@0.1.21 + - @backstage/catalog-client@0.5.1 + ## 0.4.6 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 1fea3346a7..fe2cd05c22 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -34,7 +34,7 @@ Follow this link, [Create new OAuth App](https://github.com/settings/application 1. Set Application Name to `backstage-dev` or something along those lines. 1. You can set the Homepage URL to whatever you want to. 1. The Authorization Callback URL should match the redirect URI set in Backstage. - 1. Set this to `http://localhost:7000/api/auth/github` for local development. + 1. Set this to `http://localhost:7007/api/auth/github` for local development. 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/github` for non-local deployments. ```bash @@ -58,7 +58,7 @@ Follow this link, [Add new application](https://gitlab.com/-/profile/application 1. Set Application Name to `backstage-dev` or something along those lines. 1. The Authorization Callback URL should match the redirect URI set in Backstage. - 1. Set this to `http://localhost:7000/api/auth/gitlab/handler/frame` for local development. + 1. Set this to `http://localhost:7007/api/auth/gitlab/handler/frame` for local development. 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/gitlab/handler/frame` for non-local deployments. 1. Select the following scopes from the list: - [x] `read_user` Grants read-only access to the authenticated user's profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users. @@ -91,9 +91,9 @@ export AUTH_GITLAB_CLIENT_SECRET=x Add a new Okta application using the following URI conventions: -Login redirect URI's: `http://localhost:7000/api/auth/okta/handler/frame` -Logout redirect URI's: `http://localhost:7000/api/auth/okta/logout` -Initiate login URI's: `http://localhost:7000/api/auth/okta/start` +Login redirect URI's: `http://localhost:7007/api/auth/okta/handler/frame` +Logout redirect URI's: `http://localhost:7007/api/auth/okta/logout` +Initiate login URI's: `http://localhost:7007/api/auth/okta/start` Then configure the following environment variables to be used in the `app-config.yaml` file: @@ -122,7 +122,7 @@ Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMe - Give the app a name. e.g. `backstage-dev` - Select `Accounts in this organizational directory only` under supported account types. - Enter the callback URL for your backstage backend instance: - - For local development, this is likely `http://localhost:7000/api/auth/microsoft/handler/frame` + - For local development, this is likely `http://localhost:7007/api/auth/microsoft/handler/frame` - For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame` - Click `Register`. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 25da7fa332..791bd3606b 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -75,6 +75,7 @@ export interface Config { logoutUrl?: string; issuer: string; cert: string; + audience?: string; privateKey?: string; authnContext?: string[]; identifierFormat?: string; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 5c9d70ea10..3a9e8102eb 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-auth-backend", "description": "A Backstage backend plugin that handles authentication", - "version": "0.4.6", + "version": "0.4.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", - "@backstage/test-utils": "^0.1.20", + "@backstage/test-utils": "^0.1.22", "@google-cloud/firestore": "^4.15.1", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", @@ -73,7 +73,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/scripts/start-saml-idp.sh b/plugins/auth-backend/scripts/start-saml-idp.sh index b312a4b85e..e12b725191 100755 --- a/plugins/auth-backend/scripts/start-saml-idp.sh +++ b/plugins/auth-backend/scripts/start-saml-idp.sh @@ -18,4 +18,4 @@ fi echo "Downloading and starting SAML-IdP" export NPM_CONFIG_REGISTRY=https://registry.npmjs.org -exec npx saml-idp --acsUrl "http://localhost:7000/api/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001 +exec npx saml-idp --acsUrl "http://localhost:7007/api/auth/saml/handler/frame" --audience "http://localhost:7007" --port 7001 diff --git a/plugins/auth-backend/src/identity/KeyStores.ts b/plugins/auth-backend/src/identity/KeyStores.ts index 74e60a0502..3d238adba9 100644 --- a/plugins/auth-backend/src/identity/KeyStores.ts +++ b/plugins/auth-backend/src/identity/KeyStores.ts @@ -15,6 +15,7 @@ */ import { Logger } from 'winston'; +import { pickBy } from 'lodash'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { Config } from '@backstage/config'; @@ -64,16 +65,20 @@ export class KeyStores { if (provider === 'firestore') { const settings = ks?.getConfig(provider); - const keyStore = await FirestoreKeyStore.create({ - projectId: settings?.getOptionalString('projectId'), - keyFilename: settings?.getOptionalString('keyFilename'), - host: settings?.getOptionalString('host'), - port: settings?.getOptionalNumber('port'), - ssl: settings?.getOptionalBoolean('ssl'), - path: settings?.getOptionalString('path'), - timeout: settings?.getOptionalNumber('timeout'), - }); - + const keyStore = await FirestoreKeyStore.create( + pickBy( + { + projectId: settings?.getOptionalString('projectId'), + keyFilename: settings?.getOptionalString('keyFilename'), + host: settings?.getOptionalString('host'), + port: settings?.getOptionalNumber('port'), + ssl: settings?.getOptionalBoolean('ssl'), + path: settings?.getOptionalString('path'), + timeout: settings?.getOptionalNumber('timeout'), + }, + value => value !== undefined, + ), + ); await FirestoreKeyStore.verifyConnection(keyStore, logger); return keyStore; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 27b629cc07..fa61d69d80 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -22,7 +22,7 @@ import { OAuthHandlers } from './types'; const mockResponseData = { providerInfo: { accessToken: 'ACCESS_TOKEN', - idToken: 'ID_TOKEN', + token: 'ID_TOKEN', expiresInSeconds: 10, scope: 'email', }, @@ -176,7 +176,7 @@ describe('OAuthAdapter', () => { const mockResponse = { cookie: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), } as unknown as express.Response; @@ -187,6 +187,7 @@ describe('OAuthAdapter', () => { '', expect.objectContaining({ path: '/auth/test-provider' }), ); + expect(mockResponse.end).toHaveBeenCalledTimes(1); }); it('gets new access-token when refreshing', async () => { @@ -216,7 +217,7 @@ describe('OAuthAdapter', () => { ...mockResponseData, backstageIdentity: { id: mockResponseData.backstageIdentity.id, - idToken: 'my-id-token', + token: 'my-id-token', }, }); }); @@ -230,21 +231,14 @@ describe('OAuthAdapter', () => { const mockRequest = { header: () => 'XMLHttpRequest', - cookies: { - 'test-provider-refresh-token': 'token', - }, - query: {}, } as unknown as express.Request; - const mockResponse = { - send: jest.fn().mockReturnThis(), - status: jest.fn().mockReturnThis(), - } as unknown as express.Response; + const mockResponse = {} as unknown as express.Response; - await oauthProvider.refresh(mockRequest, mockResponse); - expect(mockResponse.send).toHaveBeenCalledTimes(1); - expect(mockResponse.send).toHaveBeenCalledWith( - 'Refresh token not supported for provider: test-provider', + await expect( + oauthProvider.refresh(mockRequest, mockResponse), + ).rejects.toThrow( + 'Refresh token is not supported for provider test-provider', ); }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index a5128711bc..e1a6fdf2f9 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -22,7 +22,12 @@ import { BackstageIdentity, AuthProviderConfig, } from '../../providers/types'; -import { InputError, isError, NotAllowedError } from '@backstage/errors'; +import { + AuthenticationError, + InputError, + isError, + NotAllowedError, +} from '@backstage/errors'; import { TokenIssuer } from '../../identity/types'; import { readState, verifyNonce } from './helpers'; import { postMessageResponse, ensuresXRequestedWith } from '../flow'; @@ -166,29 +171,24 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { async logout(req: express.Request, res: express.Response): Promise { if (!ensuresXRequestedWith(req)) { - res.status(401).send('Invalid X-Requested-With header'); - return; + throw new AuthenticationError('Invalid X-Requested-With header'); } // remove refresh token cookie if it is set this.removeRefreshTokenCookie(res); - res.status(200).send('logout!'); + res.status(200).end(); } async refresh(req: express.Request, res: express.Response): Promise { if (!ensuresXRequestedWith(req)) { - res.status(401).send('Invalid X-Requested-With header'); - return; + throw new AuthenticationError('Invalid X-Requested-With header'); } if (!this.handlers.refresh || this.options.disableRefresh) { - res - .status(400) - .send( - `Refresh token not supported for provider: ${this.options.providerId}`, - ); - return; + throw new InputError( + `Refresh token is not supported for provider ${this.options.providerId}`, + ); } try { @@ -197,7 +197,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { // throw error if refresh token is missing in the request if (!refreshToken) { - throw new Error('Missing session cookie'); + throw new InputError('Missing session cookie'); } const scope = req.query.scope?.toString() ?? ''; @@ -220,7 +220,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { res.status(200).json(response); } catch (error) { - res.status(401).send(String(error)); + throw new AuthenticationError('Refresh failed', error); } } @@ -233,10 +233,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return; } - if (!identity.idToken) { - identity.idToken = await this.options.tokenIssuer.issueToken({ + if (!(identity.token || identity.idToken)) { + identity.token = await this.options.tokenIssuer.issueToken({ claims: { sub: identity.id }, }); + } else if (!identity.token && identity.idToken) { + identity.token = identity.idToken; } } diff --git a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts index a2f427e741..95a0547a5e 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -16,7 +16,7 @@ import express from 'express'; import { Config } from '@backstage/config'; -import { InputError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { readState } from './helpers'; import { AuthProviderRouteHandlers } from '../../providers/types'; @@ -42,26 +42,26 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { ) {} async start(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.start(req, res); + const provider = this.getProviderForEnv(req); + await provider.start(req, res); } async frameHandler( req: express.Request, res: express.Response, ): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.frameHandler(req, res); + const provider = this.getProviderForEnv(req); + await provider.frameHandler(req, res); } async refresh(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.refresh?.(req, res); + const provider = this.getProviderForEnv(req); + await provider.refresh?.(req, res); } async logout(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.logout?.(req, res); + const provider = this.getProviderForEnv(req); + await provider.logout?.(req, res); } private getRequestFromEnv(req: express.Request): string | undefined { @@ -77,26 +77,20 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { return env; } - private getProviderForEnv( - req: express.Request, - res: express.Response, - ): AuthProviderRouteHandlers | undefined { + private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers { const env: string | undefined = this.getRequestFromEnv(req); if (!env) { throw new InputError(`Must specify 'env' query to select environment`); } - if (!this.handlers.has(env)) { - res.status(404).send( - `Missing configuration. -
-
- For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`, + const handler = this.handlers.get(env); + if (!handler) { + throw new NotFoundError( + `No configuration available for the '${env}' environment of this provider.`, ); - return undefined; } - return this.handlers.get(env); + return handler; } } diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 07fc77e4f7..8929a1dbf0 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -125,6 +125,7 @@ export const createSamlProvider = ( callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, entryPoint: config.getString('entryPoint'), logoutUrl: config.getOptionalString('logoutUrl'), + audience: config.getOptionalString('audience'), issuer: config.getString('issuer'), cert: config.getString('cert'), privateCert: config.getOptionalString('privateKey'), diff --git a/plugins/azure-devops-backend/CHANGELOG.md b/plugins/azure-devops-backend/CHANGELOG.md index b756e7ce4f..06fc4833c4 100644 --- a/plugins/azure-devops-backend/CHANGELOG.md +++ b/plugins/azure-devops-backend/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-azure-devops-backend +## 0.2.1 + +### Patch Changes + +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime +- Updated dependencies + - @backstage/backend-common@0.9.10 + - @backstage/plugin-azure-devops-common@0.1.0 + +## 0.2.0 + +### Minor Changes + +- b85acc8c35: refactor(`@backstage/plugin-azure-devops`): Consume types from `@backstage/plugin-azure-devops-common`. + Stop re-exporting types from `@backstage/plugin-azure-devops-backend`. + Added new types to `@backstage/plugin-azure-devops-common`. + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/plugin-azure-devops-common@0.0.2 + ## 0.1.4 ### Patch Changes diff --git a/plugins/azure-devops-backend/README.md b/plugins/azure-devops-backend/README.md index 6101794cd1..79db9f5ff1 100644 --- a/plugins/azure-devops-backend/README.md +++ b/plugins/azure-devops-backend/README.md @@ -63,7 +63,7 @@ Here's how to get the backend up and running: ``` 4. Now run `yarn start-backend` from the repo root -5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}` +5. Finally open `http://localhost:7007/api/azure-devops/health` in a browser and it should return `{"status":"ok"}` ## Links diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index fac6261a39..1eabccc720 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -4,13 +4,13 @@ ```ts import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; -import { BuildResult } from 'azure-devops-node-api/interfaces/BuildInterfaces'; -import { BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Config } from '@backstage/config'; import express from 'express'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Logger as Logger_2 } from 'winston'; -import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequest } from '@backstage/plugin-azure-devops-common'; +import { PullRequestOptions } from '@backstage/plugin-azure-devops-common'; +import { RepoBuild } from '@backstage/plugin-azure-devops-common'; import { WebApi } from 'azure-devops-node-api'; // Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -29,8 +29,6 @@ export class AzureDevOpsApi { projectName: string, repoName: string, ): Promise; - // Warning: (ae-forgotten-export) The symbol "PullRequestOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) getPullRequests( projectName: string, @@ -45,48 +43,11 @@ export class AzureDevOpsApi { ): Promise; } -export { BuildResult }; - -export { BuildStatus }; - // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type PullRequest = { - pullRequestId?: number; - repoName?: string; - title?: string; - uniqueName?: string; - createdBy?: string; - creationDate?: Date; - sourceRefName?: string; - targetRefName?: string; - status?: PullRequestStatus; - isDraft?: boolean; - link: string; -}; - -// Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type RepoBuild = { - id?: number; - title: string; - link?: string; - status?: BuildStatus; - result?: BuildResult; - queueTime?: Date; - startTime?: Date; - finishTime?: Date; - source: string; - uniqueName?: string; -}; - // Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 8a468b0cc9..6d213cbce6 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops-backend", - "version": "0.1.4", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.11", + "@backstage/plugin-azure-devops-common": "^0.1.0", "@types/express": "^4.17.6", "azure-devops-node-api": "^11.0.1", "express": "^4.17.1", @@ -30,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "msw": "^0.35.0" diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts index fdd49c0440..2cb7d93c0b 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -16,17 +16,21 @@ import { Build, + DefinitionReference, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { BuildResult, BuildStatus, - GitPullRequest, - GitRepository, PullRequest, PullRequestStatus, RepoBuild, -} from './types'; +} from '@backstage/plugin-azure-devops-common'; +import { + GitPullRequest, + GitRepository, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi'; -import { DefinitionReference } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; describe('AzureDevOpsApi', () => { @@ -44,7 +48,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -52,9 +56,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: inputBuildDefinition, @@ -68,11 +72,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -89,7 +93,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -97,9 +101,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -113,11 +117,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -134,7 +138,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -142,9 +146,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: undefined, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -158,11 +162,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.None, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -179,7 +183,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -187,9 +191,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.InProgress, result: undefined, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -203,11 +207,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -218,7 +222,7 @@ describe('AzureDevOpsApi', () => { it('should return empty string for link', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -226,9 +230,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.InProgress, result: undefined, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -242,11 +246,11 @@ describe('AzureDevOpsApi', () => { link: '', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -261,7 +265,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputPullRequest: GitPullRequest = { @@ -269,7 +273,7 @@ describe('AzureDevOpsApi', () => { repository: inputGitRepository, title: 'My Awesome New Feature', createdBy: inputIdentityRef, - creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + creationDate: new Date('2020-09-12T06:10:23.932Z'), sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -283,9 +287,9 @@ describe('AzureDevOpsApi', () => { pullRequestId: 7181, repoName: 'super-feature-repo', title: 'My Awesome New Feature', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', createdBy: 'Jane Doe', - creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 26378496ea..23ea3311f0 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -15,17 +15,19 @@ */ import { - Build, BuildResult, BuildStatus, - GitPullRequest, - GitPullRequestSearchCriteria, - GitRepository, PullRequest, PullRequestOptions, RepoBuild, -} from './types'; +} from '@backstage/plugin-azure-devops-common'; +import { + GitPullRequest, + GitPullRequestSearchCriteria, + GitRepository, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Logger } from 'winston'; import { WebApi } from 'azure-devops-node-api'; @@ -147,9 +149,9 @@ export function mappedRepoBuild(build: Build): RepoBuild { link: build._links?.web.href ?? '', status: build.status ?? BuildStatus.None, result: build.result ?? BuildResult.None, - queueTime: build.queueTime, - startTime: build.startTime, - finishTime: build.finishTime, + queueTime: build.queueTime?.toISOString(), + startTime: build.startTime?.toISOString(), + finishTime: build.finishTime?.toISOString(), source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, uniqueName: build.requestedFor?.uniqueName ?? 'N/A', }; @@ -165,7 +167,7 @@ export function mappedPullRequest( title: pullRequest.title, uniqueName: pullRequest.createdBy?.uniqueName ?? 'N/A', createdBy: pullRequest.createdBy?.displayName ?? 'N/A', - creationDate: pullRequest.creationDate, + creationDate: pullRequest.creationDate?.toISOString(), sourceRefName: pullRequest.sourceRefName, targetRefName: pullRequest.targetRefName, status: pullRequest.status, diff --git a/plugins/azure-devops-backend/src/api/index.ts b/plugins/azure-devops-backend/src/api/index.ts index 97c48cb0bf..903c1bdb6a 100644 --- a/plugins/azure-devops-backend/src/api/index.ts +++ b/plugins/azure-devops-backend/src/api/index.ts @@ -15,5 +15,3 @@ */ export { AzureDevOpsApi } from './AzureDevOpsApi'; -export { BuildResult, BuildStatus } from './types'; -export type { RepoBuild, PullRequest } from './types'; diff --git a/plugins/azure-devops-backend/src/api/types.ts b/plugins/azure-devops-backend/src/api/types.ts deleted file mode 100644 index fab61d26c9..0000000000 --- a/plugins/azure-devops-backend/src/api/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { - Build, - BuildResult, - BuildStatus, -} from 'azure-devops-node-api/interfaces/BuildInterfaces'; -import { - GitPullRequest, - GitPullRequestSearchCriteria, - GitRepository, - PullRequestStatus, -} from 'azure-devops-node-api/interfaces/GitInterfaces'; - -export { BuildResult, BuildStatus, PullRequestStatus }; -export type { - Build, - GitPullRequest, - GitPullRequestSearchCriteria, - GitRepository, -}; - -export type RepoBuild = { - id?: number; - title: string; - link?: string; - status?: BuildStatus; - result?: BuildResult; - queueTime?: Date; - startTime?: Date; - finishTime?: Date; - source: string; - uniqueName?: string; -}; - -export type PullRequest = { - pullRequestId?: number; - repoName?: string; - title?: string; - uniqueName?: string; - createdBy?: string; - creationDate?: Date; - sourceRefName?: string; - targetRefName?: string; - status?: PullRequestStatus; - isDraft?: boolean; - link: string; -}; - -export type PullRequestOptions = { - top: number; - status: PullRequestStatus; -}; diff --git a/plugins/azure-devops-backend/src/index.ts b/plugins/azure-devops-backend/src/index.ts index 7a2a347b65..6977d3580f 100644 --- a/plugins/azure-devops-backend/src/index.ts +++ b/plugins/azure-devops-backend/src/index.ts @@ -13,6 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { AzureDevOpsApi, BuildResult, BuildStatus } from './api'; -export type { RepoBuild, PullRequest } from './api'; +export { AzureDevOpsApi } from './api'; export * from './service/router'; diff --git a/plugins/azure-devops-backend/src/run.ts b/plugins/azure-devops-backend/src/run.ts index addfdfd6d7..53d4e4334a 100644 --- a/plugins/azure-devops-backend/src/run.ts +++ b/plugins/azure-devops-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index 4143291409..ce5f712392 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -14,22 +14,22 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; -import express from 'express'; -import request from 'supertest'; -import { AzureDevOpsApi } from '../api'; -import { createRouter } from './router'; -import { PullRequest, RepoBuild } from '../api/types'; import { - GitRepository, - PullRequestStatus, -} from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { - Build, BuildResult, BuildStatus, -} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + PullRequest, + PullRequestStatus, + RepoBuild, +} from '@backstage/plugin-azure-devops-common'; + +import { AzureDevOpsApi } from '../api'; +import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { ConfigReader } from '@backstage/config'; +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { createRouter } from './router'; +import express from 'express'; +import { getVoidLogger } from '@backstage/backend-common'; +import request from 'supertest'; describe('createRouter', () => { let azureDevOpsApi: jest.Mocked; @@ -152,7 +152,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.PartiallySucceeded, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (f4f78b31)', }; @@ -162,7 +162,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (13c988d4)', }; @@ -172,7 +172,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (9bedf678)', }; @@ -205,7 +205,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -218,7 +218,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'Refactoring My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/refactor-super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -231,7 +231,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'Bug Fix for My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/fix-super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index b3505eae1c..d61148b4ff 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { PullRequestOptions, PullRequestStatus } from '../api/types'; +import { + PullRequestOptions, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { AzureDevOpsApi } from '../api'; diff --git a/plugins/azure-devops-common/.eslintrc.js b/plugins/azure-devops-common/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/azure-devops-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/azure-devops-common/CHANGELOG.md b/plugins/azure-devops-common/CHANGELOG.md new file mode 100644 index 0000000000..4a834fb068 --- /dev/null +++ b/plugins/azure-devops-common/CHANGELOG.md @@ -0,0 +1,15 @@ +# @backstage/plugin-azure-devops-common + +## 0.1.0 + +### Minor Changes + +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime + +## 0.0.2 + +### Patch Changes + +- b85acc8c35: refactor(`@backstage/plugin-azure-devops`): Consume types from `@backstage/plugin-azure-devops-common`. + Stop re-exporting types from `@backstage/plugin-azure-devops-backend`. + Added new types to `@backstage/plugin-azure-devops-common`. diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md new file mode 100644 index 0000000000..71f0b8b807 --- /dev/null +++ b/plugins/azure-devops-common/api-report.md @@ -0,0 +1,90 @@ +## API Report File for "@backstage/plugin-azure-devops-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// Warning: (ae-missing-release-tag) "BuildResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum BuildResult { + Canceled = 32, + Failed = 8, + None = 0, + PartiallySucceeded = 4, + Succeeded = 2, +} + +// Warning: (ae-missing-release-tag) "BuildStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum BuildStatus { + All = 47, + Cancelling = 4, + Completed = 2, + InProgress = 1, + None = 0, + NotStarted = 32, + Postponed = 8, +} + +// Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + uniqueName?: string; + createdBy?: string; + creationDate?: string; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; + +// Warning: (ae-missing-release-tag) "PullRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PullRequestOptions = { + top: number; + status: PullRequestStatus; +}; + +// Warning: (ae-missing-release-tag) "PullRequestStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PullRequestStatus { + Abandoned = 2, + Active = 1, + All = 4, + Completed = 3, + NotSet = 0, +} + +// Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type RepoBuild = { + id?: number; + title: string; + link?: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: string; + startTime?: string; + finishTime?: string; + source: string; + uniqueName?: string; +}; + +// Warning: (ae-missing-release-tag) "RepoBuildOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type RepoBuildOptions = { + top?: number; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/azure-devops-common/package.json b/plugins/azure-devops-common/package.json new file mode 100644 index 0000000000..fe9df644b1 --- /dev/null +++ b/plugins/azure-devops-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@backstage/plugin-azure-devops-common", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/azure-devops-common" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test --passWithNoTests", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/azure-devops-common/src/index.ts b/plugins/azure-devops-common/src/index.ts new file mode 100644 index 0000000000..26a854de89 --- /dev/null +++ b/plugins/azure-devops-common/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts new file mode 100644 index 0000000000..a647f87300 --- /dev/null +++ b/plugins/azure-devops-common/src/types.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 enum BuildResult { + /** + * No result + */ + None = 0, + /** + * The build completed successfully. + */ + Succeeded = 2, + /** + * The build completed compilation successfully but had other errors. + */ + PartiallySucceeded = 4, + /** + * The build completed unsuccessfully. + */ + Failed = 8, + /** + * The build was canceled before starting. + */ + Canceled = 32, +} + +export enum BuildStatus { + /** + * No status. + */ + None = 0, + /** + * The build is currently in progress. + */ + InProgress = 1, + /** + * The build has completed. + */ + Completed = 2, + /** + * The build is cancelling + */ + Cancelling = 4, + /** + * The build is inactive in the queue. + */ + Postponed = 8, + /** + * The build has not yet started. + */ + NotStarted = 32, + /** + * All status. + */ + All = 47, +} + +export type RepoBuild = { + id?: number; + title: string; + link?: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: string; + startTime?: string; + finishTime?: string; + source: string; + uniqueName?: string; +}; + +export type RepoBuildOptions = { + top?: number; +}; + +export enum PullRequestStatus { + /** + * Status not set. Default state. + */ + NotSet = 0, + /** + * Pull request is active. + */ + Active = 1, + /** + * Pull request is abandoned. + */ + Abandoned = 2, + /** + * Pull request is completed. + */ + Completed = 3, + /** + * Used in pull request search criteria to include all statuses. + */ + All = 4, +} + +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + uniqueName?: string; + createdBy?: string; + creationDate?: string; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; + +export type PullRequestOptions = { + top: number; + status: PullRequestStatus; +}; diff --git a/plugins/azure-devops/CHANGELOG.md b/plugins/azure-devops/CHANGELOG.md index 71904265dd..0bcd9bc686 100644 --- a/plugins/azure-devops/CHANGELOG.md +++ b/plugins/azure-devops/CHANGELOG.md @@ -1,5 +1,34 @@ # @backstage/plugin-azure-devops +## 0.1.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- b5eac957f2: Added entity view for Azure Repo Pull Requests +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/plugin-azure-devops-common@0.1.0 + +## 0.1.3 + +### Patch Changes + +- b85acc8c35: refactor(`@backstage/plugin-azure-devops`): Consume types from `@backstage/plugin-azure-devops-common`. + Stop re-exporting types from `@backstage/plugin-azure-devops-backend`. + Added new types to `@backstage/plugin-azure-devops-common`. +- 84ace9a29c: Simplified queue time calculation in `BuildTable`. +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/plugin-azure-devops-common@0.0.2 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.1.2 ### Patch Changes diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 47a0c4debd..7b39dae443 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -6,10 +6,16 @@ Website: [https://dev.azure.com/](https://dev.azure.com/) ### Azure Pipelines -Lists the top _n_ builds for a given repository where _n_ is a configurable value +Lists the top _n_ builds for a given Azure Repo where _n_ is a configurable value ![Azure Pipelines Builds Example](./docs/azure-devops-builds.png) +### Azure Repos + +Lists the top _n_ Active, Completed, or Abandoned Pull Requests for a given repository where _n_ is a configurable value + +![Azure Repos Pull Requests Example](./docs/azure-devops-pull-requests.png) + ## Setup The following sections will help you get the Azure DevOps plugin setup and running @@ -69,14 +75,55 @@ To get the Azure Pipelines component working you'll need to do the following two // ... - // Set defaultLimit to the max number of builds you would like to be able to see - // the default if not set is 10 // ... ``` +**Notes:** + +- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` property on the `EntityAzurePipelinesContent` will set the max number of Builds you would like to see, if not set this will default to 10 + +### Azure Repos Component + +To get the Azure Repos component working you'll need to do the following two steps: + +1. First we need to add the @backstage/plugin-azure-devops package to your frontend app: + + ```bash + # From your Backstage root directory + cd packages/app + yarn add @backstage/plugin-azure-devops + ``` + +2. Second we need to add the `EntityAzurePullRequestsContent` extension to the entity page in your app: + + ```tsx + // In packages/app/src/components/catalog/EntityPage.tsx + import { + EntityAzurePullRequestsContent, + isAzureDevOpsAvailable, + } from '@backstage/plugin-azure-devops'; + + // For example in the Service section + const serviceEntityPage = ( + + // ... + + + + // ... + + ``` + +**Notes:** + +- You'll need to add the `EntityLayout.Route` above from step 2 to all the entity sections you want to see Pull Requests in. For example if you wanted to see Pull Requests when looking at Website entities then you would need to add this to the `websiteEntityPage` section. +- The `if` prop is optional on the `EntityLayout.Route`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` property on the `EntityAzurePullRequestsContent` will set the max number of Pull Requests you would like to see, if not set this will default to 10 + ## Limitations - Currently multiple organizations are not supported diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index fbe28c179c..ab659534c1 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -7,17 +7,11 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { RouteRef } from '@backstage/core-plugin-api'; // Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const azureDevOpsPlugin: BackstagePlugin< - { - entityContent: RouteRef; - }, - {} ->; +export const azureDevOpsPlugin: BackstagePlugin<{}, {}>; // Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -28,6 +22,15 @@ export const EntityAzurePipelinesContent: ({ defaultLimit?: number | undefined; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "EntityAzurePullRequestsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityAzurePullRequestsContent: ({ + defaultLimit, +}: { + defaultLimit?: number | undefined; +}) => JSX.Element; + // Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops/docs/azure-devops-builds.png b/plugins/azure-devops/docs/azure-devops-builds.png index 4788a185ed..6ff93dc7e4 100644 Binary files a/plugins/azure-devops/docs/azure-devops-builds.png and b/plugins/azure-devops/docs/azure-devops-builds.png differ diff --git a/plugins/azure-devops/docs/azure-devops-pull-requests.png b/plugins/azure-devops/docs/azure-devops-pull-requests.png new file mode 100644 index 0000000000..ce1b2a4267 Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-pull-requests.png differ diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index a071205711..9d235b0641 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops", - "version": "0.1.2", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,16 +27,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.6.2", - "@backstage/theme": "^0.2.12", + "@backstage/plugin-azure-devops-common": "^0.1.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "azure-devops-node-api": "^11.0.1", + "humanize-duration": "^3.27.0", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -44,10 +45,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index f0bad53b43..90d291b25d 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { RepoBuild, RepoBuildOptions } from './types'; +import { + PullRequest, + PullRequestOptions, + RepoBuild, + RepoBuildOptions, +} from '@backstage/plugin-azure-devops-common'; + import { createApiRef } from '@backstage/core-plugin-api'; export const azureDevOpsApiRef = createApiRef({ @@ -29,4 +35,10 @@ export interface AzureDevOpsApi { repoName: string, options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }>; + + getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }>; } diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index e03428e511..cbcc496725 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -14,9 +14,15 @@ * limitations under the License. */ -import { AzureDevOpsApi } from './AzureDevOpsApi'; -import { RepoBuild, RepoBuildOptions } from './types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + PullRequest, + PullRequestOptions, + RepoBuild, + RepoBuildOptions, +} from '@backstage/plugin-azure-devops-common'; + +import { AzureDevOpsApi } from './AzureDevOpsApi'; import { ResponseError } from '@backstage/errors'; export class AzureDevOpsClient implements AzureDevOpsApi { @@ -31,14 +37,40 @@ export class AzureDevOpsClient implements AzureDevOpsApi { this.identityApi = options.identityApi; } - async getRepoBuilds( + public async getRepoBuilds( projectName: string, repoName: string, options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }> { - const items = await this.get( - `repo-builds/${projectName}/${repoName}?top=${options?.top}`, - ); + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + const urlSegment = `repo-builds/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); + return { items }; + } + + public async getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }> { + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + if (options?.status) { + queryString.append('status', options.status.toString()); + } + const urlSegment = `pull-requests/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); return { items }; } diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx new file mode 100644 index 0000000000..bd19331f6e --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const AzurePipelinesIcon = (props: SvgIconProps) => ( + + + + +); diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts new file mode 100644 index 0000000000..4eb161f7bc --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AzurePipelinesIcon } from './AzurePipelinesIcon'; diff --git a/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx new file mode 100644 index 0000000000..bb4cb8c081 --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const AzurePullRequestsIcon = (props: SvgIconProps) => ( + + + + + + + +); diff --git a/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts b/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts new file mode 100644 index 0000000000..1f49119b4b --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AzurePullRequestsIcon } from './AzurePullRequestsIcon'; diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx new file mode 100644 index 0000000000..84980b0b51 --- /dev/null +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + BuildResult, + BuildStatus, + RepoBuild, +} from '@backstage/plugin-azure-devops-common'; + +import { BuildTable } from './BuildTable'; +import { MemoryRouter } from 'react-router'; +import React from 'react'; + +export default { + title: 'Plugins/Azure Devops/Build Table', + component: BuildTable, +}; + +const buildStatuses: Array<[BuildStatus, BuildResult]> = [ + [BuildStatus.InProgress, BuildResult.None], // In Progress + [BuildStatus.Completed, BuildResult.Succeeded], // Succeeded + [BuildStatus.Completed, BuildResult.Failed], // Failed + [BuildStatus.Completed, BuildResult.PartiallySucceeded], // Partially Succeeded + [BuildStatus.Completed, BuildResult.Canceled], // Cancelled + [BuildStatus.Completed, BuildResult.None], // Unknown + [BuildStatus.Cancelling, BuildResult.None], // Cancelling + [BuildStatus.Postponed, BuildResult.None], // Postponed + [BuildStatus.NotStarted, BuildResult.None], // Not Started + [BuildStatus.None, BuildResult.None], // Unknown +]; + +const generateTestData = (rows = 10): RepoBuild[] => { + const repoBuilds: RepoBuild[] = []; + + for (let i = 0; i < rows; i++) { + const [status, result] = buildStatuses[i] ?? [ + BuildStatus.Completed, + BuildResult.Succeeded, + ]; + + repoBuilds.push({ + id: rows - i + 12534, + title: `backstage ci - 1.0.0-preview-${rows - i}`, + status, + result, + queueTime: new Date(Date.now() - i * 60000).toISOString(), + source: 'refs/heads/main', + link: '', + }); + } + + return repoBuilds; +}; + +export const Default = () => ( + + + +); + +export const Empty = () => ( + + + +); + +export const Loading = () => ( + + + +); + +export const ErrorMessage = () => ( + + + +); diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts b/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts index 42408f37b3..cb240aef42 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts @@ -17,7 +17,7 @@ import { BuildResult, BuildStatus, -} from 'azure-devops-node-api/interfaces/BuildInterfaces'; +} from '@backstage/plugin-azure-devops-common'; import { getBuildResultComponent, getBuildStateComponent } from './BuildTable'; import { renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx index a308afb81b..463bf15d78 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -18,7 +18,8 @@ import { Box, Typography } from '@material-ui/core'; import { BuildResult, BuildStatus, -} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + RepoBuild, +} from '@backstage/plugin-azure-devops-common'; import { Link, ResponseErrorPanel, @@ -32,9 +33,10 @@ import { TableColumn, } from '@backstage/core-components'; +import { AzurePipelinesIcon } from '../AzurePipelinesIcon'; import { DateTime } from 'luxon'; import React from 'react'; -import { RepoBuild } from '../../api/types'; +import { getDurationFromDates } from '../../utils/getDurationFromDates'; export const getBuildResultComponent = (result: number | undefined) => { switch (result) { @@ -144,13 +146,26 @@ const columns: TableColumn[] = [ ), }, + { + title: 'Duration', + field: 'queueTime', + width: 'auto', + render: (row: Partial) => ( + + + {getDurationFromDates(row.startTime, row.finishTime)} + + + ), + }, { title: 'Age', field: 'queueTime', width: 'auto', render: (row: Partial) => - DateTime.fromISO( - row.queueTime ? row.queueTime.toString() : new Date().toString(), + (row.queueTime + ? DateTime.fromISO(row.queueTime) + : DateTime.now() ).toRelative(), }, ]; @@ -180,7 +195,13 @@ export const BuildTable = ({ items, loading, error }: BuildTableProps) => { pageSize: 5, showEmptyDataSourceMessage: !loading, }} - title={`Builds (${items ? items.length : 0})`} + title={ + + + + Azure Pipelines - Builds ({items ? items.length : 0}) + + } data={items ?? []} /> ); diff --git a/plugins/azure-devops/src/api/types.ts b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx similarity index 66% rename from plugins/azure-devops/src/api/types.ts rename to plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx index 1fbc34231e..3d1a3ef77a 100644 --- a/plugins/azure-devops/src/api/types.ts +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx @@ -14,21 +14,13 @@ * limitations under the License. */ -import { - BuildResult, - BuildStatus, -} from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { PullRequestTable } from '../PullRequestTable/PullRequestTable'; +import React from 'react'; -export type RepoBuild = { - id?: number; - title: string; - link?: string; - status?: BuildStatus; - result?: BuildResult; - queueTime?: Date; - source: string; -}; - -export type RepoBuildOptions = { - top?: number; +export const EntityPageAzurePullRequests = ({ + defaultLimit, +}: { + defaultLimit?: number; +}) => { + return ; }; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts new file mode 100644 index 0000000000..2ec6c5bf04 --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { EntityPageAzurePullRequests } from './EntityPageAzurePullRequests'; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx new file mode 100644 index 0000000000..f516bfb0e5 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Button, ButtonGroup } from '@material-ui/core'; + +import { PullRequestStatus } from '@backstage/plugin-azure-devops-common'; +import React from 'react'; + +export const PullRequestStatusButtonGroup = ({ + status, + setStatus, +}: { + status: PullRequestStatus; + setStatus: (pullRequestStatus: PullRequestStatus) => void; +}) => { + return ( + + + + + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts new file mode 100644 index 0000000000..10a667803a --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestStatusButtonGroup } from './PullRequestStatusButtonGroup'; diff --git a/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx new file mode 100644 index 0000000000..4365b796bc --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx @@ -0,0 +1,139 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Box, Chip } from '@material-ui/core'; +import { + Link, + ResponseErrorPanel, + Table, + TableColumn, +} from '@backstage/core-components'; +import { + PullRequest, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; +import React, { useState } from 'react'; + +import { AzurePullRequestsIcon } from '../AzurePullRequestsIcon'; +import { DateTime } from 'luxon'; +import { PullRequestStatusButtonGroup } from '../PullRequestStatusButtonGroup'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { usePullRequests } from '../../hooks/usePullRequests'; + +const columns: TableColumn[] = [ + { + title: 'ID', + field: 'pullRequestId', + highlight: false, + width: 'auto', + }, + { + title: 'Title', + field: 'title', + width: 'auto', + render: (row: Partial) => ( + + {row.title} + {row.isDraft && ( + + + + )} + + ), + }, + { + title: 'Source', + field: 'sourceRefName', + width: 'auto', + }, + { + title: 'Target', + field: 'targetRefName', + width: 'auto', + }, + { + title: 'Created By', + field: 'createdBy', + width: 'auto', + }, + { + title: 'Created', + field: 'creationDate', + width: 'auto', + render: (row: Partial) => + (row.creationDate + ? DateTime.fromISO(row.creationDate) + : DateTime.now() + ).toRelative(), + }, +]; + +type PullRequestTableProps = { + defaultLimit?: number; +}; + +export const PullRequestTable = ({ defaultLimit }: PullRequestTableProps) => { + const [pullRequestStatusState, setPullRequestStatusState] = + useState(PullRequestStatus.Active); + const { entity } = useEntity(); + + const { items, loading, error } = usePullRequests( + entity, + defaultLimit, + pullRequestStatusState, + ); + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + + Azure Repos - Pull Requests ({items ? items.length : 0}) + + + + + } + data={items ?? []} + /> + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestTable/index.ts b/plugins/azure-devops/src/components/PullRequestTable/index.ts new file mode 100644 index 0000000000..956e8afa2d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestTable } from './PullRequestTable'; diff --git a/plugins/azure-devops/src/hooks/usePullRequests.ts b/plugins/azure-devops/src/hooks/usePullRequests.ts new file mode 100644 index 0000000000..178bf0495f --- /dev/null +++ b/plugins/azure-devops/src/hooks/usePullRequests.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + PullRequest, + PullRequestOptions, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; + +import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; + +export function usePullRequests( + entity: Entity, + defaultLimit?: number, + requestedStatus?: PullRequestStatus, +): { + items?: PullRequest[]; + loading: boolean; + error?: Error; +} { + const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP; + const status = requestedStatus ?? PullRequestStatus.Active; + const options: PullRequestOptions = { + top, + status, + }; + + const api = useApi(azureDevOpsApiRef); + const { project, repo } = useProjectRepoFromEntity(entity); + + const { value, loading, error } = useAsync(() => { + return api.getPullRequests(project, repo, options); + }, [api, project, repo, top, status]); + + return { + items: value?.items, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useRepoBuilds.ts b/plugins/azure-devops/src/hooks/useRepoBuilds.ts index df4f76c57e..af2d181c34 100644 --- a/plugins/azure-devops/src/hooks/useRepoBuilds.ts +++ b/plugins/azure-devops/src/hooks/useRepoBuilds.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { RepoBuild, RepoBuildOptions } from '../api/types'; +import { + RepoBuild, + RepoBuildOptions, +} from '@backstage/plugin-azure-devops-common'; import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; import { Entity } from '@backstage/catalog-model'; diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 71d3b654fe..4da533b534 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -16,5 +16,6 @@ export { azureDevOpsPlugin, EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, } from './plugin'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index ab6d816460..7a5f80d0d3 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + azurePipelinesEntityContentRouteRef, + azurePullRequestsEntityContentRouteRef, +} from './routes'; import { createApiFactory, createPlugin, @@ -26,7 +30,6 @@ import { AZURE_DEVOPS_ANNOTATION } from './constants'; import { AzureDevOpsClient } from './api/AzureDevOpsClient'; import { Entity } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from './api/AzureDevOpsApi'; -import { azureDevOpsRouteRef } from './routes'; export const isAzureDevOpsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]); @@ -41,9 +44,6 @@ export const azureDevOpsPlugin = createPlugin({ new AzureDevOpsClient({ discoveryApi, identityApi }), }), ], - routes: { - entityContent: azureDevOpsRouteRef, - }, }); export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( @@ -53,6 +53,17 @@ export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( import('./components/EntityPageAzurePipelines').then( m => m.EntityPageAzurePipelines, ), - mountPoint: azureDevOpsRouteRef, + mountPoint: azurePipelinesEntityContentRouteRef, + }), +); + +export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'EntityAzurePullRequestsContent', + component: () => + import('./components/EntityPageAzurePullRequests').then( + m => m.EntityPageAzurePullRequests, + ), + mountPoint: azurePullRequestsEntityContentRouteRef, }), ); diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts index 81f4b48fda..7a4adea33e 100644 --- a/plugins/azure-devops/src/routes.ts +++ b/plugins/azure-devops/src/routes.ts @@ -13,8 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createRouteRef } from '@backstage/core-plugin-api'; -export const azureDevOpsRouteRef = createRouteRef({ - title: 'azure-devops', +export const azurePipelinesEntityContentRouteRef = createRouteRef({ + id: 'azure-pipelines-entity-content', +}); + +export const azurePullRequestsEntityContentRouteRef = createRouteRef({ + id: 'azure-pull-requests-entity-content', }); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.test.ts b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts new file mode 100644 index 0000000000..fac0e917d0 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { getDurationFromDates } from './getDurationFromDates'; + +describe('getDurationFromDates', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-15T09:45:25.0000000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('getDurationFromDates with valid startTime and valid finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(startTime, finishTime); + + expect(result).toEqual('1h, 30m'); + }); + }); + + describe('getDurationFromDates with valid startTime and undefined finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + + const result = getDurationFromDates(startTime, undefined); + + expect(result).toEqual('15m, 25s'); + }); + }); + + describe('getDurationFromDates with undefined startTime and valid finishTime', () => { + it('should return empty result', () => { + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(undefined, finishTime); + + expect(result).toEqual(''); + }); + }); + + describe('getDurationFromDates with undefined startTime and undefined finishTime', () => { + it('should return empty result', () => { + const result = getDurationFromDates(undefined, undefined); + + expect(result).toEqual(''); + }); + }); +}); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.ts b/plugins/azure-devops/src/utils/getDurationFromDates.ts new file mode 100644 index 0000000000..789849f975 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DateTime, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; + +export const getDurationFromDates = ( + startTime?: string, + finishTime?: string, +): string => { + if (!startTime || (!startTime && !finishTime)) { + return ''; + } + + const start = DateTime.fromISO(startTime); + const finish = finishTime ? DateTime.fromISO(finishTime) : DateTime.now(); + + const formatted = Interval.fromDateTimes(start, finish) + .toDuration() + .valueOf(); + + const shortEnglishHumanizer = humanizeDuration.humanizer({ + language: 'shortEn', + languages: { + shortEn: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + }, + }, + }); + + return shortEnglishHumanizer(formatted, { + largest: 2, + round: true, + spacer: '', + }); +}; diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 51a181612a..2186c8d52e 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.3", "@types/express": "^4.17.6", @@ -46,7 +46,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/run.ts b/plugins/badges-backend/src/run.ts index addfdfd6d7..53d4e4334a 100644 --- a/plugins/badges-backend/src/run.ts +++ b/plugins/badges-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 51ad1e7e11..286a2335f1 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -73,7 +73,7 @@ describe('createRouter', () => { config = new ConfigReader({ backend: { baseUrl: 'http://127.0.0.1', - listen: { port: 7000 }, + listen: { port: 7007 }, }, }); discovery = SingleHostDiscovery.fromConfig(config); diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index 3a6ed61717..44bc597d0a 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-badges +## 0.2.14 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.13 ### Patch Changes diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0cc3a06509..4d02d64237 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges", "description": "A Backstage plugin that generates README badges for your entities", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,12 +27,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -42,10 +42,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index d81b042ce5..432a485a6b 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -17,7 +17,6 @@ import { generatePath } from 'react-router'; import { ResponseError } from '@backstage/errors'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { entityRoute } from '@backstage/plugin-catalog-react'; import { BadgesApi, BadgeSpec } from './types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; @@ -53,7 +52,7 @@ export class BadgesClient implements BadgesApi { private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); + const path = generatePath(`:kind/:namespace/:name`, routeParams); return `${await this.discoveryApi.getBaseUrl( 'badges', )}/entity/${path}/badge-specs`; diff --git a/plugins/badges/src/components/EntityBadgesDialog.test.tsx b/plugins/badges/src/components/EntityBadgesDialog.test.tsx index 1f5ee94e04..ea44eb20fc 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.test.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.test.tsx @@ -16,12 +16,11 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { BadgesApi, badgesApiRef } from '../api'; import { EntityBadgesDialog } from './EntityBadgesDialog'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; describe('EntityBadgesDialog', () => { @@ -42,16 +41,16 @@ describe('EntityBadgesDialog', () => { const mockEntity = { metadata: { name: 'mock' } } as Entity; const rendered = await renderWithEffects( - - , + , ); await expect( diff --git a/plugins/bazaar-backend/CHANGELOG.md b/plugins/bazaar-backend/CHANGELOG.md new file mode 100644 index 0000000000..88e646b5e3 --- /dev/null +++ b/plugins/bazaar-backend/CHANGELOG.md @@ -0,0 +1,10 @@ +# @backstage/plugin-bazaar-backend + +## 0.1.2 + +### Patch Changes + +- f6ba309d9e: A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. +- Updated dependencies + - @backstage/backend-common@0.9.10 + - @backstage/backend-test-utils@0.1.9 diff --git a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js new file mode 100644 index 0000000000..d4cb87bd82 --- /dev/null +++ b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js @@ -0,0 +1,45 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.alterTable('metadata', table => { + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.alterTable('metadata', table => { + table + .dropColumn('size') + .dropColumn('start_date') + .dropColumn('end_date') + .dropColumn('responsible'); + }); +}; diff --git a/plugins/bazaar-backend/package.json b/plugins/bazaar-backend/package.json index a9ac0153b8..a6610493e2 100644 --- a/plugins/bazaar-backend/package.json +++ b/plugins/bazaar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar-backend", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/backend-test-utils": "^0.1.8", + "@backstage/backend-common": "^0.9.10", + "@backstage/backend-test-utils": "^0.1.9", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.9.0" }, "files": [ "dist", diff --git a/plugins/bazaar-backend/src/run.ts b/plugins/bazaar-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/bazaar-backend/src/run.ts +++ b/plugins/bazaar-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts index 7cce5ee414..3afde44d5e 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts @@ -18,14 +18,17 @@ import { DatabaseHandler } from './DatabaseHandler'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; const bazaarProject: any = { - name: 'name', - entityRef: 'ref', + name: 'n1', + entityRef: 'ref1', community: '', status: 'proposed', announcement: 'a', membersCount: 0, + startDate: '2021-11-07T13:27:00.000Z', + endDate: null, + size: 'small', + responsible: 'r', }; - describe('DatabaseHandler', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], @@ -33,18 +36,40 @@ describe('DatabaseHandler', () => { async function createDatabaseHandler(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); - return await DatabaseHandler.create({ database: knex }); + return { + knex, + dbHandler: await DatabaseHandler.create({ database: knex }), + }; } it.each(databases.eachSupportedId())( - 'should do a full sync with the locations on connect, %p', + 'should insert and get entity, %p', async databaseId => { - const db = await createDatabaseHandler(databaseId); - await db.insertMetadata(bazaarProject); + const { knex, dbHandler } = await createDatabaseHandler(databaseId); - const entities = await db.getEntities(); - expect(entities.length).toEqual(1); - expect(entities[0].entity_ref).toEqual(bazaarProject.entityRef); + await knex('metadata').insert({ + entity_ref: bazaarProject.entityRef, + name: bazaarProject.name, + announcement: bazaarProject.announcement, + community: bazaarProject.community, + status: bazaarProject.status, + updated_at: new Date().toISOString(), + start_date: bazaarProject.startDate, + end_date: bazaarProject.endDate, + size: bazaarProject.size, + responsible: bazaarProject.responsible, + }); + + const res = await dbHandler.getMetadata('ref1'); + + expect(res).toHaveLength(1); + expect(res[0].announcement).toEqual('a'); + expect(res[0].community).toEqual(''); + expect(res[0].status).toEqual('proposed'); + expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z'); + expect(res[0].end_date).toEqual(null); + expect(res[0].size).toEqual('small'); + expect(res[0].responsible).toEqual('r'); }, 60_000, ); diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.ts index d4da6e65e7..60f579af2d 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.ts @@ -43,6 +43,20 @@ export class DatabaseHandler { this.database = options.database; } + private columns = [ + 'members.entity_ref', + 'metadata.entity_ref', + 'metadata.name', + 'metadata.announcement', + 'metadata.status', + 'metadata.updated_at', + 'metadata.community', + 'metadata.size', + 'metadata.start_date', + 'metadata.end_date', + 'metadata.responsible', + ]; + async getMembers(entityRef: string) { return await this.database .select('*') @@ -72,25 +86,25 @@ export class DatabaseHandler { 'coalesce(count(members.entity_ref), 0) as members_count', ); - const columns = [ - 'members.entity_ref', - 'metadata.entity_ref', - 'metadata.name', - 'metadata.announcement', - 'metadata.status', - 'metadata.updated_at', - 'metadata.community', - ]; - return await this.database('metadata') - .select([...columns, coalesce]) + .select([...this.columns, coalesce]) .where({ 'metadata.entity_ref': entityRef }) - .groupBy(columns) + .groupBy(this.columns) .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); } async insertMetadata(bazaarProject: any) { - const { name, entityRef, community, announcement, status } = bazaarProject; + const { + name, + entityRef, + community, + announcement, + status, + size, + startDate, + endDate, + responsible, + } = bazaarProject; await this.database .insert({ @@ -100,12 +114,25 @@ export class DatabaseHandler { announcement: announcement, status: status, updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, }) .into('metadata'); } async updateMetadata(bazaarProject: any) { - const { entityRef, community, announcement, status } = bazaarProject; + const { + entityRef, + community, + announcement, + status, + size, + startDate, + endDate, + responsible, + } = bazaarProject; return await this.database('metadata') .where({ entity_ref: entityRef }) @@ -114,6 +141,10 @@ export class DatabaseHandler { community: community, status: status, updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, }); } @@ -128,18 +159,9 @@ export class DatabaseHandler { 'coalesce(count(members.entity_ref), 0) as members_count', ); - const columns = [ - 'members.entity_ref', - 'metadata.entity_ref', - 'metadata.name', - 'metadata.announcement', - 'metadata.status', - 'metadata.updated_at', - 'metadata.community', - ]; return await this.database('metadata') - .select([...columns, coalesce]) - .groupBy(columns) + .select([...this.columns, coalesce]) + .groupBy(this.columns) .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); } } diff --git a/plugins/bazaar/CHANGELOG.md b/plugins/bazaar/CHANGELOG.md index 3cd13cfa8a..8988bf5d62 100644 --- a/plugins/bazaar/CHANGELOG.md +++ b/plugins/bazaar/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-bazaar +## 0.1.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- f6ba309d9e: A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/cli@0.9.0 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.1.3 + +### Patch Changes + +- 4a336fd292: Name extension to remove deprecation warning +- Updated dependencies + - @backstage/cli@0.8.2 + - @backstage/core-components@0.7.3 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.1.2 ### Patch Changes diff --git a/plugins/bazaar/README.md b/plugins/bazaar/README.md index cd26f01997..f07e751984 100644 --- a/plugins/bazaar/README.md +++ b/plugins/bazaar/README.md @@ -58,7 +58,7 @@ const overviewContent = ( -+ ++ + + @@ -75,12 +75,23 @@ The latest modified Bazaar projects are displayed in the Bazaar landing page, lo ### Workflow -To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. You will be asked to add an announcement for new team members. The purpose of the announcement is for you to present your ideas and what skills you are looking for. Further you need to provide the status of the project. +To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. + +The following fields are mandatory: + +- announcement - present your idea and what skills you are looking for +- status - whether or not the project has started +- size - small, medium or large +- responsible - main contact person of the project + +The other fields are: + +- start date +- end date +- community link - link where the project members can chat, e.g. Teams or Discord link When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it. -![workflow](media/bazaar_demo.gif) - ### Database The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project. @@ -92,6 +103,10 @@ The metadata related to the Bazaar is stored in a database. Right now there are - announcement - announcement of the project and its current need of skills/team member - status - status of the project, 'proposed' or 'ongoing' - updated_at - date when the Bazaar information was last modified (ISO 8601 format) +- size - small, medium or large +- start_date - date when the project is estimated to start (ISO 8601 format) +- end_date - date when the project is estimated to end (ISO 8601 format) +- responsible - main contact person of the project **members**: @@ -107,12 +122,8 @@ The metadata related to the Bazaar is stored in a database. Right now there are - Bazaar landing page - - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities, projects or tags you are following etc. - - Make it possible to sort the project based on the number of members - -- Bazaar card - - - Make it possible to follow tags/projects + - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities etc. + - Make it possible to sort the project based on e.g. the number of members - Bazaar tab on the EntityPage diff --git a/plugins/bazaar/media/bazaar_demo.gif b/plugins/bazaar/media/bazaar_demo.gif deleted file mode 100644 index c5383de477..0000000000 Binary files a/plugins/bazaar/media/bazaar_demo.gif and /dev/null differ diff --git a/plugins/bazaar/media/bazaar_pr_fullscreen.png b/plugins/bazaar/media/bazaar_pr_fullscreen.png index d0187977db..6c5cf1277b 100644 Binary files a/plugins/bazaar/media/bazaar_pr_fullscreen.png and b/plugins/bazaar/media/bazaar_pr_fullscreen.png differ diff --git a/plugins/bazaar/package.json b/plugins/bazaar/package.json index 5a98abfc3a..fd07a3b5c7 100644 --- a/plugins/bazaar/package.json +++ b/plugins/bazaar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar", - "version": "0.1.2", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,16 +21,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/cli": "^0.8.0", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.1", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.9.0", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/pickers": "^3.3.10", "@testing-library/jest-dom": "^5.10.1", + "@date-io/luxon": "1.x", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -39,8 +41,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/dev-utils": "^0.2.12", + "@backstage/cli": "^0.9.0", + "@backstage/dev-utils": "^0.2.13", "@testing-library/jest-dom": "^5.10.1", "cross-fetch": "^3.0.6" }, diff --git a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx index 82d682b4df..310ffe051d 100644 --- a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx +++ b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx @@ -16,11 +16,11 @@ import React, { useState, useEffect } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; -import { SubmitHandler } from 'react-hook-form'; +import { UseFormReset, UseFormGetValues } from 'react-hook-form'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; import { ProjectSelector } from '../ProjectSelector'; -import { BazaarProject, FormValues, Status } from '../../types'; +import { BazaarProject, FormValues, Size, Status } from '../../types'; import { bazaarApiRef } from '../../api'; type Props = { @@ -52,6 +52,10 @@ export const AddProjectDialog = ({ community: '', announcement: '', status: 'proposed' as Status, + size: 'medium' as Size, + responsible: '', + startDate: null, + endDate: null, }; const handleListItemClick = (entity: Entity) => { @@ -63,9 +67,9 @@ export const AddProjectDialog = ({ handleClose(); }; - const handleSave: SubmitHandler = async ( - getValues: any, - reset: any, + const handleSave: any = async ( + getValues: UseFormGetValues, + reset: UseFormReset, ) => { const formValues = getValues(); @@ -77,6 +81,10 @@ export const AddProjectDialog = ({ status: formValues.status, community: formValues.community, membersCount: 0, + size: formValues.size, + startDate: formValues.startDate ?? null, + endDate: formValues.endDate ?? null, + responsible: formValues.responsible, } as BazaarProject); fetchBazaarProjects(); diff --git a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx new file mode 100644 index 0000000000..db564a7b68 --- /dev/null +++ b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 FormControl from '@material-ui/core/FormControl'; +import { Controller, Control, UseFormSetValue } from 'react-hook-form'; +import { FormValues } from '../../types'; +import { + KeyboardDatePicker, + MuiPickersUtilsProvider, +} from '@material-ui/pickers'; +import LuxonUtils from '@date-io/luxon'; +import { IconButton } from '@material-ui/core'; +import ClearIcon from '@material-ui/icons/Clear'; + +type Props = { + name: 'startDate' | 'endDate'; + control: Control; + setValue: UseFormSetValue; +}; + +export const DateSelector = ({ name, control, setValue }: Props) => { + const label = `${ + name.charAt(0).toLocaleUpperCase('en-US') + name.slice(1, name.indexOf('D')) + } date`; + + return ( + ( + + + { + setValue(name, date?.toISO()); + }} + InputProps={{ + endAdornment: ( + setValue(name, null)}> + + + ), + }} + InputAdornmentProps={{ + position: 'start', + }} + /> + + + )} + /> + ); +}; diff --git a/plugins/bazaar/src/components/DateSelector/index.ts b/plugins/bazaar/src/components/DateSelector/index.ts new file mode 100644 index 0000000000..9bdb078164 --- /dev/null +++ b/plugins/bazaar/src/components/DateSelector/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DateSelector } from './DateSelector'; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx new file mode 100644 index 0000000000..b37e734453 --- /dev/null +++ b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Control, UseFormSetValue } from 'react-hook-form'; +import { FormValues } from '../../types'; +import { Typography } from '@material-ui/core'; +import { DateSelector } from '../DateSelector/DateSelector'; + +type Props = { + control: Control; + setValue: UseFormSetValue; +}; + +export const DoubleDateSelector = ({ control, setValue }: Props) => { + return ( +
+
+ +
+ + + - + +
+ +
+
+ ); +}; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/index.ts b/plugins/bazaar/src/components/DoubleDateSelector/index.ts new file mode 100644 index 0000000000..9162827b35 --- /dev/null +++ b/plugins/bazaar/src/components/DoubleDateSelector/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DoubleDateSelector } from './DoubleDateSelector'; diff --git a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx index 172bdf548d..41c9e474d4 100644 --- a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx +++ b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx @@ -18,8 +18,9 @@ import React, { useState, useEffect } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; -import { BazaarProject, FormValues } from '../../types'; +import { BazaarProject, FormValues, Size, Status } from '../../types'; import { bazaarApiRef } from '../../api'; +import { UseFormGetValues } from 'react-hook-form'; type Props = { entity: Entity; @@ -41,6 +42,10 @@ export const EditProjectDialog = ({ announcement: bazaarProject.announcement, community: bazaarProject.community, status: bazaarProject.status, + size: bazaarProject.size, + startDate: bazaarProject?.startDate ?? null, + endDate: bazaarProject?.endDate ?? null, + responsible: bazaarProject.responsible, }); const bazaarApi = useApi(bazaarApiRef); @@ -50,19 +55,27 @@ export const EditProjectDialog = ({ announcement: bazaarProject.announcement, community: bazaarProject.community, status: bazaarProject.status, + size: bazaarProject.size, + startDate: bazaarProject?.startDate ?? null, + endDate: bazaarProject?.endDate ?? null, + responsible: bazaarProject.responsible, }); }, [bazaarProject]); - const handleSave: any = async (getValues: any, _: any) => { + const handleSave: any = async (getValues: UseFormGetValues) => { const formValues = getValues(); const updateResponse = await bazaarApi.updateMetadata({ name: entity.metadata.name, entityRef: stringifyEntityRef(entity), announcement: formValues.announcement, - status: formValues.status, + status: formValues.status as Status, community: formValues.community, membersCount: bazaarProject.membersCount, + size: formValues.size as Size, + startDate: formValues?.startDate ?? null, + endDate: formValues?.endDate ?? null, + responsible: formValues.responsible, }); if (updateResponse.status === 'ok') fetchBazaarProject(); diff --git a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx index 6bf844d592..acae8f5342 100644 --- a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx +++ b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx @@ -121,6 +121,10 @@ export const EntityBazaarInfoCard = () => { status: metadata.status, updatedAt: metadata.updated_at, membersCount: metadata.members_count, + size: metadata.size, + startDate: metadata.start_date, + endDate: metadata.end_date, + responsible: metadata.responsible, } as BazaarProject; } } @@ -259,7 +263,7 @@ export const EntityBazaarInfoCard = () => { - + {bazaarProject?.value?.announcement ? bazaarProject?.value?.announcement @@ -278,17 +282,11 @@ export const EntityBazaarInfoCard = () => { - - - - - - - + {' '} {members?.value?.length ? ( - members.value.slice(0, 3).map((member: Member) => { + members.value.slice(0, 7).map((member: Member) => { return (
{ fontSize: '8px', float: 'left', marginRight: '0.3rem', + marginBottom: '0.25rem', }} picture={member.picture} /> @@ -317,6 +316,44 @@ export const EntityBazaarInfoCard = () => { )} + + + + + + + + + + + {bazaarProject?.value?.size} + + + + + + + + {bazaarProject?.value?.startDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject?.value?.endDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject?.value?.responsible || ''} + + + diff --git a/plugins/bazaar/src/components/InputField/InputField.tsx b/plugins/bazaar/src/components/InputField/InputField.tsx index 4d284ff82c..9932e5b1fc 100644 --- a/plugins/bazaar/src/components/InputField/InputField.tsx +++ b/plugins/bazaar/src/components/InputField/InputField.tsx @@ -19,13 +19,18 @@ import { Controller, Control, FieldError } from 'react-hook-form'; import { TextField } from '@material-ui/core'; import { FormValues } from '../../types'; +type Rules = { + required: boolean; + pattern?: any; +}; + type Props = { - inputType: 'announcement' | 'community'; + inputType: 'announcement' | 'community' | 'responsible'; error?: FieldError | undefined; control: Control; helperText?: string; placeholder?: string; - rules?: Object; + rules?: Rules | undefined; }; export const InputField = ({ @@ -47,6 +52,7 @@ export const InputField = ({ render={({ field }) => ( ; - name: 'announcement' | 'status'; + name: 'status' | 'size'; error?: FieldError | undefined; }; @@ -42,14 +42,17 @@ export const InputSelector = ({ name, options, control, error }: Props) => { render={({ field }) => ( {label}
@@ -127,12 +130,24 @@ const DetailsPage = () => { > {details?.name && ( )} {details?.name && ( )} @@ -151,16 +166,20 @@ const labels = ( ); -export const ProjectDetailsPage = () => ( - -
- {labels} -
- - - Support Button - - - -
-); +export const ProjectDetailsPage = () => { + const docsRootLink = useRouteRef(rootRouteRef)(); + + return ( + +
+ {labels} +
+ + + Support Button + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx index db3999272a..b37dc2fa50 100644 --- a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -15,19 +15,9 @@ */ // NEEDS WORK -import { - Button, - LinearProgress, - Paper, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Typography, -} from '@material-ui/core'; +import { Button, LinearProgress, Tooltip, Typography } from '@material-ui/core'; import React from 'react'; + import { useAsync } from 'react-use'; import { gcpApiRef, Project } from '../../api'; @@ -38,12 +28,15 @@ import { HeaderLabel, Link, Page, + Table, SupportButton, WarningPanel, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; +import { Link as RouterLink } from 'react-router-dom'; + const LongText = ({ text, max }: { text: string; max: number }) => { if (text.length < max) { return {text}; @@ -77,60 +70,59 @@ const PageContents = () => { ); } + function renderLink(id: string) { + return ( + + + + + + ); + } + return ( -
-
- - - Name - Project Number - Project ID - State - Creation Time - - - - {value?.map((project: Project) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
- +
+ renderLink(rowData.id), + }, + { + field: 'state', + title: 'State', + }, + { + field: 'creationTime', + title: 'Creation Time', + }, + ]} + data={ + value?.map((project: Project) => ({ + id: project.projectId, + name: project.name, + projectNumber: project?.projectNumber || 'Error', + projectID: project.projectId, + state: project?.lifecycleState || 'Error', + creationTime: project?.createTime || 'Error', + })) || [] + } + options={{ + pageSize: 5, + pageSizeOptions: [5, 10, 25, 50, 100], + }} + /> + ); }; @@ -141,7 +133,12 @@ export const ProjectListPage = () => ( - All your software catalog entities diff --git a/plugins/gcp-projects/src/routes.ts b/plugins/gcp-projects/src/routes.ts index 2d302a178d..f527393f5a 100644 --- a/plugins/gcp-projects/src/routes.ts +++ b/plugins/gcp-projects/src/routes.ts @@ -17,14 +17,11 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/gcp-projects', - title: 'GCP Projects', + id: 'gcp-projects', }); export const projectRouteRef = createRouteRef({ - path: '/gcp-projects/project', - title: 'GCP Project Page', + id: 'gcp-projects:project', }); export const newProjectRouteRef = createRouteRef({ - path: '/gcp-projects/new', - title: 'GCP Project Page', + id: 'gcp-projects:new', }); diff --git a/plugins/git-release-manager/CHANGELOG.md b/plugins/git-release-manager/CHANGELOG.md index 72bcf87b2a..1c627ab086 100644 --- a/plugins/git-release-manager/CHANGELOG.md +++ b/plugins/git-release-manager/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-git-release-manager +## 0.3.3 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.2 ### Patch Changes diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index 11f6655c5d..7f7ff6184d 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-git-release-manager", "description": "A Backstage plugin that helps you manage releases in git", - "version": "0.3.2", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/integration": "^0.6.8", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,10 +39,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/git-release-manager/src/routes.ts b/plugins/git-release-manager/src/routes.ts index 01e4cdf7ff..c4b157892a 100644 --- a/plugins/git-release-manager/src/routes.ts +++ b/plugins/git-release-manager/src/routes.ts @@ -17,5 +17,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'git-release-manager', + id: 'git-release-manager', }); diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index 222bc4c851..5eed309d68 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-github-actions +## 0.4.24 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.4.23 + +### Patch Changes + +- e849f9cc0a: Use `` instead of `` in combination with `Suspense`. +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.4.22 ### Patch Changes diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index d027368c8b..082267d424 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -11,7 +11,7 @@ TBD ### Generic Requirements 1. Provide OAuth credentials: - 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with the callback URL set to `http://localhost:7000/api/auth/github`. + 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with the callback URL set to `http://localhost:7007/api/auth/github`. 2. Take the Client ID and Client Secret from the newly created app's settings page and put them into `AUTH_GITHUB_CLIENT_ID` and `AUTH_GITHUB_CLIENT_SECRET` environment variables. 2. Annotate your component with a correct GitHub Actions repository and owner: diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 087a596c64..ae7f32be1f 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-actions", "description": "A Backstage plugin that integrates towards GitHub Actions", - "version": "0.4.22", + "version": "0.4.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,12 +33,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/integration": "^0.6.8", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -52,10 +52,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx index 4a4452be5c..0f9573e051 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -24,16 +24,13 @@ import { useWorkflowRuns } from '../useWorkflowRuns'; import type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard'; import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { errorApiRef, configApiRef, ConfigApi, } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; jest.mock('../useWorkflowRuns', () => ({ useWorkflowRuns: jest.fn(), @@ -82,16 +79,16 @@ describe('', () => { render( - - + , ); diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index cbf29d1d9d..ea07213c45 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../routes'; +import { buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; @@ -41,12 +41,9 @@ export const Router = (_props: Props) => { } return ( + } /> } - /> - } /> ) diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx index ce33ae3781..399fed9557 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -14,12 +14,15 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { Accordion, AccordionSummary, CircularProgress, Fade, - LinearProgress, makeStyles, Modal, Theme, @@ -27,15 +30,11 @@ import { Typography, Zoom, } from '@material-ui/core'; - -import React, { Suspense } from 'react'; -import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; -import { useProjectName } from '../useProjectName'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import DescriptionIcon from '@material-ui/icons/Description'; -import { Entity } from '@backstage/catalog-model'; -import { readGitHubIntegrationConfigs } from '@backstage/integration'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import React, { Suspense } from 'react'; +import { useProjectName } from '../useProjectName'; +import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); const LinePart = React.lazy(() => import('react-lazylog/build/LinePart')); @@ -72,7 +71,7 @@ const DisplayLog = ({ className: string; }) => { return ( - }> + }>
) => ( - - {row.message} - - ), + render: (row: Partial) => { + const LinkWrapper = () => { + const routeLink = useRouteRef(buildRouteRef); + return ( + + {row.message} + + ); + }; + + return ; + }, }, { title: 'Source', diff --git a/plugins/github-actions/src/routes.ts b/plugins/github-actions/src/routes.ts index 6e1d8a885a..8e854fad45 100644 --- a/plugins/github-actions/src/routes.ts +++ b/plugins/github-actions/src/routes.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import { createRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'GitHub Actions', + id: 'github-actions', }); -export const buildRouteRef = createRouteRef({ - path: ':id', - params: ['id'], - title: 'GitHub Actions Workflow Run', +export const buildRouteRef = createSubRouteRef({ + id: 'github-actions/build', + path: '/:id', + parent: rootRouteRef, }); diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 5259ea11b2..701b167135 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-deployments +## 0.1.21 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.1.20 ### Patch Changes diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index a405800387..062113a8ef 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-deployments", "description": "A Backstage plugin that integrates towards GitHub Deployments", - "version": "0.1.20", + "version": "0.1.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,14 +21,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.3", "@backstage/integration": "^0.6.8", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,10 +39,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx index 08eeb4a3ce..dba5bbfe1c 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -19,6 +19,7 @@ import { fireEvent } from '@testing-library/react'; import { setupRequestMockHandlers, renderInTestApp, + TestApiRegistry, } from '@backstage/test-utils'; import { GithubDeployment, @@ -42,11 +43,7 @@ import { Entity } from '@backstage/catalog-model'; import { GithubDeploymentsTable } from './GithubDeploymentsTable'; import { Box } from '@material-ui/core'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { errorApiRef, configApiRef, @@ -95,14 +92,14 @@ const githubAuthApi: OAuthApi = { getAccessToken: async _ => 'access_token', }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [configApiRef, configApi], [errorApiRef, errorApiMock], [ githubDeploymentsApiRef, new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }), ], -]); +); const assertFetchedData = async () => { const rendered = await renderInTestApp( diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index 08f256cdb5..42f3533d31 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-gitops-profiles +## 0.3.9 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.8 ### Patch Changes diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index cdebce653f..dfbd6297cf 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-gitops-profiles", "description": "A Backstage plugin that helps you manage GitOps profiles", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -44,10 +44,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index 8dba498507..e158bcf6ad 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import React from 'react'; @@ -23,7 +23,6 @@ import ProfileCatalog from './ProfileCatalog'; import { ApiProvider, - ApiRegistry, GithubAuth, OAuthRequestManager, UrlPatternDiscovery, @@ -34,7 +33,7 @@ import { githubAuthApiRef } from '@backstage/core-plugin-api'; describe('ProfileCatalog', () => { it('should render', async () => { const oauthRequestApi = new OAuthRequestManager(); - const apis = ApiRegistry.from([ + const apis = TestApiRegistry.from( [gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')], [ githubAuthApiRef, @@ -45,7 +44,7 @@ describe('ProfileCatalog', () => { oauthRequestApi, }), ], - ]); + ); const { getByText } = await renderInTestApp( diff --git a/plugins/gitops-profiles/src/routes.ts b/plugins/gitops-profiles/src/routes.ts index d57edcca2c..b79e5686d2 100644 --- a/plugins/gitops-profiles/src/routes.ts +++ b/plugins/gitops-profiles/src/routes.ts @@ -16,23 +16,15 @@ import { createRouteRef } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const gitOpsClusterListRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-clusters', - title: 'GitOps Clusters', + id: 'gitops-clusters', }); export const gitOpsClusterDetailsRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-cluster/:owner/:repo', - title: 'GitOps Cluster details', + id: 'gitops-cluster:details', params: ['owner', 'repo'], }); export const gitOpsClusterCreateRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-cluster-create', - title: 'GitOps Cluster create', + id: 'gitops-cluster:create', }); diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index aa58dbd332..f6c8b9fde8 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-graphiql +## 0.2.21 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.20 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index b21281ff80..5c72a4c728 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.20", + "version": "0.2.21", "private": false, "publishConfig": { "access": "public", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -44,10 +44,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index b4321343a1..2bd173f720 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -73,7 +73,12 @@ export const GraphiQLBrowser = ({ endpoints }: GraphiQLBrowserProps) => {
- +
diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx index 4e426d4364..ae84b549ec 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx @@ -19,14 +19,10 @@ import { GraphiQLPage } from './GraphiQLPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { act } from 'react-dom/test-utils'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api'; import { configApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; jest.mock('../GraphiQLBrowser', () => ({ GraphiQLBrowser: () => '', @@ -43,17 +39,17 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - , - , + , ); act(() => { jest.advanceTimersByTime(250); @@ -71,16 +67,16 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - - , + , ); rendered.getByText('GraphiQL'); @@ -95,16 +91,16 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - - , + , ); rendered.getByText('GraphiQL'); diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 9d90dd8782..335545021e 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.8", "@backstage/plugin-catalog-graphql": "^0.2.12", "@graphql-modules/core": "^0.7.17", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.35.0", diff --git a/plugins/home/CHANGELOG.md b/plugins/home/CHANGELOG.md index aa35cdf968..09e379a625 100644 --- a/plugins/home/CHANGELOG.md +++ b/plugins/home/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-home +## 0.4.6 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.4.5 + +### Patch Changes + +- 4a336fd292: Add name option to `createCardExtension` to remove deprecation warnings for extensions without name. Name will be required for extensions in a future release of `core-plugin-api` and therefore also in `createCardExtension`. +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + ## 0.4.4 ### Patch Changes diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 57b0b899e2..99d88fa4b5 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -71,9 +71,11 @@ export const ComponentTabs: ({ export function createCardExtension({ title, components, + name, }: { title: string; components: () => Promise; + name?: string; }): Extension< ({ Renderer, diff --git a/plugins/home/package.json b/plugins/home/package.json index e100dba9ca..ef13ce3a00 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-home", "description": "A Backstage plugin that helps you build a home page", - "version": "0.4.4", + "version": "0.4.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -35,10 +35,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/home/src/extensions.tsx b/plugins/home/src/extensions.tsx index 93fe4ceb4f..82394cf1ea 100644 --- a/plugins/home/src/extensions.tsx +++ b/plugins/home/src/extensions.tsx @@ -37,11 +37,14 @@ type RendererProps = { title: string } & ComponentParts; export function createCardExtension({ title, components, + name, }: { title: string; components: () => Promise; + name?: string; }) { return createReactExtension({ + name, component: { lazy: () => components().then(({ Content, Actions, Settings, ContextProvider }) => { diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index d67ea846a5..b4b9f9b6ee 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -71,6 +71,7 @@ export const ComponentTab = homePlugin.provide( */ export const WelcomeTitle = homePlugin.provide( createComponentExtension({ + name: 'WelcomeTitle', component: { lazy: () => import('./homePageComponents/WelcomeTitle').then(m => m.WelcomeTitle), @@ -80,6 +81,7 @@ export const WelcomeTitle = homePlugin.provide( export const HomePageRandomJoke = homePlugin.provide( createCardExtension<{ defaultCategory?: 'any' | 'programming' }>({ + name: 'HomePageRandomJoke', title: 'Random Joke', components: () => import('./homePageComponents/RandomJoke'), }), diff --git a/plugins/home/src/routes.ts b/plugins/home/src/routes.ts index 2c641d8433..7b6b47215b 100644 --- a/plugins/home/src/routes.ts +++ b/plugins/home/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'home', + id: 'home', }); diff --git a/plugins/ilert/CHANGELOG.md b/plugins/ilert/CHANGELOG.md index efa2d5d8f6..1bff4b22ef 100644 --- a/plugins/ilert/CHANGELOG.md +++ b/plugins/ilert/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-ilert +## 0.1.17 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.1.16 + +### Patch Changes + +- fa325fd89d: Change the version of `@date-io/luxon` from 2.x to 1.x to make it compatible with material-ui-pickers +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.1.15 ### Patch Changes diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json index f33e34beed..6011dd4f46 100644 --- a/plugins/ilert/package.json +++ b/plugins/ilert/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-ilert", "description": "A Backstage plugin that integrates towards iLert", - "version": "0.1.15", + "version": "0.1.17", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,13 +21,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", - "@date-io/luxon": "2.x", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", + "@date-io/luxon": "1.x", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,10 +39,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/ilert/src/route-refs.tsx b/plugins/ilert/src/route-refs.tsx index 97d11df98a..8a3d53001d 100644 --- a/plugins/ilert/src/route-refs.tsx +++ b/plugins/ilert/src/route-refs.tsx @@ -14,11 +14,8 @@ * limitations under the License. */ -import ILertIcon from './assets/ilert.icon.svg'; import { createRouteRef } from '@backstage/core-plugin-api'; export const iLertRouteRef = createRouteRef({ - icon: ILertIcon, - path: '/ilert', - title: 'iLert', + id: 'ilert', }); diff --git a/plugins/jenkins-backend/CHANGELOG.md b/plugins/jenkins-backend/CHANGELOG.md index 635fa080ce..48f4461f7f 100644 --- a/plugins/jenkins-backend/CHANGELOG.md +++ b/plugins/jenkins-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-jenkins-backend +## 0.1.7 + +### Patch Changes + +- e8a1c1afe2: Don't require a validation pattern for the Jenkins base URL. +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/catalog-client@0.5.1 + ## 0.1.6 ### Patch Changes diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index bac56b6eaf..1413a45136 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -12,6 +12,12 @@ This is the backend half of the 2 Jenkins plugins and is responsible for: This plugin needs to be added to an existing backstage instance. +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-jenkins-backend +``` + Typically, this means creating a `src/plugins/jenkins.ts` file and adding a reference to it to `src/index.ts` ### jenkins.ts @@ -42,6 +48,29 @@ export default async function createPlugin({ } ``` +### src/index.ts + +```diff +diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts +index f2b14b2..2c64f47 100644 +--- a/packages/backend/src/index.ts ++++ b/packages/backend/src/index.ts +@@ -22,6 +22,7 @@ import { Config } from '@backstage/config'; + import app from './plugins/app'; ++import jenkins from './plugins/jenkins'; + import scaffolder from './plugins/scaffolder'; +@@ -56,6 +57,7 @@ async function main() { + const authEnv = useHotMemoize(module, () => createEnv('auth')); ++ const jenkinsEnv = useHotMemoize(module, () => createEnv('jenkins')); + const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); +@@ -63,6 +65,7 @@ async function main() { + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); ++ apiRouter.use('/jenkins', await jenkins(jenkinsEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); +``` + This plugin must be provided with a JenkinsInfoProvider, this is a strategy object for finding the Jenkins instance and job for an entity. There is a standard one provided, but the Integrator is free to build their own. diff --git a/plugins/jenkins-backend/config.d.ts b/plugins/jenkins-backend/config.d.ts index bfb519bb58..1fc41c1592 100644 --- a/plugins/jenkins-backend/config.d.ts +++ b/plugins/jenkins-backend/config.d.ts @@ -17,7 +17,6 @@ export interface Config { jenkins?: { /** * Default instance baseUrl, can be specified on a named instance called "default" - * @pattern "^https?://" */ baseUrl?: string; /** @@ -39,9 +38,6 @@ export interface Config { */ name: string; - /** - * @pattern "^https?://" - */ baseUrl: string; username: string; /** @visibility secret */ diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 93f5fa84d2..ac9efb3ffd 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins-backend", "description": "A Backstage backend plugin that integrates towards Jenkins", - "version": "0.1.6", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,9 +22,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -35,7 +35,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/jenkins-backend/src/run.ts b/plugins/jenkins-backend/src/run.ts index b96989e4b8..068a2b9d5f 100644 --- a/plugins/jenkins-backend/src/run.ts +++ b/plugins/jenkins-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 84db4728bc..369676b195 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-jenkins +## 0.5.12 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.5.11 ### Patch Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 1af511b16f..2012555d93 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -16,7 +16,7 @@ cd packages/app yarn add @backstage/plugin-jenkins ``` -2. Add and configure the backend plugin according to it's instructions +2. Add and configure the [jenkins-backend](../jenkins-backend) plugin according to it's instructions 3. Add the `EntityJenkinsContent` extension to the `CI/CD` page and `EntityLatestJenkinsRunCard` to the `overview` page in the app (or wherever you'd prefer): diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 1bd6388fba..4456b15284 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins", "description": "A Backstage plugin that integrates towards Jenkins", - "version": "0.5.11", + "version": "0.5.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index ee17df8533..39187fe859 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -17,12 +17,13 @@ import React from 'react'; import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { JenkinsRunStatus } from '../Status'; import { useBuilds } from '../../../useBuilds'; import { buildRouteRef } from '../../../../plugin'; import { Table, TableColumn } from '@backstage/core-components'; import { Project } from '../../../../api/JenkinsApi'; +import { useRouteRef } from '@backstage/core-plugin-api'; const FailCount = ({ count }: { count: number }): JSX.Element | null => { if (count !== 0) { @@ -91,28 +92,33 @@ const generatedColumns: TableColumn[] = [ field: 'fullName', highlight: true, render: (row: Partial) => { - if (!row.fullName || !row.lastBuild?.number) { - return ( - <> - {row.fullName || - row.fullDisplayName || - row.displayName || - 'Unknown'} - - ); - } + const LinkWrapper = () => { + const routeLink = useRouteRef(buildRouteRef); + if (!row.fullName || !row.lastBuild?.number) { + return ( + <> + {row.fullName || + row.fullDisplayName || + row.displayName || + 'Unknown'} + + ); + } - return ( - - {row.fullDisplayName} - - ); + return ( + + {row.fullDisplayName} + + ); + }; + + return ; }, }, { diff --git a/plugins/jenkins/src/components/Cards/Cards.test.tsx b/plugins/jenkins/src/components/Cards/Cards.test.tsx index 2508461710..0af91da179 100644 --- a/plugins/jenkins/src/components/Cards/Cards.test.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.test.tsx @@ -15,11 +15,10 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { LatestRunCard } from './Cards'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { JenkinsApi, jenkinsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Project } from '../../api/JenkinsApi'; describe('', () => { @@ -41,14 +40,12 @@ describe('', () => { }; it('should show success status of latest build', async () => { - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApi]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText('Completed')).toBeInTheDocument(); @@ -59,14 +56,12 @@ describe('', () => { getProjects: () => Promise.reject(new Error('Unauthorized')), }; - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText("Error: Can't connect to Jenkins")).toBeInTheDocument(); @@ -82,14 +77,12 @@ describe('', () => { }), }; - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText("Error: Can't find Jenkins project")).toBeInTheDocument(); diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 35b769e9d9..0d38fe009a 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -19,7 +19,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { Route, Routes } from 'react-router'; import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants'; -import { buildRouteRef, rootRouteRef } from '../plugin'; +import { buildRouteRef } from '../plugin'; import { CITable } from './BuildsPage/lib/CITable'; import { DetailedViewPage } from './BuildWithStepsPage/'; @@ -41,7 +41,7 @@ export const Router = (_props: Props) => { return ( - } /> + } /> } /> ); diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index d445a8cfb1..c32c9b50d1 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -20,20 +20,20 @@ import { createPlugin, createRoutableExtension, createRouteRef, + createSubRouteRef, discoveryApiRef, identityApiRef, } from '@backstage/core-plugin-api'; import { JenkinsClient, jenkinsApiRef } from './api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Jenkins', + id: 'jenkins', }); -export const buildRouteRef = createRouteRef({ - path: 'build/:jobFullName/:buildNumber', - params: ['jobFullName', 'buildNumber'], - title: 'Jenkins build', +export const buildRouteRef = createSubRouteRef({ + id: 'jenkins/builds', + path: '/builds/:jobFullName/:buildNumber', + parent: rootRouteRef, }); export const jenkinsPlugin = createPlugin({ diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index 88111cd1de..482c1f75b1 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kafka-backend +## 0.2.11 + +### Patch Changes + +- 9145449220: Update Kafka configuration types +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + ## 0.2.10 ### Patch Changes diff --git a/plugins/kafka-backend/config.d.ts b/plugins/kafka-backend/config.d.ts index ee29560809..3e7c196bd7 100644 --- a/plugins/kafka-backend/config.d.ts +++ b/plugins/kafka-backend/config.d.ts @@ -31,10 +31,11 @@ export interface Config { */ ssl?: | { - ca: string[]; + ca?: string[]; /** @visibility secret */ - key: string; - cert: string; + key?: string; + cert?: string; + rejectUnauthorized?: boolean; } | boolean; /** diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index e271decc8b..dea803890f 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kafka-backend", "description": "A Backstage backend plugin that integrates towards Kafka", - "version": "0.2.10", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.3", "@types/express": "^4.17.6", @@ -44,7 +44,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 3697f8de28..33ec4bd95a 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-kafka +## 0.2.21 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.2.20 + +### Patch Changes + +- 91f8644f17: Use IdentityApi to provide Auth Token for KafkaBackendClient Api calls +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.2.19 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index e9fe6bc4bf..a6fbe36e99 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kafka", "description": "A Backstage plugin that integrates towards Kafka", - "version": "0.2.19", + "version": "0.2.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -35,10 +35,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kafka/src/Router.tsx b/plugins/kafka/src/Router.tsx index 977c71712e..c63019253b 100644 --- a/plugins/kafka/src/Router.tsx +++ b/plugins/kafka/src/Router.tsx @@ -18,7 +18,6 @@ import { Entity } from '@backstage/catalog-model'; import React from 'react'; import { Route, Routes } from 'react-router'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { rootCatalogKafkaRouteRef } from './plugin'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from './constants'; import { KafkaTopicsForConsumer } from './components/ConsumerGroupOffsets/ConsumerGroupOffsets'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -44,10 +43,7 @@ export const Router = (_props: Props) => { return ( - } - /> + } /> ); }; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index a90d3d608e..0be9cabacd 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -26,8 +26,8 @@ import { import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; import * as data from './__fixtures__/consumer-group-offsets.json'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; @@ -59,14 +59,14 @@ describe('useConsumerGroupOffsets', () => { const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - {children} - + ); }; diff --git a/plugins/kafka/src/plugin.ts b/plugins/kafka/src/plugin.ts index 539b0a0fda..0c345cdabc 100644 --- a/plugins/kafka/src/plugin.ts +++ b/plugins/kafka/src/plugin.ts @@ -25,8 +25,7 @@ import { } from '@backstage/core-plugin-api'; export const rootCatalogKafkaRouteRef = createRouteRef({ - path: '*', - title: 'Kafka', + id: 'kafka', }); export const kafkaPlugin = createPlugin({ diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index be2903dff5..4edc7b238a 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -32,8 +32,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.3", "@backstage/plugin-kubernetes-common": "^0.1.4", @@ -55,7 +55,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/aws4": "^1.5.1", "supertest": "^6.1.3", "aws-sdk-mock": "^5.2.1", diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 1b90e65fd7..de0dcdf373 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import { getVoidLogger, loadBackendConfig } from '@backstage/backend-common'; +import { getVoidLogger } from '@backstage/backend-common'; +import { Config, ConfigReader } from '@backstage/config'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; import express from 'express'; import request from 'supertest'; -import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; import { ClusterDetails, FetchResponseWrapper, @@ -27,8 +27,8 @@ import { KubernetesServiceLocator, ObjectFetchParams, } from '../types/types'; -import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; import { KubernetesBuilder } from './KubernetesBuilder'; +import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; describe('KubernetesBuilder', () => { let app: express.Express; @@ -37,7 +37,12 @@ describe('KubernetesBuilder', () => { beforeAll(async () => { const logger = getVoidLogger(); - config = await loadBackendConfig({ logger, argv: [] }); + config = new ConfigReader({ + kubernetes: { + serviceLocatorMethod: { type: 'multiTenant' }, + clusterLocatorMethods: [{ type: 'config', clusters: [] }], + }, + }); const clusters: ClusterDetails[] = [ { diff --git a/plugins/kubernetes-common/package.json b/plugins/kubernetes-common/package.json index 28dfc94c49..3c9cb5e357 100644 --- a/plugins/kubernetes-common/package.json +++ b/plugins/kubernetes-common/package.json @@ -35,11 +35,11 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", + "@backstage/catalog-model": "^0.9.7", "@kubernetes/client-node": "^0.15.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.9.0" }, "jest": { "roots": [ diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index ac7b19a1d9..0ae3a6c012 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-kubernetes +## 0.4.20 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.4.19 + +### Patch Changes + +- 9f3dd76bc7: fix: kubernetes plugin shall pass id token on get clusters request if possible +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.4.18 ### Patch Changes diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index a2c1114c2d..12de5f098f 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -29,7 +29,7 @@ import { } from '@backstage/plugin-kubernetes-common'; import fixture1 from '../src/__fixtures__/1-deployments.json'; import fixture2 from '../src/__fixtures__/2-deployments.json'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; const mockEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -80,32 +80,26 @@ createDevApp() path: '/fixture-1', title: 'Fixture 1', element: ( - - + ), }) .addPage({ path: '/fixture-2', title: 'Fixture 2', element: ( - - + ), }) .registerPlugin(kubernetesPlugin) diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 5bce077278..618f814c21 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes", "description": "A Backstage plugin that integrates towards Kubernetes", - "version": "0.4.18", + "version": "0.4.20", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.2", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", "@backstage/plugin-kubernetes-common": "^0.1.5", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@kubernetes/client-node": "^0.15.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -51,10 +51,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index f98d3ca45e..873bf128e4 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; -import { rootCatalogKubernetesRouteRef } from './plugin'; import { KubernetesContent } from './components/KubernetesContent'; import { Button } from '@material-ui/core'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -53,10 +52,7 @@ export const Router = (_props: Props) => { ) { return ( - } - /> + } /> ); } diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 3f6a0394d3..4d44c021c4 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -79,10 +79,14 @@ export class KubernetesBackendClient implements KubernetesApi { } async getClusters(): Promise<{ name: string; authProvider: string }[]> { + const idToken = await this.identityApi.getIdToken(); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; const response = await fetch(url, { method: 'GET', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, }); return (await this.handleResponse(response)).items; diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index 3f2d8c6a87..a1f2f57621 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -28,8 +28,7 @@ import { } from '@backstage/core-plugin-api'; export const rootCatalogKubernetesRouteRef = createRouteRef({ - path: '*', - title: 'Kubernetes', + id: 'kubernetes', }); export const kubernetesPlugin = createPlugin({ diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 4ff76d4f1b..48ef9a178d 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-lighthouse +## 0.2.30 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.29 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 1f3dd91d32..e53f6ee261 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-lighthouse", "description": "A Backstage plugin that integrates towards Lighthouse", - "version": "0.2.29", + "version": "0.2.30", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -47,10 +47,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 1433a186fb..53de33d21b 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -30,8 +30,9 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import * as data from '../../__fixtures__/website-list-response.json'; import { AuditListForEntity } from './AuditListForEntity'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiRegistry } from '@backstage/test-utils'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), @@ -41,7 +42,7 @@ const websiteListResponse = data as WebsiteListResponse; const entityWebsite = websiteListResponse.items[0]; describe('', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const mockErrorApi: jest.Mocked = { post: jest.fn(), @@ -49,10 +50,10 @@ describe('', () => { }; beforeEach(() => { - apis = ApiRegistry.from([ + apis = TestApiRegistry.from( [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, mockErrorApi], - ]); + ); (useWebsiteForEntity as jest.Mock).mockReturnValue({ value: entityWebsite, diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index 4c92997755..d8a54928b2 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -16,7 +16,11 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { wrapInTestApp, setupRequestMockHandlers } from '@backstage/test-utils'; +import { + wrapInTestApp, + setupRequestMockHandlers, + TestApiRegistry, +} from '@backstage/test-utils'; import AuditListTable from './AuditListTable'; import { @@ -28,19 +32,20 @@ import { formatTime } from '../../utils'; import { setupServer } from 'msw/node'; import * as data from '../../__fixtures__/website-list-response.json'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const websiteListResponse = data as WebsiteListResponse; describe('AuditListTable', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('http://lighthouse'), ]); }); diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index ea3587fea9..2b422cce7a 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -23,7 +23,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent, render } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -35,20 +39,21 @@ import { } from '../../api'; import * as data from '../../__fixtures__/website-list-response.json'; import AuditList from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const { useNavigate } = jest.requireMock('react-router-dom'); const websiteListResponse = data as WebsiteListResponse; describe('AuditList', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('http://lighthouse'), ]); }); diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index ed1bbbcaf9..00b2843a50 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -26,7 +26,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { render } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -35,14 +39,14 @@ import { Audit, lighthouseApiRef, LighthouseRestApi, Website } from '../../api'; import { formatTime } from '../../utils'; import * as data from '../../__fixtures__/website-response.json'; import AuditView from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const { useParams }: { useParams: jest.Mock } = jest.requireMock('react-router-dom'); const websiteResponse = data as Website; describe('AuditView', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; let id: string; const server = setupServer(); @@ -55,8 +59,9 @@ describe('AuditView', () => { ), ); - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('https://lighthouse'), ]); id = websiteResponse.audits.find(a => a.status === 'COMPLETED') ?.id as string; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index 84493e243f..59847b516e 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -23,7 +23,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -32,7 +36,7 @@ import { Audit, lighthouseApiRef, LighthouseRestApi } from '../../api'; import * as data from '../../__fixtures__/create-audit-response.json'; import CreateAudit from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; const { useNavigate }: { useNavigate: jest.Mock } = @@ -41,17 +45,17 @@ const createAuditResponse = data as Audit; // TODO add act() to these tests without breaking them! describe('CreateAudit', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; let errorApi: ErrorApi; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { errorApi = { post: jest.fn(), error$: jest.fn() }; - apis = ApiRegistry.from([ + apis = TestApiRegistry.from( [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, errorApi], - ]); + ); }); it('renders the form', () => { diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 64f7483081..13da0f6f4f 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -21,8 +21,8 @@ import { lighthouseApiRef, WebsiteListResponse } from '../api'; import * as data from '../__fixtures__/website-list-response.json'; import { useWebsiteForEntity } from './useWebsiteForEntity'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; const websiteListResponse = data as WebsiteListResponse; const website = websiteListResponse.items[0]; @@ -55,14 +55,14 @@ describe('useWebsiteForEntity', () => { const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - {children} - + ); }; diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index 25cbeb3f31..1c9bc295f8 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -25,22 +25,19 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Lighthouse', + id: 'lighthouse', }); export const viewAuditRouteRef = createRouteRef({ - path: 'audit/:id', - title: 'View Lighthouse Audit', + id: 'lighthouse:audit', }); export const createAuditRouteRef = createRouteRef({ - path: 'create-audit', - title: 'Create Lighthouse Audit', + id: 'lighthouse:create-audit', }); export const entityContentRouteRef = createRouteRef({ - title: 'Lighthouse Entity Content', + id: 'lighthouse:entity-content', }); export const lighthousePlugin = createPlugin({ diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index 7ffd4b06d1..89b77e9866 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-newrelic +## 0.3.9 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.8 ### Patch Changes diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index c60f1ee9a5..16029f1783 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-newrelic", "description": "A Backstage plugin that integrates towards New Relic", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -43,10 +43,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/newrelic/src/plugin.ts b/plugins/newrelic/src/plugin.ts index cb2505e36e..6cf77a785f 100644 --- a/plugins/newrelic/src/plugin.ts +++ b/plugins/newrelic/src/plugin.ts @@ -24,8 +24,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/newrelic', - title: 'newrelic', + id: 'newrelic', }); export const newRelicPlugin = createPlugin({ diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 6e1cc7064f..f46095f46a 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-org +## 0.3.28 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.27 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index dc91b59816..db6cfe4d0b 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-org", "description": "A Backstage plugin that helps you create entity pages for your organization", - "version": "0.3.27", + "version": "0.3.28", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -37,10 +37,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx index 0a5a0cfef1..b6b549d934 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx @@ -15,8 +15,8 @@ */ import { Entity, GroupEntity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; +import { TestApiProvider } from '@backstage/test-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; import { MemoryRouter } from 'react-router'; @@ -99,12 +99,9 @@ const catalogApi = (items: Entity[]) => ({ getEntities: () => Promise.resolve({ items }), }); -const apiRegistry = (items: Entity[]) => - ApiRegistry.from([[catalogApiRef, catalogApi(items)]]); - export const Default = () => ( - + @@ -112,13 +109,13 @@ export const Default = () => ( - + ); export const Empty = () => ( - + @@ -126,6 +123,6 @@ export const Empty = () => ( - + ); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 75cb9efd7f..b0c68d9a83 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -20,10 +20,13 @@ import { catalogApiRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import React from 'react'; import { MembersListCard } from './MembersListCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('MemberTab Test', () => { const groupEntity: GroupEntity = { @@ -103,17 +106,15 @@ describe('MemberTab Test', () => { }), }; - const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]); - it('Display Profile Card', async () => { const rendered = await renderWithEffects( wrapInTestApp( - + , - , + , ), ); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx index 14fed985b6..6a20035dfa 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx @@ -15,14 +15,14 @@ */ import { GroupEntity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { CatalogApi, catalogApiRef, catalogRouteRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { BackstageTheme, createTheme, @@ -86,11 +86,11 @@ const catalogApi: Partial = { getEntities: () => Promise.resolve({ items: [serviceA, serviceB, websiteA] }), }; -const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); +const apis = TestApiRegistry.from([catalogApiRef, catalogApi]); export const Default = () => wrapInTestApp( - + @@ -123,7 +123,7 @@ const monochromeTheme = (outer: BackstageTheme) => export const Themed = () => wrapInTestApp( - + diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index b5ffd37083..8d6fc1a002 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, catalogRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { queryByText } from '@testing-library/react'; import React from 'react'; import { OwnershipCard } from './OwnershipCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('OwnershipCard', () => { const groupEntity: GroupEntity = { @@ -121,11 +120,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, @@ -157,11 +156,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, @@ -205,11 +204,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index 11ae548db8..bb74598f2a 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-pagerduty +## 0.3.18 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.17 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 2efc824746..7981b2c104 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-pagerduty", "description": "A Backstage plugin that integrates towards PagerDuty", - "version": "0.3.17", + "version": "0.3.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx index 446c867f35..723e7b7b19 100644 --- a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { ChangeEvent } from '../types'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { ChangeEvents } from './ChangeEvents'; const mockPagerDutyApi = { - getChangeEventsByServiceId: () => [], + getChangeEventsByServiceId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Incidents', () => { it('Renders an empty state when there are no change events', async () => { diff --git a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx index 52aa9a860f..47f18002fe 100644 --- a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx +++ b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { EscalationPolicy } from './EscalationPolicy'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { User } from '../types'; import { pagerDutyApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi = { - getOnCallByPolicyId: () => [], + getOnCallByPolicyId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Escalation', () => { it('Handles an empty response', async () => { diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx index d66acc0879..74e7d82e8b 100644 --- a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { Incidents } from './Incidents'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Incident } from '../types'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi = { - getIncidentsByServiceId: () => [], + getIncidentsByServiceId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Incidents', () => { it('Renders an empty state when there are no incidents', async () => { diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx index d644aa019a..e0a857ccc7 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx @@ -18,12 +18,12 @@ import { render, waitFor, fireEvent, act } from '@testing-library/react'; import { PagerDutyCard } from '../PagerDutyCard'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api'; import { Service, User } from '../types'; -import { alertApiRef, createApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi: Partial = { getServiceByIntegrationKey: async () => [], @@ -31,16 +31,10 @@ const mockPagerDutyApi: Partial = { getIncidentsByServiceId: async () => [], }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [pagerDutyApiRef, mockPagerDutyApi], - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], -]); + [alertApiRef, {}], +); const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/plugins/pagerduty/src/components/TriggerButton/index.test.tsx b/plugins/pagerduty/src/components/TriggerButton/index.test.tsx index 21f0dd5f1d..3776bcaf1c 100644 --- a/plugins/pagerduty/src/components/TriggerButton/index.test.tsx +++ b/plugins/pagerduty/src/components/TriggerButton/index.test.tsx @@ -15,16 +15,15 @@ */ import React from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { TriggerButton } from './'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; @@ -39,17 +38,11 @@ describe('TriggerButton', () => { triggerAlarm: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [pagerDutyApiRef, mockPagerDutyApi], - ]); + ); it('renders the trigger button, opens and closes dialog', async () => { const entity: Entity = { diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx index 444e820b98..d6f378b4bf 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -15,16 +15,15 @@ */ import React from 'react'; import { fireEvent, act } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { TriggerDialog } from './TriggerDialog'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; @@ -39,17 +38,11 @@ describe('TriggerDialog', () => { triggerAlarm: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [pagerDutyApiRef, mockPagerDutyApi], - ]); + ); it('open the dialog and trigger an alarm', async () => { const entity: Entity = { diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts index fb1e573f0c..d1c4e6d35b 100644 --- a/plugins/pagerduty/src/plugin.ts +++ b/plugins/pagerduty/src/plugin.ts @@ -24,8 +24,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/pagerduty', - title: 'pagerduty', + id: 'pagerduty', }); export const pagerDutyPlugin = createPlugin({ diff --git a/plugins/permission-common/.eslintrc.js b/plugins/permission-common/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/permission-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/permission-common/README.md b/plugins/permission-common/README.md new file mode 100644 index 0000000000..7ba998bed5 --- /dev/null +++ b/plugins/permission-common/README.md @@ -0,0 +1,7 @@ +# @backstage/plugin-permission-common + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Isomorphic types and client for Backstage permissions and authorization. For +more information, see the [authorization +PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md new file mode 100644 index 0000000000..043b27554c --- /dev/null +++ b/plugins/permission-common/api-report.md @@ -0,0 +1,97 @@ +## API Report File for "@backstage/plugin-permission-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; + +// @public +export type AuthorizeRequest = { + permission: Permission; + resourceRef?: string; +}; + +// @public +export type AuthorizeRequestOptions = { + token?: string; +}; + +// @public +export type AuthorizeResponse = + | { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; + } + | { + result: AuthorizeResult.CONDITIONAL; + conditions: PermissionCriteria; + }; + +// @public +export enum AuthorizeResult { + ALLOW = 'ALLOW', + CONDITIONAL = 'CONDITIONAL', + DENY = 'DENY', +} + +// @public +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; + +// @public +export type Identified = T & { + id: string; +}; + +// @public +export function isCreatePermission(permission: Permission): boolean; + +// @public +export function isDeletePermission(permission: Permission): boolean; + +// @public +export function isReadPermission(permission: Permission): boolean; + +// @public +export function isUpdatePermission(permission: Permission): boolean; + +// @public +export type Permission = { + name: string; + attributes: PermissionAttributes; + resourceType?: string; +}; + +// @public +export type PermissionAttributes = { + action?: 'create' | 'read' | 'update' | 'delete'; +}; + +// @public +export class PermissionClient { + constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }); + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; +} + +// @public +export type PermissionCondition = { + rule: string; + params: TParams; +}; + +// @public +export type PermissionCriteria = + | { + allOf: PermissionCriteria[]; + } + | { + anyOf: PermissionCriteria[]; + } + | { + not: PermissionCriteria; + } + | TQuery; +``` diff --git a/plugins/permission-common/config.d.ts b/plugins/permission-common/config.d.ts new file mode 100644 index 0000000000..2378fa4a71 --- /dev/null +++ b/plugins/permission-common/config.d.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + /** Configuration options for Backstage permissions and authorization */ + permission?: { + /** + * Whether authorization is enabled in Backstage. Defaults to false, which means authorization + * requests will be automatically allowed without invoking the authorization policy. + * @visibility frontend + */ + enabled?: boolean; + }; +} diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json new file mode 100644 index 0000000000..9e1d60f782 --- /dev/null +++ b/plugins/permission-common/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-permission-common", + "description": "Isomorphic types and client for Backstage permissions and authorization", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/permission-common" + }, + "keywords": [ + "backstage", + "permissions" + ], + "license": "Apache-2.0", + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts", + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.2", + "cross-fetch": "^3.0.6", + "uuid": "^8.0.0", + "zod": "^3.11.6" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@types/jest": "^26.0.7", + "msw": "^0.35.0" + } +} diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts new file mode 100644 index 0000000000..b2e66986a1 --- /dev/null +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { RestContext, rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ConfigReader } from '@backstage/config'; +import { PermissionClient } from './PermissionClient'; +import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api'; +import { DiscoveryApi } from './types/discovery'; +import { Permission } from './types/permission'; + +const server = setupServer(); +const token = 'fake-token'; + +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discoveryApi: DiscoveryApi = { + async getBaseUrl() { + return mockBaseUrl; + }, +}; +const client: PermissionClient = new PermissionClient({ + discoveryApi, + configApi: new ConfigReader({ permission: { enabled: true } }), +}); + +const mockPermission: Permission = { + name: 'test.permission', + attributes: {}, + resourceType: 'test-resource', +}; + +const mockAuthorizeRequest = { + permission: mockPermission, + resourceRef: 'foo', +}; + +describe('PermissionClient', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + describe('authorize', () => { + const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + result: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); + }); + + beforeEach(() => { + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch entities from correct endpoint', async () => { + await client.authorize([mockAuthorizeRequest]); + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.authorize([mockAuthorizeRequest]); + + const request = mockAuthorizeHandler.mock.calls[0][0]; + expect(request.body[0]).toEqual( + expect.objectContaining({ + permission: mockPermission, + resourceRef: 'foo', + }), + ); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.authorize([mockAuthorizeRequest]); + + const request = mockAuthorizeHandler.mock.calls[0][0]; + expect(request.headers.has('authorization')).toEqual(false); + }); + + it('should include correctly-constructed authorization header if token is supplied', async () => { + await client.authorize([mockAuthorizeRequest], { token }); + + const request = mockAuthorizeHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/request failed with 401/i); + }); + + it('should reject responses with missing ids', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }])); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/Unexpected authorization response/i); + }); + + it('should reject invalid responses', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/invalid input/i); + }); + + it('should allow all when permission.enabled is false', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + })); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ + discoveryApi, + configApi: new ConfigReader({ permission: { enabled: false } }), + }); + const response = await disabled.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockAuthorizeHandler).not.toBeCalled(); + }); + + it('should allow all when permission.enabled is not configured', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + })); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ + discoveryApi, + configApi: new ConfigReader({}), + }); + const response = await disabled.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockAuthorizeHandler).not.toBeCalled(); + }); + }); +}); diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts new file mode 100644 index 0000000000..f98d40a3c4 --- /dev/null +++ b/plugins/permission-common/src/PermissionClient.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ResponseError } from '@backstage/errors'; +import fetch from 'cross-fetch'; +import * as uuid from 'uuid'; +import { z } from 'zod'; +import { + AuthorizeResult, + AuthorizeRequest, + AuthorizeResponse, + Identified, + PermissionCriteria, + PermissionCondition, +} from './types/api'; +import { DiscoveryApi } from './types/discovery'; + +const permissionCriteriaSchema: z.ZodSchema< + PermissionCriteria +> = z.lazy(() => + z + .object({ + rule: z.string(), + params: z.array(z.unknown()), + }) + .or(z.object({ anyOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ allOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ not: permissionCriteriaSchema })), +); + +const responseSchema = z.array( + z + .object({ + id: z.string(), + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }) + .or( + z.object({ + id: z.string(), + result: z.literal(AuthorizeResult.CONDITIONAL), + conditions: permissionCriteriaSchema, + }), + ), +); + +/** + * Options for authorization requests; currently only an optional auth token. + * @public + */ +export type AuthorizeRequestOptions = { + token?: string; +}; + +/** + * An isomorphic client for requesting authorization for Backstage permissions. + * @public + */ +export class PermissionClient { + private readonly enabled: boolean; + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }) { + this.discoveryApi = options.discoveryApi; + this.enabled = + options.configApi.getOptionalBoolean('permission.enabled') ?? false; + } + + /** + * Request authorization from the permission-backend for the given set of permissions. + * + * Authorization requests check that a given Backstage user can perform a protected operation, + * potentially for a specific resource (such as a catalog entity). The Backstage identity token + * should be included in the `options` if available. + * + * Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`. + * + * The response will be either ALLOW or DENY when either the permission has no resourceType, or a + * resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be + * returned if no resourceRef is provided in the request. Conditional responses are intended only + * for backends which have access to the data source for permissioned resources, so that filters + * can be applied when loading collections of resources. + * @public + */ + async authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise { + // TODO(permissions): it would be great to provide some kind of typing guarantee that + // conditional responses will only ever be returned for requests containing a resourceType + // but no resourceRef. That way clients who aren't prepared to handle filtering according + // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. + + if (!this.enabled) { + return requests.map(_ => ({ result: AuthorizeResult.ALLOW })); + } + + const identifiedRequests: Identified[] = requests.map( + request => ({ + id: uuid.v4(), + ...request, + }), + ); + + const permissionApi = await this.discoveryApi.getBaseUrl('permission'); + const response = await fetch(`${permissionApi}/authorize`, { + method: 'POST', + body: JSON.stringify(identifiedRequests), + headers: { + ...this.getAuthorizationHeader(options?.token), + 'content-type': 'application/json', + }, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const identifiedResponses = await response.json(); + this.assertValidResponses(identifiedRequests, identifiedResponses); + + const responsesById = identifiedResponses.reduce((acc, r) => { + acc[r.id] = r; + return acc; + }, {} as Record>); + + return identifiedRequests.map(request => responsesById[request.id]); + } + + private getAuthorizationHeader(token?: string): Record { + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + private assertValidResponses( + requests: Identified[], + json: any, + ): asserts json is Identified[] { + const authorizedResponses = responseSchema.parse(json); + const responseIds = authorizedResponses.map(r => r.id); + const hasAllRequestIds = requests.every(r => responseIds.includes(r.id)); + if (!hasAllRequestIds) { + throw new Error( + 'Unexpected authorization response from permission-backend', + ); + } + } +} diff --git a/plugins/permission-common/src/index.ts b/plugins/permission-common/src/index.ts new file mode 100644 index 0000000000..7c3f6dd613 --- /dev/null +++ b/plugins/permission-common/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Isomorphic types and client for Backstage permissions and authorization + * + * @packageDocumentation + */ +export * from './types'; +export * from './permissions'; +export * from './PermissionClient'; diff --git a/plugins/permission-common/src/permissions/index.ts b/plugins/permission-common/src/permissions/index.ts new file mode 100644 index 0000000000..736d898539 --- /dev/null +++ b/plugins/permission-common/src/permissions/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './util'; diff --git a/plugins/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts new file mode 100644 index 0000000000..188609f4ae --- /dev/null +++ b/plugins/permission-common/src/permissions/util.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Permission } from '../types'; + +/** + * Check if a given permission is related to a create action. + * @public + */ +export function isCreatePermission(permission: Permission) { + return permission.attributes.action === 'create'; +} + +/** + * Check if a given permission is related to a read action. + * @public + */ +export function isReadPermission(permission: Permission) { + return permission.attributes.action === 'read'; +} + +/** + * Check if a given permission is related to an update action. + * @public + */ +export function isUpdatePermission(permission: Permission) { + return permission.attributes.action === 'update'; +} + +/** + * Check if a given permission is related to a delete action. + * @public + */ +export function isDeletePermission(permission: Permission) { + return permission.attributes.action === 'delete'; +} diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts new file mode 100644 index 0000000000..7f4cd0730b --- /dev/null +++ b/plugins/permission-common/src/types/api.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { Permission } from './permission'; + +/** + * A request with a UUID identifier, so that batched responses can be matched up with the original + * requests. + * @public + */ +export type Identified = T & { id: string }; + +/** + * The result of an authorization request. + * @public + */ +export enum AuthorizeResult { + /** + * The authorization request is denied. + */ + DENY = 'DENY', + /** + * The authorization request is allowed. + */ + ALLOW = 'ALLOW', + /** + * The authorization request is allowed if the provided conditions are met. + */ + CONDITIONAL = 'CONDITIONAL', +} + +/** + * An authorization request for {@link PermissionClient#authorize}. + * @public + */ +export type AuthorizeRequest = { + permission: Permission; + resourceRef?: string; +}; + +/** + * A condition returned with a CONDITIONAL authorization response. + * + * Conditions are a reference to a rule defined by a plugin, and parameters to apply the rule. For + * example, a rule might be `isOwner` from the catalog-backend, and params may be a list of entity + * claims from a identity token. + * @public + */ +export type PermissionCondition = { + rule: string; + params: TParams; +}; + +/** + * Composes several {@link PermissionCondition}s as criteria with a nested AND/OR structure. + * @public + */ +export type PermissionCriteria = + | { allOf: PermissionCriteria[] } + | { anyOf: PermissionCriteria[] } + | { not: PermissionCriteria } + | TQuery; + +/** + * An authorization response from {@link PermissionClient#authorize}. + * @public + */ +export type AuthorizeResponse = + | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } + | { + result: AuthorizeResult.CONDITIONAL; + conditions: PermissionCriteria; + }; diff --git a/plugins/permission-common/src/types/discovery.ts b/plugins/permission-common/src/types/discovery.ts new file mode 100644 index 0000000000..19ee5ed19c --- /dev/null +++ b/plugins/permission-common/src/types/discovery.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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. + * + * @public + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts new file mode 100644 index 0000000000..e21fa19e8b --- /dev/null +++ b/plugins/permission-common/src/types/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AuthorizeResult } from './api'; +export type { + AuthorizeRequest, + AuthorizeResponse, + Identified, + PermissionCondition, + PermissionCriteria, +} from './api'; +export type { DiscoveryApi } from './discovery'; +export type { PermissionAttributes, Permission } from './permission'; diff --git a/plugins/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts new file mode 100644 index 0000000000..181b36065f --- /dev/null +++ b/plugins/permission-common/src/types/permission.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 attributes related to a given permission; these should be generic and widely applicable to + * all permissions in the system. + * @public + */ +export type PermissionAttributes = { + action?: 'create' | 'read' | 'update' | 'delete'; +}; + +/** + * A permission that can be checked through authorization. + * + * Permissions are the "what" part of authorization, the action to be performed. This may be reading + * an entity from the catalog, executing a software template, or any other action a plugin author + * may wish to protect. + * + * To evaluate authorization, a permission is paired with a Backstage identity (the "who") and + * evaluated using an authorization policy. + * @public + */ +export type Permission = { + name: string; + attributes: PermissionAttributes; + resourceType?: string; +}; diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index c43ea8b31a..05499d88e8 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.8", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -43,7 +43,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/proxy-backend/src/run.ts b/plugins/proxy-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/proxy-backend/src/run.ts +++ b/plugins/proxy-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 11a1068b68..ddb524d3aa 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -14,11 +14,8 @@ * limitations under the License. */ -import { - getVoidLogger, - loadBackendConfig, - SingleHostDiscovery, -} from '@backstage/backend-common'; +import { getVoidLogger, SingleHostDiscovery } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { Request, Response } from 'express'; import * as http from 'http'; import { createProxyMiddleware, Options } from 'http-proxy-middleware'; @@ -35,7 +32,14 @@ const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction< describe('createRouter', () => { it('works', async () => { const logger = getVoidLogger(); - const config = await loadBackendConfig({ logger, argv: [] }); + const config = new ConfigReader({ + backend: { + baseUrl: 'https://example.com:7007', + listen: { + port: 7007, + }, + }, + }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 1d7be09b85..be8b6e65e5 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -48,7 +48,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar-backend/src/run.ts b/plugins/rollbar-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/rollbar-backend/src/run.ts +++ b/plugins/rollbar-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 4f7f3c9a16..aee0a74525 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-rollbar +## 0.3.19 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.18 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 38e295a37b..ee3fc9f5a6 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-rollbar", "description": "A Backstage plugin that integrates towards Rollbar", - "version": "0.3.18", + "version": "0.3.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,10 +49,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index 845da22a20..032bf0166e 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -19,7 +19,6 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { Route, Routes } from 'react-router'; import { ROLLBAR_ANNOTATION } from '../constants'; -import { rootRouteRef } from '../plugin'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -40,10 +39,7 @@ export const Router = (_props: Props) => { return ( - } - /> + } /> ); }; diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index b0de6510f3..3ca456058f 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -26,8 +26,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Rollbar', + id: 'rollbar', }); export const rollbarPlugin = createPlugin({ diff --git a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md index 9e870a9e0b..43a522482f 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-scaffolder-backend-module-cookiecutter +## 0.1.4 + +### Patch Changes + +- 290fbb3ec2: Add missing API docs to scaffolder action plugins +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/plugin-scaffolder-backend@0.15.12 + ## 0.1.3 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-cookiecutter/api-report.md b/plugins/scaffolder-backend-module-cookiecutter/api-report.md index 6fa57fd0f3..132d3d4225 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/api-report.md +++ b/plugins/scaffolder-backend-module-cookiecutter/api-report.md @@ -10,9 +10,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; import { UrlReader } from '@backstage/backend-common'; -// Warning: (ae-missing-release-tag) "createFetchCookiecutterAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createFetchCookiecutterAction(options: { reader: UrlReader; integrations: ScmIntegrations; diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 38c2eb493c..90258d3c28 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend-module-cookiecutter", "description": "A module for the scaffolder backend that lets you template projects using cookiecutter", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", + "@backstage/backend-common": "^0.9.10", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", - "@backstage/plugin-scaffolder-backend": "^0.15.11", + "@backstage/plugin-scaffolder-backend": "^0.15.13", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "command-exists": "^1.2.9", @@ -33,7 +33,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/jest": "^26.0.7", diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts index 5120cd3b27..c4d7f1e3e8 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts @@ -121,6 +121,15 @@ export class CookiecutterRunner { } } +/** + * Creates a `fetch:cookiecutter` Scaffolder action. + * + * @remarks + * + * See {@link https://cookiecutter.readthedocs.io/} and {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. + * @param options - Templating configuration. + * @public + */ export function createFetchCookiecutterAction(options: { reader: UrlReader; integrations: ScmIntegrations; diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/index.ts b/plugins/scaffolder-backend-module-cookiecutter/src/index.ts index cce8011059..4f0ba9a407 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/index.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/index.ts @@ -15,7 +15,7 @@ */ /** - * A module for the scaffolder backend that lets you template projects using cookiecutter + * A module for the scaffolder backend that lets you template projects using {@link https://cookiecutter.readthedocs.io/ | cookiecutter}. * * @packageDocumentation */ diff --git a/plugins/scaffolder-backend-module-rails/CHANGELOG.md b/plugins/scaffolder-backend-module-rails/CHANGELOG.md index f1e53abc5b..9dcd6bcb88 100644 --- a/plugins/scaffolder-backend-module-rails/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-rails/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-scaffolder-backend-module-rails +## 0.1.7 + +### Patch Changes + +- 290fbb3ec2: Add missing API docs to scaffolder action plugins +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/plugin-scaffolder-backend@0.15.12 + ## 0.1.6 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-rails/api-report.md b/plugins/scaffolder-backend-module-rails/api-report.md index 477483f70e..9c0610d4f2 100644 --- a/plugins/scaffolder-backend-module-rails/api-report.md +++ b/plugins/scaffolder-backend-module-rails/api-report.md @@ -8,9 +8,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; import { UrlReader } from '@backstage/backend-common'; -// Warning: (ae-missing-release-tag) "createFetchRailsAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createFetchRailsAction(options: { reader: UrlReader; integrations: ScmIntegrations; diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index e79b7fb818..37b077568e 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend-module-rails", "description": "A module for the scaffolder backend that lets you template projects using Rails", - "version": "0.1.6", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/plugin-scaffolder-backend": "^0.15.11", + "@backstage/backend-common": "^0.9.10", + "@backstage/plugin-scaffolder-backend": "^0.15.13", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", @@ -31,7 +31,7 @@ "fs-extra": "^9.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/command-exists": "^1.2.0", diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts index 5ce7d46689..00a5690c0a 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -27,6 +27,16 @@ import { import { resolve as resolvePath } from 'path'; import { RailsNewRunner } from './railsNewRunner'; +/** + * Creates the `fetch:rails` Scaffolder action. + * + * @remarks + * + * See {@link https://guides.rubyonrails.org/rails_application_templates.html} and {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. + * + * @param options - Configuration of the templater. + * @public + */ export function createFetchRailsAction(options: { reader: UrlReader; integrations: ScmIntegrations; diff --git a/plugins/scaffolder-backend-module-rails/src/index.ts b/plugins/scaffolder-backend-module-rails/src/index.ts index eb51c06c49..773e1feabd 100644 --- a/plugins/scaffolder-backend-module-rails/src/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/index.ts @@ -15,7 +15,7 @@ */ /** - * A module for the scaffolder backend that lets you template projects using Rails + * A module for the scaffolder backend that lets you template projects using {@link https://guides.rubyonrails.org/rails_application_templates.html | Rails}. * * @packageDocumentation */ diff --git a/plugins/scaffolder-backend-module-yeoman/CHANGELOG.md b/plugins/scaffolder-backend-module-yeoman/CHANGELOG.md index b15614b0ba..9373564b1c 100644 --- a/plugins/scaffolder-backend-module-yeoman/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-yeoman/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-scaffolder-backend-module-yeoman +## 0.1.2 + +### Patch Changes + +- 290fbb3ec2: Add missing API docs to scaffolder action plugins +- Updated dependencies + - @backstage/plugin-scaffolder-backend@0.15.12 + ## 0.1.1 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-yeoman/api-report.md b/plugins/scaffolder-backend-module-yeoman/api-report.md index 1f38d3945c..fc18814faa 100644 --- a/plugins/scaffolder-backend-module-yeoman/api-report.md +++ b/plugins/scaffolder-backend-module-yeoman/api-report.md @@ -5,10 +5,6 @@ ```ts import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; -// Warning: (ae-missing-release-tag) "createRunYeomanAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export function createRunYeomanAction(): TemplateAction; - -// (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder-backend-module-yeoman/package.json b/plugins/scaffolder-backend-module-yeoman/package.json index b853309c56..8d17df0239 100644 --- a/plugins/scaffolder-backend-module-yeoman/package.json +++ b/plugins/scaffolder-backend-module-yeoman/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend-module-yeoman", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,13 +21,13 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/plugin-scaffolder-backend": "^0.15.11", + "@backstage/plugin-scaffolder-backend": "^0.15.12", "@backstage/types": "^0.1.1", "winston": "^3.2.1", "yeoman-environment": "^3.6.0" }, "devDependencies": { - "@backstage/backend-common": "^0.9.8", + "@backstage/backend-common": "^0.9.9", "@types/jest": "^26.0.7" }, "files": [ diff --git a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts index f6a29037a3..f7b0c54b41 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts @@ -18,6 +18,15 @@ import { JsonObject } from '@backstage/types'; import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; import { yeomanRun } from './yeomanRun'; +/** + * Creates a `run:yeoman` Scaffolder action. + * + * @remarks + * + * See {@link https://yeoman.io/} and {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. + * + * @public + */ export function createRunYeomanAction() { return createTemplateAction<{ namespace: string; diff --git a/plugins/scaffolder-backend-module-yeoman/src/index.ts b/plugins/scaffolder-backend-module-yeoman/src/index.ts index 4f06b14a86..1aac8e12f9 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/index.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/index.ts @@ -13,4 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +/** + * A module for the scaffolder backend that lets you template projects using + * {@link https://yeoman.io/ | Yeoman}. + * + * @packageDocumentation + */ export * from './actions'; diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 689cb51d24..f71849a1d2 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,67 @@ # @backstage/plugin-scaffolder-backend +## 0.15.13 + +### Patch Changes + +- 26eb174ce8: Skip empty file names when scaffolding with nunjucks +- ecdcbd08ee: Expose template metadata to custom action handler in Scaffolder. +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/plugin-catalog-backend@0.17.4 + +## 0.15.12 + +### Patch Changes + +- 9990df8a1f: Expose some classes and interfaces public so TaskWorkers can run externally from the scaffolder API. +- b45a34fb15: Adds a new endpoint for consuming logs from the Scaffolder that uses long polling instead of Server Sent Events. + + This is useful if Backstage is accessed from an environment that doesn't support SSE correctly, which happens in combination with certain enterprise HTTP Proxy servers. + + It is intended to switch the endpoint globally for the whole instance. + If you want to use it, you can provide a reconfigured API to the `scaffolderApiRef`: + + ```tsx + // packages/app/src/apis.ts + + // ... + import { + scaffolderApiRef, + ScaffolderClient, + } from '@backstage/plugin-scaffolder'; + + export const apis: AnyApiFactory[] = [ + // ... + + createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + }, + factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => + new ScaffolderClient({ + discoveryApi, + identityApi, + scmIntegrationsApi, + // use long polling instead of an eventsource + useLongPollingLogs: true, + }), + }), + ]; + ``` + +- a794c341ca: Fix a bug where only file mode 775 is considered an executable +- Updated dependencies + - @backstage/backend-common@0.9.9 + - @backstage/catalog-client@0.5.1 + - @backstage/plugin-catalog-backend@0.17.3 + - @backstage/plugin-scaffolder-backend-module-cookiecutter@0.1.4 + ## 0.15.11 ### Patch Changes diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 3f9b77a3a3..4b27b938c9 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -41,6 +41,7 @@ export type ActionContext = { input: Input; output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; + metadata?: TemplateMetadata; }; // Warning: (ae-missing-release-tag) "CatalogEntityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -432,6 +433,8 @@ export interface TaskSpecV1beta2 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: string; }; @@ -454,6 +457,8 @@ export interface TaskSpecV1beta3 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: JsonValue; }; @@ -558,4 +563,9 @@ export class TemplateActionRegistry { action: TemplateAction, ): void; } + +// @public +export type TemplateMetadata = { + name: string; +}; ``` diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index fe5d61d4d3..374cfeffd6 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend", "description": "The Backstage backend plugin that helps you create new things", - "version": "0.15.11", + "version": "0.15.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,15 +30,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", - "@backstage/plugin-catalog-backend": "^0.17.2", + "@backstage/plugin-catalog-backend": "^0.17.4", "@backstage/plugin-scaffolder-common": "^0.1.1", - "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.3", + "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.4", "@backstage/types": "^0.1.1", "@gitbeaker/core": "^30.2.0", "@gitbeaker/node": "^30.2.0", @@ -71,8 +71,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 4befec5a81..755b435149 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -144,6 +144,7 @@ describe('fetch:template', () => { name: 'test-project', count: 1234, itemList: ['first', 'second', 'third'], + showDummyFile: false, }, }); @@ -163,6 +164,10 @@ describe('fetch:template', () => { }, '.${{ values.name }}': '${{ values.itemList | dump }}', 'a-binary-file.png': aBinaryFile, + '{% if values.showDummyFile %}dummy-file.txt{% else %}{% endif %}': + 'dummy file', + '${{ "dummy-file2.txt" if values.showDummyFile else "" }}': + 'some dummy file', }, }); @@ -181,6 +186,18 @@ describe('fetch:template', () => { ); }); + it('skips empty filename', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty filename syntax #2', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file2.txt`), + ).resolves.toEqual(false); + }); + it('copies files with no templating in names or content successfully', async () => { await expect( fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index bb568c25e6..47bfcccfdf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -241,6 +241,11 @@ export function createFetchTemplateAction(options: { localOutputPath = templater.renderString(localOutputPath, context); } const outputPath = resolvePath(outputDir, localOutputPath); + // variables have been expanded to make an empty file name + // this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip + if (outputDir === outputPath) { + continue; + } if (!renderContents && !extension) { ctx.logger.info( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts index 27c6f1e59d..0d4deb87e0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts @@ -254,7 +254,7 @@ describe('createPublishGithubPullRequestAction', () => { }); }); - describe('with executable file', () => { + describe('with executable file mode 755', () => { let input: GithubPullRequestActionInput; let ctx: ActionContext; @@ -268,9 +268,9 @@ describe('createPublishGithubPullRequestAction', () => { mockFs({ [workspacePath]: { - 'file.txt': mockFs.file({ - content: 'Hello there!', - mode: 33277, // File mode: 100755 + 'hello.sh': mockFs.file({ + content: 'echo Hello there!', + mode: 0o100755, }), }, }); @@ -297,8 +297,76 @@ describe('createPublishGithubPullRequestAction', () => { { commit: 'Create my new app', files: { - 'file.txt': { - content: Buffer.from('Hello there!').toString('base64'), + 'hello.sh': { + content: Buffer.from('echo Hello there!').toString('base64'), + encoding: 'base64', + mode: '100755', + }, + }, + }, + ], + }); + }); + + it('creates outputs for the url', async () => { + await instance.handler(ctx); + + expect(ctx.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/myorg/myrepo/pull/123', + ); + }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + }); + + describe('with executable file mode 775', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + repoUrl: 'github.com?owner=myorg&repo=myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + }; + + mockFs({ + [workspacePath]: { + 'hello.sh': mockFs.file({ + content: 'echo Hello there!', + mode: 0o100775, + }), + }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + }); + it('creates a pull request', async () => { + await instance.handler(ctx); + + expect(fakeClient.createPullRequest).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + head: 'new-app', + body: 'This PR is really good', + changes: [ + { + commit: 'Create my new app', + files: { + 'hello.sh': { + content: Buffer.from('echo Hello there!').toString('base64'), encoding: 'base64', mode: '100755', }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index e147f49b23..0cceb47d71 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import path from 'path'; -import { parseRepoUrl } from './util'; +import { parseRepoUrl, isExecutable } from './util'; import { GithubCredentialsProvider, @@ -202,10 +202,11 @@ export const createPublishGithubPullRequestAction = ({ .readFileSync(absPath) .toString('base64'); const fileStat = fs.statSync(absPath); - const isExecutable = fileStat.mode === 33277; // aka. 100755 // See the properties of tree items // in https://docs.github.com/en/rest/reference/git#trees - const githubTreeItemMode = isExecutable ? '100755' : '100644'; + const githubTreeItemMode = isExecutable(fileStat.mode) + ? '100755' + : '100644'; // Always use base64 encoding to avoid doubling a binary file in size // due to interpreting a binary file as utf-8 and sending github // the utf-8 encoded content. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.test.ts index f179289624..cc879d4f55 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.test.ts @@ -15,7 +15,7 @@ */ import path from 'path'; -import { getRepoSourceDirectory } from './util'; +import { getRepoSourceDirectory, isExecutable } from './util'; describe('getRepoSourceDirectory', () => { it('should return workspace root if no sub folder is given', () => { @@ -55,3 +55,33 @@ describe('getRepoSourceDirectory', () => { ).toEqual(path.join('/', 'var', 'workspace', 'absolute', 'secret')); }); }); + +describe('isExecutable', () => { + it('should return true for file mode 777', () => { + expect(isExecutable(0o100777)).toBe(true); + }); + it('should return true for file mode 775', () => { + expect(isExecutable(0o100775)).toBe(true); + }); + it('should return true for file mode 755', () => { + expect(isExecutable(0o100755)).toBe(true); + }); + it('should return true for file mode 700', () => { + expect(isExecutable(0o100700)).toBe(true); + }); + it('should return true for file mode 770', () => { + expect(isExecutable(0o100770)).toBe(true); + }); + it('should return true for file mode 670', () => { + expect(isExecutable(0o100670)).toBe(true); + }); + it('should return false for file mode 644', () => { + expect(isExecutable(0o100644)).toBe(false); + }); + it('should return false for file mode 600', () => { + expect(isExecutable(0o100600)).toBe(false); + }); + it('should return false for file mode 640', () => { + expect(isExecutable(0o100640)).toBe(false); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts index 58ba5bfdd1..49156e7d97 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -101,3 +101,8 @@ export const parseRepoUrl = ( return { host, owner, repo, organization, workspace, project }; }; +export const isExecutable = (fileMode: number) => { + const executeBitMask = 0o000111; + const res = fileMode & executeBitMask; + return res > 0; +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index ca3459d29c..52149be724 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -18,6 +18,7 @@ import { Logger } from 'winston'; import { Writable } from 'stream'; import { JsonValue, JsonObject } from '@backstage/types'; import { Schema } from 'jsonschema'; +import { TemplateMetadata } from '../tasks/types'; type PartialJsonObject = Partial; type PartialJsonValue = PartialJsonObject | JsonValue | undefined; @@ -44,6 +45,8 @@ export type ActionContext = { * Creates a temporary directory for use by the action, which is then cleaned up automatically. */ createTemporaryDirectory(): Promise; + + metadata?: TemplateMetadata; }; export type TemplateAction = { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts index d07ff29fae..1f7877791c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts @@ -146,6 +146,30 @@ describe('DefaultWorkflowRunner', () => { expect(fakeActionHandler).toHaveBeenCalledTimes(1); }); + + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + ], + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); }); describe('conditionals', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts index 77d0a7be09..5582b53023 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts @@ -225,6 +225,12 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { input: JSON.stringify(input, null, 2), }); + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, logger: taskLogger, @@ -242,6 +248,7 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutputs[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts index 91e772dc4e..9b05c43831 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts @@ -28,6 +28,7 @@ describe('LegacyWorkflowRunner', () => { let runner: HandlebarsWorkflowRunner; const logger = getVoidLogger(); let actionRegistry = new TemplateActionRegistry(); + let fakeActionHandler: jest.Mock; const integrations = ScmIntegrations.fromConfig( new ConfigReader({ @@ -59,6 +60,11 @@ describe('LegacyWorkflowRunner', () => { ctx.output('badOutput', false); }, }); + fakeActionHandler = jest.fn(); + actionRegistry.register({ + id: 'test-metadata-action', + handler: fakeActionHandler, + }); runner = new HandlebarsWorkflowRunner({ actionRegistry, @@ -87,6 +93,30 @@ describe('LegacyWorkflowRunner', () => { ); }); + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'backstage.io/v1beta2', + steps: [ + { + id: 'test', + name: 'name', + action: 'test-metadata-action', + input: { foo: 1 }, + }, + ], + output: {}, + values: {}, + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); + describe('templating', () => { it('should template the output', async () => { const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 587a93e1f2..c66d967103 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -228,6 +228,12 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const tmpDirs = new Array(); const stepOutput: { [outputName: string]: JsonValue } = {}; + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, input, @@ -244,6 +250,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutput[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts index 510c862dae..f563a08df6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts @@ -34,4 +34,5 @@ export type { TaskContext, TaskStore, DispatchResult, + TemplateMetadata, } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 6cfd914bd2..b492f8ad2c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -69,6 +69,15 @@ export type SerializedTaskEvent = { createdAt: string; }; +/** + * TemplateMetadata + * + * @public + */ +export type TemplateMetadata = { + name: string; +}; + /** * TaskSpecV1beta2 * @@ -86,6 +95,7 @@ export interface TaskSpecV1beta2 { if?: string | boolean; }>; output: { [name: string]: string }; + metadata?: TemplateMetadata; } export interface TaskStep { @@ -107,6 +117,7 @@ export interface TaskSpecV1beta3 { parameters: JsonObject; steps: TaskStep[]; output: { [name: string]: JsonValue }; + metadata?: TemplateMetadata; } /** diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 1c5343d458..d8ba15279f 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -29,24 +29,25 @@ jest.doMock('fs-extra', () => ({ })); import { + DatabaseManager, + DockerContainerRunner, getVoidLogger, PluginDatabaseManager, - DatabaseManager, UrlReaders, - DockerContainerRunner, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; -import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; /** * TODO: The following should import directly from the router file. * Due to a circular dependency between this plugin and the * plugin-scaffolder-backend-module-cookiecutter plugin, it results in an error: * TypeError: _pluginscaffolderbackend.createTemplateAction is not a function */ -import { createRouter } from '../index'; +import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; +import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; const createCatalogClient = (templates: any[] = []) => ({ @@ -73,6 +74,7 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; + let taskBroker: TaskBroker; const template: TemplateEntityV1beta2 = { apiVersion: 'backstage.io/v1beta2', kind: 'Template', @@ -102,7 +104,17 @@ describe('createRouter', () => { }, }; - beforeAll(async () => { + beforeEach(async () => { + const logger = getVoidLogger(); + const databaseTaskStore = await DatabaseTaskStore.create({ + database: await createDatabase().getClient(), + }); + taskBroker = new StorageTaskBroker(databaseTaskStore, logger); + + jest.spyOn(taskBroker, 'dispatch'); + jest.spyOn(taskBroker, 'get'); + jest.spyOn(taskBroker, 'observe'); + const router = await createRouter({ logger: getVoidLogger(), config: new ConfigReader({}), @@ -110,11 +122,12 @@ describe('createRouter', () => { catalogClient: createCatalogClient([template]), containerRunner: new DockerContainerRunner({} as any), reader: mockUrlReader, + taskBroker, }); app = express().use(router); }); - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); @@ -142,6 +155,12 @@ describe('createRouter', () => { }); it('return the template id', async () => { + ( + taskBroker.dispatch as jest.Mocked['dispatch'] + ).mockResolvedValue({ + taskId: 'a-random-id', + }); + const response = await request(app) .post('/v2/tasks') .send({ @@ -151,28 +170,241 @@ describe('createRouter', () => { }, }); - expect(response.body.id).toBeDefined(); + expect(response.body.id).toBe('a-random-id'); expect(response.status).toEqual(201); }); }); describe('GET /v2/tasks/:taskId', () => { it('does not divulge secrets', async () => { - const postResponse = await request(app) - .post('/v2/tasks') - .set('Authorization', 'Bearer secret') - .send({ - templateName: 'create-react-app-template', - values: { - required: 'required-value', - }, - }); + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + secrets: { token: 'secret' }, + }); - const response = await request(app) - .get(`/v2/tasks/${postResponse.body.id}`) - .send(); + const response = await request(app).get(`/v2/tasks/a-random-id`); expect(response.status).toEqual(200); + expect(response.body.status).toBe('completed'); expect(response.body.secrets).toBeUndefined(); }); }); + + describe('GET /v2/tasks/:taskId/eventstream', () => { + it('should return log messages', async () => { + const unsubscribe = jest.fn(); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + // emit after this function returned + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ], + }); + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + + return { unsubscribe }; + }); + + let statusCode: any = undefined; + let headers: any = {}; + const responseDataFn = jest.fn(); + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toBeCalledTimes(2); + expect(responseDataFn).toBeCalledWith(`event: log +data: {"id":0,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}} + +`); + expect(responseDataFn).toBeCalledWith(`event: completion +data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}} + +`); + + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( + { taskId: 'a-random-id' }, + expect.any(Function), + ); + + expect(unsubscribe).toBeCalledTimes(1); + }); + + it('should return log messages with after query', async () => { + const unsubscribe = jest.fn(); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + return { unsubscribe }; + }); + + let statusCode: any = undefined; + let headers: any = {}; + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .query({ after: 10 }) + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', () => { + // close immediately + req.abort(); + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( + { taskId: 'a-random-id', after: 10 }, + expect.any(Function), + ); + + expect(unsubscribe).toBeCalledTimes(1); + }); + }); + + describe('GET /v2/tasks/:taskId/events', () => { + it('should return log messages', async () => { + const unsubscribe = jest.fn(); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + return { unsubscribe }; + }); + + const response = await request(app).get('/v2/tasks/a-random-id/events'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: 0, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]); + + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( + { taskId: 'a-random-id' }, + expect.any(Function), + ); + expect(unsubscribe).toBeCalledTimes(1); + }); + + it('should return log messages with after query', async () => { + const unsubscribe = jest.fn(); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation((_, callback) => { + callback(undefined, { events: [] }); + return { unsubscribe }; + }); + + const response = await request(app) + .get('/v2/tasks/a-random-id/events') + .query({ after: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( + { taskId: 'a-random-id', after: 10 }, + expect.any(Function), + ); + expect(unsubscribe).toBeCalledTimes(1); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a78b04da78..b8f2968287 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,33 +14,33 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import express from 'express'; -import Router from 'express-promise-router'; -import { Logger } from 'winston'; -import { CatalogEntityClient } from '../lib/catalog'; -import { validate } from 'jsonschema'; -import { - DatabaseTaskStore, - TemplateActionRegistry, - TaskWorker, - TemplateAction, - createBuiltinActions, -} from '../scaffolder'; -import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; import { ContainerRunner, PluginDatabaseManager, UrlReader, } from '@backstage/backend-common'; -import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; -import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model'; -import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; - +import { Entity, TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { InputError, NotFoundError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; -import { TaskBroker, TaskSpec } from '../scaffolder/tasks/types'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { validate } from 'jsonschema'; +import { Logger } from 'winston'; +import { CatalogEntityClient } from '../lib/catalog'; +import { + createBuiltinActions, + DatabaseTaskStore, + TaskBroker, + TaskSpec, + TaskWorker, + TemplateAction, + TemplateActionRegistry, +} from '../scaffolder'; +import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; +import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; /** * RouterOptions @@ -209,6 +209,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, } : { apiVersion: template.apiVersion, @@ -220,6 +221,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, }; } else { throw new InputError( @@ -247,7 +249,9 @@ export async function createRouter( }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { const { taskId } = req.params; - const after = Number(req.query.after) || undefined; + const after = + req.query.after !== undefined ? Number(req.query.after) : undefined; + logger.debug(`Event stream observing taskId '${taskId}' opened`); // Mandatory headers and http status to keep connection open @@ -278,7 +282,8 @@ export async function createRouter( // to automatically reconnect because it lost connection. } } - res.flush(); + // res.flush() is only available with the compression middleware + res.flush?.(); if (shouldUnsubscribe) unsubscribe(); }, ); @@ -288,6 +293,43 @@ export async function createRouter( unsubscribe(); logger.debug(`Event stream observing taskId '${taskId}' closed`); }); + }) + .get('/v2/tasks/:taskId/events', async (req, res) => { + const { taskId } = req.params; + const after = Number(req.query.after) || undefined; + + let unsubscribe = () => {}; + + // cancel the request after 30 seconds. this aligns with the recommendations of RFC 6202. + const timeout = setTimeout(() => { + unsubscribe(); + res.json([]); + }, 30_000); + + // Get all known events after an id (always includes the completion event) and return the first callback + ({ unsubscribe } = taskBroker.observe( + { taskId, after }, + (error, { events }) => { + // stop the timeout + clearTimeout(timeout); + unsubscribe(); + + if (error) { + logger.error( + `Received error from log when observing taskId '${taskId}', ${error}`, + ); + } + + res.json(events); + }, + )); + + // When client closes connection we update the clients list + // avoiding the disconnected one + req.on('close', () => { + unsubscribe(); + clearTimeout(timeout); + }); }); const app = express(); diff --git a/plugins/scaffolder-common/package.json b/plugins/scaffolder-common/package.json index 006d8ad7aa..05d5ae3031 100644 --- a/plugins/scaffolder-common/package.json +++ b/plugins/scaffolder-common/package.json @@ -36,10 +36,10 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.9.0" } } diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index b9873b5357..8c43d8d010 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,69 @@ # @backstage/plugin-scaffolder +## 0.11.11 + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + +## 0.11.10 + +### Patch Changes + +- fe5738fe1c: Lazy load `LazyLog` as it is rarely used. +- b45a34fb15: Adds a new endpoint for consuming logs from the Scaffolder that uses long polling instead of Server Sent Events. + + This is useful if Backstage is accessed from an environment that doesn't support SSE correctly, which happens in combination with certain enterprise HTTP Proxy servers. + + It is intended to switch the endpoint globally for the whole instance. + If you want to use it, you can provide a reconfigured API to the `scaffolderApiRef`: + + ```tsx + // packages/app/src/apis.ts + + // ... + import { + scaffolderApiRef, + ScaffolderClient, + } from '@backstage/plugin-scaffolder'; + + export const apis: AnyApiFactory[] = [ + // ... + + createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + }, + factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => + new ScaffolderClient({ + discoveryApi, + identityApi, + scmIntegrationsApi, + // use long polling instead of an eventsource + useLongPollingLogs: true, + }), + }), + ]; + ``` + +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/catalog-client@0.5.1 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.11.9 ### Patch Changes diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 554fccabb7..0ac6910264 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -178,6 +178,7 @@ export class ScaffolderClient implements ScaffolderApi { discoveryApi: DiscoveryApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; + useLongPollingLogs?: boolean; }); // (undocumented) getIntegrationsList(options: { allowedHosts: string[] }): Promise< @@ -199,13 +200,7 @@ export class ScaffolderClient implements ScaffolderApi { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen scaffold(templateName: string, values: Record): Promise; // (undocumented) - streamLogs({ - taskId, - after, - }: { - taskId: string; - after?: number; - }): Observable; + streamLogs(opts: { taskId: string; after?: number }): Observable; } // Warning: (ae-missing-release-tag) "ScaffolderFieldExtensions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 0b517671ba..ce185403bb 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder", "description": "The Backstage plugin that helps you create new things", - "version": "0.11.9", + "version": "0.11.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,30 +31,31 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", - "@backstage/integration-react": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.2", - "@backstage/theme": "^0.2.12", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@rjsf/core": "^3.0.0", - "@rjsf/material-ui": "^3.0.0", + "@rjsf/core": "^3.2.1", + "@rjsf/material-ui": "^3.2.1", "@types/react": "*", "classnames": "^2.2.6", "git-url-parse": "^11.6.0", "humanize-duration": "^3.25.1", "immer": "^9.0.1", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "luxon": "^2.0.2", + "qs": "^6.9.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.2", @@ -65,10 +66,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", @@ -77,6 +78,7 @@ "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", + "event-source-polyfill": "^1.0.25", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index cc4b70283c..01463a6e1c 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -14,12 +14,24 @@ * limitations under the License. */ -import { ScmIntegrations } from '@backstage/integration'; -import { ScaffolderClient } from './api'; import { ConfigReader } from '@backstage/core-app-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ScaffolderClient } from './api'; + +const MockedEventSource = global.EventSource as jest.MockedClass< + typeof EventSource +>; + +const server = setupServer(); describe('api', () => { - const discoveryApi = {} as any; + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api'; + + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; const identityApi = {} as any; const scmIntegrationsApi = ScmIntegrations.fromConfig( new ConfigReader({ @@ -32,10 +44,14 @@ describe('api', () => { }, }), ); - const apiClient = new ScaffolderClient({ - scmIntegrationsApi, - discoveryApi, - identityApi, + + let apiClient: ScaffolderClient; + beforeEach(() => { + apiClient = new ScaffolderClient({ + scmIntegrationsApi, + discoveryApi, + identityApi, + }); }); it('should return default and custom integrations', async () => { @@ -51,4 +67,240 @@ describe('api', () => { expect(allowedHosts).toContain(integration.host), ); }); + + describe('streamEvents', () => { + describe('eventsource', () => { + it('should work', async () => { + MockedEventSource.prototype.addEventListener.mockImplementation( + (type, fn) => { + if (typeof fn !== 'function') { + return; + } + + if (type === 'log') { + fn({ + data: '{"id":1,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}}', + } as any); + } else if (type === 'completion') { + fn({ + data: '{"id":2,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}}', + } as any); + } + }, + ); + + const next = jest.fn(); + + await new Promise(complete => { + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }); + }); + + expect(MockedEventSource).toBeCalledWith( + 'http://backstage/api/v2/tasks/a-random-task-id/eventstream', + { withCredentials: true }, + ); + expect(MockedEventSource.prototype.close).toBeCalled(); + + expect(next).toBeCalledTimes(2); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + }); + + describe('longPolling', () => { + beforeEach(() => { + apiClient = new ScaffolderClient({ + scmIntegrationsApi, + discoveryApi, + identityApi, + useLongPollingLogs: true, + }); + }); + + it('should work', async () => { + server.use( + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (req, res, ctx) => { + const { taskId } = req.params; + const after = req.url.searchParams.get('after'); + + if (taskId === 'a-random-task-id') { + if (!after) { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } else if (after === '1') { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } + } + + return res(ctx.status(500)); + }, + ), + ); + + const next = jest.fn(); + + await new Promise(complete => + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }), + ); + + expect(next).toBeCalledTimes(2); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + + it('should unsubscribe', async () => { + expect.assertions(3); + + server.use( + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (req, res, ctx) => { + const { taskId } = req.params; + + const after = req.url.searchParams.get('after'); + + // use assertion to make sure it is not called after unsubscribing + expect(after).toBe(null); + + if (taskId === 'a-random-task-id') { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } + + return res(ctx.status(500)); + }, + ), + ); + + const next = jest.fn(); + + await new Promise(complete => { + const subscription = apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ + next: (...args) => { + next(...args); + subscription.unsubscribe(); + complete(); + }, + }); + }); + + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + }); + + it('should continue after error', async () => { + const called = jest.fn(); + + server.use( + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (_req, res, ctx) => { + called(); + + if (called.mock.calls.length > 1) { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } + + return res(ctx.status(500)); + }, + ), + ); + + const next = jest.fn(); + + await new Promise(complete => + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }), + ); + + expect(called).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + }); + }); }); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1b531b652d..34e16708ea 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -15,17 +15,18 @@ */ import { EntityName } from '@backstage/catalog-model'; -import { JsonObject, JsonValue, Observable } from '@backstage/types'; -import { ResponseError } from '@backstage/errors'; -import { ScmIntegrationRegistry } from '@backstage/integration'; -import { Field, FieldValidation } from '@rjsf/core'; -import ObservableImpl from 'zen-observable'; -import { ListActionsResponse, ScaffolderTask, Status } from './types'; import { createApiRef, DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { JsonObject, JsonValue, Observable } from '@backstage/types'; +import { Field, FieldValidation } from '@rjsf/core'; +import qs from 'qs'; +import ObservableImpl from 'zen-observable'; +import { ListActionsResponse, ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -94,15 +95,18 @@ export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; + private readonly useLongPollingLogs: boolean; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; + useLongPollingLogs?: boolean; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; this.scmIntegrationsApi = options.scmIntegrationsApi; + this.useLongPollingLogs = options.useLongPollingLogs ?? false; } async getIntegrationsList(options: { allowedHosts: string[] }) { @@ -183,13 +187,21 @@ export class ScaffolderClient implements ScaffolderApi { }); if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } return await response.json(); } - streamLogs({ + streamLogs(opts: { taskId: string; after?: number }): Observable { + if (this.useLongPollingLogs) { + return this.streamLogsPolling(opts); + } + + return this.streamLogsEventStream(opts); + } + + private streamLogsEventStream({ taskId, after, }: { @@ -239,6 +251,46 @@ export class ScaffolderClient implements ScaffolderApi { }); } + private streamLogsPolling({ + taskId, + after: inputAfter, + }: { + taskId: string; + after?: number; + }): Observable { + let after = inputAfter; + + return new ObservableImpl(subscriber => { + this.discoveryApi.getBaseUrl('scaffolder').then(async baseUrl => { + while (!subscriber.closed) { + const url = `${baseUrl}/v2/tasks/${encodeURIComponent( + taskId, + )}/events?${qs.stringify({ after })}`; + const response = await fetch(url); + + if (!response.ok) { + // wait for one second to not run into an + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + + const logs = (await response.json()) as LogEvent[]; + + for (const event of logs) { + after = Number(event.id); + + subscriber.next(event); + + if (event.type === 'completion') { + subscriber.complete(); + return; + } + } + } + }); + }); + } + /** * @returns ListActionsResponse containing all registered actions. */ @@ -250,7 +302,7 @@ export class ScaffolderClient implements ScaffolderApi { }); if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } return await response.json(); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 77c8240974..ce0354b817 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { ActionsPage } from './ActionsPage'; import { rootRouteRef } from '../../routes'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { ApiProvider } from '@backstage/core-app-api'; const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), @@ -29,7 +29,7 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), }; -const apis = ApiRegistry.from([[scaffolderApiRef, scaffolderApiMock]]); +const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index aeb47b2789..11d12282d2 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -14,38 +14,40 @@ * limitations under the License. */ -import React, { useState, useEffect, memo, useMemo } from 'react'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; -import Stepper from '@material-ui/core/Stepper'; -import Step from '@material-ui/core/Step'; -import StepLabel from '@material-ui/core/StepLabel'; -import Grid from '@material-ui/core/Grid'; -import Typography from '@material-ui/core/Typography'; -import { useParams } from 'react-router'; -import { useTaskEventStream } from '../hooks/useEventStream'; -import LazyLog from 'react-lazylog/build/LazyLog'; +import { + Content, + ErrorPage, + Header, + Lifecycle, + Page, + Progress, +} from '@backstage/core-components'; +import { BackstageTheme } from '@backstage/theme'; import { CircularProgress, Paper, StepButton, StepIconProps, } from '@material-ui/core'; -import { Status, TaskOutput } from '../../types'; -import { DateTime, Interval } from 'luxon'; -import { useInterval } from 'react-use'; -import Check from '@material-ui/icons/Check'; +import Grid from '@material-ui/core/Grid'; +import Step from '@material-ui/core/Step'; +import StepLabel from '@material-ui/core/StepLabel'; +import Stepper from '@material-ui/core/Stepper'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; import Cancel from '@material-ui/icons/Cancel'; +import Check from '@material-ui/icons/Check'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import classNames from 'classnames'; -import { BackstageTheme } from '@backstage/theme'; +import { DateTime, Interval } from 'luxon'; +import React, { memo, Suspense, useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router'; +import { useInterval } from 'react-use'; +import { Status, TaskOutput } from '../../types'; +import { useTaskEventStream } from '../hooks/useEventStream'; import { TaskPageLinks } from './TaskPageLinks'; -import { - Page, - Header, - Lifecycle, - Content, - ErrorPage, -} from '@backstage/core-components'; + +const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -213,9 +215,17 @@ export const TaskStatusStepper = memo( const TaskLogger = memo(({ log }: { log: string }) => { return ( -
- -
+ }> +
+ +
+
); }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index e42bcbdbdc..60e9ee8840 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; +import { + renderInTestApp, + renderWithEffects, + TestApiRegistry, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { fireEvent, within } from '@testing-library/react'; @@ -24,7 +28,7 @@ import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; jest.mock('react-router-dom', () => { @@ -47,10 +51,10 @@ const scaffolderApiMock: jest.Mocked = { const errorApiMock = { post: jest.fn(), error$: jest.fn() }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], [errorApiRef, errorApiMock], -]); +); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx index 3d0267ff1f..c70f251eac 100644 --- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx +++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx @@ -26,8 +26,8 @@ import { MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { renderWithEffects } from '@backstage/test-utils'; +import { ApiProvider } from '@backstage/core-app-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; const entities: Entity[] = [ { @@ -62,7 +62,7 @@ const entities: Entity[] = [ }, ]; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [ catalogApiRef, { @@ -77,7 +77,7 @@ const apis = ApiRegistry.from([ post: jest.fn(), } as unknown as AlertApi, ], -]); +); describe('', () => { it('renders available entity types', async () => { diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index f293969dff..b7e72e3583 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -16,12 +16,11 @@ import { Entity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FieldProps } from '@rjsf/core'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { EntityPicker } from './EntityPicker'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'backstage.io/v1beta1', @@ -53,14 +52,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); entities = [ makeEntity('Group', 'default', 'team-a'), makeEntity('Group', 'default', 'squad-b'), ]; Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx index 18d6fd7e5c..31992fbc8a 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FieldProps } from '@rjsf/core'; import React from 'react'; import { OwnerPicker } from './OwnerPicker'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'backstage.io/v1beta1', @@ -50,14 +49,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); entities = [ makeEntity('Group', 'default', 'team-a'), makeEntity('Group', 'default', 'squad-b'), ]; Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 9b4861fc1e..df5868c9ea 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -24,5 +24,5 @@ export const registerComponentRouteRef = createExternalRouteRef({ }); export const rootRouteRef = createRouteRef({ - title: 'Create new entity', + id: 'scaffolder', }); diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 963c0f188b..10252413a7 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -15,3 +15,7 @@ */ import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; + +const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill'); +global.EventSource = EventSourcePolyfill; diff --git a/plugins/search-backend-module-elasticsearch/CHANGELOG.md b/plugins/search-backend-module-elasticsearch/CHANGELOG.md index e950678cef..6bd39200c0 100644 --- a/plugins/search-backend-module-elasticsearch/CHANGELOG.md +++ b/plugins/search-backend-module-elasticsearch/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-search-backend-module-elasticsearch +## 0.0.5 + +### Patch Changes + +- 36350bf8b3: Pinning version of elastic search client to 7.13.0 to prevent breaking change towards third party ElasticSearch clusters on 7.14.0. + ## 0.0.4 ### Patch Changes diff --git a/plugins/search-backend-module-elasticsearch/package.json b/plugins/search-backend-module-elasticsearch/package.json index 87688e455f..5ebc7850a9 100644 --- a/plugins/search-backend-module-elasticsearch/package.json +++ b/plugins/search-backend-module-elasticsearch/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-search-backend-module-elasticsearch", "description": "A module for the search backend that implements search using ElasticSearch", - "version": "0.0.4", + "version": "0.0.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ "dependencies": { "@backstage/config": "^0.1.8", "@backstage/search-common": "^0.2.0", - "@elastic/elasticsearch": "^7.13.0", + "@elastic/elasticsearch": "7.13.0", "@acuris/aws-es-connection": "^2.2.0", "aws-sdk": "^2.948.0", "elastic-builder": "^2.16.0", @@ -30,8 +30,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/cli": "^0.8.0", + "@backstage/backend-common": "^0.9.10", + "@backstage/cli": "^0.9.0", "@elastic/elasticsearch-mock": "^0.3.0" }, "files": [ diff --git a/plugins/search-backend-module-pg/package.json b/plugins/search-backend-module-pg/package.json index a31d908ac4..74e9698ea4 100644 --- a/plugins/search-backend-module-pg/package.json +++ b/plugins/search-backend-module-pg/package.json @@ -20,15 +20,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.10", "@backstage/search-common": "^0.2.0", "@backstage/plugin-search-backend-node": "^0.4.2", "lodash": "^4.17.21", "knex": "^0.95.1" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.8", - "@backstage/cli": "^0.8.0" + "@backstage/backend-test-utils": "^0.1.9", + "@backstage/cli": "^0.9.0" }, "files": [ "dist", diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index 4514d07756..369c52a4f6 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -26,8 +26,8 @@ "@types/lunr": "^2.3.3" }, "devDependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/cli": "^0.8.1" + "@backstage/backend-common": "^0.9.10", + "@backstage/cli": "^0.9.0" }, "files": [ "dist" diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 18d4484451..2df51e1bb4 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.10", "@backstage/search-common": "^0.2.0", "@backstage/plugin-search-backend-node": "^0.4.2", "@types/express": "^4.17.6", @@ -30,7 +30,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/search-backend/src/run.ts b/plugins/search-backend/src/run.ts index addfdfd6d7..53d4e4334a 100644 --- a/plugins/search-backend/src/run.ts +++ b/plugins/search-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 66c4a61ead..ef3736b1df 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/plugin-search +## 0.4.18 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- 704b267e1c: Smaller UX improvements to search components such as optional autoFocus prop to SearchBar components, decreased debounce value and closing modal on link click of SearchModal, terminology updates of SearchResultPager. +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.4.17 + +### Patch Changes + +- 5dcea2586c: Added `SearchModal` component. + + Now you can import `SearchModal` in your apps: + + ```js + import { SearchModal } from '@backstage/plugin-search'; + ``` + + You can also use the `SidebarSearchModal` component to integrate it into the sidebar of your sample apps: + + ```js + import { SidebarSearchModal } from '@backstage/plugin-search'; + ``` + +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.4.16 ### Patch Changes diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index c638fb4af6..c211c48e53 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -80,6 +80,7 @@ export const searchApiRef: ApiRef; // // @public (undocumented) export const SearchBar: ({ + autoFocus, className, debounceTime, placeholder, @@ -89,10 +90,12 @@ export const SearchBar: ({ // // @public @deprecated (undocumented) export const SearchBarNext: ({ + autoFocus, className, debounceTime, placeholder, }: { + autoFocus?: boolean | undefined; className?: string | undefined; debounceTime?: number | undefined; placeholder?: string | undefined; @@ -126,6 +129,24 @@ export const SearchFilterNext: { Select(props: Omit & Component): JSX.Element; }; +// Warning: (ae-missing-release-tag) "SearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SearchModal: ({ + open, + toggleModal, +}: SearchModalProps) => JSX.Element; + +// Warning: (ae-missing-release-tag) "SearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SearchModalProps { + // (undocumented) + open?: boolean; + // (undocumented) + toggleModal: () => void; +} + // Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -179,6 +200,11 @@ export const SearchType: ({ // @public (undocumented) export const SidebarSearch: () => JSX.Element; +// Warning: (ae-missing-release-tag) "SidebarSearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SidebarSearchModal: () => JSX.Element; + // Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/search/package.json b/plugins/search/package.json index 60e8a8eb3c..aaddf130bf 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-search", "description": "The Backstage plugin that provides your backstage app with search", - "version": "0.4.16", + "version": "0.4.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,14 +30,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.6.2", + "@backstage/plugin-catalog-react": "^0.6.4", "@backstage/search-common": "^0.2.1", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -51,10 +51,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx index 2c990e512c..72b8429026 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx @@ -62,3 +62,22 @@ export const CustomPlaceholder = () => { ); }; + +export const Focused = () => { + return ( + + {/* @ts-ignore (defaultValue requires more than what is used here) */} + + + + + {/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */} + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchBar/SearchBar.test.tsx b/plugins/search/src/components/SearchBar/SearchBar.test.tsx index 3a11daa9fa..81f90640c1 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.test.tsx @@ -21,12 +21,9 @@ import { SearchContextProvider } from '../SearchContext'; import { SearchBar } from './SearchBar'; import { configApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { searchApiRef } from '../../apis'; +import { TestApiRegistry } from '@backstage/test-utils'; jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), @@ -42,10 +39,10 @@ describe('SearchBar', () => { const query = jest.fn().mockResolvedValue({}); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [configApiRef, new ConfigReader({ app: { title: 'Mock title' } })], [searchApiRef, { query }], - ]); + ); const name = 'Search'; const term = 'term'; diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index 53802899eb..4ab680d8fa 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -30,9 +30,11 @@ type PresenterProps = { onSubmit?: () => void; className?: string; placeholder?: string; + autoFocus?: boolean; }; export const SearchBarBase = ({ + autoFocus, value, onChange, onSubmit, @@ -60,6 +62,9 @@ export const SearchBarBase = ({ return ( ) => + wrapInTestApp( + <> + + , + { mountedRoutes: { '/search': rootRouteRef } }, + ), + ], +}; + +const mockSearchApi = { + query: () => + Promise.resolve({ + results: [ + { + type: 'custom-result-item', + document: { + location: 'search/search-result-1', + title: 'Search Result 1', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-2', + title: 'Search Result 2', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-3', + title: 'Search Result 3', + text: 'some text from the search result', + }, + }, + ], + }), +}; + +const apiRegistry = () => ApiRegistry.from([[searchApiRef, mockSearchApi]]); + +export const Default = () => { + const [open, setOpen] = useState(false); + const toggleModal = (): void => setOpen(prevState => !prevState); + + return ( + + + + + ); +}; diff --git a/plugins/search/src/components/SearchModal/SearchModal.test.tsx b/plugins/search/src/components/SearchModal/SearchModal.test.tsx new file mode 100644 index 0000000000..ff9622dfb9 --- /dev/null +++ b/plugins/search/src/components/SearchModal/SearchModal.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; +import { rootRouteRef } from '../../plugin'; +import { searchApiRef } from '../../apis'; + +import { SearchModal } from './SearchModal'; + +jest.mock('../SearchContext', () => ({ + ...jest.requireActual('../SearchContext'), + useSearch: jest.fn().mockReturnValue({ + result: {}, + }), +})); + +describe('SearchModal', () => { + const query = jest.fn().mockResolvedValue({}); + + const apiRegistry = TestApiRegistry.from( + [configApiRef, new ConfigReader({ app: { title: 'Mock app' } })], + [searchApiRef, { query }], + ); + + const toggleModal = jest.fn(); + + it('Should render the Modal correctly', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/search': rootRouteRef, + }, + }, + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('Calls toggleModal handler', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/search': rootRouteRef, + }, + }, + ); + userEvent.keyboard('{esc}'); + expect(toggleModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx new file mode 100644 index 0000000000..75d4b07a3c --- /dev/null +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -0,0 +1,165 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + List, + Paper, +} from '@material-ui/core'; +import { Launch } from '@material-ui/icons/'; +import { makeStyles } from '@material-ui/core/styles'; +import { SearchBarBase } from '../SearchBar'; +import { DefaultResultListItem } from '../DefaultResultListItem'; +import { SearchResult } from '../SearchResult'; +import { SearchContextProvider, useSearch } from '../SearchContext'; +import { SearchResultPager } from '../SearchResultPager'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { Link } from '@backstage/core-components'; +import { rootRouteRef } from '../../plugin'; + +import { useDebounce } from 'react-use'; + +export interface SearchModalProps { + open?: boolean; + toggleModal: () => void; +} + +const useStyles = makeStyles(theme => ({ + container: { + borderRadius: 30, + display: 'flex', + height: '2.4em', + }, + input: { + flex: 1, + }, + // Reduces default height of the modal, keeping a gap of 128px between the top and bottom of the page. + paperFullWidth: { height: 'calc(100% - 128px)' }, + dialogActionsContainer: { padding: theme.spacing(1, 3) }, + viewResultsLink: { verticalAlign: '0.5em' }, +})); + +export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { + const getSearchLink = useRouteRef(rootRouteRef); + const classes = useStyles(); + + const { term, setTerm } = useSearch(); + const [value, setValue] = useState(term); + + useEffect(() => { + setValue(prevValue => (prevValue !== term ? term : prevValue)); + }, [term]); + + useDebounce(() => setTerm(value), 500, [value]); + + const handleQuery = (newValue: string) => { + setValue(newValue); + }; + + const handleClear = () => setValue(''); + + const handleResultClick = () => { + toggleModal(); + handleClear(); + }; + + const handleKeyPress = () => { + handleResultClick(); + }; + + return ( + + + + + + + + + + + View Full Results + + + + + + + {({ results }) => ( + + {results.map(({ document }) => ( +
+ +
+ ))} +
+ )} +
+
+ + + + + + + +
+ ); +}; + +export const SearchModal = ({ open = true, toggleModal }: SearchModalProps) => { + return ( + + + + ); +}; diff --git a/plugins/search/src/components/SearchModal/index.tsx b/plugins/search/src/components/SearchModal/index.tsx new file mode 100644 index 0000000000..ec0053951d --- /dev/null +++ b/plugins/search/src/components/SearchModal/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SearchModal } from './SearchModal'; +export type { SearchModalProps } from './SearchModal'; diff --git a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx index f1850772bb..12f07530ef 100644 --- a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx +++ b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx @@ -25,7 +25,7 @@ const useStyles = makeStyles(theme => ({ display: 'flex', justifyContent: 'space-between', gap: theme.spacing(2), - margin: theme.spacing(4), + margin: theme.spacing(2, 0), }, })); @@ -44,9 +44,8 @@ export const SearchResultPager = () => { disabled={!fetchPreviousPage} onClick={fetchPreviousPage} startIcon={} - size="small" > - Back + Previous diff --git a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx new file mode 100644 index 0000000000..4d4fa1a9fc --- /dev/null +++ b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 SearchIcon from '@material-ui/icons/Search'; +import { SearchModal } from '../SearchModal'; +import { SidebarItem } from '@backstage/core-components'; + +export const SidebarSearchModal = () => { + const [open, setOpen] = useState(false); + const toggleModal = (): void => setOpen(prevState => !prevState); + + return ( + <> + + + + ); +}; diff --git a/plugins/search/src/components/SidebarSearchModal/index.ts b/plugins/search/src/components/SidebarSearchModal/index.ts new file mode 100644 index 0000000000..849180878d --- /dev/null +++ b/plugins/search/src/components/SidebarSearchModal/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { SidebarSearchModal } from './SidebarSearchModal'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx index 8b6493ae9b..6a4eae4001 100644 --- a/plugins/search/src/components/index.tsx +++ b/plugins/search/src/components/index.tsx @@ -19,8 +19,10 @@ export * from './Filters'; export * from './SearchBar'; export * from './SearchContext'; export * from './SearchFilter'; +export * from './SearchModal'; export * from './SearchPage'; export * from './SearchResult'; export * from './SearchResultPager'; export * from './SearchType'; export * from './SidebarSearch'; +export * from './SidebarSearchModal'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 07e4443f04..09cf204017 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -28,20 +28,23 @@ export { SearchContextProvider, SearchFilter, SearchFilterNext, + SearchModal, SearchPage as Router, SearchResultPager, SearchType, SidebarSearch, useSearch, } from './components'; +export type { SearchModalProps } from './components'; export type { FiltersState } from './components'; export { DefaultResultListItem, + HomePageSearchBar, SearchBarNext, SearchPage, SearchPageNext, searchPlugin as plugin, searchPlugin, SearchResult, - HomePageSearchBar, + SidebarSearchModal, } from './plugin'; diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index 491582aead..9dda2a84bb 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -26,13 +26,11 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/search', - title: 'search', + id: 'search', }); export const rootNextRouteRef = createRouteRef({ - path: '/search-next', - title: 'search', + id: 'search:next', }); export const searchPlugin = createPlugin({ @@ -122,6 +120,18 @@ export const SearchResultNext = searchPlugin.provide( }), ); +export const SidebarSearchModal = searchPlugin.provide( + createComponentExtension({ + name: 'SidebarSearchModal', + component: { + lazy: () => + import('./components/SidebarSearchModal').then( + m => m.SidebarSearchModal, + ), + }, + }), +); + export const DefaultResultListItem = searchPlugin.provide( createComponentExtension({ name: 'DefaultResultListItem', diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 2f44314100..cad08289ed 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-sentry +## 0.3.29 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + +## 0.3.28 + +### Patch Changes + +- 33d7bd0baa: fix: sentry-plugin can forward identity token to backend (for case when it requires authorization) +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.3.27 ### Patch Changes diff --git a/plugins/sentry/api-report.md b/plugins/sentry/api-report.md index a23fa112c1..6d83ddc5e6 100644 --- a/plugins/sentry/api-report.md +++ b/plugins/sentry/api-report.md @@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; import { RouteRef } from '@backstage/core-plugin-api'; @@ -34,7 +35,11 @@ export class MockSentryApi implements SentryApi { // // @public (undocumented) export class ProductionSentryApi implements SentryApi { - constructor(discoveryApi: DiscoveryApi, organization: string); + constructor( + discoveryApi: DiscoveryApi, + organization: string, + identityApi?: IdentityApi | undefined, + ); // (undocumented) fetchIssues( project: string, diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 005bfb2b3a..a04043c789 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-sentry", "description": "A Backstage plugin that integrates towards Sentry", - "version": "0.3.27", + "version": "0.3.29", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.2", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/sentry/src/api/production-api.ts b/plugins/sentry/src/api/production-api.ts index 5968d20011..1fc6ece37f 100644 --- a/plugins/sentry/src/api/production-api.ts +++ b/plugins/sentry/src/api/production-api.ts @@ -16,12 +16,13 @@ import { SentryIssue } from './sentry-issue'; import { SentryApi } from './sentry-api'; -import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; export class ProductionSentryApi implements SentryApi { constructor( private readonly discoveryApi: DiscoveryApi, private readonly organization: string, + private readonly identityApi?: IdentityApi, ) {} async fetchIssues( @@ -34,11 +35,13 @@ export class ProductionSentryApi implements SentryApi { } const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sentry/api`; + const options = await this.authOptions(); const queryPart = query ? `&query=${query}` : ''; const response = await fetch( `${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsPeriod=${statsFor}${queryPart}`, + options, ); if (response.status >= 400 && response.status < 600) { @@ -47,4 +50,16 @@ export class ProductionSentryApi implements SentryApi { return (await response.json()) as SentryIssue[]; } + + private async authOptions() { + if (!this.identityApi) { + return {}; + } + const token = await this.identityApi.getIdToken(); + return { + headers: { + authorization: `Bearer ${token}`, + }, + }; + } } diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 75de6ba6dc..981745c8fe 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -21,11 +21,11 @@ import { createPlugin, createRouteRef, discoveryApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/sentry', - title: 'Sentry', + id: 'sentry', }); export const sentryPlugin = createPlugin({ @@ -33,11 +33,16 @@ export const sentryPlugin = createPlugin({ apis: [ createApiFactory({ api: sentryApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new ProductionSentryApi( discoveryApi, configApi.getString('sentry.organization'), + identityApi, ), }), ], diff --git a/plugins/shortcuts/CHANGELOG.md b/plugins/shortcuts/CHANGELOG.md index 41581cc5b4..fdd9f44231 100644 --- a/plugins/shortcuts/CHANGELOG.md +++ b/plugins/shortcuts/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-shortcuts +## 0.1.14 + +### Patch Changes + +- f93e56ae3a: Add tooltip to display shortcut title on hover. This should improve the readability of shortcuts with long names, which are cutoff by ellipsis. +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.13 ### Patch Changes diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index 8b6f57bc52..f0139d5728 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-shortcuts", "description": "A Backstage plugin that provides a shortcuts feature to the sidebar", - "version": "0.1.13", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -38,10 +38,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/shortcuts/src/ShortcutItem.tsx b/plugins/shortcuts/src/ShortcutItem.tsx index d2ac716737..8ba1a96358 100644 --- a/plugins/shortcuts/src/ShortcutItem.tsx +++ b/plugins/shortcuts/src/ShortcutItem.tsx @@ -15,7 +15,9 @@ */ import React from 'react'; -import { IconButton, makeStyles } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; import EditIcon from '@material-ui/icons/Edit'; import { ShortcutIcon } from './ShortcutIcon'; import { EditShortcut } from './EditShortcut'; @@ -76,21 +78,23 @@ export const ShortcutItem = ({ shortcut, api }: Props) => { return ( <> - } - > - + } > - - - + + + + + { it('displays an add button', async () => { await renderInTestApp( - + - + , ); await waitFor(() => !screen.queryByTestId('progress')); diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 8886ca3645..7de1ea6552 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sonarqube +## 0.2.7 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.6 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 30cdd83368..f92c21ad7e 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-sonarqube", "description": "", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,11 +33,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,10 +49,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/splunk-on-call/CHANGELOG.md b/plugins/splunk-on-call/CHANGELOG.md index 4bd0b9e9bc..b6d77a50cc 100644 --- a/plugins/splunk-on-call/CHANGELOG.md +++ b/plugins/splunk-on-call/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-splunk-on-call +## 0.3.15 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.14 ### Patch Changes diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 632de7e413..10eb807ba1 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-splunk-on-call", "description": "A Backstage plugin that integrates towards Splunk On-Call", - "version": "0.3.14", + "version": "0.3.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -47,10 +47,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx index 63d617aff2..eb5c99d4e7 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef, SplunkOnCallClient, @@ -37,13 +37,8 @@ import { alertApiRef, ConfigApi, configApiRef, - createApiRef, } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; const mockSplunkOnCallApi: Partial = { getUsers: async () => [], @@ -59,17 +54,11 @@ const configApi: ConfigApi = new ConfigReader({ }, }); -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [splunkOnCallApiRef, mockSplunkOnCallApi], [configApiRef, configApi], - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], -]); + [alertApiRef, {}], +); const mockEntity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx index 1319493742..046c6a37f9 100644 --- a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx +++ b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { EscalationPolicy } from './EscalationPolicy'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockSplunkOnCallApi = { - getOnCallUsers: () => [], + getOnCallUsers: jest.fn(), }; -const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]); +const apis = TestApiRegistry.from([splunkOnCallApiRef, mockSplunkOnCallApi]); describe('Escalation', () => { it('Handles an empty response', async () => { diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx index df4254d3ad..181c2fda77 100644 --- a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx @@ -16,43 +16,39 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { Incidents } from './Incidents'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockIdentityApi: Partial = { getUserId: () => 'test', }; const mockSplunkOnCallApi = { - getIncidents: () => [], - getTeams: () => [], + getIncidents: jest.fn(), + getTeams: jest.fn(), }; -const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], +const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [splunkOnCallApiRef, mockSplunkOnCallApi], -]); +); describe('Incidents', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it('Renders an empty state when there are no incidents', async () => { - mockSplunkOnCallApi.getTeams = jest - .fn() - .mockImplementationOnce(async () => [MOCK_TEAM]); + mockSplunkOnCallApi.getIncidents.mockResolvedValue([]); + mockSplunkOnCallApi.getTeams.mockResolvedValue([MOCK_TEAM]); const { getByText, queryByTestId } = render( wrapInTestApp( @@ -69,13 +65,9 @@ describe('Incidents', () => { }); it('Renders all incidents', async () => { - mockSplunkOnCallApi.getIncidents = jest - .fn() - .mockImplementationOnce(async () => [MOCK_INCIDENT]); + mockSplunkOnCallApi.getIncidents.mockResolvedValue([MOCK_INCIDENT]); + mockSplunkOnCallApi.getTeams.mockResolvedValue([MOCK_TEAM]); - mockSplunkOnCallApi.getTeams = jest - .fn() - .mockImplementationOnce(async () => [MOCK_TEAM]); const { getByText, getByTitle, @@ -108,9 +100,10 @@ describe('Incidents', () => { }); it('Handle errors', async () => { - mockSplunkOnCallApi.getIncidents = jest - .fn() - .mockRejectedValueOnce(new Error('Error occurred')); + mockSplunkOnCallApi.getIncidents.mockRejectedValueOnce( + new Error('Error occurred'), + ); + mockSplunkOnCallApi.getTeams.mockResolvedValue([]); const { getByText, queryByTestId } = render( wrapInTestApp( diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx index 94fd40bfe5..0b64a137af 100644 --- a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; import { render, fireEvent, act } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { TriggerDialog } from './TriggerDialog'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; -import { alertApiRef, createApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; describe('TriggerDialog', () => { const mockTriggerAlarmFn = jest.fn(); @@ -28,16 +28,10 @@ describe('TriggerDialog', () => { incidentAction: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [splunkOnCallApiRef, mockSplunkOnCallApi], - ]); + ); it('open the dialog and trigger an alarm', async () => { const { getByText, getByRole, getByTestId } = render( diff --git a/plugins/splunk-on-call/src/plugin.ts b/plugins/splunk-on-call/src/plugin.ts index 0b54538f06..2256b382ee 100644 --- a/plugins/splunk-on-call/src/plugin.ts +++ b/plugins/splunk-on-call/src/plugin.ts @@ -24,9 +24,7 @@ import { createComponentExtension, } from '@backstage/core-plugin-api'; -export const rootRouteRef = createRouteRef({ - title: 'splunk-on-call', -}); +export const rootRouteRef = createRouteRef({ id: 'splunk-on-call' }); export const splunkOnCallPlugin = createPlugin({ id: 'splunk-on-call', diff --git a/plugins/tech-insights-backend-module-jsonfc/README.md b/plugins/tech-insights-backend-module-jsonfc/README.md index 48c1e69919..c0f992ffc7 100644 --- a/plugins/tech-insights-backend-module-jsonfc/README.md +++ b/plugins/tech-insights-backend-module-jsonfc/README.md @@ -24,7 +24,7 @@ and modify the `techInsights.ts` file to contain a reference to the FactCheckers + logger, +}), - const builder = new DefaultTechInsightsBuilder({ + const builder = buildTechInsightsContext({ logger, config, database, @@ -59,7 +59,7 @@ export const exampleCheck: TechInsightJsonRuleCheck = { name: 'demodatacheck', // A human readable name of this check to be displayed in the UI type: JSON_RULE_ENGINE_CHECK_TYPE, // Type identifier of the check. Used to run logic against, determine persistence option to use and render correct components on the UI description: 'A fact check for demoing purposes', // A description to be displayed in the UI - factRefs: ['demo-poc.factretriever'], // References to fact containers that this check uses. See documentation on FactRetrievers for more information on these + factIds: ['documentation-number-factretriever'], // References to fact ids that this check uses. See documentation on FactRetrievers for more information on these rule: { // The actual rule conditions: { diff --git a/plugins/tech-insights-backend-module-jsonfc/package.json b/plugins/tech-insights-backend-module-jsonfc/package.json index 8943b0e461..5504cb2d81 100644 --- a/plugins/tech-insights-backend-module-jsonfc/package.json +++ b/plugins/tech-insights-backend-module-jsonfc/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.0", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.8", "@backstage/errors": "^0.1.1", "@backstage/plugin-tech-insights-common": "^0.1.0", @@ -43,7 +43,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/node-cron": "^2.0.4" }, "files": [ diff --git a/plugins/tech-insights-backend/CHANGELOG.md b/plugins/tech-insights-backend/CHANGELOG.md new file mode 100644 index 0000000000..abb8eaac6f --- /dev/null +++ b/plugins/tech-insights-backend/CHANGELOG.md @@ -0,0 +1,15 @@ +# @backstage/plugin-tech-insights-backend + +## 0.1.1 + +### Patch Changes + +- 5c00e45045: Add catalog fact retrievers + + Add fact retrievers which generate facts related to the completeness + of entity data in the catalog. + +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 diff --git a/plugins/tech-insights-backend/README.md b/plugins/tech-insights-backend/README.md index e2cc1c0252..764ad860b8 100644 --- a/plugins/tech-insights-backend/README.md +++ b/plugins/tech-insights-backend/README.md @@ -22,7 +22,7 @@ do this by creating a file called `packages/backend/src/plugins/techInsights.ts` ```ts import { createRouter, - DefaultTechInsightsBuilder, + buildTechInsightsContext, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -33,7 +33,7 @@ export default async function createPlugin({ discovery, database, }: PluginEnvironment): Promise { - const builder = new DefaultTechInsightsBuilder({ + const builder = buildTechInsightsContext({ logger, config, database, diff --git a/plugins/tech-insights-backend/api-report.md b/plugins/tech-insights-backend/api-report.md index 826789dd1d..ac2d1d1170 100644 --- a/plugins/tech-insights-backend/api-report.md +++ b/plugins/tech-insights-backend/api-report.md @@ -36,6 +36,12 @@ export function createRouter< CheckResultType extends CheckResult, >(options: RouterOptions): Promise; +// @public +export const entityMetadataFactRetriever: FactRetriever; + +// @public +export const entityOwnershipFactRetriever: FactRetriever; + // @public export type PersistenceContext = { techInsightsStore: TechInsightsStore; @@ -52,6 +58,9 @@ export interface RouterOptions< persistenceContext: PersistenceContext; } +// @public +export const techdocsFactRetriever: FactRetriever; + // @public (undocumented) export type TechInsightsContext< CheckType extends TechInsightCheck, diff --git a/plugins/tech-insights-backend/package.json b/plugins/tech-insights-backend/package.json index 678bec25c4..5bc616db71 100644 --- a/plugins/tech-insights-backend/package.json +++ b/plugins/tech-insights-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-insights-backend", - "version": "0.1.0", + "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.0", - "@backstage/catalog-client": "^0.3.16", - "@backstage/catalog-model": "^0.9.0", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.8", "@backstage/errors": "^0.1.1", "@backstage/plugin-tech-insights-common": "^0.1.0", @@ -52,8 +52,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.8", - "@backstage/cli": "^0.8.0", + "@backstage/backend-test-utils": "^0.1.9", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "@types/node-cron": "^3.0.0", "@types/semver": "^7.3.8", diff --git a/plugins/tech-insights-backend/src/index.ts b/plugins/tech-insights-backend/src/index.ts index 5e8e690e8d..763c565108 100644 --- a/plugins/tech-insights-backend/src/index.ts +++ b/plugins/tech-insights-backend/src/index.ts @@ -25,3 +25,4 @@ export type { export type { PersistenceContext } from './service/persistence/persistenceContext'; export { createFactRetrieverRegistration } from './service/fact/createFactRetriever'; +export * from './service/fact/factRetrievers'; diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts index 4dc002465b..8d1f7180a3 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts @@ -44,7 +44,7 @@ const testFactRetriever: FactRetriever = { description: '', }, }, - handler: async () => { + handler: jest.fn(async () => { return [ { entity: { @@ -57,7 +57,7 @@ const testFactRetriever: FactRetriever = { }, }, ]; - }, + }), }; const cadence = '1 * * * *'; describe('FactRetrieverEngine', () => { @@ -145,5 +145,8 @@ describe('FactRetrieverEngine', () => { const job: any = engine.getJob('test-factretriever'); job.triggerScheduledJobNow(); expect(job.cadence!!).toEqual(cadence); + expect(testFactRetriever.handler).toHaveBeenCalledWith( + expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }), + ); }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts index aa2207cca8..11fd0d29b8 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts @@ -108,7 +108,10 @@ export class FactRetrieverEngine { this.logger.info( `Retrieving facts for fact retriever ${factRetriever.id}`, ); - const facts = await factRetriever.handler(this.factRetrieverContext); + const facts = await factRetriever.handler({ + ...this.factRetrieverContext, + entityFilter: factRetriever.entityFilter, + }); if (this.logger.isDebugEnabled()) { this.logger.debug( `Retrieved ${facts.length} facts for fact retriever ${ diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts new file mode 100644 index 0000000000..b99f6a1c02 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; +import { entityMetadataFactRetriever } from './entityMetadataFactRetriever'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityMetadataFactRetriever; + +describe('entityMetadataFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasDescription', () => { + describe('where the entity has a description', () => { + it('returns true for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasDescription: true, + }, + }); + }); + }); + describe('where the entity does not have a description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + describe('where the entity has an empty value for description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + }); + describe('hasTags', () => { + describe('where the entity has tags', () => { + it('returns true for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasTags: true, + }, + }); + }); + }); + describe('where the entity has no tags', () => { + it('returns false for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasTags: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts new file mode 100644 index 0000000000..535aede115 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import isEmpty from 'lodash/isEmpty'; + +/** + * Generates facts which indicate the completeness of entity metadata. + * + * @public + */ +export const entityMetadataFactRetriever: FactRetriever = { + id: 'entityMetadataFactRetriever', + version: '0.0.1', + schema: { + hasTitle: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + hasDescription: { + type: 'boolean', + description: 'The entity has a description in metadata', + }, + hasTags: { + type: 'boolean', + description: 'The entity has tags in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasTitle: Boolean(entity.metadata?.title), + hasDescription: Boolean(entity.metadata?.description), + hasTags: !isEmpty(entity.metadata?.tags), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts new file mode 100644 index 0000000000..ea0801f17c --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { entityOwnershipFactRetriever } from './entityOwnershipFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityOwnershipFactRetriever; + +describe('entityFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasOwner', () => { + describe('where the entity has an owner in the spec', () => { + it('returns true for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasOwner: true, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + + describe('where the entity doesn not have an owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + }); + describe('hasGroupOwner', () => { + describe('where the entity has an group as owner in the spec', () => { + it('returns true for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: true, + }, + }); + }); + }); + describe('where the entity has a user as owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-user-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts new file mode 100644 index 0000000000..8f61f182ad --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; + +/** + * Generates facts which indicate the quality of data in the spec.owner field. + * + * @public + */ +export const entityOwnershipFactRetriever: FactRetriever = { + id: 'entityOwnershipFactRetriever', + version: '0.0.1', + entityFilter: [ + { kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] }, + ], + schema: { + hasOwner: { + type: 'boolean', + description: 'The spec.owner field is set', + }, + hasGroupOwner: { + type: 'boolean', + description: 'The spec.owner field is set and refers to a group', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasOwner: Boolean(entity.spec?.owner), + hasGroupOwner: Boolean( + entity.spec?.owner && + !(entity.spec?.owner as string).startsWith('user:'), + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts new file mode 100644 index 0000000000..587db8c4b9 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './entityOwnershipFactRetriever'; +export * from './entityMetadataFactRetriever'; +export * from './techdocsFactRetriever'; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts new file mode 100644 index 0000000000..6ed9b12523 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { techdocsFactRetriever } from './techdocsFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = techdocsFactRetriever; + +describe('techdocsFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasAnnotationBackstageIoTechdocsRef', () => { + describe('where the retriever is configured to check for the techdocs annotation', () => { + describe('where the entity has the techdocs annotation', () => { + it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: true, + }, + }); + }); + }); + describe('where the entity is missing the techdocs annotation', () => { + it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: false, + }, + }); + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts new file mode 100644 index 0000000000..c1c3ba5d20 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { entityHasAnnotation, generateAnnotationFactName } from './utils'; + +const techdocsAnnotation = 'backstage.io/techdocs-ref'; +const techdocsAnnotationFactName = + generateAnnotationFactName(techdocsAnnotation); + +/** + * Generates facts related to the completeness of techdocs configuration for entities. + * + * @public + */ +export const techdocsFactRetriever: FactRetriever = { + id: 'techdocsFactRetriever', + version: '0.0.1', + schema: { + [techdocsAnnotationFactName]: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + [techdocsAnnotationFactName]: entityHasAnnotation( + entity, + techdocsAnnotation, + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts new file mode 100644 index 0000000000..7967502a63 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 camelCase from 'lodash/camelCase'; +import { Entity } from '@backstage/catalog-model'; +import { get } from 'lodash'; + +export const generateAnnotationFactName = (annotation: string) => + camelCase(`hasAnnotation-${annotation}`); + +export const entityHasAnnotation = (entity: Entity, annotation: string) => + Boolean(get(entity, ['metadata', 'annotations', annotation])); diff --git a/plugins/tech-insights-common/package.json b/plugins/tech-insights-common/package.json index 5e02c9e4b0..26d8922329 100644 --- a/plugins/tech-insights-common/package.json +++ b/plugins/tech-insights-common/package.json @@ -34,7 +34,7 @@ "luxon": "^2.0.2" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.9.0" }, "files": [ "dist" diff --git a/plugins/tech-insights-node/api-report.md b/plugins/tech-insights-node/api-report.md index caf4b4cf0e..fc137da4bc 100644 --- a/plugins/tech-insights-node/api-report.md +++ b/plugins/tech-insights-node/api-report.md @@ -53,6 +53,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger_2; + entityFilter?: + | Record[] + | Record; }; // @public diff --git a/plugins/tech-insights-node/package.json b/plugins/tech-insights-node/package.json index 4a7cf3589b..3c2ba5da08 100644 --- a/plugins/tech-insights-node/package.json +++ b/plugins/tech-insights-node/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.0", + "@backstage/backend-common": "^0.9.10", "@backstage/config": "^0.1.8", "@backstage/plugin-tech-insights-common": "^0.1.0 ", "@types/luxon": "^2.0.5", @@ -38,7 +38,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.9.0" }, "files": [ "dist" diff --git a/plugins/tech-insights-node/src/facts.ts b/plugins/tech-insights-node/src/facts.ts index a52a91f2f4..4a3157d391 100644 --- a/plugins/tech-insights-node/src/facts.ts +++ b/plugins/tech-insights-node/src/facts.ts @@ -136,6 +136,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger; + entityFilter?: + | Record[] + | Record; }; /** diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 8e7b5875a9..cef7885007 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-tech-radar +## 0.4.12 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.11 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 31b12a97a9..99fce10345 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-tech-radar", "description": "A Backstage plugin that lets you display a Tech Radar for your organization", - "version": "0.4.11", + "version": "0.4.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -45,10 +45,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index cce411e897..2b237c8d83 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -19,13 +19,12 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { act } from 'react-dom/test-utils'; -import { withLogCollector } from '@backstage/test-utils'; +import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarComponent } from './RadarComponent'; import { TechRadarLoaderResponse, techRadarApiRef, TechRadarApi } from '../api'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; describe('RadarComponent', () => { @@ -55,18 +54,18 @@ describe('RadarComponent', () => { const errorApi = { post: () => {} }; const { getByTestId, queryByTestId } = render( - - + , ); @@ -87,18 +86,18 @@ describe('RadarComponent', () => { const { queryByTestId } = render( - - + , ); @@ -115,13 +114,13 @@ describe('RadarComponent', () => { expect(() => { render( - + - + , ); }).toThrow(); diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index 7215fc40df..7f4e69e388 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -17,6 +17,7 @@ import { MockErrorApi, renderInTestApp, + TestApiProvider, wrapInTestApp, } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; @@ -28,7 +29,6 @@ import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarPage } from './RadarPage'; import { TechRadarLoaderResponse, techRadarApiRef, TechRadarApi } from '../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; describe('RadarPage', () => { @@ -63,9 +63,9 @@ describe('RadarPage', () => { const { getByTestId, queryByTestId } = render( wrapInTestApp( - + - + , ), ); @@ -89,9 +89,9 @@ describe('RadarPage', () => { const { getByText, getByTestId } = await renderInTestApp( - + - + , ); @@ -115,9 +115,9 @@ describe('RadarPage', () => { const { getByTestId } = await renderInTestApp( - + - + , ); @@ -142,14 +142,14 @@ describe('RadarPage', () => { const { queryByTestId } = await renderInTestApp( - - + , ); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index bd30d6a984..495aa47879 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -25,7 +25,7 @@ import { } from '@backstage/core-plugin-api'; const rootRouteRef = createRouteRef({ - title: 'Tech Radar', + id: 'tech-radar', }); /** diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 7331ea0147..ad7a8af85e 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-techdocs-backend +## 0.10.8 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 9e64a7ac1e: Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/techdocs-common@0.10.7 + ## 0.10.7 ### Patch Changes diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 0023a4b757..9e563137bd 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -121,6 +121,14 @@ export interface Config { * @visibility backend */ s3ForcePathStyle?: boolean; + + /** + * (Optional) AWS Server Side Encryption + * Defaults to undefined. + * If not set, encrypted buckets will fail to publish. + * https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + */ + sse?: 'aws:kms' | 'AES256'; }; } | { @@ -219,14 +227,14 @@ export interface Config { }; /** - * @example http://localhost:7000/api/techdocs + * @example http://localhost:7007/api/techdocs * @visibility frontend * @deprecated */ requestUrl?: string; /** - * @example http://localhost:7000/api/techdocs/static/docs + * @example http://localhost:7007/api/techdocs/static/docs * @deprecated */ storageUrl?: string; diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index c71833948b..332884c694 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-techdocs-backend", "description": "The Backstage backend plugin that renders technical documentation for your components", - "version": "0.10.7", + "version": "0.10.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,17 +31,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", "@backstage/search-common": "^0.2.1", - "@backstage/techdocs-common": "^0.10.5", + "@backstage/techdocs-common": "^0.10.7", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", + "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "9.1.0", @@ -51,9 +51,9 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", - "@types/dockerode": "^3.2.1", + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", + "@types/dockerode": "^3.3.0", "msw": "^0.35.0", "supertest": "^6.1.3" }, diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index a825b9a008..6b8a8f0ff9 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-techdocs +## 0.12.6 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- c1858c4cf9: Fixed entity triplet case handling for certain locales. +- f7703981a9: Use a better checkbox rendering in a task list. +- e266687580: Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/plugin-search@0.4.18 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + +## 0.12.5 + +### Patch Changes + +- fe5738fe1c: Lazy load `LazyLog` as it is rarely used. +- 53c9ad7e04: Update font weight for headings in TechDocs +- Updated dependencies + - @backstage/core-components@0.7.3 + - @backstage/theme@0.2.13 + - @backstage/plugin-search@0.4.17 + - @backstage/core-plugin-api@0.1.13 + - @backstage/plugin-catalog-react@0.6.3 + ## 0.12.4 ### Patch Changes diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index d6bf21b10b..45fd524eff 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -34,7 +34,7 @@ export interface Config { legacyUseCaseSensitiveTripletPaths?: boolean; /** - * @example http://localhost:7000/api/techdocs + * @example http://localhost:7007/api/techdocs * @visibility frontend * @deprecated */ diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index c72bcab499..663c83118e 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-techdocs", "description": "The Backstage plugin that renders technical documentation for your components", - "version": "0.12.4", + "version": "0.12.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,17 +32,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", - "@backstage/integration-react": "^0.1.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.2", - "@backstage/plugin-search": "^0.4.16", - "@backstage/theme": "^0.2.12", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-search": "^0.4.18", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -61,10 +61,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 1d8d7d2b04..8618c4432f 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef, @@ -30,7 +26,11 @@ import { DefaultStarredEntitiesApi, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { rootDocsRouteRef } from '../../routes'; @@ -69,12 +69,12 @@ describe('TechDocs Home', () => { const storageApi = MockStorageApi.create(); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [configApiRef, configApi], [storageApiRef, storageApi], [starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })], - ]); + ); it('should render a TechDocs home page', async () => { await renderInTestApp( diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index e84454ae54..77e69995ae 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -39,7 +39,7 @@ export const DocsCardGrid = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; return ( diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index f210e5f678..0e64c2e2e8 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -56,7 +56,7 @@ export const DocsTable = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; diff --git a/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx index d9d2c6a345..a8fd472f7c 100644 --- a/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx @@ -15,16 +15,12 @@ */ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { LegacyTechDocsHome } from './LegacyTechDocsHome'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef } from '@backstage/core-plugin-api'; import { rootDocsRouteRef } from '../../routes'; @@ -59,10 +55,10 @@ describe('Legacy TechDocs Home', () => { }, }); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [configApiRef, configApi], - ]); + ); it('should render a TechDocs home page', async () => { await renderInTestApp( diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx index bcbfdeed86..e9c3a55b15 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -15,11 +15,11 @@ */ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { rootDocsRouteRef } from '../../routes'; jest.mock('@backstage/plugin-catalog-react', () => { @@ -47,7 +47,7 @@ const mockCatalogApi = { } as Partial; describe('TechDocsCustomHome', () => { - const apiRegistry = ApiRegistry.with(catalogApiRef, mockCatalogApi); + const apiRegistry = TestApiRegistry.from([catalogApiRef, mockCatalogApi]); it('should render a TechDocs home page', async () => { const tabsConfig = [ diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx index 8b5dbced3f..6e8609694d 100644 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ b/plugins/techdocs/src/reader/components/Reader.test.tsx @@ -19,12 +19,12 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, render } from '@testing-library/react'; import React from 'react'; import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api'; import { Reader } from './Reader'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; jest.mock('react-router-dom', () => { @@ -57,11 +57,11 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 9bb0a7bbad..90a3aa3489 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -76,8 +76,12 @@ const TechDocsReaderContext = createContext( {} as TechDocsReaderValue, ); -const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { - const { namespace = '', kind = '', name = '', '*': path } = useParams(); +const TechDocsReaderProvider = ({ + children, + entityRef, +}: PropsWithChildren<{ entityRef: EntityName }>) => { + const { '*': path } = useParams(); + const { kind, namespace, name } = entityRef; const value = useReaderState(kind, namespace, name, path); return ( @@ -96,10 +100,10 @@ const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { * @internal */ export const withTechDocsReaderProvider = - (Component: ComponentType) => + (Component: ComponentType, entityRef: EntityName) => (props: T) => ( - + ); @@ -128,12 +132,12 @@ export const useTechDocsReader = () => useContext(TechDocsReaderContext); * todo: Make public or stop exporting (see others: "altReaderExperiments") * @internal */ -export const useTechDocsReaderDom = (): Element | null => { +export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { const navigate = useNavigate(); const theme = useTheme(); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const { namespace = '', kind = '', name = '' } = useParams(); + const { namespace = '', kind = '', name = '' } = entityRef; const { state, path, content: rawPage } = useTechDocsReader(); const [sidebars, setSidebars] = useState(); @@ -198,6 +202,7 @@ export const useTechDocsReaderDom = (): Element | null => { .md-footer-nav__link { width: 20rem;} .md-content { margin-left: 20rem; max-width: calc(100% - 20rem * 2 - 3rem); } .md-typeset { font-size: 1rem; } + .md-typeset h1, .md-typeset h2, .md-typeset h3 { font-weight: bold; } .md-nav { font-size: 1rem; } .md-grid { max-width: 90vw; margin: 0 } .md-typeset table:not([class]) { @@ -213,6 +218,16 @@ export const useTechDocsReaderDom = (): Element | null => { .md-typeset .admonition, .md-typeset details { font-size: 1rem; } + + /* style the checkmarks of the task list */ + .md-typeset .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .md-typeset .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + /**/ + @media screen and (max-width: 76.1875em) { .md-nav { background-color: ${theme.palette.background.default}; @@ -292,8 +307,8 @@ export const useTechDocsReaderDom = (): Element | null => { --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); } :host { - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); } `, }), @@ -304,9 +319,11 @@ export const useTechDocsReaderDom = (): Element | null => { namespace, scmIntegrationsApi, techdocsStorageApi, + theme.palette.action.disabledBackground, theme.palette.background.default, theme.palette.background.paper, theme.palette.primary.main, + theme.palette.success.main, theme.palette.text.primary, theme.typography.fontFamily, ], @@ -399,7 +416,7 @@ const TheReader = ({ withSearch = true, }: Props) => { const classes = useStyles(); - const dom = useTechDocsReaderDom(); + const dom = useTechDocsReaderDom(entityRef); const shadowDomRef = useRef(null); const onReadyRef = useRef<() => void>(onReady); @@ -439,7 +456,7 @@ export const Reader = ({ onReady = () => {}, withSearch = true, }: Props) => ( - + { +jest.mock('react-lazylog/build/LazyLog', () => { return { - LazyLog: ({ text }: { text: string }) => { + default: ({ text }: { text: string }) => { return

{text}

; }, }; @@ -46,18 +46,20 @@ describe('', () => { }); describe('', () => { - it('should render with empty log', () => { + it('should render with empty log', async () => { const onClose = jest.fn(); const rendered = render( , ); expect(rendered.getByText(/Build Details/i)).toBeInTheDocument(); - expect(rendered.getByText(/Waiting for logs.../i)).toBeInTheDocument(); + expect( + await rendered.findByText(/Waiting for logs.../i), + ).toBeInTheDocument(); expect(onClose).toBeCalledTimes(0); }); - it('should render with empty logs', () => { + it('should render logs', async () => { const onClose = jest.fn(); const rendered = render( ', () => { />, ); expect(rendered.getByText(/Build Details/i)).toBeInTheDocument(); - expect( - rendered.queryByText(/Waiting for logs.../i), - ).not.toBeInTheDocument(); - expect(rendered.getByText(/Line 1/i)).toBeInTheDocument(); - expect(rendered.getByText(/Line 2/i)).toBeInTheDocument(); + expect(await rendered.findByText(/Line 1/i)).toBeInTheDocument(); + expect(await rendered.findByText(/Line 2/i)).toBeInTheDocument(); expect(onClose).toBeCalledTimes(0); }); @@ -80,7 +79,7 @@ describe('', () => { const rendered = render( , ); - rendered.getByRole('button').click(); + rendered.getByTitle('Close the drawer').click(); expect(onClose).toBeCalledTimes(1); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx index 4283932fb3..8c739a9bcf 100644 --- a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Progress } from '@backstage/core-components'; import { Button, createStyles, @@ -25,9 +26,9 @@ import { Typography, } from '@material-ui/core'; import Close from '@material-ui/icons/Close'; -import * as React from 'react'; -import { useState } from 'react'; -import { LazyLog } from 'react-lazylog'; +import React, { Suspense, useState } from 'react'; + +const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); const useDrawerStyles = makeStyles((theme: Theme) => createStyles({ @@ -83,15 +84,17 @@ export const TechDocsBuildLogsDrawerContent = ({
- + }> + +
); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 6a5af982fd..b297466a62 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -21,7 +21,7 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { Header } from '@backstage/core-components'; import { techdocsApiRef, @@ -29,7 +29,7 @@ import { techdocsStorageApiRef, TechDocsStorageApi, } from '../../api'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; jest.mock('react-router-dom', () => { @@ -90,12 +90,12 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( @@ -147,12 +147,12 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx index 602a94e758..5b3d714a04 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, fireEvent, @@ -54,7 +54,7 @@ describe('', () => { const querySpy = jest.fn(query); const searchApi = { query: querySpy }; - const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]); + const apiRegistry = TestApiRegistry.from([searchApiRef, searchApi]); await act(async () => { const rendered = render( @@ -75,7 +75,7 @@ describe('', () => { const querySpy = jest.fn(query); const searchApi = { query: querySpy }; - const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]); + const apiRegistry = TestApiRegistry.from([searchApiRef, searchApi]); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx index cce8282b3d..e41a114dbe 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.test.tsx +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { NotFoundError } from '@backstage/errors'; +import { TestApiProvider } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { techdocsStorageApiRef } from '../../api'; @@ -38,10 +38,10 @@ describe('useReaderState', () => { }; beforeEach(() => { - const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/techdocs/src/routes.ts b/plugins/techdocs/src/routes.ts index e0b7661ffd..e781550899 100644 --- a/plugins/techdocs/src/routes.ts +++ b/plugins/techdocs/src/routes.ts @@ -17,17 +17,14 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - id: 'techdocs-index-page', - title: 'TechDocs Landing Page', + id: 'techdocs:index-page', }); export const rootDocsRouteRef = createRouteRef({ - id: 'techdocs-reader-page', - title: 'Docs', + id: 'techdocs:reader-page', params: ['namespace', 'kind', 'name'], }); export const rootCatalogDocsRouteRef = createRouteRef({ - id: 'catalog-techdocs-reader-view', - title: 'Docs', + id: 'techdocs:catalog-reader-view', }); diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md index 61ca5202d0..5540ca598e 100644 --- a/plugins/todo-backend/README.md +++ b/plugins/todo-backend/README.md @@ -36,6 +36,19 @@ export default async function createPlugin({ } ``` +And then add to `packages/backend/src/index.ts`: + +```js +// In packages/backend/src/index.ts +import todo from './plugins/todo'; +// ... +async function main() { + // ... + const todoEnv = useHotMemoize(module, () => createEnv('todo')); + // ... + apiRouter.use('/todo', await todo(todoEnv)); +``` + ## Scanned Files The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location` diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index c3f0e469aa..be1dc658f1 100644 --- a/plugins/todo-backend/package.json +++ b/plugins/todo-backend/package.json @@ -25,9 +25,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.10", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.3", "@backstage/integration": "^0.6.7", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "msw": "^0.35.0", "supertest": "^6.1.3" diff --git a/plugins/todo/CHANGELOG.md b/plugins/todo/CHANGELOG.md index 99c33c425e..1a6a352e73 100644 --- a/plugins/todo/CHANGELOG.md +++ b/plugins/todo/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-todo +## 0.1.15 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.14 ### Patch Changes diff --git a/plugins/todo/README.md b/plugins/todo/README.md index 4165582b65..ad0ad4fc1d 100644 --- a/plugins/todo/README.md +++ b/plugins/todo/README.md @@ -2,6 +2,77 @@ This plugin lists `// TODO` comments in source code. It currently exports a single component extension for use on entity pages. +## Setup + +1. Run: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-todo +yarn --cwd packages/backend add @backstage/plugin-todo-backend +``` + +2. Add the plugin backend: + +In a new file named `todo.ts` under `backend/src/plugins`: + +```js +import { Router } from 'express'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + createRouter, + TodoReaderService, + TodoScmReader, +} from '@backstage/plugin-todo-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + reader, + config, + discovery, +}: PluginEnvironment): Promise { + const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const todoService = new TodoReaderService({ + todoReader, + catalogClient, + }); + + return await createRouter({ todoService }); +} +``` + +And then add to `packages/backend/src/index.ts`: + +```js +// In packages/backend/src/index.ts +import todo from './plugins/todo'; +// ... +async function main() { + // ... + const todoEnv = useHotMemoize(module, () => createEnv('todo')); + // ... + apiRouter.use('/todo', await todo(todoEnv)); +``` + +3. Add the plugin as a tab to your service entities: + +```jsx +// In packages/app/src/components/catalog/EntityPage.tsx +import { EntityTodoContent } from '@backstage/plugin-todo'; + +const serviceEntityPage = ( + + {/* other tabs... */} + + + +``` + ## Format The default parser uses [Leasot](https://github.com/pgilad/leasot), which supports a wide range of languages. By default it supports the `TODO` and `FIXME` tags, along with `@` prefix and author reference through with either a `()` suffix or trailing `/`. For more information on how to configure the parser, see `@backstage/plugin-todo-backend`. diff --git a/plugins/todo/dev/index.tsx b/plugins/todo/dev/index.tsx index a4221dc710..fe547812ec 100644 --- a/plugins/todo/dev/index.tsx +++ b/plugins/todo/dev/index.tsx @@ -22,8 +22,8 @@ import OfflineIcon from '@material-ui/icons/Storage'; import React from 'react'; import { EntityTodoContent, todoApiRef, todoPlugin } from '../src'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Content, Header, HeaderLabel, Page } from '@backstage/core-components'; +import { TestApiProvider } from '@backstage/test-utils'; const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -60,7 +60,7 @@ createDevApp() .registerPlugin(todoPlugin) .addPage({ element: ( - +
@@ -71,7 +71,7 @@ createDevApp() - + ), title: 'Entity Todo Content', icon: OfflineIcon, diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 9f27679615..22a8e6b1e2 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-todo", "description": "A Backstage plugin that lets you browse TODO comments in your source code", - "version": "0.1.14", + "version": "0.1.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,12 +27,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -41,10 +41,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/todo/src/components/TodoList/TodoList.test.tsx b/plugins/todo/src/components/TodoList/TodoList.test.tsx index 0e4d64172d..2759fe40f8 100644 --- a/plugins/todo/src/components/TodoList/TodoList.test.tsx +++ b/plugins/todo/src/components/TodoList/TodoList.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { TodoApi, todoApiRef } from '../../api'; import { TodoList } from './TodoList'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('TodoList', () => { it('should render', async () => { @@ -42,11 +41,11 @@ describe('TodoList', () => { const mockEntity = { metadata: { name: 'mock' } } as Entity; const rendered = await renderWithEffects( - + - , + , ); await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument(); diff --git a/plugins/todo/src/routes.ts b/plugins/todo/src/routes.ts index 8ab3fb1b94..01d8d9f354 100644 --- a/plugins/todo/src/routes.ts +++ b/plugins/todo/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'todo', + id: 'todo', }); diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index ae3bc1c72d..a309afb497 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-user-settings +## 0.3.11 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- 274a4fc633: Add Props Icon for Sidebar Item SidebarSearchField and Settings +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.10 ### Patch Changes diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index 653e85a541..a801f2e807 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -37,10 +37,11 @@ export const ProviderSettingsItem: ({ // @public (undocumented) export const Router: ({ providerSettings }: Props) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "SettingsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Settings" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const Settings: () => JSX.Element; +export const Settings: (props: SettingsProps) => JSX.Element; // Warning: (ae-missing-release-tag) "UserSettingsAppearanceCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 9ec642892b..def12ac1d2 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-user-settings", "description": "A Backstage plugin that provides a settings page", - "version": "0.3.10", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -43,10 +43,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx index 085a884f2e..4e14dec2ef 100644 --- a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx +++ b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx @@ -14,22 +14,24 @@ * limitations under the License. */ -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsAuthProviders } from './UserSettingsAuthProviders'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef, googleAuthApiRef } from '@backstage/core-plugin-api'; const mockSignInHandler = jest.fn().mockReturnValue(''); const mockGoogleAuth = { sessionState$: () => ({ + [Symbol.observable]: jest.fn(), subscribe: () => ({ + closed: false, unsubscribe: () => null, }), }), @@ -47,10 +49,10 @@ const createConfig = () => const config = createConfig(); -const apiRegistry = ApiRegistry.from([ +const apiRegistry = TestApiRegistry.from( [configApiRef, config], [googleAuthApiRef, mockGoogleAuth], -]); +); describe('', () => { it('displays a provider and calls its sign-in handler on click', async () => { diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx index caae4d69bc..83d9e23f83 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx @@ -15,16 +15,16 @@ */ import { AppTheme, appThemeApiRef } from '@backstage/core-plugin-api'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; -import { - ApiProvider, - ApiRegistry, - AppThemeSelector, -} from '@backstage/core-app-api'; +import { ApiProvider, AppThemeSelector } from '@backstage/core-app-api'; const mockTheme: AppTheme = { id: 'light-theme', @@ -33,8 +33,9 @@ const mockTheme: AppTheme = { theme: lightTheme, }; -const apiRegistry = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme])], +const apiRegistry = TestApiRegistry.from([ + appThemeApiRef, + AppThemeSelector.createWithStorage([mockTheme]), ]); describe('', () => { diff --git a/plugins/user-settings/src/components/Settings.tsx b/plugins/user-settings/src/components/Settings.tsx index deb8dddf58..e9b3ae7d0a 100644 --- a/plugins/user-settings/src/components/Settings.tsx +++ b/plugins/user-settings/src/components/Settings.tsx @@ -18,13 +18,14 @@ import React from 'react'; import SettingsIcon from '@material-ui/icons/Settings'; import { settingsRouteRef } from '../plugin'; import { SidebarItem } from '@backstage/core-components'; +import { useRouteRef, IconComponent } from '@backstage/core-plugin-api'; -export const Settings = () => { - return ( - - ); +type SettingsProps = { + icon?: IconComponent; +}; + +export const Settings = (props: SettingsProps) => { + const routePath = useRouteRef(settingsRouteRef); + const Icon = props.icon ? props.icon : SettingsIcon; + return ; }; diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 54f6faceed..01793dc418 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -21,8 +21,7 @@ import { } from '@backstage/core-plugin-api'; export const settingsRouteRef = createRouteRef({ - path: '/settings', - title: 'Settings', + id: 'user-settings', }); export const userSettingsPlugin = createPlugin({ diff --git a/plugins/xcmetrics/CHANGELOG.md b/plugins/xcmetrics/CHANGELOG.md index a7d2d480b7..4ae5c49c32 100644 --- a/plugins/xcmetrics/CHANGELOG.md +++ b/plugins/xcmetrics/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-xcmetrics +## 0.2.10 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.9 ### Patch Changes diff --git a/plugins/xcmetrics/package.json b/plugins/xcmetrics/package.json index 1ef31ecd6d..f29f763666 100644 --- a/plugins/xcmetrics/package.json +++ b/plugins/xcmetrics/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-xcmetrics", "description": "A Backstage plugin that shows XCode build metrics for your components", - "version": "0.2.9", + "version": "0.2.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", + "@backstage/core-components": "^0.7.4", + "@backstage/core-plugin-api": "^0.2.0", "@backstage/errors": "^0.1.3", - "@backstage/theme": "^0.2.12", + "@backstage/theme": "^0.2.13", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -36,10 +36,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/core-app-api": "^0.1.19", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.22", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx index 2d949d7abc..7083d890d6 100644 --- a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx +++ b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { BuildDetails, withRequest } from './BuildDetails'; import { xcmetricsApiRef } from '../../api'; @@ -35,11 +34,9 @@ jest.mock('../BuildTimeline', () => ({ describe('BuildDetails', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('accordion-Host')).toBeInTheDocument(); @@ -61,11 +58,9 @@ describe('BuildDetails with request', () => { it('should fetch the build and render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(client.mockBuild.id)).toBeInTheDocument(); @@ -78,11 +73,9 @@ describe('BuildDetails with request', () => { .mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(errorMessage)).toBeInTheDocument(); @@ -92,11 +85,9 @@ describe('BuildDetails with request', () => { client.XcmetricsClient.getBuild = jest.fn().mockReturnValue(undefined); const rendered = await renderInTestApp( - + - , + , ); expect( diff --git a/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx b/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx index eb141209e7..53471f5e9f 100644 --- a/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx +++ b/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { BuildList } from './BuildList'; import { xcmetricsApiRef } from '../../api'; import userEvent from '@testing-library/user-event'; @@ -35,11 +34,9 @@ jest.mock('../BuildDetails', () => ({ describe('BuildList', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Builds')).toBeInTheDocument(); @@ -50,11 +47,9 @@ describe('BuildList', () => { it('should show build details', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click( @@ -70,11 +65,9 @@ describe('BuildList', () => { .mockRejectedValue({ message }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(message)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx b/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx index b997de68e0..06f4aed1b7 100644 --- a/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx +++ b/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import { BuildListFilter } from './BuildListFilter'; import { BuildFilters, xcmetricsApiRef } from '../../api'; @@ -37,14 +36,12 @@ const renderWithFiltersVisible = async ( callback?: (filters: BuildFilters) => void, ) => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByLabelText('show filters')); @@ -67,14 +64,12 @@ const setProjectFilter = async (rendered: RenderResult, option: string) => { describe('BuildListFilter', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Filters (0)')).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/Overview/Overview.test.tsx b/plugins/xcmetrics/src/components/Overview/Overview.test.tsx index 3d24d2a422..42e3772d04 100644 --- a/plugins/xcmetrics/src/components/Overview/Overview.test.tsx +++ b/plugins/xcmetrics/src/components/Overview/Overview.test.tsx @@ -14,9 +14,8 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Overview } from './Overview'; jest.mock('../../api/XcmetricsClient'); @@ -33,11 +32,9 @@ jest.mock('../StatusMatrix', () => ({ describe('Overview', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument(); @@ -49,9 +46,9 @@ describe('Overview', () => { api.getBuilds = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('No builds to show')).toBeInTheDocument(); @@ -64,9 +61,9 @@ describe('Overview', () => { api.getBuilds = jest.fn().mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(errorMessage)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx b/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx index 76b47f4943..db7904a8d1 100644 --- a/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx @@ -15,9 +15,8 @@ */ import React from 'react'; import { OverviewTrends } from './OverviewTrends'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import userEvent from '@testing-library/user-event'; jest.mock('../../api/XcmetricsClient'); @@ -26,11 +25,9 @@ const client = require('../../api/XcmetricsClient'); describe('OverviewTrends', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Trends for')).toBeInTheDocument(); expect(rendered.getAllByText('Build Count').length).toEqual(3); @@ -42,20 +39,18 @@ describe('OverviewTrends', () => { api.getBuildCounts = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('--')).toBeInTheDocument(); }); it('should change number of days when select is changed', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByText('14 days')); @@ -76,9 +71,9 @@ describe('OverviewTrends', () => { .mockRejectedValue({ message: buildTimesError }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(buildCountError)).toBeInTheDocument(); expect(rendered.getByText(buildTimesError)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx b/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx index cac111d13e..6e5b7f73b9 100644 --- a/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx +++ b/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import { StatusCell } from './StatusCell'; import { xcmetricsApiRef } from '../../api'; @@ -27,9 +26,7 @@ const client = require('../../api/XcmetricsClient'); describe('StatusCell', () => { it('should render', async () => { const rendered = await renderInTestApp( - + { size={10} spacing={10} /> - , + , ); userEvent.hover(rendered.getByTestId(client.mockBuild.id)); diff --git a/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx b/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx index 7d3854bbda..f18d322b43 100644 --- a/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { StatusMatrix } from './StatusMatrix'; import { xcmetricsApiRef } from '../../api'; @@ -25,11 +24,9 @@ const client = require('../../api/XcmetricsClient'); describe('StatusMatrix', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); const cell = rendered.getByTestId(client.mockBuild.id); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx index 84bd25624c..29d6407b03 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { XcmetricsLayout } from './XcmetricsLayout'; import { xcmetricsApiRef } from '../../api'; import userEvent from '@testing-library/user-event'; @@ -34,11 +33,9 @@ jest.mock('../BuildList', () => ({ describe('XcmetricsLayout', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Overview')).toBeInTheDocument(); @@ -49,11 +46,9 @@ describe('XcmetricsLayout', () => { it('should show a list of builds when the Builds tab is selected', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByText('Builds')); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx index 53545890a4..a21918e322 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx @@ -22,24 +22,23 @@ import { TabbedLayout, } from '@backstage/core-components'; import { Overview } from '../Overview'; -import { buildsRouteRef, rootRouteRef } from '../../routes'; -import { RouteRef, SubRouteRef } from '@backstage/core-plugin-api'; +import { buildsRouteRef } from '../../routes'; import { BuildsPage } from '../BuildsPage'; export interface TabConfig { - routeRef: RouteRef | SubRouteRef; + path: string; title: string; component: ReactChild; } const TABS: TabConfig[] = [ { - routeRef: rootRouteRef, + path: '/', title: 'Overview', component: , }, { - routeRef: buildsRouteRef, + path: buildsRouteRef.path, title: 'Builds', component: , }, @@ -53,11 +52,7 @@ export const XcmetricsLayout = () => (
{TABS.map(tab => ( - + {tab.component} ))} diff --git a/plugins/xcmetrics/src/routes.ts b/plugins/xcmetrics/src/routes.ts index 212ecbae48..81934c860f 100644 --- a/plugins/xcmetrics/src/routes.ts +++ b/plugins/xcmetrics/src/routes.ts @@ -16,7 +16,7 @@ import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'XCMetrics', + id: 'xcmetrics', }); export const buildsRouteRef = createSubRouteRef({ diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index c8f35508fa..22eae267ee 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -30,8 +30,15 @@ import { ExtractorConfig, CompilerState, ExtractorLogLevel, + ExtractorMessage, } from '@microsoft/api-extractor'; -import { DocNode, IDocNodeContainerParameters } from '@microsoft/tsdoc'; +import { Program } from 'typescript'; +import { + DocNode, + IDocNodeContainerParameters, + TSDocTagSyntaxKind, +} from '@microsoft/tsdoc'; +import { TSDocConfigFile } from '@microsoft/tsdoc-config'; import { ApiPackage, ApiModel } from '@microsoft/api-extractor-model'; import { IMarkdownDocumenterOptions, @@ -42,6 +49,7 @@ import { DocTableRow } from '@microsoft/api-documenter/lib/nodes/DocTableRow'; import { DocHeading } from '@microsoft/api-documenter/lib/nodes/DocHeading'; import { CustomMarkdownEmitter } from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter'; import { IMarkdownEmitterContext } from '@microsoft/api-documenter/lib/markdown/MarkdownEmitter'; +import { AstDeclaration } from '@microsoft/api-extractor/lib/analyzer/AstDeclaration'; const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); @@ -79,11 +87,96 @@ const { ApiReportGenerator, } = require('@microsoft/api-extractor/lib/generators/ApiReportGenerator'); +function patchFileMessageFetcher( + router: any, + transform: (messages: ExtractorMessage[], ast?: AstDeclaration) => void, +) { + const { + fetchAssociatedMessagesForReviewFile, + fetchUnassociatedMessagesForReviewFile, + } = router; + + router.fetchAssociatedMessagesForReviewFile = + function patchedFetchAssociatedMessagesForReviewFile(ast) { + const messages = fetchAssociatedMessagesForReviewFile.call(this, ast); + return transform(messages, ast); + }; + router.fetchUnassociatedMessagesForReviewFile = + function patchedFetchUnassociatedMessagesForReviewFile() { + const messages = fetchUnassociatedMessagesForReviewFile.call(this); + return transform(messages); + }; +} + const originalGenerateReviewFileContent = ApiReportGenerator.generateReviewFileContent; ApiReportGenerator.generateReviewFileContent = - function decoratedGenerateReviewFileContent(...args) { - const content = originalGenerateReviewFileContent.apply(this, args); + function decoratedGenerateReviewFileContent(collector, ...moreArgs) { + const program = collector.program as Program; + + // The purpose of this override is to allow the @ignore tag to be used to ignore warnings + // of the form "Warning: (ae-forgotten-export) The symbol "FooBar" needs to be exported by the entry point index.d.ts" + patchFileMessageFetcher( + collector.messageRouter, + (messages: ExtractorMessage[]) => { + return messages.filter(message => { + if (message.messageId !== 'ae-forgotten-export') { + return true; + } + + // Symbol name has to be extracted from the message :( + // There's frequently no AST for these exports because type literals + // aren't traversed by the generator. + const symbolMatch = message.text.match(/The symbol "([^"]+)"/); + if (!symbolMatch) { + throw new Error( + `Failed to extract symbol name from message "${message.text}"`, + ); + } + const [, symbolName] = symbolMatch; + + const sourceFile = program.getSourceFile(message.sourceFilePath); + if (!sourceFile) { + throw new Error( + `Failed to find source file in program at path "${message.sourceFilePath}"`, + ); + } + + // NOTE: we limit the @internal functionality to only apply to types that are declared + // in the same module as where they're being referenced from. This limitation makes + // the implementation here simpler but could be revisited if needed. + + // The local name of the symbol within the file, rather than the exported name + const localName = (sourceFile as any).identifiers?.get(symbolName); + if (!localName) { + return true; + } + // The local AST node of the export that we're missing + const local = (sourceFile as any).locals?.get(localName); + if (!local) { + return true; + } + + // If any of the TSDoc comments contain a @ignore tag, we ignore this message + const isIgnored = local.declarations.some(declaration => { + const tags = [declaration.jsDoc] + .flat() + .filter(Boolean) + .flatMap((tagNode: any) => tagNode.tags); + + return tags.some(tag => tag?.tagName.text === 'ignore'); + }); + + return !isIgnored; + }); + }, + ); + + const content = originalGenerateReviewFileContent.call( + this, + collector, + ...moreArgs, + ); return prettier.format(content, { ...require('@spotify/prettier-config'), parser: 'markdown', @@ -99,6 +192,7 @@ const SKIPPED_PACKAGES = [ join('packages', 'codemods'), join('packages', 'create-app'), join('packages', 'e2e-test'), + join('packages', 'embedded-techdocs-app'), join('packages', 'storybook'), join('packages', 'techdocs-cli'), ]; @@ -134,6 +228,18 @@ async function findPackageDirs() { return packageDirs; } +async function getTsDocConfig() { + const tsdocConfigFile = await TSDocConfigFile.loadFile( + require.resolve('@microsoft/api-extractor/extends/tsdoc-base.json'), + ); + tsdocConfigFile.addTagDefinition({ + tagName: '@ignore', + syntaxKind: TSDocTagSyntaxKind.ModifierTag, + }); + tsdocConfigFile.setSupportForTag('@ignore', true); + return tsdocConfigFile; +} + function logApiReportInstructions() { console.log(''); console.log( @@ -236,6 +342,7 @@ async function runApiExtraction({ }, configObjectFullPath: projectFolder, packageJsonFullPath: resolvePath(projectFolder, 'package.json'), + tsdocConfigFile: await getTsDocConfig(), }); // The `packageFolder` needs to point to the location within `dist-types` in order for relative diff --git a/scripts/check-if-release.js b/scripts/check-if-release.js index 4475ed6207..b61b0e2eb7 100755 --- a/scripts/check-if-release.js +++ b/scripts/check-if-release.js @@ -96,7 +96,9 @@ async function main() { const newVersions = packageVersions.filter( ({ oldVersion, newVersion }) => - oldVersion !== newVersion && newVersion !== '', + oldVersion !== newVersion && + oldVersion !== '' && + newVersion !== '', ); if (newVersions.length === 0) { diff --git a/scripts/list-deprecations.js b/scripts/list-deprecations.js new file mode 100755 index 0000000000..920b01c7b5 --- /dev/null +++ b/scripts/list-deprecations.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 import/no-extraneous-dependencies */ + +const _ = require('lodash'); +const fs = require('fs-extra'); +const globby = require('globby'); +const { resolve: resolvePath, relative: relativePath } = require('path'); +const { execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); + +const execFile = promisify(execFileCb); + +const WORKER_COUNT = 16; + +const deprecatedPattern = /@deprecated|DEPRECATION/; + +class ReleaseProvider { + cache = new Map(); + + constructor(rootPath) { + this.rootPath = rootPath; + } + + async lookup(commitSha, packageDir) { + const key = commitSha + packageDir; + + if (this.cache.has(key)) { + return this.cache.get(key); + } + + const pkgJsonPath = relativePath( + this.rootPath, + resolvePath(this.rootPath, packageDir, 'package.json'), + ); + const releasePromise = Promise.resolve().then(async () => { + // Find all tags that contain the commit + const { stdout: tagOutput } = await execFile( + 'git', + ['tag', '--contains', commitSha], + { shell: true }, + ); + + // Filter out just the releases + const releases = tagOutput + .split('\n') + .filter(l => l.startsWith('release-')); + + // Then find the earliest release that affected our package + for (const release of releases) { + let oldVersion; + let newVersion; + + try { + const { stdout: content } = await execFile( + 'git', + ['show', `${release}^:${pkgJsonPath}`], + { shell: true }, + ); + oldVersion = JSON.parse(content).version; + } catch { + /* */ + } + try { + const { stdout: content } = await execFile( + 'git', + ['show', `${release}:${pkgJsonPath}`], + { shell: true }, + ); + newVersion = JSON.parse(content).version; + } catch { + /* */ + } + + if (oldVersion !== newVersion) { + return release; + } + } + return undefined; + }); + + this.cache.set(key, releasePromise); + return releasePromise; + } +} + +async function main() { + let packageDirQueue = process.argv.slice(2); + + const rootPath = resolvePath(__dirname, '..'); + + if (packageDirQueue.length === 0) { + packageDirQueue = await Promise.all([ + fs.readdir(resolvePath(rootPath, 'packages')), + fs.readdir(resolvePath(rootPath, 'plugins')), + ]).then(([packages, plugins]) => [ + ...packages.map(dir => `packages/${dir}`), + ...plugins.map(dir => `plugins/${dir}`), + ]); + } + + const fileQueue = []; + const deprecationQueue = []; + const releaseProvider = new ReleaseProvider(rootPath); + const deprecations = []; + + await Promise.all( + Array(WORKER_COUNT) + .fill() + .map(async () => { + // Find all files we want to scan + while (packageDirQueue.length) { + const packageDir = packageDirQueue.pop(); + const srcDir = resolvePath(rootPath, packageDir, 'src'); + + if (await fs.pathExists(srcDir)) { + const files = await globby(['**/*.{js,jsx,ts,tsx}'], { + cwd: srcDir, + }); + fileQueue.push( + ...files.map(file => ({ + packageDir, + file: resolvePath(srcDir, file), + })), + ); + } + } + + // Parse files and search for deprecations + while (fileQueue.length) { + const { packageDir, file } = fileQueue.pop(); + const content = await fs.readFile(file, 'utf8'); + if (!deprecatedPattern.test(content)) { + continue; + } + + const lines = content.split('\n'); + for (const [index, line] of lines.entries()) { + if (deprecatedPattern.test(line)) { + deprecationQueue.push({ + packageDir, + file, + lineNumber: index + 1, + lineContent: line, + }); + } + } + } + + // Lookup git information for each deprecation + while (deprecationQueue.length) { + const deprecation = deprecationQueue.pop(); + + const { file, packageDir, lineNumber: n, lineContent } = deprecation; + const { stdout: blameOutput } = await execFile( + 'git', + ['blame', '--porcelain', `-L ${n},${n}`, file], + { shell: true }, + ); + + const blameInfo = Object.fromEntries( + blameOutput + .split('\n') + .slice(1, -2) + .map(line => { + const [key] = line.split(' ', 1); + return [key, line.slice(key.length + 1)]; + }), + ); + const [commit] = blameOutput.split(' ', 1); + const { author, ['author-time']: authorTime } = blameInfo; + + const release = await releaseProvider.lookup(commit, packageDir); + + deprecations.push({ + file: relativePath(rootPath, file), + release, + commit, + author, + authorTime: new Date(authorTime * 1000), + lineContent, + lineNumber: n, + }); + } + }), + ); + + const maxAuthor = Math.max(...deprecations.map(d => d.author.length)) + 1; + + // Group and sort by release + const sortedByRelease = _.sortBy( + Object.entries(_.groupBy(deprecations, 'release')), + ([release]) => release, + ); + + for (const [release, ds] of sortedByRelease) { + console.log(`\n### ${release === 'undefined' ? 'Not released' : release}`); + for (const d of _.sortBy(ds, 'authorTime', 'file', 'lineNumber')) { + console.log( + [ + d.commit.slice(0, 8), + d.authorTime.toLocaleDateString().padEnd('00/00/0000'.length + 1), + d.author.padEnd(maxAuthor + 1), + `${d.file}:${d.lineNumber}`, + ].join(' '), + ); + } + } +} + +main().catch(err => { + console.error(err.stack); + process.exit(1); +}); diff --git a/scripts/migrate-location-types.js b/scripts/migrate-location-types.js index d6ac5c6a2a..168a5e825c 100755 --- a/scripts/migrate-location-types.js +++ b/scripts/migrate-location-types.js @@ -20,7 +20,7 @@ // catalog API endpoint and execute the script. It will delete and add // back the locations with the correct type one by one. -const BASE_URL = 'http://localhost:7000/api/catalog'; // Change me +const BASE_URL = 'http://localhost:7007/api/catalog'; // Change me const deprecatedTypes = [ 'github', @@ -81,9 +81,9 @@ async function request(method, url, body) { return; } try { - const body = Buffer.concat(chunks).toString('utf8').trim(); - if (body) { - resolve(JSON.parse(body)); + const responseBody = Buffer.concat(chunks).toString('utf8').trim(); + if (responseBody) { + resolve(JSON.parse(responseBody)); } else { resolve(); } diff --git a/scripts/snyk-github-issue-sync.ts b/scripts/snyk-github-issue-sync.ts new file mode 100755 index 0000000000..da64e2ea82 --- /dev/null +++ b/scripts/snyk-github-issue-sync.ts @@ -0,0 +1,201 @@ +#!/usr/bin/env yarn ts-node --transpile-only +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 import/no-extraneous-dependencies */ +import { Octokit } from '@octokit/rest'; +import minimist from 'minimist'; +// Generated by GitHub workflow .github/workflows/snyk-github-issue-creator +import synkJsonOutput from '../snyk.json'; + +type Vulnerability = { + description: string; + packages: { + name: string; + target: string; + }[]; + snykId: string; +}; + +const argv = minimist(process.argv.slice(2)); + +const GH_OWNER = 'backstage'; +const GH_REPO = 'backstage'; +const SNYK_GH_LABEL = 'snyk-vulnerability'; +const SNYK_ID_REGEX = /\[([^\]]+)]/i; + +const isDryRun = 'dryrun' in argv; + +if (!process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN is not set. Please provide a Github token'); + process.exit(1); +} + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +if (isDryRun) { + console.log( + 'โš ๏ธ Running in dryrun mode, no issues will be updated on Github โš ๏ธ', + ); +} + +const fetchSnykGithubIssueMap = async (): Promise> => { + const snykGithubIssueMap: Record = {}; + + const iterator = octokit.paginate.iterator(octokit.rest.issues.listForRepo, { + owner: GH_OWNER, + repo: GH_REPO, + per_page: 100, + state: 'open', + labels: SNYK_GH_LABEL, + }); + + for await (const { data: issues } of iterator) { + for (const issue of issues) { + // Gets the Vulnerability ID from square braces + const match = SNYK_ID_REGEX.exec(issue.title); + + if (match && match[1]) { + snykGithubIssueMap[match[1]] = issue.number; + } else { + console.log(`Unmatched Snyk ID for ${issue.title}`); + } + } + } + + return snykGithubIssueMap; +}; + +const generateIssueBody = (vulnerability: Vulnerability) => ` +## Affecting Packages/Plugins + +${Array.from(vulnerability.packages) + .map(({ name, target }) => `* [${name}](${target})`) + .join('\n')} + +${vulnerability.description} +`; + +const createGithubIssue = async (vulnerability: Vulnerability) => { + console.log( + `Create Github Issue for Snyk Vulnerability ${vulnerability.snykId}`, + ); + + vulnerability.packages.forEach(({ name, target }) => { + console.log(`- ${name} [${target}]`); + }); + + if (!isDryRun) { + await octokit.issues.create({ + owner: GH_OWNER, + repo: GH_REPO, + title: `Snyk vulnerability [${vulnerability.snykId}]`, + labels: [SNYK_GH_LABEL, 'help wanted'], + body: generateIssueBody(vulnerability), + }); + } +}; + +const updateGithubIssue = async ( + githubIssueId: number, + vulnerability: Vulnerability, +) => { + console.log( + `Update Github Issue #${githubIssueId} for Snky Vulnerability ${vulnerability.snykId}`, + ); + + if (!isDryRun) { + await octokit.issues.update({ + owner: GH_OWNER, + repo: GH_REPO, + issue_number: githubIssueId, + body: generateIssueBody(vulnerability), + }); + } +}; + +const closeGithubIssue = async (githubIssueId: number) => { + console.log(`Closing Github Issue #${githubIssueId}`); + + if (!isDryRun) { + await octokit.issues.update({ + owner: GH_OWNER, + repo: GH_REPO, + issue_number: githubIssueId, + state: 'closed', + }); + } +}; + +async function main() { + const snykGithubIssueMap = await fetchSnykGithubIssueMap(); + const vulnerabilityStore: Record = {}; + + // Group the Snyk vulnerabilities, and link back to the affecting packages. + synkJsonOutput.forEach( + ({ projectName, displayTargetFile, vulnerabilities }) => { + vulnerabilities.forEach( + ({ id, description }: { id: string; description: string }) => { + if (id !== undefined && description !== undefined) { + if (vulnerabilityStore[id]) { + if ( + !vulnerabilityStore[id].packages.some( + ({ name }) => name === projectName, + ) + ) { + vulnerabilityStore[id].packages.push({ + name: projectName, + target: displayTargetFile, + }); + } + } else { + vulnerabilityStore[id] = { + description, + snykId: id, + packages: [ + { + name: projectName, + target: displayTargetFile, + }, + ], + }; + } + } + }, + ); + }, + ); + + for (const [id, vulnerability] of Object.entries(vulnerabilityStore)) { + if (snykGithubIssueMap[id]) { + await updateGithubIssue(snykGithubIssueMap[id], vulnerability); + } else { + await createGithubIssue(vulnerability); + } + } + + for (const [snykId, githubIssueId] of Object.entries(snykGithubIssueMap)) { + if (!vulnerabilityStore[snykId]) { + await closeGithubIssue(githubIssueId); + } + } +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/techdocs-cli.js b/scripts/techdocs-cli.js new file mode 100644 index 0000000000..7cc07f15fa --- /dev/null +++ b/scripts/techdocs-cli.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +execSync(`yarn -s workspace @techdocs/cli build`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli link`, { stdio: 'ignore' }); +execSync(`techdocs-cli ${args.join(' ')}`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli unlink`, { stdio: 'ignore' }); diff --git a/yarn.lock b/yarn.lock index 302f77af4a..c8ac75f4b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2172,7 +2172,7 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" -"@backstage/catalog-client@^0.3.16", "@backstage/catalog-client@^0.3.18": +"@backstage/catalog-client@^0.3.18": version "0.3.19" resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-0.3.19.tgz#109b0fde1cc2d9a306635f3775fc8f6174172b14" integrity sha512-0ydcC/1ISNgR8SggOUnMNNHtsRwrKN5GX+AXgTVdZk4qpz1MqG1+fzrNld9V7KVvXdsbGDZAl/b5a7/FJEpCqg== @@ -2390,49 +2390,7 @@ remark-gfm "^1.0.0" zen-observable "^0.8.15" -"@backstage/core-components@^0.7.3": - version "0.7.3" - resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.7.3.tgz#5fbb11e599b44ce14d8daa33355fec58ee1c1634" - integrity sha512-qlr6y0bdH2IK40VfmVNHDVIblzlX9IUmXo77a38C0vJY1mCyoXVXv9pw6Gx74OvE/vBpr2MgMvRIfhZAuJpCHQ== - dependencies: - "@backstage/config" "^0.1.11" - "@backstage/core-plugin-api" "^0.1.13" - "@backstage/errors" "^0.1.4" - "@backstage/theme" "^0.2.13" - "@material-table/core" "^3.1.0" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - "@types/react" "*" - "@types/react-sparklines" "^1.7.0" - "@types/react-text-truncate" "^0.14.0" - classnames "^2.2.6" - clsx "^1.1.0" - d3-selection "^3.0.0" - d3-shape "^3.0.0" - d3-zoom "^3.0.0" - dagre "^0.8.5" - immer "^9.0.1" - lodash "^4.17.21" - 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 "^7.12.2" - react-markdown "^7.0.1" - 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 "^2.0.0" - zen-observable "^0.8.15" - -"@backstage/core-plugin-api@^0.1.13": +"@backstage/core-plugin-api@^0.1.10", "@backstage/core-plugin-api@^0.1.3", "@backstage/core-plugin-api@^0.1.4", "@backstage/core-plugin-api@^0.1.6", "@backstage/core-plugin-api@^0.1.8": version "0.1.13" resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.1.13.tgz#583c0fe3440450c304f13dcc85eac3bd80f357d7" integrity sha512-BWEqleTXR7m7nsCyt6cEnc+Gx/VT4vLdNr9fc1kXfzSAA7cUT4VKjlVmm5kdyih3BSQ4+0k7Pm/NIf0jfa7rlg== @@ -2807,11 +2765,6 @@ resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.7.tgz#0fe1fa0ef02c827919e23c2802a4b25589ac522d" integrity sha512-EG/1qDiQvd12RoNJ6H+sZcHVswC/3uMx/ySvfaJ24vB30rLjkgHggEXbgMbfgki7wMuiQ/zXI8QlmF1k3kWRGQ== -"@date-io/core@^2.10.11": - version "2.10.11" - resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.11.tgz#b1a3d57730f3eaaab54d5658be4a71727297e357" - integrity sha512-keXQnwH0LM8wyvu+j5Z2KGK56D+eItjy7DnwuWl/oV+DM2UEYl0z5WhdPMpfswSyt/kjuPOzcVF/7u/skMLaoA== - "@date-io/date-fns@^1.3.13": version "1.3.13" resolved "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz#7798844041640ab393f7e21a7769a65d672f4735" @@ -2819,12 +2772,12 @@ dependencies: "@date-io/core" "^1.3.13" -"@date-io/luxon@2.x": - version "2.10.11" - resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.10.11.tgz#d0981b9fdf5e5f17f8ce59265a3ac6c335565fac" - integrity sha512-SS6SIkp0Y9GFwpQycCTUAyW3OZTW05CWI1DJu10hUzcg8SmjJfhjs7hQY3TOeW+JT6VtXGTVGwbWPUBJsNkhZg== +"@date-io/luxon@1.x": + version "1.3.13" + resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a" + integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw== dependencies: - "@date-io/core" "^2.10.11" + "@date-io/core" "^1.3.13" "@discoveryjs/json-ext@^0.5.3": version "0.5.5" @@ -2840,7 +2793,7 @@ find-my-way "^2.2.2" into-stream "^5.1.1" -"@elastic/elasticsearch@^7.13.0": +"@elastic/elasticsearch@7.13.0": version "7.13.0" resolved "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.13.0.tgz#6dcf511dfa91187e22c81e54f41f4bd0fd96b4d6" integrity sha512-WgwLWo2p9P2tdqzBGX9fHeG8p5IOTXprXNTECQG2mJ7z9n93N5AFBJpEw4d35tWWeCWi9jI13A2wzQZH7XZ/xw== @@ -3096,16 +3049,14 @@ stream-events "^1.0.1" xdg-basedir "^4.0.0" -"@graphiql/toolkit@^0.2.0": - version "0.2.2" - resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.2.2.tgz#193d570afcf686c9ee61c92054c1782b9f3c1255" - integrity sha512-kDgYhqnS4p4LqSo1KvLd3tbX8Hhdj0ZrgQuGsosjjEnahiPYmmylxUL1p9lj6348OsypcTlCncGpEjeb9S3TiQ== +"@graphiql/toolkit@^0.3.2": + version "0.3.2" + resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.3.2.tgz#551753436ada2bc27ea870b7668e5199a958ccfb" + integrity sha512-IweIT9VC8uDovg7kuCO9YqZcnIuWU8IGzrpUisXv6CUNK2Ed1ke8yERDTMmF/rjvLd2DeVZwM8iEOjEs4sUJQw== dependencies: - "@n1ru4l/push-pull-async-iterable-iterator" "^2.1.4" - graphql-ws "^4.3.2" + "@n1ru4l/push-pull-async-iterable-iterator" "^3.0.0" + graphql-ws "^4.9.0" meros "^1.1.4" - optionalDependencies: - subscriptions-transport-ws "^0.9.18" "@graphql-codegen/cli@^1.21.3": version "1.21.6" @@ -4670,19 +4621,28 @@ resolved "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== -"@microsoft/api-documenter@^7.13.47": - version "7.13.47" - resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.13.47.tgz#0b7726634232b37f76c0e5e8353cdbb5b52d4ece" - integrity sha512-jk78Pf8cKL2WZf6CkKUUtwegdsTA1Jf0MfIzD50qpG7T257HLrqCi1t70ZA85VpRLR8oSeNHMayqNTWkdku9iA== +"@microsoft/api-documenter@^7.13.68": + version "7.13.68" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.13.68.tgz#c1e144764cac0684adefe78fd848d78c3f374681" + integrity sha512-cRjwK1TDyGxFGgCsRG8G0Yi3Z4akvfWgw1pWAxKFbm7ajlQQGZcHPnb+n4lKlSeQ5g/cxc7hcdw54Mvisne9Bg== dependencies: - "@microsoft/api-extractor-model" "7.13.5" + "@microsoft/api-extractor-model" "7.13.16" "@microsoft/tsdoc" "0.13.2" - "@rushstack/node-core-library" "3.40.0" - "@rushstack/ts-command-line" "4.9.0" + "@rushstack/node-core-library" "3.43.2" + "@rushstack/ts-command-line" "4.10.4" colors "~1.2.1" js-yaml "~3.13.1" resolve "~1.17.0" +"@microsoft/api-extractor-model@7.13.16": + version "7.13.16" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.13.16.tgz#1d67541ebbcea32672c5fdd9392dc1579b2fc23a" + integrity sha512-ttdxVXsTWL5dd26W1YNLe3LgDsE0EE273aZlcLe58W0opymBybCYU1Mn+OHQM8BuErrdvdN8LdpWAAbkiOEN/Q== + dependencies: + "@microsoft/tsdoc" "0.13.2" + "@microsoft/tsdoc-config" "~0.15.2" + "@rushstack/node-core-library" "3.43.2" + "@microsoft/api-extractor-model@7.13.5", "@microsoft/api-extractor-model@^7.13.5": version "7.13.5" resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.13.5.tgz#7836a81ba47b9a654062ed0361e4eee69afae51e" @@ -4763,10 +4723,10 @@ outvariant "^1.2.0" strict-event-emitter "^0.2.0" -"@n1ru4l/push-pull-async-iterable-iterator@^2.1.4": - version "2.1.4" - resolved "https://registry.npmjs.org/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-2.1.4.tgz#a90225474352f9f159bff979905f707b9c6bcf04" - integrity sha512-qLIvoOUJ+zritv+BlzcBMePKNjKQzH9Rb2i9W98YXxf/M62Lye8qH0peyiU8yJ1tL0kfulWi31BoK10E6BKJeA== +"@n1ru4l/push-pull-async-iterable-iterator@^3.0.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.1.0.tgz#be450c97d1c7cd6af1a992d53232704454345df9" + integrity sha512-K4scWxGhdQM0masHHy4gIQs2iGiLEXCrXttumknyPJqtdl4J179BjpibWSSQ1fxKdCcHgIlCTKXJU6cMM6D6Wg== "@nodelib/fs.scandir@2.1.3": version "2.1.3" @@ -4844,22 +4804,7 @@ "@gar/promisify" "^1.0.1" semver "^7.3.5" -"@npmcli/git@^2.0.1": - version "2.0.6" - resolved "https://registry.npmjs.org/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2" - integrity sha512-a1MnTfeRPBaKbFY07fd+6HugY1WAkKJzdiJvlRub/9o5xz2F/JtPacZZapx5zRJUQFIzSL677vmTSxEcDMrDbg== - dependencies: - "@npmcli/promise-spawn" "^1.1.0" - lru-cache "^6.0.0" - mkdirp "^1.0.3" - npm-pick-manifest "^6.0.0" - promise-inflight "^1.0.1" - promise-retry "^2.0.1" - semver "^7.3.2" - unique-filename "^1.1.1" - which "^2.0.2" - -"@npmcli/git@^2.1.0": +"@npmcli/git@^2.0.1", "@npmcli/git@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@npmcli/git/-/git-2.1.0.tgz#2fbd77e147530247d37f325930d457b3ebe894f6" integrity sha512-/hBFX/QG1b+N7PZBFs0bi+evgRZcK9nWBxQKZkGoXUT5hJSwl5c4d7y8/hm+NQZRPhQ67RzFaj5UM9YeyKoryw== @@ -4932,7 +4877,7 @@ dependencies: json-parse-even-better-errors "^2.3.1" -"@npmcli/promise-spawn@^1.1.0", "@npmcli/promise-spawn@^1.2.0", "@npmcli/promise-spawn@^1.3.2": +"@npmcli/promise-spawn@^1.2.0", "@npmcli/promise-spawn@^1.3.2": version "1.3.2" resolved "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-1.3.2.tgz#42d4e56a8e9274fba180dabc0aea6e38f29274f5" integrity sha512-QyAGYo/Fbj4MXeGdJcFzZ+FkDkomfRBrPM+9QYJSg+PxgAUL+LU3FneQk37rKR2/zjqkCV1BLHccX98wRXG3Sg== @@ -5021,6 +4966,19 @@ before-after-hook "^2.1.0" universal-user-agent "^6.0.0" +"@octokit/core@^3.5.1": + version "3.5.1" + resolved "https://registry.npmjs.org/@octokit/core/-/core-3.5.1.tgz#8601ceeb1ec0e1b1b8217b960a413ed8e947809b" + integrity sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.6.0" + "@octokit/request-error" "^2.0.5" + "@octokit/types" "^6.0.3" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + "@octokit/endpoint@^6.0.1": version "6.0.3" resolved "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.3.tgz#dd09b599662d7e1b66374a177ab620d8cdf73487" @@ -5055,6 +5013,11 @@ "@octokit/types" "^6.12.2" btoa-lite "^1.0.0" +"@octokit/openapi-types@^11.2.0": + version "11.2.0" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-11.2.0.tgz#b38d7fc3736d52a1e96b230c1ccd4a58a2f400a6" + integrity sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA== + "@octokit/openapi-types@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-7.3.2.tgz#065ce49b338043ec7f741316ce06afd4d459d944" @@ -5065,6 +5028,13 @@ resolved "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-6.0.1.tgz#e07896739618dab8da7d4077c658003775f95437" integrity sha512-93uGjlhUD+iNg1iWhUENAtJata6w5nE+V4urXOAlIXdco6xNZtUSfYY8dzp3Udy74aqO/B5UZL80x/YMa5PKRw== +"@octokit/plugin-paginate-rest@^2.16.8": + version "2.17.0" + resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.17.0.tgz#32e9c7cab2a374421d3d0de239102287d791bce7" + integrity sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw== + dependencies: + "@octokit/types" "^6.34.0" + "@octokit/plugin-paginate-rest@^2.6.2": version "2.7.0" resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.7.0.tgz#6bb7b043c246e0654119a6ec4e72a172c9e2c7f3" @@ -5077,6 +5047,11 @@ resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44" integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg== +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + "@octokit/plugin-rest-endpoint-methods@5.3.1": version "5.3.1" resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.3.1.tgz#deddce769b4ec3179170709ab42e4e9e6195aaa9" @@ -5085,6 +5060,14 @@ "@octokit/types" "^6.16.2" deprecation "^2.3.1" +"@octokit/plugin-rest-endpoint-methods@^5.12.0": + version "5.13.0" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.13.0.tgz#8c46109021a3412233f6f50d28786f8e552427ba" + integrity sha512-uJjMTkN1KaOIgNtUPMtIXDOjx6dGYysdIFhgA52x4xSadQCz3b/zJexvITDVpANnfKPW/+E0xkOvLntqMYpviA== + dependencies: + "@octokit/types" "^6.34.0" + deprecation "^2.3.1" + "@octokit/request-error@^2.0.0", "@octokit/request-error@^2.0.2", "@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" @@ -5116,6 +5099,16 @@ "@octokit/plugin-request-log" "^1.0.2" "@octokit/plugin-rest-endpoint-methods" "5.3.1" +"@octokit/rest@^18.12.0": + version "18.12.0" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.12.0.tgz#f06bc4952fc87130308d810ca9d00e79f6988881" + integrity sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q== + dependencies: + "@octokit/core" "^3.5.1" + "@octokit/plugin-paginate-rest" "^2.16.8" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^5.12.0" + "@octokit/types@^5.0.0", "@octokit/types@^5.0.1": version "5.5.0" resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b" @@ -5130,24 +5123,31 @@ dependencies: "@octokit/openapi-types" "^7.3.2" +"@octokit/types@^6.34.0": + version "6.34.0" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.34.0.tgz#c6021333334d1ecfb5d370a8798162ddf1ae8218" + integrity sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw== + dependencies: + "@octokit/openapi-types" "^11.2.0" + "@octokit/webhooks-methods@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-2.0.0.tgz#1108b9ea661ca6c81e4a8bfa63a09eb27d5bc2db" integrity sha512-35cfQ4YWlnZnmZKmIxlGPUPLtbkF8lr/A/1Sk1eC0ddLMwQN06dOuLc+dI3YLQS+T+MoNt3DIQ0NynwgKPilig== -"@octokit/webhooks-types@4.12.0": - version "4.12.0" - resolved "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-4.12.0.tgz#add8b98d60ab42e965c4ba31040097f810e222de" - integrity sha512-G0k7CoS9bK+OI7kPHgqi1KqK4WhrjDQSjy0wJI+0OTx/xvbHUIZDeqatY60ceeRINP/1ExEk6kTARboP0xavEw== +"@octokit/webhooks-types@4.15.0": + version "4.15.0" + resolved "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-4.15.0.tgz#1158cba6578237d60957a37963a4a05654f5668b" + integrity sha512-s9LgKsUzq/JH3PWDjaD/m1DIlC/QWgBWbmXVqjdxJXJQBA67KZrLWjStVlYPf0mWlVZ1MOKphDyHiOGCbs0+Kg== "@octokit/webhooks@^9.14.1": - version "9.17.0" - resolved "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-9.17.0.tgz#81140b2e127157aa9817d085cd8758545e4a72e4" - integrity sha512-/+9WSLuDuoqNWnMY4w6ePioILBqsUOiUt0ygQzugYzd112WB+yPIjmUQjAbNXImDsAa1myLpBICAMQDZlULyAA== + version "9.18.0" + resolved "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-9.18.0.tgz#19cc70e1ef281e33d830ea23e8011d25d8051f7f" + integrity sha512-N2hP7vCouKk9UWZxvqgWTPbp34i6g9Om/jk+TZeZ5Z+VsKjXvGtONlEd9H8DM1yOeEC+ARDpfhraX6UsK5tesQ== dependencies: "@octokit/request-error" "^2.0.2" "@octokit/webhooks-methods" "^2.0.0" - "@octokit/webhooks-types" "4.12.0" + "@octokit/webhooks-types" "4.15.0" aggregate-error "^3.1.0" "@open-draft/until@^1.0.3": @@ -5270,25 +5270,25 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rjsf/core@^3.0.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@rjsf/core/-/core-3.2.0.tgz#189ff7fb132cb223463792d75fb1e04e9dd5d9f9" - integrity sha512-Cgonq9bBWlpE0yeeIv7rAIDuJvO1wAvp9G7tT/UvhODAyb4tDb1q8J/8ubWuRRiEyzYUkuxN0W8uPIzQsJgtTQ== +"@rjsf/core@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@rjsf/core/-/core-3.2.1.tgz#8a7b24c9a6f01f0ecb093fdfc777172c12b1b009" + integrity sha512-dk8ihvxFbcuIwU7G+HiJbFgwyIvaumPt5g5zfnuC26mwTUPlaDGFXKK2yITp8tJ3+hcwS5zEXtAN9wUkfuM4jA== dependencies: "@types/json-schema" "^7.0.7" ajv "^6.7.0" core-js-pure "^3.6.5" json-schema-merge-allof "^0.6.0" - jsonpointer "^4.0.1" + jsonpointer "^5.0.0" lodash "^4.17.15" nanoid "^3.1.23" prop-types "^15.7.2" react-is "^16.9.0" -"@rjsf/material-ui@^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-3.1.0.tgz#ed15fc75de1594f87cd8336b7f2ebc492a432d8b" - integrity sha512-kWz37spT5SOXkb8Axq4g4BzQjXRylQr6B7eFAg1NPhSK7KrJYwSMSsFJ9Ze2vEBxwEiR0Z0n/4puaamGuGFRBw== +"@rjsf/material-ui@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-3.2.1.tgz#84fbf322485aee3a84101e189161f0687779ec8d" + integrity sha512-8UiDeDbjCImFSfOegGu13otQ7OdP9FOYpcLjeouppnhs+MPeIEAtYS+jCcBKmi3reyTagC15/KVSRhde1wS1vg== "@roadiehq/backstage-plugin-buildkite@^1.0.8": version "1.0.8" @@ -5384,10 +5384,10 @@ react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -"@rollup/plugin-commonjs@^17.1.0": - version "17.1.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" - integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.1" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee" + integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -5457,6 +5457,21 @@ timsort "~0.3.0" z-schema "~3.18.3" +"@rushstack/node-core-library@3.43.2": + version "3.43.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.43.2.tgz#f067371a94fd92ed8f9d9aa8201c5e9e17a19f0f" + integrity sha512-b7AEhSf6CvZgvuDcWMFDeKx2mQSn9AVnMQVyxNxFeHCtLz3gJicqCOlw2GOXM8HKh6PInLdil/NVCDcstwSrIw== + dependencies: + "@types/node" "12.20.24" + colors "~1.2.1" + fs-extra "~7.0.1" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.17.0" + semver "~7.3.0" + timsort "~0.3.0" + z-schema "~3.18.3" + "@rushstack/rig-package@0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.0.tgz#334ad2846797861361b3445d4cc9ae9164b1885c" @@ -5465,6 +5480,16 @@ resolve "~1.17.0" strip-json-comments "~3.1.1" +"@rushstack/ts-command-line@4.10.4": + version "4.10.4" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.4.tgz#05142b74e5cb207d3dd9b935c82f80d7fcb68042" + integrity sha512-4T5ao4UgDb6LmiRj4GumvG3VT/p6RSMgl7TN7S58ifaAGN2GeTNBajFCDdJs9QQP0d/4tA5p0SFzT7Ps5Byirg== + dependencies: + "@types/argparse" "1.0.38" + argparse "~1.0.9" + colors "~1.2.1" + string-argv "~0.3.1" + "@rushstack/ts-command-line@4.9.0": version "4.9.0" resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.9.0.tgz#781ba42cff73cae097b6d5241b6441e7cc2fe6e0" @@ -5556,15 +5581,15 @@ resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-12.0.0.tgz#0b1e41bb436d5c1c20714703629514d64c3c0f06" integrity sha512-5Uud/TmzakqmdUNCZpD8JFQRa2VG3dVd3DanSMpU/nVdu6K5LyX8EMU3Tz1vGP18Wih8iAu/sBSJhntNzw7e6w== -"@spotify/eslint-config-react@^10.0.0": - version "10.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-10.0.0.tgz#6f83ada05f79b49c1f9def5b8815e3231ed24969" - integrity sha512-MozX6W3aMp7EQPliuUQYI58Ni5vh65mItXMG0CgZBj0v1ZEeZVM5XS/nqhsCaIHYXskmZM2O1qqLFaEg5PqGdg== +"@spotify/eslint-config-react@^12.0.0": + version "12.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-12.0.0.tgz#5b8d4bc3b81a8ec2824648f482f1f6c3cf711893" + integrity sha512-lNHZRtJesNA273OJHBVUGAg2JYyVDZ+bsT7h3OwnX1HYgejJ3YcKPSziPM8TGFAN8DruH3tHFfaM63uAIA1+uw== -"@spotify/eslint-config-typescript@^10.0.0": - version "10.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-10.0.0.tgz#4df7074f3f4ef31d76c617e55d335f9a36cfed5b" - integrity sha512-qR4WOU3gJrpz26O8BlNbXas4Yj93NeVH7yvULVYO2j9bCAEZJu2sfl1BGfOy4qAsYGutZhJtNwMqK0Rl4DcFHQ== +"@spotify/eslint-config-typescript@^12.0.0": + version "12.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-12.0.0.tgz#4c7af3f74a47668bec0c860b72e2a0103e78a138" + integrity sha512-nMVll8ZkN/W8+IHn6Iz3YzCKW0qhrn3TVfyxkAr3qmXm5cex+GzyUdZEuxb8rdN2inZL6A1Il2NFfO5p/UKxog== "@spotify/prettier-config@^11.0.0": version "11.0.0" @@ -5572,18 +5597,18 @@ integrity sha512-dOI13j1uHMZkRxhZuge/ugOE7Aqcg7Nxki932lDZuXyY4G8CGxkc/66PeQ8pR4PCzThHORXo7Ptvau6bh101lQ== "@storybook/addon-a11y@^6.3.4": - version "6.3.7" - resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.7.tgz#a802455f2d932eda07314e3d44a96c94bbd22b3d" - integrity sha512-Z5Lhxm8r5CkPW9FYf6zmAk9c7IhUeUQZxKZeEWGZdOvcjQ32rtg4IYvO2SHgWNrEKBdxxFm3pMiyK3wylQLfsQ== + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.12.tgz#2f930fc84fc275a4ed43a716fc09cc12caf4e110" + integrity sha512-q1NdRHFJV6sLEEJw0hatCc5ZIthELqM/AWdrEWDyhcJNyiq7Tq4nKqQBMTQSYwHiUAmxVgw7i4oa1vM2M51/3g== dependencies: - "@storybook/addons" "6.3.7" - "@storybook/api" "6.3.7" - "@storybook/channels" "6.3.7" - "@storybook/client-api" "6.3.7" - "@storybook/client-logger" "6.3.7" - "@storybook/components" "6.3.7" - "@storybook/core-events" "6.3.7" - "@storybook/theming" "6.3.7" + "@storybook/addons" "6.3.12" + "@storybook/api" "6.3.12" + "@storybook/channels" "6.3.12" + "@storybook/client-api" "6.3.12" + "@storybook/client-logger" "6.3.12" + "@storybook/components" "6.3.12" + "@storybook/core-events" "6.3.12" + "@storybook/theming" "6.3.12" axe-core "^4.2.0" core-js "^3.8.2" global "^4.4.0" @@ -5654,7 +5679,7 @@ react-syntax-highlighter "^13.5.3" regenerator-runtime "^0.13.7" -"@storybook/addons@6.3.11", "@storybook/addons@^6.1.11": +"@storybook/addons@6.3.11": version "6.3.11" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.11.tgz#4b5e88793bcce7ef823340e9010a96e35e3284cc" integrity sha512-2Y03lOwzWDRB/glISa/4luBMM5uyYhkIBixbZF9miIb2SCWRlNmom5NCnKsR18Wu6g7zI7os3aAMfKr24aSofQ== @@ -5669,6 +5694,21 @@ global "^4.4.0" regenerator-runtime "^0.13.7" +"@storybook/addons@6.3.12", "@storybook/addons@^6.1.11": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.12.tgz#8773dcc113c5086dfff722388b7b65580e43b65b" + integrity sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg== + dependencies: + "@storybook/api" "6.3.12" + "@storybook/channels" "6.3.12" + "@storybook/client-logger" "6.3.12" + "@storybook/core-events" "6.3.12" + "@storybook/router" "6.3.12" + "@storybook/theming" "6.3.12" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + "@storybook/addons@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.7.tgz#7c6b8d11b65f67b1884f6140437fe996dc39537a" @@ -5710,6 +5750,32 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/api@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.12.tgz#2845c20464d5348d676d09665e8ab527825ed7b5" + integrity sha512-LScRXUeCWEW/OP+jiooNMQICVdusv7azTmULxtm72fhkXFRiQs2CdRNTiqNg46JLLC9z95f1W+pGK66X6HiiQA== + dependencies: + "@reach/router" "^1.3.4" + "@storybook/channels" "6.3.12" + "@storybook/client-logger" "6.3.12" + "@storybook/core-events" "6.3.12" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.3.12" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.3.12" + "@types/reach__router" "^1.3.7" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.20" + memoizerific "^1.11.3" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + store2 "^2.12.0" + telejson "^5.3.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/api@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.7.tgz#88b8a51422cd0739c91bde0b1d65fb6d8a8485d0" @@ -5825,6 +5891,19 @@ qs "^6.10.0" telejson "^5.3.2" +"@storybook/channel-postmessage@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.3.12.tgz#3ff9412ac0f445e3b8b44dd414e783a5a47ff7c1" + integrity sha512-Ou/2Ga3JRTZ/4sSv7ikMgUgLTeZMsXXWLXuscz4oaYhmOqAU9CrJw0G1NitwBgK/+qC83lEFSLujHkWcoQDOKg== + dependencies: + "@storybook/channels" "6.3.12" + "@storybook/client-logger" "6.3.12" + "@storybook/core-events" "6.3.12" + core-js "^3.8.2" + global "^4.4.0" + qs "^6.10.0" + telejson "^5.3.2" + "@storybook/channel-postmessage@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.3.7.tgz#bd4edf84a29aa2cd4a22d26115c60194d289a840" @@ -5847,6 +5926,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/channels@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.3.12.tgz#aa0d793895a8b211f0ad3459c61c1bcafd0093c7" + integrity sha512-l4sA+g1PdUV8YCbgs47fIKREdEQAKNdQIZw0b7BfTvY9t0x5yfBywgQhYON/lIeiNGz2OlIuD+VUtqYfCtNSyw== + dependencies: + core-js "^3.8.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/channels@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.3.7.tgz#85ed5925522b802d959810f78d37aacde7fea66e" @@ -5880,6 +5968,30 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/client-api@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.12.tgz#a0c6d72a871d1cb02b4b98675472839061e39b5b" + integrity sha512-xnW+lKKK2T774z+rOr9Wopt1aYTStfb86PSs9p3Fpnc2Btcftln+C3NtiHZl8Ccqft8Mz/chLGgewRui6tNI8g== + dependencies: + "@storybook/addons" "6.3.12" + "@storybook/channel-postmessage" "6.3.12" + "@storybook/channels" "6.3.12" + "@storybook/client-logger" "6.3.12" + "@storybook/core-events" "6.3.12" + "@storybook/csf" "0.0.1" + "@types/qs" "^6.9.5" + "@types/webpack-env" "^1.16.0" + core-js "^3.8.2" + global "^4.4.0" + lodash "^4.17.20" + memoizerific "^1.11.3" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + stable "^0.1.8" + store2 "^2.12.0" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-api@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.7.tgz#cb1dca05467d777bd09aadbbdd1dd22ca537ce14" @@ -5912,6 +6024,14 @@ core-js "^3.8.2" global "^4.4.0" +"@storybook/client-logger@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.3.12.tgz#6585c98923b49fcb25dbceeeb96ef2a83e28e0f4" + integrity sha512-zNDsamZvHnuqLznDdP9dUeGgQ9TyFh4ray3t1VGO7ZqWVZ2xtVCCXjDvMnOXI2ifMpX5UsrOvshIPeE9fMBmiQ== + dependencies: + core-js "^3.8.2" + global "^4.4.0" + "@storybook/client-logger@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.3.7.tgz#ff17b7494e7e9e23089b0d5c5364c371c726bdd1" @@ -5950,6 +6070,36 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/components@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.12.tgz#0c7967c60354c84afa20dfab4753105e49b1927d" + integrity sha512-kdQt8toUjynYAxDLrJzuG7YSNL6as1wJoyzNUaCfG06YPhvIAlKo7le9tS2mThVFN5e9nbKrW3N1V1sp6ypZXQ== + dependencies: + "@popperjs/core" "^2.6.0" + "@storybook/client-logger" "6.3.12" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.3.12" + "@types/color-convert" "^2.0.0" + "@types/overlayscrollbars" "^1.12.0" + "@types/react-syntax-highlighter" "11.0.5" + color-convert "^2.0.1" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.20" + markdown-to-jsx "^7.1.3" + memoizerific "^1.11.3" + overlayscrollbars "^1.13.1" + polished "^4.0.5" + prop-types "^15.7.2" + react-colorful "^5.1.2" + react-popper-tooltip "^3.1.1" + react-syntax-highlighter "^13.5.3" + react-textarea-autosize "^8.3.0" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/components@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.7.tgz#42b1ca6d24e388e02eab82aa9ed3365db2266ecc" @@ -6064,6 +6214,13 @@ dependencies: core-js "^3.8.2" +"@storybook/core-events@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.3.12.tgz#73f6271d485ef2576234e578bb07705b92805290" + integrity sha512-SXfD7xUUMazaeFkB92qOTUV8Y/RghE4SkEYe5slAdjeocSaH7Nz2WV0rqNEgChg0AQc+JUI66no8L9g0+lw4Gw== + dependencies: + core-js "^3.8.2" + "@storybook/core-events@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.3.7.tgz#c5bc7cae7dc295de73b6b9f671ecbe582582e9bd" @@ -6258,6 +6415,22 @@ qs "^6.10.0" ts-dedent "^2.0.0" +"@storybook/router@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.3.12.tgz#0d572ec795f588ca886f39cb9b27b94ff3683f84" + integrity sha512-G/pNGCnrJRetCwyEZulHPT+YOcqEj/vkPVDTUfii2qgqukup6K0cjwgd7IukAURnAnnzTi1gmgFuEKUi8GE/KA== + dependencies: + "@reach/router" "^1.3.4" + "@storybook/client-logger" "6.3.12" + "@types/reach__router" "^1.3.7" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.20" + memoizerific "^1.11.3" + qs "^6.10.0" + ts-dedent "^2.0.0" + "@storybook/router@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/router/-/router-6.3.7.tgz#1714a99a58a7b9f08b6fcfe2b678dad6ca896736" @@ -6316,6 +6489,24 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" +"@storybook/theming@6.3.12": + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.3.12.tgz#5bddf9bd90a60709b5ab238ecdb7d9055dd7862e" + integrity sha512-wOJdTEa/VFyFB2UyoqyYGaZdym6EN7RALuQOAMT6zHA282FBmKw8nL5DETHEbctpnHdcrMC/391teK4nNSrdOA== + dependencies: + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.27" + "@storybook/client-logger" "6.3.12" + core-js "^3.8.2" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.27" + global "^4.4.0" + memoizerific "^1.11.3" + polished "^4.0.5" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + "@storybook/theming@6.3.7": version "6.3.7" resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.3.7.tgz#6daf9a21b26ed607f3c28a82acd90c0248e76d8b" @@ -6604,16 +6795,21 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@trendyol-js/openstack-swift-sdk@^0.0.4": - version "0.0.4" - resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.4.tgz#570c6ab950319156c175ace005b4fb4d9f895d47" - integrity sha512-9YKOjov+V+yzptei6+B9QPuC5pOMTBTg/NQpb1ZbxvlOaYpWU4HHpSH2BkIFYZ8vYyAfzFNG1T2rjpQ2ZQDUtQ== +"@trendyol-js/openstack-swift-sdk@^0.0.5": + version "0.0.5" + resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.5.tgz#65be3c42b8dbafc57f2f2a46c327e2ad51e5a70e" + integrity sha512-KS5nz0cvd35UUyMzhZm+btGV4prtA1KNE7CCMOGBdVxoMGl06Qidli3HgHoc2I9jLPmky1SPp5yzQUwrsyWa0g== dependencies: agentkeepalive "^4.1.4" axios "^0.21.1" axios-cached-dns-resolve "0.5.2" file-type "16.5.3" +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -6653,6 +6849,13 @@ dependencies: "@types/glob" "*" +"@types/archiver@^5.1.1": + version "5.1.1" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.1.tgz#d6d7610de4386b293abd5c1cb1875e0a4f4e1c30" + integrity sha512-heuaCk0YH5m274NOLSi66H1zX6GtZoMsdE6TYFcpFFjBjg0FoU4i4/M/a/kNlgNg26Xk3g364mNOYe1JaiEPOQ== + dependencies: + "@types/glob" "*" + "@types/argparse@1.0.38": version "1.0.38" resolved "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" @@ -6749,6 +6952,14 @@ dependencies: classnames "*" +"@types/clean-css@*": + version "4.2.5" + resolved "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89" + integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw== + dependencies: + "@types/node" "*" + source-map "^0.6.0" + "@types/codemirror@^0.0.108": version "0.0.108" resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz#e640422b666bf49251b384c390cdeb2362585bde" @@ -6780,6 +6991,13 @@ resolved "https://registry.npmjs.org/@types/command-exists/-/command-exists-1.2.0.tgz#d97e0ed10097090e4ab0367ed425b0312fad86f3" integrity sha512-ugsxEJfsCuqMLSuCD4PIJkp5Uk2z6TCMRCgYVuhRo5cYQY3+1xXTQkSlPtkpGHuvWMjS2KTeVQXxkXRACMbM6A== +"@types/commander@^2.12.2": + version "2.12.2" + resolved "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" + integrity sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q== + dependencies: + commander "*" + "@types/compression@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" @@ -6930,19 +7148,19 @@ "@types/ms" "*" "@types/diff@^5.0.0": - version "5.0.0" - resolved "https://registry.npmjs.org/@types/diff/-/diff-5.0.0.tgz#eb71e94feae62548282c4889308a3dfb57e36020" - integrity sha512-jrm2K65CokCCX4NmowtA+MfXyuprZC13jbRuwprs6/04z/EcFg/MCwYdsHn+zgV4CQBiATiI7AEq7y1sZCtWKA== + version "5.0.1" + resolved "https://registry.npmjs.org/@types/diff/-/diff-5.0.1.tgz#9c9b9a331d4e41ccccff553f5d7ef964c6cf4042" + integrity sha512-XIpxU6Qdvp1ZE6Kr3yrkv1qgUab0fyf4mHYvW8N3Bx3PCsbN6or1q9/q72cv5jIFWolaGH08U9XyYoLLIykyKQ== "@types/docker-modem@*": - version "3.0.1" - resolved "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.1.tgz#2a29c914dd64c4c9213510d2ce75fa85232934df" - integrity sha512-ZUXPF0WNnvs7AxoQRijt+DW2jsXnWCk8ac28tTYzTpBNnOEIAw83A+pYkCXjTFdJHMTc+wUmwNr71Zy2TRjlWg== + version "3.0.2" + resolved "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.2.tgz#c49c902e17364fc724e050db5c1d2b298c6379d4" + integrity sha512-qC7prjoEYR2QEe6SmCVfB1x3rfcQtUr1n4x89+3e0wSTMQ/KYCyf+/RAA9n2tllkkNc6//JMUZePdFRiGIWfaQ== dependencies: "@types/node" "*" "@types/ssh2" "*" -"@types/dockerode@^3.2.1": +"@types/dockerode@^3.2.5", "@types/dockerode@^3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz#d6afcc1de31e211ee900da3a48ef4f1a496cf05a" integrity sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg== @@ -7036,6 +7254,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.6": + version "9.0.13" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -7093,6 +7318,24 @@ resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== +"@types/html-minifier@*": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-4.0.1.tgz#9486ffc144f8d7b8f75b07939c500ac3d73617a0" + integrity sha512-6u58FWQbWP45bsxVeMJo0yk2LEsjjZsCwn0JDe/i5Edk3L+b9TR5eZ2FGaMCrLdtGYpME5AGxUqv8o+3hWKogw== + dependencies: + "@types/clean-css" "*" + "@types/relateurl" "*" + "@types/uglify-js" "*" + +"@types/html-webpack-plugin@*": + version "3.2.6" + resolved "https://registry.npmjs.org/@types/html-webpack-plugin/-/html-webpack-plugin-3.2.6.tgz#07951aaf0fa260dbf626f9644f1d13106d537625" + integrity sha512-U8uJSvlf9lwrKG6sKFnMhqY4qJw2QXad+PHlX9sqEXVUMilVt96aVvFde73tzsdXD+QH9JS6kEytuGO2JcYZog== + dependencies: + "@types/html-minifier" "*" + "@types/tapable" "^1" + "@types/webpack" "^4" + "@types/http-assert@*": version "1.5.1" resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" @@ -7212,9 +7455,9 @@ pretty-format "^26.0.0" "@types/jquery@^3.3.34": - version "3.5.5" - resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz#2c63f47c9c8d96693d272f5453602afd8338c903" - integrity sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w== + version "3.5.8" + resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.8.tgz#83bfbcdf4e625c5471590f92703c06aadb052a09" + integrity sha512-cXk6NwqjDYg+UI9p2l3x0YmPa4m7RrXqmbK4IpVVpRJiYXU/QTo+UZrn54qfE1+9Gao4qpYqUnxm5ZCy2FTXAw== dependencies: "@types/sizzle" "*" @@ -7458,6 +7701,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c" integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg== +"@types/node@12.20.24": + version "12.20.24" + resolved "https://registry.npmjs.org/@types/node/-/node-12.20.24.tgz#c37ac69cb2948afb4cef95f424fa0037971a9a5c" + integrity sha512-yxDeaQIAJlMav7fH5AQqPH1u8YIuhYJXYBzxaQ4PifsU0GDO38MSdmEDeRlIxrKbC6NbEaaEHDanWb+y30U8SQ== + "@types/node@^12.7.1": version "12.12.58" resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c" @@ -7484,9 +7732,9 @@ integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA== "@types/nunjucks@^3.1.4": - version "3.1.5" - resolved "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.1.5.tgz#e0782333b75b4ff9ac9213aa160ca8c5132eaf57" - integrity sha512-0zEdmQNNvQ+xyV9kqQvAV93UVroTwhE78toVUDT0GBnGcW2jQBZnB4al9qq2LqI5qHOqROy/DvvAY/UwrbvV1A== + version "3.2.0" + resolved "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.0.tgz#2303629c91cdd39de103af979046d4a8590351e4" + integrity sha512-1FM36Hm3EdidJmWlZZafkg/kZME0UZ/0vQ46JE8R7R0JqQafah0r+d4i6d/MJg5DnKxEeAOAeifEVkzo7fEvGg== "@types/oauth@*": version "0.9.1" @@ -7616,6 +7864,17 @@ dependencies: "@types/react" "*" +"@types/react-dev-utils@^9.0.4": + version "9.0.8" + resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.8.tgz#7e4d63d1e1c71cd236c9055bc0c0dbaa3772bcf9" + integrity sha512-H/R8BvtCf9BUWPLL9a2agUWWBOKQQPkBIe5osdrgGaDJHZggQRiNN3emH16rQkzm5zi6TVuslOFrYrfMx+QTjw== + dependencies: + "@types/eslint" "*" + "@types/express" "*" + "@types/html-webpack-plugin" "*" + "@types/webpack" "^4" + "@types/webpack-dev-server" "^3" + "@types/react-dom@*", "@types/react-dom@>=16.9.0": version "17.0.9" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -7726,6 +7985,11 @@ resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.2.tgz#a1ad747fbcc6726643a8eb2c42bb804bbf34ce02" integrity sha512-i7KOGl6xdkfpq5+p2ooC+/XFIRUMkYymZ29SD8p+Ko9lesKGUsh6860ey3YM7Y+ZG7kEDGcjzyLO3sOhozqEeA== +"@types/relateurl@*": + version "0.2.29" + resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6" + integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg== + "@types/request@^2.47.1": version "2.48.5" resolved "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" @@ -7787,6 +8051,13 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-7.3.8.tgz#508a27995498d7586dcecd77c25e289bfaf90c59" integrity sha512-D/2EJvAlCEtYFEYmmlGwbGXuK886HzyCc3nZX/tkFTQdEU8jZDAgiv08P162yB17y4ZXZoq7yFAnW4GDBb9Now== +"@types/serve-handler@^6.1.0": + version "6.1.1" + resolved "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.1.tgz#629dc9a62b201ab79a216e1e46e162aa4c8d1455" + integrity sha512-bIwSmD+OV8w0t2e7EWsuQYlGoS1o5aEdVktgkXaa43Zm0qVWi21xaSRb3DQA1UXD+DJ5bRq1Rgu14ZczB+CjIQ== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.9" resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -7832,10 +8103,10 @@ "@types/node" "*" "@types/ssh2-streams" "*" -"@types/ssh2@^0.5.45": - version "0.5.46" - resolved "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.46.tgz#e12341a242aea0e98ac2dec89e039bf421fd3584" - integrity sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA== +"@types/ssh2@^0.5.48": + version "0.5.48" + resolved "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.48.tgz#0d9e8654a76eaaf4cfeaeb88d74c4489cfcf7aea" + integrity sha512-cmQu0gp/6RtDXe1r2xXGgi0V0TeCdueDSRMEvBX8cTRT/sSREkUpgCYZLyh+iI8Ql+VNV8Az9toQoYa/IdgHbQ== dependencies: "@types/node" "*" "@types/ssh2-streams" "*" @@ -7981,9 +8252,9 @@ integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== "@types/unzipper@^0.10.3": - version "0.10.3" - resolved "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.3.tgz#9eea872fb1fa460da76f253878b6275af588f464" - integrity sha512-01mQdTLp3/KuBVDhP82FNBf+enzVOjJ9dGsCWa5z8fcYAFVgA9bqIQ2NmsgNFzN/DhD0PUQj4n5p7k6I9mq80g== + version "0.10.4" + resolved "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.4.tgz#db5be3e1f7d37fdfae290024ffe4f46bdcfa47f2" + integrity sha512-mryXpAwwQadmfjKWoR7NXnELZVlU90xTON1v3Pq2AcOmuAPFkPh09E0X8fpbx2zofoR5zmOIxGqmWOhD0qXE7g== dependencies: "@types/node" "*" @@ -8000,6 +8271,17 @@ "@types/expect" "^1.20.4" "@types/node" "*" +"@types/webpack-dev-server@^3": + version "3.11.6" + resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.6.tgz#d8888cfd2f0630203e13d3ed7833a4d11b8a34dc" + integrity sha512-XCph0RiiqFGetukCTC3KVnY1jwLcZ84illFRMbyFzCcWl90B/76ew0tSqF46oBhnLC4obNDG7dMO0JfTN0MgMQ== + dependencies: + "@types/connect-history-api-fallback" "*" + "@types/express" "*" + "@types/serve-static" "*" + "@types/webpack" "^4" + http-proxy-middleware "^1.0.0" + "@types/webpack-dev-server@^3.11.5": version "3.11.5" resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.5.tgz#f4a254a3dd0667c8ee4af90d42afdb4ad1d607f3" @@ -8016,6 +8298,11 @@ resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== +"@types/webpack-env@^1.15.3": + version "1.16.3" + resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a" + integrity sha512-9gtOPPkfyNoEqCQgx4qJKkuNm/x0R2hKR7fdl7zvTJyHnIisuE/LfvXOsYWL0o3qq6uiBnKZNNNzi3l0y/X+xw== + "@types/webpack-sources@*": version "0.1.6" resolved "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.6.tgz#3d21dfc2ec0ad0c77758e79362426a9ba7d7cbcb" @@ -8719,7 +9006,7 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" -alphanum-sort@^1.0.0: +alphanum-sort@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= @@ -8834,7 +9121,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== -any-promise@^1.0.0, any-promise@^1.1.0: +any-promise@^1.0.0: version "1.3.0" resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= @@ -8920,10 +9207,10 @@ apollo-server-caching@^0.7.0: dependencies: lru-cache "^6.0.0" -apollo-server-core@^2.21.0, apollo-server-core@^2.25.2: - version "2.25.2" - resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.25.2.tgz#ff65da5e512d9b5ca54c8e5e8c78ee28b5987247" - integrity sha512-lrohEjde2TmmDTO7FlOs8x5QQbAS0Sd3/t0TaK2TWaodfzi92QAvIsq321Mol6p6oEqmjm8POIDHW1EuJd7XMA== +apollo-server-core@^2.25.3: + version "2.25.3" + resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.25.3.tgz#1a649fd14b3928f5b6e65f0002b380fcfde56862" + integrity sha512-Midow3uZoJ9TjFNeCNSiWElTVZlvmB7G7tG6PPoxIR9Px90/v16Q6EzunDIO0rTJHRC3+yCwZkwtf8w2AcP0sA== dependencies: "@apollographql/apollo-tools" "^0.5.0" "@apollographql/graphql-playground-html" "1.6.27" @@ -8964,10 +9251,10 @@ apollo-server-errors@^2.5.0: resolved "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.5.0.tgz#5d1024117c7496a2979e3e34908b5685fe112b68" integrity sha512-lO5oTjgiC3vlVg2RKr3RiXIIQ5pGXBFxYGGUkKDhTud3jMIhs+gel8L8zsEjKaKxkjHhCQAA/bcEfYiKkGQIvA== -apollo-server-express@^2.16.1, apollo-server-express@^2.21.0: - version "2.25.2" - resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.25.2.tgz#58cd819694ff4c2dec6945a95c5dff6aa2719ef6" - integrity sha512-A2gF2e85vvDugPlajbhr0A14cDFDIGX0mteNOJ8P3Z3cIM0D4hwrWxJidI+SzobefDIyIHu1dynFedJVhV0euQ== +apollo-server-express@^2.16.1, apollo-server-express@^2.25.3: + version "2.25.3" + resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.25.3.tgz#33fe0dae27fa71c8710e714efd93451bf2eb105f" + integrity sha512-tTFYn0oKH2qqLwVj7Ez2+MiKleXACODiGh5IxsB7VuYCPMAi9Yl8iUSlwTjQUvgCWfReZjnf0vFL2k5YhDlrtQ== dependencies: "@apollographql/graphql-playground-html" "1.6.27" "@types/accepts" "^1.3.5" @@ -8976,7 +9263,7 @@ apollo-server-express@^2.16.1, apollo-server-express@^2.21.0: "@types/express" "^4.17.12" "@types/express-serve-static-core" "^4.17.21" accepts "^1.3.5" - apollo-server-core "^2.25.2" + apollo-server-core "^2.25.3" apollo-server-types "^0.9.0" body-parser "^1.18.3" cors "^2.8.5" @@ -9004,12 +9291,12 @@ apollo-server-types@^0.9.0: apollo-server-env "^3.1.0" apollo-server@^2.16.1: - version "2.21.0" - resolved "https://registry.npmjs.org/apollo-server/-/apollo-server-2.21.0.tgz#4e62131885b4a8a26bb8b5e77177bd0d4d210852" - integrity sha512-OqngjOSB0MEH6VKGWHcrqt4y39HlhYh9CrMvn4PhadTt53IPYRmBglk5qSRA8xMorGqy60iKrOReqj5YfCjTOg== + version "2.25.3" + resolved "https://registry.npmjs.org/apollo-server/-/apollo-server-2.25.3.tgz#2e5db9ce5217389625ac5014551dcbdeeedcd1d8" + integrity sha512-+eUY2//DLkU7RkJLn6CTl1P89/ZMHuUQnWqv8La2iJ2hLT7Me+nMx+hgHl3LqlT/qDstQ8qA45T85FuCayplmQ== dependencies: - apollo-server-core "^2.21.0" - apollo-server-express "^2.21.0" + apollo-server-core "^2.25.3" + apollo-server-express "^2.25.3" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.8" @@ -9273,7 +9560,7 @@ asn1.js@^4.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -asn1@^0.2.4, asn1@~0.2.0, asn1@~0.2.3: +asn1@^0.2.4, asn1@~0.2.3: version "0.2.4" resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== @@ -9860,6 +10147,11 @@ before-after-hook@^2.1.0: resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== +before-after-hook@^2.2.0: + version "2.2.2" + resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e" + integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ== + better-opn@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz#94a55b4695dc79288f31d7d0e5f658320759f7c6" @@ -10164,16 +10456,16 @@ browserslist@4.14.2: escalade "^3.0.2" node-releases "^1.1.61" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.6: - version "4.16.6" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" - integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0, browserslist@^4.16.6: + version "4.18.1" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.18.1.tgz#60d3920f25b6860eb917c6c7b185576f4d8b017f" + integrity sha512-8ScCzdpPwR2wQh8IT82CA2VgDwjHyqMovPBZSNH54+tm4Jk2pCuv90gmAdH6J84OCRWi0b4gMe6O6XPXuJnjgQ== dependencies: - caniuse-lite "^1.0.30001219" - colorette "^1.2.2" - electron-to-chromium "^1.3.723" + caniuse-lite "^1.0.30001280" + electron-to-chromium "^1.3.896" escalade "^3.1.1" - node-releases "^1.1.71" + node-releases "^2.0.1" + picocolors "^1.0.0" bser@2.1.1: version "2.1.1" @@ -10446,25 +10738,6 @@ call-me-maybe@^1.0.1: resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= -caller-callsite@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" - integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= - dependencies: - callsites "^2.0.0" - -caller-path@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" - integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= - dependencies: - caller-callsite "^2.0.0" - -callsites@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" - integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= - callsites@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -10548,10 +10821,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.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001219: - version "1.0.30001230" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz#8135c57459854b2240b57a4a6786044bdc5a9f71" - integrity sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001280: + version "1.0.30001282" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001282.tgz#38c781ee0a90ccfe1fe7fefd00e43f5ffdcb96fd" + integrity sha512-YhF/hG6nqBEllymSIjLtR2iWDDnChvhnVJqp+vloyt2tEHFG1yBR+ac2B/rOw0qOK0m0lEXU2dv4E/sMk5P9Kg== canvas@^2.6.1: version "2.8.0" @@ -11038,18 +11311,18 @@ code-point-at@^1.0.0: resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -codemirror-graphql@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-1.0.0.tgz#ba8db60dc42b87768d643b3d19bf088f43dc5380" - integrity sha512-6LnSeRldL7psIBfjDr4xXKxCqPVYfQE4Yj04p2VpIyAIpc4MVE4VOjzvILgnmAW8X93ou5/s5gQXvB4huDwTUQ== +codemirror-graphql@^1.0.3: + version "1.1.0" + resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-1.1.0.tgz#dd22ddf7761efa9131fa99a70a4a85fe653484e5" + integrity sha512-bp2XUg7epL07kJcylM8VCISK6X+rFsHL2lUkPQAw2v721MVhn+80FgjMP8tiZCOfJgHn1+JgsA71L5nOHWgUdA== dependencies: - graphql-language-service-interface "^2.8.2" - graphql-language-service-parser "^1.9.0" + graphql-language-service-interface "^2.9.0" + graphql-language-service-parser "^1.10.0" -codemirror@^5.54.0: - version "5.59.4" - resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.59.4.tgz#bfc11c8ce32b04818e8d661bbd790a94f4b3a6f3" - integrity sha512-achw5JBgx8QPcACDDn+EUUXmCYzx/zxEtOGXyjvLEvYY8GleUrnfm5D+Zb+UjShHggXKDT9AXrbkBZX6a0YSQg== +codemirror@^5.58.2: + version "5.63.3" + resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.63.3.tgz#97042a242027fe0c87c09b36bc01931d37b76527" + integrity sha512-1C+LELr+5grgJYqwZKqxrcbPsHFHapVaVAloBsFBASbpLnQqLw1U8yXJ3gT5D+rhxIiSpo+kTqN+hQ+9ialIXw== codeowners-utils@^1.0.2: version "1.0.2" @@ -11108,7 +11381,7 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2, color-string@^1.5.4, color-string@^1.6.0: +color-string@^1.5.2, color-string@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== @@ -11124,14 +11397,6 @@ color@3.0.x: color-convert "^1.9.1" color-string "^1.5.2" -color@^3.0.0: - version "3.1.3" - resolved "https://registry.npmjs.org/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" - integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== - dependencies: - color-convert "^1.9.1" - color-string "^1.5.4" - color@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/color/-/color-4.0.1.tgz#21df44cd10245a91b1ccf5ba031609b0e10e7d67" @@ -11140,6 +11405,11 @@ color@^4.0.1: color-convert "^2.0.1" color-string "^1.6.0" +colord@^2.9.1: + version "2.9.1" + resolved "https://registry.npmjs.org/colord/-/colord-2.9.1.tgz#c961ea0efeb57c9f0f4834458f26cb9cc4a3f90e" + integrity sha512-4LBMSt09vR0uLnPVkOUBnmxgoaeN4ewRbx801wY/bXcltXfpR/G46OdWn96XpYmCWuYvO46aBZP4NgX8HpNAcw== + colorette@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" @@ -11208,12 +11478,17 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== +commander@*: + version "8.3.0" + resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + commander@7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg== -commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.7.1, commander@~2.20.3: +commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.7.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== @@ -11426,6 +11701,11 @@ contains-path@^0.1.0: resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -11644,16 +11924,6 @@ cosmiconfig@7.0.0, cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -cosmiconfig@^5.0.0: - version "5.2.1" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" - integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== - dependencies: - import-fresh "^2.0.0" - is-directory "^0.3.1" - js-yaml "^3.13.1" - parse-json "^4.0.0" - cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -11675,6 +11945,13 @@ cp-file@^7.0.0: nested-error-stacks "^2.0.0" p-event "^4.1.0" +cpu-features@0.0.2: + version "0.0.2" + resolved "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.2.tgz#9f636156f1155fd04bdbaa028bb3c2fbef3cea7a" + integrity sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA== + dependencies: + nan "^2.14.1" + cpy@^8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/cpy/-/cpy-8.1.1.tgz#066ed4c6eaeed9577df96dae4db9438c1a90df62" @@ -11843,17 +12120,11 @@ css-color-converter@^2.0.0: color-name "^1.1.4" css-unit-converter "^1.1.2" -css-color-names@0.0.4, css-color-names@^0.0.4: - version "0.0.4" - resolved "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" - integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= - -css-declaration-sorter@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" - integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== +css-declaration-sorter@^6.0.3: + version "6.1.3" + resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.1.3.tgz#e9852e4cf940ba79f509d9425b137d1f94438dc2" + integrity sha512-SvjQjNRZgh4ULK1LDJ2AduPKUKxIqmtU7ZAyi47BTV+M90Qvxr9AB6lKlLbDUfXqI9IQeYA8LbAsCZPpJEV3aA== dependencies: - postcss "^7.0.1" timsort "^0.3.0" css-in-js-utils@^2.0.0: @@ -11904,16 +12175,6 @@ css-select-base-adapter@^0.1.1: resolved "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== -css-select@^1.1.0: - version "1.2.0" - resolved "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" - integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= - dependencies: - boolbase "~1.0.0" - css-what "2.1" - domutils "1.5.1" - nth-check "~1.0.1" - css-select@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef" @@ -11924,6 +12185,17 @@ css-select@^2.0.0: domutils "^1.7.0" nth-check "^1.0.2" +css-select@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/css-select/-/css-select-4.1.3.tgz#a70440f70317f2669118ad74ff105e65849c7067" + integrity sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA== + dependencies: + boolbase "^1.0.0" + css-what "^5.0.0" + domhandler "^4.2.0" + domutils "^2.6.0" + nth-check "^2.0.0" + css-tree@1.0.0-alpha.37: version "1.0.0-alpha.37" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" @@ -11940,6 +12212,14 @@ css-tree@^1.1.2: mdn-data "2.0.14" source-map "^0.6.1" +css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + css-unit-converter@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" @@ -11953,16 +12233,16 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" -css-what@2.1: - version "2.1.3" - resolved "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" - integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== - css-what@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/css-what/-/css-what-3.2.1.tgz#f4a8f12421064621b456755e34a03a2c22df5da1" integrity sha512-WwOrosiQTvyms+Ti5ZC5vGEK0Vod3FTt1ca+payZqvKuGJF+dq7bG63DstxtN0dpm6FxY27a/zS3Wten+gEtGw== +css-what@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe" + integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== + css.escape@1.5.1, css.escape@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -11987,73 +12267,55 @@ cssfilter@0.0.10: resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4= -cssnano-preset-default@^4.0.7: - version "4.0.7" - resolved "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" - integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== +cssnano-preset-default@^5.1.7: + version "5.1.7" + resolved "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.1.7.tgz#68c3ad1ec6a810482ec7d06b2d70fc34b6b0d70c" + integrity sha512-bWDjtTY+BOqrqBtsSQIbN0RLGD2Yr2CnecpP0ydHNafh9ZUEre8c8VYTaH9FEbyOt0eIfEUAYYk5zj92ioO8LA== dependencies: - css-declaration-sorter "^4.0.1" - cssnano-util-raw-cache "^4.0.1" - postcss "^7.0.0" - postcss-calc "^7.0.1" - postcss-colormin "^4.0.3" - postcss-convert-values "^4.0.1" - postcss-discard-comments "^4.0.2" - postcss-discard-duplicates "^4.0.2" - postcss-discard-empty "^4.0.1" - postcss-discard-overridden "^4.0.1" - postcss-merge-longhand "^4.0.11" - postcss-merge-rules "^4.0.3" - postcss-minify-font-values "^4.0.2" - postcss-minify-gradients "^4.0.2" - postcss-minify-params "^4.0.2" - postcss-minify-selectors "^4.0.2" - postcss-normalize-charset "^4.0.1" - postcss-normalize-display-values "^4.0.2" - postcss-normalize-positions "^4.0.2" - postcss-normalize-repeat-style "^4.0.2" - postcss-normalize-string "^4.0.2" - postcss-normalize-timing-functions "^4.0.2" - postcss-normalize-unicode "^4.0.1" - postcss-normalize-url "^4.0.1" - postcss-normalize-whitespace "^4.0.2" - postcss-ordered-values "^4.1.2" - postcss-reduce-initial "^4.0.3" - postcss-reduce-transforms "^4.0.2" - postcss-svgo "^4.0.2" - postcss-unique-selectors "^4.0.1" + css-declaration-sorter "^6.0.3" + cssnano-utils "^2.0.1" + postcss-calc "^8.0.0" + postcss-colormin "^5.2.1" + postcss-convert-values "^5.0.2" + postcss-discard-comments "^5.0.1" + postcss-discard-duplicates "^5.0.1" + postcss-discard-empty "^5.0.1" + postcss-discard-overridden "^5.0.1" + postcss-merge-longhand "^5.0.4" + postcss-merge-rules "^5.0.3" + postcss-minify-font-values "^5.0.1" + postcss-minify-gradients "^5.0.3" + postcss-minify-params "^5.0.2" + postcss-minify-selectors "^5.1.0" + postcss-normalize-charset "^5.0.1" + postcss-normalize-display-values "^5.0.1" + postcss-normalize-positions "^5.0.1" + postcss-normalize-repeat-style "^5.0.1" + postcss-normalize-string "^5.0.1" + postcss-normalize-timing-functions "^5.0.1" + postcss-normalize-unicode "^5.0.1" + postcss-normalize-url "^5.0.3" + postcss-normalize-whitespace "^5.0.1" + postcss-ordered-values "^5.0.2" + postcss-reduce-initial "^5.0.1" + postcss-reduce-transforms "^5.0.1" + postcss-svgo "^5.0.3" + postcss-unique-selectors "^5.0.2" -cssnano-util-get-arguments@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" - integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= +cssnano-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.1.tgz#8660aa2b37ed869d2e2f22918196a9a8b6498ce2" + integrity sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ== -cssnano-util-get-match@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" - integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= - -cssnano-util-raw-cache@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" - integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== +cssnano@^5.0.1: + version "5.0.11" + resolved "https://registry.npmjs.org/cssnano/-/cssnano-5.0.11.tgz#743397a05e04cb87e9df44b7659850adfafc3646" + integrity sha512-5SHM31NAAe29jvy0MJqK40zZ/8dGlnlzcfHKw00bWMVFp8LWqtuyPSFwbaoIoxvt71KWJOfg8HMRGrBR3PExCg== dependencies: - postcss "^7.0.0" - -cssnano-util-same-parent@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" - integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== - -cssnano@^4.1.10: - version "4.1.10" - resolved "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" - integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== - dependencies: - cosmiconfig "^5.0.0" - cssnano-preset-default "^4.0.7" - is-resolvable "^1.0.0" - postcss "^7.0.0" + cssnano-preset-default "^5.1.7" + is-resolvable "^1.1.0" + lilconfig "^2.0.3" + yaml "^1.10.2" csso@^4.0.2: version "4.0.2" @@ -12062,6 +12324,13 @@ csso@^4.0.2: dependencies: css-tree "1.0.0-alpha.37" +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + cssom@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -12868,27 +13137,27 @@ dns-txt@^2.0.2: dependencies: buffer-indexof "^1.0.0" -docker-compose@^0.23.10: - version "0.23.12" - resolved "https://registry.npmjs.org/docker-compose/-/docker-compose-0.23.12.tgz#fa883b98be08f6926143d06bf9e522ef7ed3210c" - integrity sha512-KFbSMqQBuHjTGZGmYDOCO0L4SaML3BsWTId5oSUyaBa22vALuFHNv+UdDWs3HcMylHWKsxCbLB7hnM/nCosWZw== +docker-compose@^0.23.13: + version "0.23.13" + resolved "https://registry.npmjs.org/docker-compose/-/docker-compose-0.23.13.tgz#77d37bd05b6a966345f631e6d05e961c79514f06" + integrity sha512-/9fYC4g3AO+qsqxIZhmbVnFvJJPcYEV2yJbAPPXH+6AytU3urIY8lUAXOlvY8sl4u25pdKu1JrOfAmWC7lJDJg== dependencies: yaml "^1.10.2" docker-modem@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.0.tgz#cb912ad8daed42f858269fb3be6944df281ec12d" - integrity sha512-WwFajJ8I5geZ/dDZ5FDMDA6TBkWa76xWwGIGw8uzUjNUGCN0to83wJ8Oi1AxrJTC0JBn+7fvIxUctnawtlwXeg== + version "3.0.3" + resolved "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.3.tgz#ac4bb1f32f81ac2e7120c5e99a068fab2458a32f" + integrity sha512-Tgkn2a+yiNP9FoZgMa/D9Wk+D2Db///0KOyKSYZRJa8w4+DzKyzQMkczKSdR/adQ0x46BOpeNkoyEOKjPhCzjw== dependencies: debug "^4.1.1" readable-stream "^3.5.0" split-ca "^1.0.1" - ssh2 "^0.8.7" + ssh2 "^1.4.0" -dockerode@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.3.0.tgz#bedaf48ef9fa9124275a54a9881a92374c51008e" - integrity sha512-St08lfOjpYCOXEM8XA0VLu3B3hRjtddODphNW5GFoA0AS3JHgoPQKOz0Qmdzg3P+hUPxhb02g1o1Cu1G+U3lRg== +dockerode@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.3.1.tgz#74f66e239e092e7910e2beae6322d35c44b08cdc" + integrity sha512-AS2mr8Lp122aa5n6d99HkuTNdRV1wkkhHwBdcnY6V0+28D3DSYwhxAk85/mM9XwD3RMliTxyr63iuvn5ZblFYQ== dependencies: docker-modem "^3.0.0" tar-fs "~2.0.1" @@ -12920,7 +13189,7 @@ dom-accessibility-api@^0.5.4, dom-accessibility-api@^0.5.6: resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz#3f5d43b52c7a3bd68b5fb63fa47b4e4c1fdf65a9" integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw== -dom-converter@^0.2: +dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== @@ -12969,7 +13238,7 @@ domain-browser@^1.1.1: resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== -domelementtype@1, domelementtype@^1.3.1: +domelementtype@1: version "1.3.1" resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== @@ -12979,6 +13248,11 @@ domelementtype@^2.0.1, domelementtype@^2.1.0: resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== +domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -12986,13 +13260,6 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" -domhandler@^2.3.0: - version "2.4.2" - resolved "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" - integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== - dependencies: - domelementtype "1" - domhandler@^3.0, domhandler@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz#51cd13efca31da95bbb0c5bee3a48300e333b3e9" @@ -13007,20 +13274,19 @@ domhandler@^4.0.0: dependencies: domelementtype "^2.1.0" +domhandler@^4.2.0: + version "4.2.2" + resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f" + integrity sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w== + dependencies: + domelementtype "^2.2.0" + dompurify@^2.1.1, dompurify@^2.2.9: version "2.3.3" resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.3.3.tgz#c1af3eb88be47324432964d8abc75cf4b98d634c" integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg== -domutils@1.5.1: - version "1.5.1" - resolved "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" - integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= - dependencies: - dom-serializer "0" - domelementtype "1" - -domutils@^1.5.1, domutils@^1.7.0: +domutils@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== @@ -13037,6 +13303,15 @@ domutils@^2.0.0: domelementtype "^2.0.1" domhandler "^4.0.0" +domutils@^2.5.2, domutils@^2.6.0: + version "2.8.0" + resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + dot-case@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" @@ -13189,10 +13464,10 @@ elastic-builder@^2.16.0: lodash.isstring "^4.0.1" lodash.omit "^4.5.0" -electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.723: - version "1.3.739" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz#f07756aa92cabd5a6eec6f491525a64fe62f98b9" - integrity sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A== +electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.896: + version "1.3.900" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.900.tgz#5be2c5818a2a012c511b4b43e87b6ab7a296d4f5" + integrity sha512-SuXbQD8D4EjsaBaJJxySHbC+zq8JrFfxtb4GIr4E9n1BcROyMcRrJCYQNpJ9N+Wjf5mFp7Wp0OHykd14JNEzzQ== elegant-spinner@^1.0.1: version "1.0.1" @@ -13315,12 +13590,17 @@ ent@^2.2.0: resolved "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= -entities@^1.1.1, entities@^1.1.2: +entities@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -entities@^2.0.0, entities@~2.0.0: +entities@^2.0.0, entities@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== + +entities@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== @@ -13556,9 +13836,9 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-cypress@^2.10.3: - version "2.11.3" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.3.tgz#54ee4067aa8192aa62810cd35080eb577e191ab7" - integrity sha512-hOoAid+XNFtpvOzZSNWP5LDrQBEJwbZwjib4XJ1KcRYKjeVj0mAmPmucG4Egli4j/aruv+Ow/acacoloWWCl9Q== + version "2.12.1" + resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.12.1.tgz#9aeee700708ca8c058e00cdafe215199918c2632" + integrity sha512-c2W/uPADl5kospNDihgiLc7n87t5XhUbFDoTl6CfVkmG+kDAb5Ux10V9PoLPu9N+r7znpc+iQlcmAqT1A/89HA== dependencies: globals "^11.12.0" @@ -14271,6 +14551,13 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== +fast-url-parser@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" + integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= + dependencies: + punycode "^1.3.2" + fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -14478,6 +14765,15 @@ find-my-way@^2.2.2: safe-regex2 "^2.0.0" semver-store "^0.3.0" +find-process@^1.4.5: + version "1.4.5" + resolved "https://registry.npmjs.org/find-process/-/find-process-1.4.5.tgz#6a0e4c87a32ca927c05cbed7b9078d62ffaac1a4" + integrity sha512-v11rJYYISUWn+s8qZzgGnBvlzRKf3bOtlGFM8H0kw56lGQtOmLuLCzuclA5kehA2j7S5sioOWdI4woT3jDavAw== + dependencies: + chalk "^4.0.0" + commander "^5.1.0" + debug "^4.1.1" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" @@ -15193,6 +15489,18 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + 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-dirs@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" @@ -15443,18 +15751,19 @@ grapheme-splitter@^1.0.4: integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== graphiql@^1.0.0-alpha.10: - version "1.4.2" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.4.2.tgz#a1dc1a4d8d35f60c90d6d8a9eb62a99756e9fd9b" - integrity sha512-TQDuuU/ZqTWV1yQDpVEiKskg0IYA+Wck37DYrrFzLlpgZWRbWiyab1PyHKiRep7J540CgScBg6C/gGCymKyO3g== + version "1.4.7" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.4.7.tgz#6a35acf0786d7518fbb986b75bf0a3d752c19c1a" + integrity sha512-oHsBTzdWTbRJhqazbjrC6wY7YInViErAeXLqetCxdFFu2Zk5FV3V3rs7KPrCyr7kM6lW0nfXMzIfKuIgxAqx7g== dependencies: - "@graphiql/toolkit" "^0.2.0" - codemirror "^5.54.0" - codemirror-graphql "^1.0.0" + "@graphiql/toolkit" "^0.3.2" + codemirror "^5.58.2" + codemirror-graphql "^1.0.3" copy-to-clipboard "^3.2.0" dset "^3.1.0" entities "^2.0.0" - graphql-language-service "^3.1.2" - markdown-it "^10.0.0" + escape-html "^1.0.3" + graphql-language-service "^3.1.6" + markdown-it "^12.2.0" graphlib@^2.1.8: version "2.1.8" @@ -15489,20 +15798,20 @@ graphql-extensions@^0.15.0: apollo-server-env "^3.1.0" apollo-server-types "^0.9.0" -graphql-language-service-interface@2.8.2, graphql-language-service-interface@^2.8.2: - version "2.8.2" - resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.8.2.tgz#b3bb2aef7eaf0dff0b4ea419fa412c5f66fa268b" - integrity sha512-otbOQmhgkAJU1QJgQkMztNku6SbJLu/uodoFOYOOtJsizTjrMs93vkYaHCcYnLA3oi1Goj27XcHjMnRCYQOZXQ== +graphql-language-service-interface@^2.9.0: + version "2.9.1" + resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.9.1.tgz#be0b11b06b78730ea9d250e0e2290e7ed9c8d283" + integrity sha512-yGsE67fxJBXxY82+rLDMvUpmzpOUM8XFB+k+xOTUyABWs27osKaoGiuDDXAVGg1adhm+cpunWbipe763ZJkAVA== dependencies: - graphql-language-service-parser "^1.9.0" - graphql-language-service-types "^1.8.0" - graphql-language-service-utils "^2.5.1" + graphql-language-service-parser "^1.10.0" + graphql-language-service-types "^1.8.3" + graphql-language-service-utils "^2.6.0" vscode-languageserver-types "^3.15.1" -graphql-language-service-parser@1.9.0, graphql-language-service-parser@^1.9.0: - version "1.9.0" - resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.9.0.tgz#79af21294119a0a7e81b6b994a1af36833bab724" - integrity sha512-B5xPZLbBmIp0kHvpY1Z35I5DtPoDK9wGxQVRDIzcBaiIvAmlTrDvjo3bu7vKREdjFbYKvWNgrEWENuprMbF17Q== +graphql-language-service-parser@^1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.10.0.tgz#116f4be849754f6afb4c196421a43fe96d87b278" + integrity sha512-cLExv0EjqT2hsKdwVTPmKU6eMfjZAjxqywgCPnWD48eJn6tyuePMyG7ye+jpX1PRPPx/cDHfFJGf8sUclchvng== dependencies: graphql-language-service-types "^1.8.0" @@ -15511,21 +15820,41 @@ graphql-language-service-types@^1.8.0: resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.8.1.tgz#963810010924f2b5eaea415d5b8eb0b7d42c479b" integrity sha512-IpYS0mEHEmRsFlq+loWCpSYYYizAID7Alri6GoFN1QqUdux+8rp1Tkp2NGsGDpDmm3Dbz5ojmJWzNWQGpuwveA== -graphql-language-service-utils@^2.5.1: - version "2.5.1" - resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.5.1.tgz#832ad4b0a9da03fdded756932c27e057ccf71302" - integrity sha512-Lzz723cYrYlVN4WVzIyFGg3ogoe+QYAIBfdtDboiIILoy0FTmqbyC2TOErqbmWKqO4NK9xDA95cSRFbWiHYj0g== +graphql-language-service-types@^1.8.2: + version "1.8.2" + resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.8.2.tgz#50ae56f69cc24fcfc3daa129b68b0eb9421e8578" + integrity sha512-Sj07RHnMwAhEvAt7Jdt1l/x56ZpoNh+V6g+T58CF6GiYqI5l4vXqqRB4d4xHDcNQX98GpJfnf3o8BqPgP3C5Sw== + +graphql-language-service-types@^1.8.3: + version "1.8.3" + resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.8.3.tgz#d7d688d74c122c4d9cc4cceae761a1f2a3c396a1" + integrity sha512-m+RHnlGkKDcesW/gC4M7I2pSmWJB84uWS6LtnjplO/07JN312nJCJYCwV/DBny2m1fmSOxN7H/o+JW0l56KwBA== + +graphql-language-service-utils@^2.5.3: + version "2.5.3" + resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.5.3.tgz#185f4f65cf8c010871eb9405452a3a0bfdf88748" + integrity sha512-ydevEZ0AgzEKQF3hiCbLXuS0o7189Ww/T30WtCKCLaRHDYk9Yyb2PZWdhSTWLxYZTaX2TccV6NtFWvzIC7UP3g== dependencies: graphql-language-service-types "^1.8.0" nullthrows "^1.0.0" -graphql-language-service@^3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.1.2.tgz#6f50d5d824ea09c402cb02902b10e54b9da899d5" - integrity sha512-OiOH8mVE+uotrl3jGA2Pgt9k7rrI8lgw/8p+Cf6nwyEHbmIZj37vX9KoOWgpdFhuQlw824nNxWHSbz6k90xjWQ== +graphql-language-service-utils@^2.6.0: + version "2.6.0" + resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.6.0.tgz#d04904641248167ccbb381d8705ba97daa784954" + integrity sha512-idqwmbREixhDuQMcYp8WH0btQT02xZny8MO/HduNTVjnPrmTYnZUbpZ9AejdflmaKoS0o8nNvgXQ0GpIOzbG5g== dependencies: - graphql-language-service-interface "^2.8.2" - graphql-language-service-types "^1.8.0" + graphql-language-service-types "^1.8.3" + nullthrows "^1.0.0" + +graphql-language-service@^3.1.6: + version "3.2.0" + resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.2.0.tgz#e0eb6d5dea2cab92549a253d7a6b4fa0cce178b7" + integrity sha512-xM5Ua5p7ttG/oEaDy2zk35FP2O2I9qD2N0DOrjCDUVDRC06FNDG+/CvF4qX9+i8DWOI65xch5vAhSQEfS2jFsA== + dependencies: + graphql-language-service-interface "^2.9.0" + graphql-language-service-parser "^1.10.0" + graphql-language-service-types "^1.8.2" + graphql-language-service-utils "^2.5.3" graphql-request@^3.3.0: version "3.4.0" @@ -15580,16 +15909,16 @@ graphql-type-json@^0.3.2: resolved "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz#f53a851dbfe07bd1c8157d24150064baab41e115" integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg== -graphql-ws@^4.3.2: - version "4.9.0" - resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.9.0.tgz#5cfd8bb490b35e86583d8322f5d5d099c26e365c" - integrity sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag== - graphql-ws@^4.4.1: version "4.7.0" resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.7.0.tgz#b323fbf35a3736eed85dac24c0054d6d10c93e62" integrity sha512-Md8SsmC9ZlsogFPd3Ot8HbIAAqsHh8Xoq7j4AmcIat1Bh6k91tjVyQvA0Au1/BolXSYq+RDvib6rATU2Hcf1Xw== +graphql-ws@^4.9.0: + version "4.9.0" + resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.9.0.tgz#5cfd8bb490b35e86583d8322f5d5d099c26e365c" + integrity sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag== + graphql@15.5.0: version "15.5.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" @@ -15753,7 +16082,7 @@ has-yarn@^2.1.0: resolved "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== -has@^1.0.0, has@^1.0.3: +has@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -15877,11 +16206,6 @@ helmet@^4.0.0: resolved "https://registry.npmjs.org/helmet/-/helmet-4.4.1.tgz#a17e1444d81d7a83ddc6e6f9bc6e2055b994efe7" integrity sha512-G8tp0wUMI7i8wkMk2xLcEvESg5PiCitFMYgGRc/PwULB0RVhTP5GFdxOwvJwp9XVha8CuS8mnhmE8I/8dx/pbw== -hex-color-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" - integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== - highlight.js@^10.1.0, highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@^10.6.0: version "10.7.2" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" @@ -15961,21 +16285,6 @@ hpagent@^0.1.1: resolved "https://registry.npmjs.org/hpagent/-/hpagent-0.1.2.tgz#cab39c66d4df2d4377dbd212295d878deb9bdaa9" integrity sha512-ePqFXHtSQWAFXYmj+JtOTHr84iNrII4/QRlAAPPE+zqnKy4xJo7Ie1Y4kC7AdB+LxLxSTTzBMASsEcy0q8YyvQ== -hsl-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" - integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= - -hsla-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" - integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= - -html-comment-regex@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" - integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== - html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -16052,18 +16361,6 @@ html-webpack-plugin@^5.3.1: pretty-error "^2.1.1" tapable "^2.0.0" -htmlparser2@^3.3.0: - version "3.10.1" - resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" - integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== - dependencies: - domelementtype "^1.3.1" - domhandler "^2.3.0" - domutils "^1.5.1" - entities "^1.1.1" - inherits "^2.0.1" - readable-stream "^3.1.1" - htmlparser2@^4.0: version "4.1.0" resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" @@ -16074,6 +16371,16 @@ htmlparser2@^4.0: domutils "^2.0.0" entities "^2.0.0" +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" @@ -16351,14 +16658,6 @@ import-cwd@^3.0.0: dependencies: import-from "^3.0.0" -import-fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" - integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= - dependencies: - caller-path "^2.0.0" - resolve-from "^3.0.0" - import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" @@ -16628,10 +16927,10 @@ ipaddr.js@^2.0.1: resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== -is-absolute-url@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" - integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== is-absolute@^1.0.0: version "1.0.0" @@ -16751,18 +17050,6 @@ is-ci@^3.0.0: dependencies: ci-info "^3.1.1" -is-color-stop@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" - integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= - dependencies: - css-color-names "^0.0.4" - hex-color-regex "^1.1.0" - hsl-regex "^1.0.0" - hsla-regex "^1.0.0" - rgb-regex "^1.0.1" - rgba-regex "^1.0.0" - is-core-module@^2.1.0, 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" @@ -16817,11 +17104,6 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= - is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" @@ -17135,7 +17417,7 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" -is-resolvable@^1.0.0: +is-resolvable@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== @@ -17191,13 +17473,6 @@ is-subdir@^1.1.1: dependencies: better-path-resolve "1.0.0" -is-svg@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" - integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== - dependencies: - html-comment-regex "^1.1.0" - is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" @@ -17939,9 +18214,9 @@ joycon@^2.2.5: integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== js-base64@^3.6.0: - version "3.6.1" - resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.6.1.tgz#555aae398b74694b4037af1f8a5a6209d170efbe" - integrity sha512-Frdq2+tRRGLQUIQOgsIGSCd1VePCS2fsddTG5dTCqR0JHgltXWfsxnY0gIXPoMeRmdom6Oyq+UMOFg5suduOjQ== + version "3.7.2" + resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745" + integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ== js-cookie@^2.2.1: version "2.2.1" @@ -18161,10 +18436,10 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b" - integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" @@ -18266,10 +18541,10 @@ jsonpath-plus@^5.0.7: resolved "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-5.1.0.tgz#2fc4b2e461950626c98525425a3a3518b85af6c3" integrity sha512-890w2Pjtj0iswAxalRlt2kHthi6HKrXEfZcn+ZNZptv7F3rUGIeDuZo+C+h4vXBHLEsVjJrHeCm35nYeZLzSBQ== -jsonpointer@^4.0.1: - version "4.1.0" - resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.1.0.tgz#501fb89986a2389765ba09e6053299ceb4f2c2cc" - integrity sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg== +jsonpointer@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.0.tgz#f802669a524ec4805fa7389eadbc9921d5dc8072" + integrity sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg== jsonschema@^1.2.6: version "1.4.0" @@ -18720,18 +18995,16 @@ libnpmpublish@^4.0.0: semver "^7.1.3" ssri "^8.0.0" +lilconfig@^2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" + integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA== + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -linkify-it@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" - integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw== - dependencies: - uc.micro "^1.0.1" - linkify-it@^3.0.1: version "3.0.2" resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8" @@ -19427,17 +19700,6 @@ markdown-escapes@^1.0.0: resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== -markdown-it@^10.0.0: - version "10.0.0" - resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" - integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg== - dependencies: - argparse "^1.0.7" - entities "~2.0.0" - linkify-it "^2.0.0" - mdurl "^1.0.1" - uc.micro "^1.0.5" - markdown-it@^11.0.1: version "11.0.1" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz#b54f15ec2a2193efa66dda1eb4173baea08993d6" @@ -19449,6 +19711,17 @@ markdown-it@^11.0.1: mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-it@^12.2.0: + version "12.2.0" + resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz#091f720fd5db206f80de7a8d1f1a7035fd0d38db" + integrity sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg== + dependencies: + argparse "^2.0.1" + entities "~2.1.0" + linkify-it "^3.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + markdown-table@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz#194a90ced26d31fe753d8b9434430214c011865b" @@ -20244,6 +20517,18 @@ mime-db@1.49.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.32" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" @@ -20457,10 +20742,10 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" -mixme@^0.3.1: - version "0.3.5" - resolved "https://registry.npmjs.org/mixme/-/mixme-0.3.5.tgz#304652cdaf24a3df0487205e61ac6162c6906ddd" - integrity sha512-SyV9uPETRig5ZmYev0ANfiGeB+g6N2EnqqEfBbCGmmJ6MgZ3E4qv5aPbnHVdZ60KAHHXV+T3sXopdrnIXQdmjQ== +mixme@^0.5.1: + version "0.5.4" + resolved "https://registry.npmjs.org/mixme/-/mixme-0.5.4.tgz#8cb3bd0cd32a513c161bf1ca99d143f0bcf2eff3" + integrity sha512-3KYa4m4Vlqx98GPdOHghxSdNtTvcP8E0kkaJ5Dlh+h2DRzF7zpuVVcA8B0QpKd11YJeP9QQ7ASkKzOeu195Wzw== mkdirp-classic@^0.5.2: version "0.5.2" @@ -20498,6 +20783,11 @@ mock-fs@^5.1.0: resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.0.tgz#a9aebd4e6d74a626f84b86eae8a372bd061754e8" integrity sha512-wXdQ2nIk81TYIGLphUnbXl8akQpjb9ItfZefMcTxZcoe+djMkd5POU8fQdSEErxVAeT4CgDHWveYquys4H6Cmw== +mock-fs@^5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7" + integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -20669,6 +20959,11 @@ nan@^2.12.1, nan@^2.14.0: resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== +nan@^2.14.1, nan@^2.15.0: + version "2.15.0" + resolved "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + nano-css@^5.1.0, nano-css@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.1.tgz#b709383e07ad3be61f64edffacb9d98250b87a1f" @@ -20797,6 +21092,11 @@ node-abi@^2.21.0: dependencies: semver "^5.4.1" +node-abort-controller@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e" + integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw== + node-addon-api@^3.0.0: version "3.2.1" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" @@ -20833,6 +21133,13 @@ node-fetch@2.6.1, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== +node-fetch@2.6.5: + version "2.6.5" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd" + integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ== + dependencies: + whatwg-url "^5.0.0" + node-forge@^0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -20961,11 +21268,16 @@ node-pre-gyp@^0.11.0: semver "^5.3.0" tar "^4" -node-releases@^1.1.61, node-releases@^1.1.71: +node-releases@^1.1.61: 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== +node-releases@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5" + integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== + nodemon@^2.0.2: version "2.0.12" resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.12.tgz#5dae4e162b617b91f1873b3bfea215dd71e144d5" @@ -21048,7 +21360,7 @@ normalize-range@^0.1.2: resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21058,6 +21370,11 @@ normalize-url@^4.1.0: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + npm-bundled@^1.0.1, npm-bundled@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" @@ -21197,13 +21514,20 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: gauge "~2.7.3" set-blocking "~2.0.0" -nth-check@^1.0.2, nth-check@~1.0.1: +nth-check@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== dependencies: boolbase "~1.0.0" +nth-check@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2" + integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w== + dependencies: + boolbase "^1.0.0" + nullthrows@^1.0.0, nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -22013,9 +22337,9 @@ passport-oauth2@1.2.0: uid2 "0.0.x" passport-oauth2@1.x.x, passport-oauth2@^1.1.2, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0: - version "1.6.0" - resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.6.0.tgz#5f599735e0ea40ea3027643785f81a3a9b4feb50" - integrity sha512-emXPLqLcVEcLFR/QvQXZcwLmfK8e9CqvMgmOFJxcNT3okSFMtUbRRKpY20x5euD+01uHsjjCa07DYboEeLXYiw== + version "1.6.1" + resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.6.1.tgz#c5aee8f849ce8bd436c7f81d904a3cd1666f181b" + integrity sha512-ZbV43Hq9d/SBSYQ22GOiglFsjsD1YY/qdiptA+8ej+9C1dL1TVB+mBE5kDH/D4AJo50+2i8f4bx0vg4/yDDZCQ== dependencies: base64url "3.x.x" oauth "0.9.x" @@ -22115,7 +22439,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.2: +path-is-inside@1.0.2, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -22152,6 +22476,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -22329,12 +22658,12 @@ pgtools@^0.3.0: pg-connection-string "^0.1.3" yargs "^5.0.0" -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: - version "2.2.2" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" - integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.2.3: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: version "2.3.0" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== @@ -22495,61 +22824,50 @@ posix-character-classes@^0.1.0: resolved "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= -postcss-calc@^7.0.1: - version "7.0.2" - resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.2.tgz#504efcd008ca0273120568b0792b16cdcde8aac1" - integrity sha512-rofZFHUg6ZIrvRwPeFktv06GdbDYLcGqh9EwiMutZg+a0oePCCw1zHOEiji6LCpyRcjTREtPASuUqeAvYlEVvQ== +postcss-calc@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.0.0.tgz#a05b87aacd132740a5db09462a3612453e5df90a" + integrity sha512-5NglwDrcbiy8XXfPM11F3HeC6hoT9W7GUH/Zi5U/p7u3Irv4rHhdDcIZwG0llHXV4ftsBjpfWMXAnXNl4lnt8g== dependencies: - postcss "^7.0.27" postcss-selector-parser "^6.0.2" postcss-value-parser "^4.0.2" -postcss-colormin@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" - integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== +postcss-colormin@^5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.2.1.tgz#6e444a806fd3c578827dbad022762df19334414d" + integrity sha512-VVwMrEYLcHYePUYV99Ymuoi7WhKrMGy/V9/kTS0DkCoJYmmjdOMneyhzYUxcNgteKDVbrewOkSM7Wje/MFwxzA== dependencies: - browserslist "^4.0.0" - color "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + browserslist "^4.16.6" + caniuse-api "^3.0.0" + colord "^2.9.1" + postcss-value-parser "^4.1.0" -postcss-convert-values@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" - integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== +postcss-convert-values@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.0.2.tgz#879b849dc3677c7d6bc94b6a2c1a3f0808798059" + integrity sha512-KQ04E2yadmfa1LqXm7UIDwW1ftxU/QWZmz6NKnHnUvJ3LEYbbcX6i329f/ig+WnEByHegulocXrECaZGLpL8Zg== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-discard-comments@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" - integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== - dependencies: - postcss "^7.0.0" +postcss-discard-comments@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz#9eae4b747cf760d31f2447c27f0619d5718901fe" + integrity sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg== -postcss-discard-duplicates@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" - integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== - dependencies: - postcss "^7.0.0" +postcss-discard-duplicates@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz#68f7cc6458fe6bab2e46c9f55ae52869f680e66d" + integrity sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA== -postcss-discard-empty@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" - integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== - dependencies: - postcss "^7.0.0" +postcss-discard-empty@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz#ee136c39e27d5d2ed4da0ee5ed02bc8a9f8bf6d8" + integrity sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw== -postcss-discard-overridden@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" - integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== - dependencies: - postcss "^7.0.0" +postcss-discard-overridden@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.1.tgz#454b41f707300b98109a75005ca4ab0ff2743ac6" + integrity sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q== postcss-flexbugs-fixes@^4.2.1: version "4.2.1" @@ -22577,67 +22895,57 @@ postcss-loader@^4.2.0: schema-utils "^3.0.0" semver "^7.3.4" -postcss-merge-longhand@^4.0.11: - version "4.0.11" - resolved "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" - integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== +postcss-merge-longhand@^5.0.4: + version "5.0.4" + resolved "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.0.4.tgz#41f4f3270282ea1a145ece078b7679f0cef21c32" + integrity sha512-2lZrOVD+d81aoYkZDpWu6+3dTAAGkCKbV5DoRhnIR7KOULVrI/R7bcMjhrH9KTRy6iiHKqmtG+n/MMj1WmqHFw== dependencies: - css-color-names "0.0.4" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - stylehacks "^4.0.0" + postcss-value-parser "^4.1.0" + stylehacks "^5.0.1" -postcss-merge-rules@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" - integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== +postcss-merge-rules@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.0.3.tgz#b5cae31f53129812a77e3eb1eeee448f8cf1a1db" + integrity sha512-cEKTMEbWazVa5NXd8deLdCnXl+6cYG7m2am+1HzqH0EnTdy8fRysatkaXb2dEnR+fdaDxTvuZ5zoBdv6efF6hg== dependencies: - browserslist "^4.0.0" + browserslist "^4.16.6" caniuse-api "^3.0.0" - cssnano-util-same-parent "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" - vendors "^1.0.0" + cssnano-utils "^2.0.1" + postcss-selector-parser "^6.0.5" -postcss-minify-font-values@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" - integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== +postcss-minify-font-values@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.0.1.tgz#a90cefbfdaa075bd3dbaa1b33588bb4dc268addf" + integrity sha512-7JS4qIsnqaxk+FXY1E8dHBDmraYFWmuL6cgt0T1SWGRO5bzJf8sUoelwa4P88LEWJZweHevAiDKxHlofuvtIoA== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-minify-gradients@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" - integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== +postcss-minify-gradients@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.0.3.tgz#f970a11cc71e08e9095e78ec3a6b34b91c19550e" + integrity sha512-Z91Ol22nB6XJW+5oe31+YxRsYooxOdFKcbOqY/V8Fxse1Y3vqlNRpi1cxCqoACZTQEhl+xvt4hsbWiV5R+XI9Q== dependencies: - cssnano-util-get-arguments "^4.0.0" - is-color-stop "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + colord "^2.9.1" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-minify-params@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" - integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== +postcss-minify-params@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.0.2.tgz#1b644da903473fbbb18fbe07b8e239883684b85c" + integrity sha512-qJAPuBzxO1yhLad7h2Dzk/F7n1vPyfHfCCh5grjGfjhi1ttCnq4ZXGIW77GSrEbh9Hus9Lc/e/+tB4vh3/GpDg== dependencies: - alphanum-sort "^1.0.0" - browserslist "^4.0.0" - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - uniqs "^2.0.0" + alphanum-sort "^1.0.2" + browserslist "^4.16.6" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-minify-selectors@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" - integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== +postcss-minify-selectors@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.1.0.tgz#4385c845d3979ff160291774523ffa54eafd5a54" + integrity sha512-NzGBXDa7aPsAcijXZeagnJBKBPMYLaJJzB8CQh6ncvyl2sIndLVWfbcDi0SBjRWk5VqEjXvf8tYwzoKf4Z07og== dependencies: - alphanum-sort "^1.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" + alphanum-sort "^1.0.2" + postcss-selector-parser "^6.0.5" postcss-modules-extract-imports@^2.0.0: version "2.0.0" @@ -22714,124 +23022,96 @@ postcss-modules@^4.0.0: postcss-modules-values "^4.0.0" string-hash "^1.1.1" -postcss-normalize-charset@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" - integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== - dependencies: - postcss "^7.0.0" +postcss-normalize-charset@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz#121559d1bebc55ac8d24af37f67bd4da9efd91d0" + integrity sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg== -postcss-normalize-display-values@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" - integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== +postcss-normalize-display-values@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.1.tgz#62650b965981a955dffee83363453db82f6ad1fd" + integrity sha512-uupdvWk88kLDXi5HEyI9IaAJTE3/Djbcrqq8YgjvAVuzgVuqIk3SuJWUisT2gaJbZm1H9g5k2w1xXilM3x8DjQ== dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-positions@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" - integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== +postcss-normalize-positions@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.0.1.tgz#868f6af1795fdfa86fbbe960dceb47e5f9492fe5" + integrity sha512-rvzWAJai5xej9yWqlCb1OWLd9JjW2Ex2BCPzUJrbaXmtKtgfL8dBMOOMTX6TnvQMtjk3ei1Lswcs78qKO1Skrg== dependencies: - cssnano-util-get-arguments "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-normalize-repeat-style@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" - integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== +postcss-normalize-repeat-style@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.1.tgz#cbc0de1383b57f5bb61ddd6a84653b5e8665b2b5" + integrity sha512-syZ2itq0HTQjj4QtXZOeefomckiV5TaUO6ReIEabCh3wgDs4Mr01pkif0MeVwKyU/LHEkPJnpwFKRxqWA/7O3w== dependencies: - cssnano-util-get-arguments "^4.0.0" - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-string@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" - integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== +postcss-normalize-string@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.0.1.tgz#d9eafaa4df78c7a3b973ae346ef0e47c554985b0" + integrity sha512-Ic8GaQ3jPMVl1OEn2U//2pm93AXUcF3wz+OriskdZ1AOuYV25OdgS7w9Xu2LO5cGyhHCgn8dMXh9bO7vi3i9pA== dependencies: - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-normalize-timing-functions@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" - integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== +postcss-normalize-timing-functions@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.1.tgz#8ee41103b9130429c6cbba736932b75c5e2cb08c" + integrity sha512-cPcBdVN5OsWCNEo5hiXfLUnXfTGtSFiBU9SK8k7ii8UD7OLuznzgNRYkLZow11BkQiiqMcgPyh4ZqXEEUrtQ1Q== dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-unicode@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" - integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== +postcss-normalize-unicode@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.1.tgz#82d672d648a411814aa5bf3ae565379ccd9f5e37" + integrity sha512-kAtYD6V3pK0beqrU90gpCQB7g6AOfP/2KIPCVBKJM2EheVsBQmx/Iof+9zR9NFKLAx4Pr9mDhogB27pmn354nA== dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + browserslist "^4.16.0" + postcss-value-parser "^4.1.0" -postcss-normalize-url@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" - integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== +postcss-normalize-url@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.0.3.tgz#42eca6ede57fe69075fab0f88ac8e48916ef931c" + integrity sha512-qWiUMbvkRx3kc1Dp5opzUwc7MBWZcSDK2yofCmdvFBCpx+zFPkxBC1FASQ59Pt+flYfj/nTZSkmF56+XG5elSg== dependencies: - is-absolute-url "^2.0.0" - normalize-url "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + is-absolute-url "^3.0.3" + normalize-url "^6.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-whitespace@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" - integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== +postcss-normalize-whitespace@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.1.tgz#b0b40b5bcac83585ff07ead2daf2dcfbeeef8e9a" + integrity sha512-iPklmI5SBnRvwceb/XH568yyzK0qRVuAG+a1HFUsFRf11lEJTiQQa03a4RSCQvLKdcpX7XsI1Gen9LuLoqwiqA== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-ordered-values@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" - integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== +postcss-ordered-values@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.0.2.tgz#1f351426977be00e0f765b3164ad753dac8ed044" + integrity sha512-8AFYDSOYWebJYLyJi3fyjl6CqMEG/UVworjiyK1r573I56kb3e879sCJLGvR3merj+fAdPpVplXKQZv+ey6CgQ== dependencies: - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-reduce-initial@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" - integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== +postcss-reduce-initial@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.0.1.tgz#9d6369865b0f6f6f6b165a0ef5dc1a4856c7e946" + integrity sha512-zlCZPKLLTMAqA3ZWH57HlbCjkD55LX9dsRyxlls+wfuRfqCi5mSlZVan0heX5cHr154Dq9AfbH70LyhrSAezJw== dependencies: - browserslist "^4.0.0" + browserslist "^4.16.0" caniuse-api "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" -postcss-reduce-transforms@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" - integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== +postcss-reduce-transforms@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.1.tgz#93c12f6a159474aa711d5269923e2383cedcf640" + integrity sha512-a//FjoPeFkRuAguPscTVmRQUODP+f3ke2HqFNgGPwdYnpeC29RZdCBvGRGTsKpMURb/I3p6jdKoBQ2zI+9Q7kA== dependencies: - cssnano-util-get-match "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-selector-parser@^3.0.0: - version "3.1.2" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz#b310f5c4c0fdaf76f94902bbaa30db6aa84f5270" - integrity sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA== - dependencies: - dot-prop "^5.2.0" - indexes-of "^1.0.1" - uniq "^1.0.1" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: version "6.0.4" @@ -22843,36 +23123,36 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector uniq "^1.0.1" util-deprecate "^1.0.2" -postcss-svgo@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" - integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== +postcss-selector-parser@^6.0.5: + version "6.0.6" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz#2c5bba8174ac2f6981ab631a42ab0ee54af332ea" + integrity sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg== dependencies: - is-svg "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - svgo "^1.0.0" + cssesc "^3.0.0" + util-deprecate "^1.0.2" -postcss-unique-selectors@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" - integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== +postcss-svgo@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.0.3.tgz#d945185756e5dfaae07f9edb0d3cae7ff79f9b30" + integrity sha512-41XZUA1wNDAZrQ3XgWREL/M2zSw8LJPvb5ZWivljBsUQAGoEKMYm6okHsTjJxKYI4M75RQEH4KYlEM52VwdXVA== dependencies: - alphanum-sort "^1.0.0" - postcss "^7.0.0" - uniqs "^2.0.0" + postcss-value-parser "^4.1.0" + svgo "^2.7.0" -postcss-value-parser@^3.0.0: - version "3.3.1" - resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" - integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== +postcss-unique-selectors@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.0.2.tgz#5d6893daf534ae52626708e0d62250890108c0c1" + integrity sha512-w3zBVlrtZm7loQWRPVC0yjUwwpty7OM6DnEHkxcSQXO1bMS3RJ+JUS5LFMSDZHJcvGsRwhZinCWVqn8Kej4EDA== + dependencies: + alphanum-sort "^1.0.2" + postcss-selector-parser "^6.0.5" postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: +"postcss@5 - 7", postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: version "7.0.32" resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== @@ -22981,9 +23261,9 @@ prettier@^1.19.1: integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== prettier@^2.2.1: - version "2.4.0" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz#85bdfe0f70c3e777cf13a4ffff39713ca6f64cba" - integrity sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ== + version "2.4.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" + integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== prettier@~2.2.1: version "2.2.1" @@ -22996,12 +23276,12 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.6.0: integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== pretty-error@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" - integrity sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM= + version "2.1.2" + resolved "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6" + integrity sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw== dependencies: - renderkid "^2.0.1" - utila "~0.4" + lodash "^4.17.20" + renderkid "^2.0.4" pretty-format@^24.9.0: version "24.9.0" @@ -23033,10 +23313,10 @@ printj@~1.1.0: resolved "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== -prismjs@^1.21.0, prismjs@^1.22.0, prismjs@~1.24.0: - version "1.24.1" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.24.1.tgz#c4d7895c4d6500289482fa8936d9cdd192684036" - integrity sha512-mNPsedLuk90RVJioIky8ANZEwYm5w9LcvCXrxHlwf4fNVSn8jEipMybMkWUyyF0JhnC+C4VcOVSBuHRKs1L5Ow== +prismjs@^1.21.0, prismjs@^1.22.0, prismjs@~1.25.0: + version "1.25.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== private@^0.1.8: version "0.1.8" @@ -23130,7 +23410,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prompts@2.4.0, prompts@^2.0.1: +prompts@2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== @@ -23138,10 +23418,10 @@ prompts@2.4.0, prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prompts@^2.4.0: - version "2.4.1" - resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz#befd3b1195ba052f9fd2fde8a486c4e82ee77f61" - integrity sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ== +prompts@^2.0.1, prompts@^2.4.0: + version "2.4.2" + resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== dependencies: kleur "^3.0.3" sisteransi "^1.0.5" @@ -23316,7 +23596,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -23465,6 +23745,11 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" +range-parser@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -24348,13 +24633,13 @@ reflect-metadata@^0.1.13: integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== refractor@^3.1.0, refractor@^3.2.0: - version "3.4.0" - resolved "https://registry.npmjs.org/refractor/-/refractor-3.4.0.tgz#62bd274b06c942041f390c371b676eb67cb0a678" - integrity sha512-dBeD02lC5eytm9Gld2Mx0cMcnR+zhSnsTfPpWqFaMgUMJfC9A6bcN3Br/NaXrnBJcuxnLFR90k1jrkaSyV8umg== + version "3.5.0" + resolved "https://registry.npmjs.org/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec" + integrity sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg== dependencies: hastscript "^6.0.0" parse-entities "^2.0.0" - prismjs "~1.24.0" + prismjs "~1.25.0" regenerate-unicode-properties@^8.2.0: version "8.2.0" @@ -24605,16 +24890,16 @@ remove-trailing-spaces@^1.0.6: resolved "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.8.tgz#4354d22f3236374702f58ee373168f6d6887ada7" integrity sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA== -renderkid@^2.0.1: - version "2.0.3" - resolved "https://registry.npmjs.org/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149" - integrity sha512-z8CLQp7EZBPCwCnncgf9C4XAi3WR0dv+uWu/PjIyhhAb5d6IJ/QZqlHFprHeKT+59//V6BNUsLbvN8+2LarxGA== +renderkid@^2.0.4: + version "2.0.7" + resolved "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz#464f276a6bdcee606f4a15993f9b29fc74ca8609" + integrity sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ== dependencies: - css-select "^1.1.0" - dom-converter "^0.2" - htmlparser2 "^3.3.0" - strip-ansi "^3.0.0" - utila "^0.4.0" + css-select "^4.1.3" + dom-converter "^0.2.0" + htmlparser2 "^6.1.0" + lodash "^4.17.21" + strip-ansi "^3.0.1" repeat-element@^1.1.2: version "1.1.3" @@ -24758,11 +25043,6 @@ resolve-from@5.0.0, resolve-from@^5.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -24874,16 +25154,6 @@ rfc4648@^1.3.0: resolved "https://registry.npmjs.org/rfc4648/-/rfc4648-1.4.0.tgz#c75b2856ad2e2d588b6ddb985d556f1f7f2a2abd" integrity sha512-3qIzGhHlMHA6PoT6+cdPKZ+ZqtxkIvg8DZGKA5z6PQ33/uuhoJ+Ws/D/J9rXW6gXodgH8QYlz2UCl+sdUDmNIg== -rgb-regex@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" - integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= - -rgba-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" - integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= - rifm@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz#debe951a9c83549ca6b33e5919f716044c2230be" @@ -24951,13 +25221,13 @@ rollup-plugin-peer-deps-external@^2.2.2: integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== rollup-plugin-postcss@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.0.tgz#2131fb6db0d5dce01a37235e4f6ad4523c681cea" - integrity sha512-OQzT+YspV01/6dxfyEw8lBO2px3hyL8Xn+k2QGctL7V/Yx2Z1QaMKdYVslP1mqv7RsKt6DROIlnbpmgJ3yxf6g== + version "4.0.2" + resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz#15e9462f39475059b368ce0e49c800fa4b1f7050" + integrity sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w== dependencies: chalk "^4.1.0" concat-with-sourcemaps "^1.1.0" - cssnano "^4.1.10" + cssnano "^5.0.1" import-cwd "^3.0.0" p-queue "^6.6.2" pify "^5.0.0" @@ -25301,10 +25571,12 @@ serialize-error@^8.0.1, serialize-error@^8.1.0: dependencies: type-fest "^0.20.2" -serialize-javascript@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" - integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" serialize-javascript@^5.0.1: version "5.0.1" @@ -25331,6 +25603,20 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" +serve-handler@^6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" + integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== + dependencies: + bytes "3.0.0" + content-disposition "0.5.2" + fast-url-parser "1.1.3" + mime-types "2.1.18" + minimatch "3.0.4" + path-is-inside "1.0.2" + path-to-regexp "2.2.1" + range-parser "1.2.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -25916,29 +26202,24 @@ sqlstring@^2.3.2: resolved "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz#cdae7169389a1375b18e885f2e60b3e460809514" integrity sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg== -ssh-remote-port-forward@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.3.tgz#074cebed6d54a42ce4659d6aa24987e433ff5fef" - integrity sha512-PJ6qGFmB6n0iMCQIp+qzx8qVUE5cgacopvEh63t3NJjEQHOaza/JT9zywmmVlaol/eGtCmpvBXx2A03ih1Y+xg== +ssh-remote-port-forward@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" + integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== dependencies: - "@types/ssh2" "^0.5.45" - ssh2 "^0.8.9" + "@types/ssh2" "^0.5.48" + ssh2 "^1.4.0" -ssh2-streams@~0.4.10: - version "0.4.10" - resolved "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34" - integrity sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ== +ssh2@^1.4.0: + version "1.5.0" + resolved "https://registry.npmjs.org/ssh2/-/ssh2-1.5.0.tgz#4dc559ba98a1cbb420e8d42998dfe35d0eda92bc" + integrity sha512-iUmRkhH9KGeszQwDW7YyyqjsMTf4z+0o48Cp4xOwlY5LjtbIAvyd3fwnsoUZW/hXmTCRA3yt7S/Jb9uVjErVlA== dependencies: - asn1 "~0.2.0" + asn1 "^0.2.4" bcrypt-pbkdf "^1.0.2" - streamsearch "~0.1.2" - -ssh2@^0.8.7, ssh2@^0.8.9: - version "0.8.9" - resolved "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz#54da3a6c4ba3daf0d8477a538a481326091815f3" - integrity sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw== - dependencies: - ssh2-streams "~0.4.10" + optionalDependencies: + cpu-features "0.0.2" + nan "^2.15.0" sshpk@^1.7.0: version "1.16.1" @@ -26132,21 +26413,14 @@ stream-shift@^1.0.0: resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== -stream-to-array@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz#bbf6b39f5f43ec30bc71babcb37557acecf34353" - integrity sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M= - dependencies: - any-promise "^1.1.0" - stream-transform@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-2.0.2.tgz#3cb7a14c802eb39bc40caaab0535e584f3a65caf" - integrity sha512-J+D5jWPF/1oX+r9ZaZvEXFbu7znjxSkbNAHJ9L44bt/tCVuOEWZlDqU9qJk7N2xBU1S+K2DPpSKeR/MucmCA1Q== + version "2.1.3" + resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-2.1.3.tgz#a1c3ecd72ddbf500aa8d342b0b9df38f5aa598e3" + integrity sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ== dependencies: - mixme "^0.3.1" + mixme "^0.5.1" -streamsearch@0.1.2, streamsearch@~0.1.2: +streamsearch@0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= @@ -26439,14 +26713,13 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -stylehacks@^4.0.0: - version "4.0.3" - resolved "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" - integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== +stylehacks@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/stylehacks/-/stylehacks-5.0.1.tgz#323ec554198520986806388c7fdaebc38d2c06fb" + integrity sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA== dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" + browserslist "^4.16.0" + postcss-selector-parser "^6.0.4" stylis@^4.0.6: version "4.0.7" @@ -26578,7 +26851,7 @@ svg-parser@^2.0.2: resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== -svgo@^1.0.0, svgo@^1.2.2: +svgo@^1.2.2: version "1.3.2" resolved "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" integrity sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== @@ -26597,6 +26870,19 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +svgo@^2.7.0: + version "2.8.0" + resolved "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + swagger-client@3.16.1, swagger-client@^3.16.1: version "3.16.1" resolved "https://registry.npmjs.org/swagger-client/-/swagger-client-3.16.1.tgz#df86c9d407ab52c00cb356e714b0ec732bb3ad40" @@ -26880,15 +27166,15 @@ terser-webpack-plugin@*, terser-webpack-plugin@^5.1.3: terser "^5.7.2" terser-webpack-plugin@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" - integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== + version "1.4.5" + resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b" + integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw== dependencies: cacache "^12.0.2" find-cache-dir "^2.1.0" is-wsl "^1.1.0" schema-utils "^1.0.0" - serialize-javascript "^2.1.2" + serialize-javascript "^4.0.0" source-map "^0.6.1" terser "^4.1.2" webpack-sources "^1.4.0" @@ -26945,23 +27231,22 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -testcontainers@^7.10.0: - version "7.11.1" - resolved "https://registry.npmjs.org/testcontainers/-/testcontainers-7.11.1.tgz#b3810badf79433ba02f210683eec1a1c488c107d" - integrity sha512-lfZeys5bLkADjOaoXfQy0V0+G8sGKr8ESANz7MhSVBwC+OTTxkP3+FVwP48bW4mwRcQ4Hojwbfw10OYT80QZmQ== +testcontainers@^7.23.0: + version "7.23.0" + resolved "https://registry.npmjs.org/testcontainers/-/testcontainers-7.23.0.tgz#7d73a5e219a970fb75ff6a23a28dace8b7f3f232" + integrity sha512-90H1iijeIjOLp7WVNYKTNkM1sd+dlW5019ns45hSPcOET43WebEZQVJl8/Ag9vwSZD2mjomMum9a/EXk/st4sQ== dependencies: - "@types/archiver" "^5.1.0" - "@types/dockerode" "^3.2.1" + "@types/archiver" "^5.1.1" + "@types/dockerode" "^3.2.5" archiver "^5.3.0" byline "^5.0.0" - debug "^4.3.1" - docker-compose "^0.23.10" - dockerode "^3.2.1" + debug "^4.3.2" + docker-compose "^0.23.13" + dockerode "^3.3.1" get-port "^5.1.1" - glob "^7.1.7" + glob "^7.2.0" slash "^3.0.0" - ssh-remote-port-forward "^1.0.3" - stream-to-array "^2.3.0" + ssh-remote-port-forward "^1.0.4" tar-fs "^2.1.1" text-encoding-utf-8@^1.0.1: @@ -27236,6 +27521,11 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= + "traverse@>=0.3.0 <0.4": version "0.3.9" resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" @@ -27360,7 +27650,7 @@ ts-node@^10.0.0: source-map-support "^0.5.17" yn "3.1.1" -ts-node@^10.2.1: +ts-node@^10.2.1, ts-node@^10.4.0: version "10.4.0" resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.4.0.tgz#680f88945885f4e6cf450e7f0d6223dd404895f7" integrity sha512-g0FlPvvCXSIO1JDF6S232P5jPYqBkRL9qly81ZgAOSU7rwI0stphCgd2kLiCrU9DjQCrJMWEqcNSjQL02s6d8A== @@ -27613,12 +27903,9 @@ uc.micro@^1.0.1, uc.micro@^1.0.5: integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== uglify-js@^3.1.4: - version "3.8.0" - resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.8.0.tgz#f3541ae97b2f048d7e7e3aa4f39fd8a1f5d7a805" - integrity sha512-ugNSTT8ierCsDHso2jkBHXYrU8Y5/fY2ZUprfrJUiD7YpuFvV4jODLFmb3h4btQjqr5Nh4TX4XtgDfCU1WdioQ== - dependencies: - commander "~2.20.3" - source-map "~0.6.1" + version "3.14.3" + resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.14.3.tgz#c0f25dfea1e8e5323eccf59610be08b6043c15cf" + integrity sha512-mic3aOdiq01DuSVx0TseaEzMIVqebMZ0Z3vaeDhFEh9bsc24hV1TFvN74reA2vs08D0ZWfNjAcJ3UbVLaBss+g== uid-number@0.0.6: version "0.0.6" @@ -27753,11 +28040,6 @@ uniq@^1.0.1: resolved "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -28176,7 +28458,7 @@ util@^0.12.3: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -utila@^0.4.0, utila@~0.4: +utila@~0.4: version "0.4.0" resolved "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= @@ -28297,11 +28579,6 @@ vasync@^2.2.0: dependencies: verror "1.10.0" -vendors@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" - integrity sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w== - verror@1.10.0, verror@^1.8.1: version "1.10.0" resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" @@ -28485,6 +28762,11 @@ web-streams-polyfill@4.0.0-beta.1: resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz#3b19b9817374b7cee06d374ba7eeb3aeb80e8c95" integrity sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ== +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" @@ -28700,6 +28982,14 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0= + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + whatwg-url@^8.0.0, whatwg-url@^8.4.0: version "8.4.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" @@ -29043,9 +29333,9 @@ xpath@0.0.32: integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw== xss@^1.0.8: - version "1.0.9" - resolved "https://registry.npmjs.org/xss/-/xss-1.0.9.tgz#3ffd565571ff60d2e40db7f3b80b4677bec770d2" - integrity sha512-2t7FahYnGJys6DpHLhajusId7R0Pm2yTmuL0GV9+mV0ZlaLSnb2toBmppATfg5sWIhZQGlsTLoecSzya+l4EAQ== + version "1.0.10" + resolved "https://registry.npmjs.org/xss/-/xss-1.0.10.tgz#5cd63a9b147a755a14cb0455c7db8866120eb4d2" + integrity sha512-qmoqrRksmzqSKvgqzN0055UFWY7OKx1/9JWeRswwEVX9fCG5jcYRxa/A2DHcmZX6VJvjzHRQ2STeeVcQkrmLSw== dependencies: commander "^2.20.3" cssfilter "0.0.10" @@ -29323,6 +29613,11 @@ zip-stream@^4.1.0: compress-commons "^4.1.0" readable-stream "^3.6.0" +zod@^3.11.6, zod@^3.9.5: + version "3.11.6" + resolved "https://registry.npmjs.org/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" + integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"