diff --git a/.changeset/blue-planes-fail.md b/.changeset/blue-planes-fail.md deleted file mode 100644 index ba8eca812a..0000000000 --- a/.changeset/blue-planes-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -improve the wrapping behavior of long entity links diff --git a/.changeset/breezy-books-matter.md b/.changeset/breezy-books-matter.md deleted file mode 100644 index 1bc206bdc5..0000000000 --- a/.changeset/breezy-books-matter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search': patch ---- - -Fix empty state not being displayed on missing results. diff --git a/.changeset/config.json b/.changeset/config.json index 86963b7d09..067dcb5f9f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,5 +6,8 @@ "access": "public", "baseBranch": "master", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/cuddly-donuts-whisper.md b/.changeset/cuddly-donuts-whisper.md deleted file mode 100644 index fd1b665b99..0000000000 --- a/.changeset/cuddly-donuts-whisper.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@backstage/plugin-explore': patch ---- - -Refactors the explore plugin to be more customizable. This includes the following non-breaking changes: - -- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage` -- Refactor `ExplorePage` to use a new `ExploreLayout` component -- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components -- Allows `title` props to be customized - -Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`. - -```tsx -import { - DomainExplorerContent, - ExploreLayout, -} from '@backstage/plugin-explore'; -import React from 'react'; -import { InnserSourceExplorerContent } from './InnserSourceExplorerContent'; - -export const ExplorePage = () => { - return ( - - - - - - - - - ); -}; - -export const explorePage = ; -``` - -Now register the new explore page in `packages/app/src/App.tsx`. - -```diff -+ import { explorePage } from './components/explore/ExplorePage'; - -const routes = ( - -- } /> -+ }> -+ {explorePage} -+ - -); -``` diff --git a/.changeset/curly-eggs-destroy.md b/.changeset/curly-eggs-destroy.md new file mode 100644 index 0000000000..9441f3aeb7 --- /dev/null +++ b/.changeset/curly-eggs-destroy.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated dependencies diff --git a/.changeset/early-buckets-type.md b/.changeset/early-buckets-type.md new file mode 100644 index 0000000000..376408dcae --- /dev/null +++ b/.changeset/early-buckets-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Upgrade `rjsf` to 3.0.0. diff --git a/.changeset/fair-dolphins-relate.md b/.changeset/fair-dolphins-relate.md new file mode 100644 index 0000000000..85bc517a8d --- /dev/null +++ b/.changeset/fair-dolphins-relate.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': minor +--- + +Handle error gracefully if failure occurs while loading photos using Microsoft Graph API. + +This includes a breaking change: you now have to pass the `options` object to `readMicrosoftGraphUsers` and `readMicrosoftGraphOrg`. diff --git a/.changeset/fair-knives-relax.md b/.changeset/fair-knives-relax.md deleted file mode 100644 index bc1357d841..0000000000 --- a/.changeset/fair-knives-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -Introducing new UnhandledErrorForwarder installed by default. For catching unhandled promise rejections, you can override the API to align with general error handling. diff --git a/.changeset/few-windows-whisper.md b/.changeset/few-windows-whisper.md deleted file mode 100644 index 565e8798a1..0000000000 --- a/.changeset/few-windows-whisper.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/core-app-api': patch -'@backstage/core-plugin-api': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-scaffolder': patch ---- - -Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API diff --git a/.changeset/five-baboons-explain.md b/.changeset/five-baboons-explain.md deleted file mode 100644 index b30c1a6237..0000000000 --- a/.changeset/five-baboons-explain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/dev-utils': minor ---- - -Removed support for deprecated registered plugin routes. All routes now need to be added using `addPage` instead. diff --git a/.changeset/forty-dodos-own.md b/.changeset/forty-dodos-own.md deleted file mode 100644 index 6b60872f9d..0000000000 --- a/.changeset/forty-dodos-own.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-client': patch ---- - -Return entities sorted alphabetically by ref diff --git a/.changeset/forty-students-brake.md b/.changeset/forty-students-brake.md new file mode 100644 index 0000000000..8be76e59d2 --- /dev/null +++ b/.changeset/forty-students-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fix `catalog:write` on windows systems diff --git a/.changeset/friendly-bikes-double.md b/.changeset/friendly-bikes-double.md deleted file mode 100644 index 5ee9f959c1..0000000000 --- a/.changeset/friendly-bikes-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -updated plugin template to generate path equals plugin id for the root page diff --git a/.changeset/fuzzy-bottles-greet.md b/.changeset/fuzzy-bottles-greet.md new file mode 100644 index 0000000000..d54a4300ae --- /dev/null +++ b/.changeset/fuzzy-bottles-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Reintroduce export of `defaultConfigLoader`. diff --git a/.changeset/gold-avocados-thank.md b/.changeset/gold-avocados-thank.md new file mode 100644 index 0000000000..c7399ee016 --- /dev/null +++ b/.changeset/gold-avocados-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Download archives as compressed tar files for GitLab to fix the `readTree` bug in TODO Plugin. diff --git a/.changeset/good-jars-turn.md b/.changeset/good-jars-turn.md deleted file mode 100644 index 0bbe344ad2..0000000000 --- a/.changeset/good-jars-turn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search': patch ---- - -Use the `identityApi` to forward authorization headers to the `search-backend` diff --git a/.changeset/great-ads-yawn.md b/.changeset/great-ads-yawn.md deleted file mode 100644 index 3271dde108..0000000000 --- a/.changeset/great-ads-yawn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Allow `defaultKind` from `CatalogTable.column.creatNameColumn` to be configurable diff --git a/.changeset/grumpy-sloths-check.md b/.changeset/grumpy-sloths-check.md new file mode 100644 index 0000000000..e2c2483c00 --- /dev/null +++ b/.changeset/grumpy-sloths-check.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-catalog': patch +--- + +Fixed sizing of the System diagram when the rendered graph was wider than the container. diff --git a/.changeset/heavy-numbers-refuse.md b/.changeset/heavy-numbers-refuse.md deleted file mode 100644 index c5d9dc6deb..0000000000 --- a/.changeset/heavy-numbers-refuse.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@backstage/plugin-api-docs': minor -'@backstage/plugin-cost-insights': minor -'@backstage/plugin-gcp-projects': minor -'@backstage/plugin-gitops-profiles': minor -'@backstage/plugin-newrelic': minor -'@backstage/plugin-welcome': minor ---- - -**BREAKING CHANGE** Remove deprecated route registrations, meaning that it is no longer enough to only import the plugin in the app and the exported page extension must be used instead. diff --git a/.changeset/perfect-gifts-compete.md b/.changeset/long-olives-cheer.md similarity index 52% rename from .changeset/perfect-gifts-compete.md rename to .changeset/long-olives-cheer.md index 7c03798888..65c79aeae7 100644 --- a/.changeset/perfect-gifts-compete.md +++ b/.changeset/long-olives-cheer.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-backend': patch --- -Don't export the `defaultGoogleAuthProvider` +Unbreak `.well-known` OIDC routes diff --git a/.changeset/loud-pets-hang.md b/.changeset/loud-pets-hang.md new file mode 100644 index 0000000000..52e5c8a7dd --- /dev/null +++ b/.changeset/loud-pets-hang.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Use new utilities from `@backstage/backend-common` for safely resolving child paths diff --git a/.changeset/many-penguins-clap.md b/.changeset/many-penguins-clap.md new file mode 100644 index 0000000000..be88536af6 --- /dev/null +++ b/.changeset/many-penguins-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-common': patch +--- + +Add new `isChildPath` export diff --git a/.changeset/metal-badgers-carry.md b/.changeset/metal-badgers-carry.md deleted file mode 100644 index b82e638c6f..0000000000 --- a/.changeset/metal-badgers-carry.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-catalog-backend-module-msgraph': patch ---- - -Move `MicrosoftGraphOrgReaderProcessor` from `@backstage/plugin-catalog-backend` -to `@backstage/plugin-catalog-backend-module-msgraph`. - -The `MicrosoftGraphOrgReaderProcessor` isn't registered by default anymore, if -you want to continue using it you have to register it manually at the catalog -builder: - -1. Add dependency to `@backstage/plugin-catalog-backend-module-msgraph` to the `package.json` of your backend. -2. Add the processor to the catalog builder: - -```typescript -// packages/backend/src/plugins/catalog.ts -builder.addProcessor( - MicrosoftGraphOrgReaderProcessor.fromConfig(config, { - logger, - }), -); -``` - -For more configuration details, see the [README of the `@backstage/plugin-catalog-backend-module-msgraph` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-msgraph/README.md). diff --git a/.changeset/metal-cycles-run.md b/.changeset/metal-cycles-run.md deleted file mode 100644 index b4a2597aa2..0000000000 --- a/.changeset/metal-cycles-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Fix the overlapping between the sidebar and the tabs navigation when enabled in mkdocs (features: navigation.tabs) diff --git a/.changeset/nasty-crews-boil.md b/.changeset/nasty-crews-boil.md new file mode 100644 index 0000000000..5b4108c972 --- /dev/null +++ b/.changeset/nasty-crews-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Replaced moment and dayjs with Luxon diff --git a/.changeset/nasty-games-share.md b/.changeset/nasty-games-share.md new file mode 100644 index 0000000000..f154817634 --- /dev/null +++ b/.changeset/nasty-games-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Update dependencies diff --git a/.changeset/odd-humans-exercise.md b/.changeset/odd-humans-exercise.md deleted file mode 100644 index 136ec86b69..0000000000 --- a/.changeset/odd-humans-exercise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Make the `create-github-app` command disable webhooks by default. diff --git a/.changeset/orange-news-sparkle.md b/.changeset/orange-news-sparkle.md new file mode 100644 index 0000000000..d3a0292d52 --- /dev/null +++ b/.changeset/orange-news-sparkle.md @@ -0,0 +1,46 @@ +--- +'@backstage/integration': patch +'@backstage/plugin-catalog-backend': patch +--- + +Support ingesting multiple GitHub organizations via a new `GithubMultiOrgReaderProcessor`. + +This new processor handles namespacing created groups according to the org of the associated GitHub team to prevent potential name clashes between organizations. Be aware that this processor is considered alpha and may not be compatible with future org structures in the catalog. + +NOTE: This processor only fully supports auth via GitHub Apps + +To install this processor, import and add it as follows: + +```typescript +// Typically in packages/backend/src/plugins/catalog.ts +import { GithubMultiOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; +// ... +export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + builder.addProcessor( + GithubMultiOrgReaderProcessor.fromConfig(env.config, { + logger: env.logger, + }), + ); + // ... +} +``` + +Configure in your `app-config.yaml` by pointing to your GitHub instance and optionally list which GitHub organizations you wish to import. You can also configure what namespace you want to set for teams from each org. If unspecified, the org name will be used as the namespace. If no organizations are listed, by default this processor will import from all organizations accessible by all configured GitHub Apps: + +```yaml +catalog: + locations: + - type: github-multi-org + target: https://github.myorg.com + + processors: + githubMultiOrg: + orgs: + - name: fooOrg + groupNamespace: foo + - name: barOrg + groupNamespace: bar + - name: awesomeOrg + - name: anotherOrg +``` diff --git a/.changeset/plenty-kings-shout.md b/.changeset/plenty-kings-shout.md deleted file mode 100644 index 16453b96de..0000000000 --- a/.changeset/plenty-kings-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -chore: bump `@typescript-eslint/eslint-plugin` from 4.26.0 to 4.27.0 diff --git a/.changeset/popular-rice-wonder.md b/.changeset/popular-rice-wonder.md deleted file mode 100644 index f815ad949a..0000000000 --- a/.changeset/popular-rice-wonder.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Moved installation instructions from the main [backstage.io](https://backstage.io) documentation to the package README file. These instructions are not generally needed, since the plugin comes installed by default with `npx @backstage/create-app`. diff --git a/.changeset/purple-carpets-fold.md b/.changeset/purple-carpets-fold.md new file mode 100644 index 0000000000..bdade07931 --- /dev/null +++ b/.changeset/purple-carpets-fold.md @@ -0,0 +1,7 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-register-component': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Update "service catalog" references to "software catalog" diff --git a/.changeset/red-apricots-perform.md b/.changeset/red-apricots-perform.md deleted file mode 100644 index 3d27381e6f..0000000000 --- a/.changeset/red-apricots-perform.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -chore: bump `del` from 5.1.0 to 6.0.0 diff --git a/.changeset/rich-trees-chew.md b/.changeset/rich-trees-chew.md deleted file mode 100644 index 5ec79c36d0..0000000000 --- a/.changeset/rich-trees-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -chore: bump `@spotify/eslint-config-typescript` from 9.0.0 to 10.0.0 diff --git a/.changeset/rude-pumpkins-hang.md b/.changeset/rude-pumpkins-hang.md new file mode 100644 index 0000000000..9a95b0e455 --- /dev/null +++ b/.changeset/rude-pumpkins-hang.md @@ -0,0 +1,32 @@ +--- +'@backstage/core-components': patch +--- + +Make `ErrorBoundary` display more helpful information about the error that +occurred. + +The `slackChannel` (optional) prop can now be passed as an object on the form +`{ name: string; href?: string; }` in addition to the old string form. If you +are using the error boundary like + +```tsx + + + +``` + +you may like to migrate it to + +```tsx +const support = { + name: '#support', + href: 'https://slack.com/channels/your-channel', +}; + + + + +``` + +Also deprecated the prop `slackChannel` on `TabbedCard` and `InfoCard`, while +adding the prop `errorBoundaryProps` to replace it. diff --git a/.changeset/search-tasty-crews-tie.md b/.changeset/search-tasty-crews-tie.md deleted file mode 100644 index 3d5b1ad1b0..0000000000 --- a/.changeset/search-tasty-crews-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search-backend-node': patch ---- - -Handle errors in collators and decorators and log them. diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md deleted file mode 100644 index fa2839aad2..0000000000 --- a/.changeset/seven-adults-act.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Adds support for custom sign-in resolvers and profile transformations for the -Google auth provider. - -Adds an `ent` claim in Backstage tokens, with a list of -[entity references](https://backstage.io/docs/features/software-catalog/references) -related to your signed-in user's identities and groups across multiple systems. - -Adds an optional `providerFactories` argument to the `createRouter` exported by -the `auth-backend` plugin. - -Updates `BackstageIdentity` so that - -- `idToken` is deprecated in favor of `token` -- An optional `entity` field is added which represents the entity that the user is represented by within Backstage. - -More information: - -- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver) - explains the concepts and shows how to implement your own. -- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089) - RFC contains details about how this affects ownership in the catalog diff --git a/.changeset/short-numbers-carry.md b/.changeset/short-numbers-carry.md deleted file mode 100644 index 0b2fbe80ae..0000000000 --- a/.changeset/short-numbers-carry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search-backend-node': patch ---- - -Fixed bug preventing searches with filter values containing `:` from returning results. diff --git a/.changeset/silent-avocados-beam.md b/.changeset/silent-avocados-beam.md new file mode 100644 index 0000000000..b3f2796b86 --- /dev/null +++ b/.changeset/silent-avocados-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Use new `isChildPath` export from `@backstage/cli-common` diff --git a/.changeset/silent-ways-laugh.md b/.changeset/silent-ways-laugh.md deleted file mode 100644 index ea152c68ef..0000000000 --- a/.changeset/silent-ways-laugh.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-catalog-backend-module-msgraph': patch ---- - -Allow customizations of `MicrosoftGraphOrgReaderProcessor` by passing an -optional `groupTransformer`, `userTransformer`, and `organizationTransformer`. diff --git a/.changeset/six-weeks-shop.md b/.changeset/six-weeks-shop.md new file mode 100644 index 0000000000..3adb37d144 --- /dev/null +++ b/.changeset/six-weeks-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Add new `isChildPath` and `resolveSafeChildPath` exports diff --git a/.changeset/slow-jobs-doubt.md b/.changeset/slow-jobs-doubt.md new file mode 100644 index 0000000000..0cc4ff20e8 --- /dev/null +++ b/.changeset/slow-jobs-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Migrate from Moment to Luxon diff --git a/.changeset/smart-insects-care.md b/.changeset/smart-insects-care.md deleted file mode 100644 index 2d1fc8f2aa..0000000000 --- a/.changeset/smart-insects-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Disambiguated titles of `EntityDependencyOfComponentsCard` and `EntityDependsOnComponentsCard`. diff --git a/.changeset/sour-cherries-occur.md b/.changeset/sour-cherries-occur.md new file mode 100644 index 0000000000..a8837749d3 --- /dev/null +++ b/.changeset/sour-cherries-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Don't crash if the entities response doesn't include the entities name and kind diff --git a/.changeset/sour-donuts-tickle.md b/.changeset/sour-donuts-tickle.md deleted file mode 100644 index 5cc3323662..0000000000 --- a/.changeset/sour-donuts-tickle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Adds an optional `actions` prop to `CatalogTable` and `CatalogPage` to support supplying custom actions for each entity row in the table. This uses the default actions if not provided. diff --git a/.changeset/stupid-scissors-brake.md b/.changeset/stupid-scissors-brake.md deleted file mode 100644 index 25c948a632..0000000000 --- a/.changeset/stupid-scissors-brake.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@backstage/catalog-model': patch -'@backstage/cli': patch -'@backstage/create-app': patch -'@backstage/dev-utils': patch -'@backstage/integration-react': patch -'@backstage/test-utils': patch -'@backstage/plugin-api-docs': patch -'@backstage/plugin-badges': patch -'@backstage/plugin-bitrise': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-catalog-react': patch -'@backstage/plugin-circleci': patch -'@backstage/plugin-cloudbuild': patch -'@backstage/plugin-code-coverage': patch -'@backstage/plugin-config-schema': patch -'@backstage/plugin-cost-insights': patch -'@backstage/plugin-explore': patch -'@backstage/plugin-explore-react': patch -'@backstage/plugin-fossa': patch -'@backstage/plugin-gcp-projects': patch -'@backstage/plugin-git-release-manager': patch -'@backstage/plugin-github-actions': patch -'@backstage/plugin-github-deployments': patch -'@backstage/plugin-gitops-profiles': patch -'@backstage/plugin-graphiql': patch -'@backstage/plugin-ilert': patch -'@backstage/plugin-jenkins': patch -'@backstage/plugin-kafka': patch -'@backstage/plugin-kubernetes': patch -'@backstage/plugin-lighthouse': patch -'@backstage/plugin-newrelic': patch -'@backstage/plugin-org': patch -'@backstage/plugin-pagerduty': patch -'@backstage/plugin-register-component': patch -'@backstage/plugin-rollbar': patch -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-search': patch -'@backstage/plugin-sentry': patch -'@backstage/plugin-shortcuts': patch -'@backstage/plugin-sonarqube': patch -'@backstage/plugin-splunk-on-call': patch -'@backstage/plugin-tech-radar': patch -'@backstage/plugin-techdocs': patch -'@backstage/plugin-todo': patch -'@backstage/plugin-user-settings': patch -'@backstage/plugin-welcome': patch ---- - -Migrated to use the new `@backstage/core-*` packages rather than `@backstage/core`. diff --git a/.changeset/swift-fishes-taste.md b/.changeset/swift-fishes-taste.md new file mode 100644 index 0000000000..8003e67698 --- /dev/null +++ b/.changeset/swift-fishes-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Ensure that emitted relations are deduplicated diff --git a/.changeset/techdocs-cool-rivers-suffer.md b/.changeset/techdocs-cool-rivers-suffer.md deleted file mode 100644 index 2386d27525..0000000000 --- a/.changeset/techdocs-cool-rivers-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Refactor the implicit logic from `` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend. diff --git a/.changeset/techdocs-poor-forks-repeat.md b/.changeset/techdocs-poor-forks-repeat.md deleted file mode 100644 index 89c4e0dc5a..0000000000 --- a/.changeset/techdocs-poor-forks-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs-backend': patch ---- - -Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built. This enables the caller to know whether a refresh of the docs page will return updated content (-> `201 Created`) or not (-> `304 Not Modified`). diff --git a/.changeset/tender-trees-notice.md b/.changeset/tender-trees-notice.md deleted file mode 100644 index cf8c395a2d..0000000000 --- a/.changeset/tender-trees-notice.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Move `EntityTypePicker` to be consistent with `CatalogPage` and remove `api:` prefix from entity names diff --git a/.changeset/thirty-turtles-carry.md b/.changeset/thirty-turtles-carry.md new file mode 100644 index 0000000000..96b0495561 --- /dev/null +++ b/.changeset/thirty-turtles-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Show better error message when configs defined under auth.providers. are undefined. diff --git a/.changeset/tricky-carrots-lick.md b/.changeset/tricky-carrots-lick.md new file mode 100644 index 0000000000..dd5c2d4288 --- /dev/null +++ b/.changeset/tricky-carrots-lick.md @@ -0,0 +1,24 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Move `LdapOrgReaderProcessor` from `@backstage/plugin-catalog-backend` +to `@backstage/plugin-catalog-backend-module-ldap`. + +The `LdapOrgReaderProcessor` isn't registered by default anymore, if +you want to continue using it you have to register it manually at the catalog +builder: + +1. Add dependency to `@backstage/plugin-catalog-backend-module-ldap` to the `package.json` of your backend. +2. Add the processor to the catalog builder: + +```typescript +// packages/backend/src/plugins/catalog.ts +builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(config, { + logger, + }), +); +``` + +For more configuration details, see the [README of the `@backstage/plugin-catalog-backend-module-ldap` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md). diff --git a/.changeset/twenty-pumpkins-work.md b/.changeset/twenty-pumpkins-work.md new file mode 100644 index 0000000000..b729c989e5 --- /dev/null +++ b/.changeset/twenty-pumpkins-work.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Clear the previously selected sign-in provider on failure diff --git a/.changeset/two-pots-joke.md b/.changeset/two-pots-joke.md new file mode 100644 index 0000000000..7a97777524 --- /dev/null +++ b/.changeset/two-pots-joke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Get rid of flex console warning for IconLink diff --git a/.changeset/warm-mangos-share.md b/.changeset/warm-mangos-share.md new file mode 100644 index 0000000000..4f54b100d2 --- /dev/null +++ b/.changeset/warm-mangos-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kafka-backend': patch +--- + +Properly return a 404 when an unknown cluster is given diff --git a/.changeset/weak-needles-peel.md b/.changeset/weak-needles-peel.md deleted file mode 100644 index 5315e2cb66..0000000000 --- a/.changeset/weak-needles-peel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Correctly recognize whether the cookiecutter command exists diff --git a/.changeset/wet-suits-attack.md b/.changeset/wet-suits-attack.md deleted file mode 100644 index 8506381dc5..0000000000 --- a/.changeset/wet-suits-attack.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/core-api': patch ---- - -Add deprecation warning to package README. diff --git a/.github/codecov.yml b/.github/codecov.yml index a15de165d0..6450a704bb 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -14,11 +14,15 @@ coverage: # Since Backstage is a mono repo, flags here help in getting the code coverage of individual packages. # Documentation: https://docs.codecov.io/docs/flags flags: - core: + core-app-api: paths: - - packages/core/ + - packages/core-app-api/ carryforward: true - core-api: + core-components: paths: - - packages/core-api/ + - packages/core-components/ + carryforward: true + core-plugin-api: + paths: + - packages/core-plugin-api/ carryforward: true diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 906cbbb2a3..846e050c49 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -52,10 +52,12 @@ cookiecutter css Datadog dataflow +dayjs deadnaming debounce Debounce declaratively +deduplicated deps destructured dev @@ -120,6 +122,7 @@ Knex kubectl kubernetes kubernetes +ldap learnings Leasot lerna @@ -205,6 +208,8 @@ repo Repo repos rerender +Reusability +reusability rollbar Rollbar Rollup @@ -222,6 +227,7 @@ seb semlas semver Serverless +siloed Sinon Snyk sourcemaps @@ -268,6 +274,7 @@ transpilation transpiled truthy ui +unbreak unmanaged unregister unregistration diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index 89df848c76..d189236987 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -4,8 +4,7 @@ on: paths: - '.github/workflows/chromatic-storybook-test.yml' - 'packages/storybook/**' - - 'packages/core/src/components/**' - - 'packages/core/src/layout/**' + - 'packages/core-components/src/**' jobs: chromatic: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 75e26d52d3..daac9a535d 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -119,8 +119,9 @@ jobs: yarn lerna -- run test -- --coverage bash <(curl -s https://codecov.io/bash) # Upload code coverage for some specific flags. Also see .codecov.yml - bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core - bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api + bash <(curl -s https://codecov.io/bash) -f packages/core-app-api/coverage/* -F core-app-api + bash <(curl -s https://codecov.io/bash) -f packages/core-components/coverage/* -F core-components + bash <(curl -s https://codecov.io/bash) -f packages/core-plugin-api/coverage/* -F core-plugin-api env: BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }} BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }} diff --git a/.github/workflows/microsite-with-storybook-deploy.yml b/.github/workflows/microsite-with-storybook-deploy.yml index cf571ef4e8..e8886b7d6b 100644 --- a/.github/workflows/microsite-with-storybook-deploy.yml +++ b/.github/workflows/microsite-with-storybook-deploy.yml @@ -7,7 +7,7 @@ on: paths: - '.github/workflows/microsite-with-storybook-deploy.yml' - 'packages/storybook/**' - - 'packages/core/src/**' + - 'packages/core-components/src/**' - 'microsite/**' - 'docs/**' diff --git a/ADOPTERS.md b/ADOPTERS.md index 9f59835cbc..70a6cbc3f4 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,7 +1,7 @@ | Organization | Contact | Description of Use | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [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. | @@ -32,4 +32,5 @@ | [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) | 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). | +| [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. | diff --git a/README.md b/README.md index 6dd490e43b..f8b54ad200 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,15 @@ ## What is Backstage? -[Backstage](https://backstage.io/) is an open platform for building developer portals. Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. +[Backstage](https://backstage.io/) is an open platform for building developer portals. Powered by a centralized software catalog, Backstage restores order to your microservices and infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. Backstage unifies all your infrastructure tooling, services, and documentation to create a streamlined development environment from end to end. -![service-catalog](https://backstage.io/blog/assets/6/header.png) +![software-catalog](https://backstage.io/blog/assets/6/header.png) Out of the box, Backstage includes: -- [Backstage Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) +- [Backstage Software Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) - [Backstage Software Templates](https://backstage.io/docs/features/software-templates/software-templates-index) for quickly spinning up new projects and standardizing your tooling with your organization’s best practices - [Backstage TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview) for making it easy to create, maintain, find, and use technical documentation, using a "docs like code" approach - Plus, a growing ecosystem of [open source plugins](https://github.com/backstage/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality @@ -38,7 +38,7 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how ## Documentation - [Main documentation](https://backstage.io/docs) -- [Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) +- [Software Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) - [Architecture](https://backstage.io/docs/overview/architecture-overview) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview)) - [Designing for Backstage](https://backstage.io/docs/dls/design) - [Storybook - UI components](https://backstage.io/storybook) diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index bc26a8c89a..6fc39811b5 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -99,7 +99,7 @@ async function main() { ```typescript // packages/app/src/App.tsx from a create-app deployment -import { discoveryApiRef, useApi } from '@backstage/core'; +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; // ... diff --git a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md index 0567a6bbb6..b121bab25f 100644 --- a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md +++ b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md @@ -51,7 +51,7 @@ The Backstage App needs a SignInPage when authentication is required. When using ALB authentication Backstage will only be loaded once the user has successfully authenticated; we won't need to display a SignIn page, however we will need to create a dummy SignIn component that can refresh the token. - edit `packages/app/src/App.tsx` -- import the following two additional definitions from `@backstage/core`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB +- import the following two additional definitions from `@backstage/core-plugin-api`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB - add the following definition just before the app is created (`const app = createApp`): ```ts diff --git a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md index 841e13dd8b..8a4d6fb273 100644 --- a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md +++ b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md @@ -26,6 +26,7 @@ import 'global-agent/bootstrap'; ```sh export GLOBAL_AGENT_HTTP_PROXY=$HTTP_PROXY +export GLOBAL_AGENT_NO_PROXY=$NO_PROXY yarn start ``` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 18734682d5..fe56adddd8 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -5,6 +5,7 @@ ExampleComponent.tsx reference ```tsx import React from 'react'; import { Typography, Grid } from '@material-ui/core'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import { InfoCard, Header, @@ -13,9 +14,7 @@ import { ContentHeader, HeaderLabel, SupportButton, - identityApiRef, - useApi, -} from '@backstage/core'; +} from '@backstage/core-components'; import { ExampleFetchComponent } from '../ExampleFetchComponent'; export const ExampleComponent = () => { diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 262330119e..086175a0ce 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -6,13 +6,8 @@ ExampleFetchComponent.tsx reference import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; -import { - Table, - TableColumn, - Progress, - githubAuthApiRef, - useApi, -} from '@backstage/core'; +import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api'; +import { Table, TableColumn, Progress } from '@backstage/core-components'; import { graphql } from '@octokit/graphql'; const query = `{ diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 56dd46794a..f04202b79b 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -23,18 +23,18 @@ during their entire life cycle. Each Utility API is tied to an `ApiRef` instance, which is a global singleton object without any additional state or functionality, its only purpose is to reference Utility APIs. `ApiRef`s are created using `createApiRef`, which is -exported by `@backstage/core`. There are many +exported by `@backstage/core-plugin-api`. There are many [predefined Utility APIs](../reference/utility-apis/README.md) defined in -`@backstage/core`, and they're all exported with a name of the pattern -`*ApiRef`, for example `errorApiRef`. +`@backstage/core-plugin-api`, and they're all exported with a name of the +pattern `*ApiRef`, for example `errorApiRef`. To access one of the Utility APIs inside a React component, use the `useApi` -hook exported by `@backstage/core`, or the `withApis` HOC if you prefer class -components. For example, the `ErrorApi` can be accessed like this: +hook exported by `@backstage/core-plugin-api`, or the `withApis` HOC if you +prefer class components. For example, the `ErrorApi` can be accessed like this: ```tsx import React from 'react'; -import { useApi, errorApiRef } from '@backstage/core'; +import { useApi, errorApiRef } from '@backstage/core-plugin-api'; export const MyComponent = () => { const errorApi = useApi(errorApiRef); @@ -52,9 +52,9 @@ Note that there is no explicit type given for `ErrorApi`. This is because the `errorApiRef` has the type embedded, and `useApi` is able to infer the type. Also note that consuming Utility APIs is not limited to plugins, it can be done -from any component inside Backstage, including the ones in `@backstage/core`. -The only requirement is that they are beneath the `AppProvider` in the react -tree. +from any component inside Backstage, including the ones in +`@backstage/core-plugin-api`. The only requirement is that they are beneath the +`AppProvider` in the react tree. ## Supplying APIs @@ -101,13 +101,13 @@ app, and the app itself. ### Core APIs Starting with the Backstage core library, it provides implementations for all of -the core APIs. The core APIs are the ones exported by `@backstage/core`, such as -the `errorApiRef` and `configApiRef`. You can find a full list of them -[here](../reference/utility-apis/README.md). +the core APIs. The core APIs are the ones exported by +`@backstage/core-plugin-api`, such as the `errorApiRef` and `configApiRef`. You +can find a full list of them [here](../reference/utility-apis/README.md). The core APIs are loaded for any app created with `createApp` from -`@backstage/core`, which means that there is no step that needs to be taken to -include these APIs in an app. +`@backstage/core-plugin-api`, which means that there is no step that needs to be +taken to include these APIs in an app. ### Plugin APIs @@ -213,8 +213,9 @@ implement the `ErrorApi`, as it is checked by the type embedded in the Plugins are free to define their own Utility APIs. Simply define the TypeScript interface for the API, and create an `ApiRef` using `createApiRef` exported from -`@backstage/core`. Also be sure to provide at least one implementation of the -API, and to declare a default factory for the API in `createPlugin`. +`@backstage/core-plugin-api`. Also be sure to provide at least one +implementation of the API, and to declare a default factory for the API in +`createPlugin`. Custom Utility APIs can be either public or private, which is up to the plugin to choose. Private APIs do not expose an external API surface, and it's diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 846ca8feae..e6e378da9d 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -6,8 +6,8 @@ description: Architecture Decision Record (ADR) log on Module Export Structure ## Context -With a growing number of exports of packages like `@backstage/core`, it is -becoming more and more difficult to answer questions such as +With a growing number of exports of packages like `@backstage/core-components`, +it is becoming more and more difficult to answer questions such as > Is the export in this module also exported by the package? @@ -86,7 +86,7 @@ import { helperFunc } from '../../lib/UtilityX/helper'; ## Consequences We will actively work to rework the export structure in our codebase, -prioritizing the library packages such as `@backstage/core` and +prioritizing the library packages such as `@backstage/core-components` and `@backstage/backend-common`. If possible, we will add tools, such as lint rules, to help enforce the export diff --git a/docs/architecture-decisions/adr011-plugin-package-structure.md b/docs/architecture-decisions/adr011-plugin-package-structure.md index 9133e5b7c2..374a3ed546 100644 --- a/docs/architecture-decisions/adr011-plugin-package-structure.md +++ b/docs/architecture-decisions/adr011-plugin-package-structure.md @@ -35,6 +35,8 @@ example `catalog` or `techdocs`): - `x`: Contains the main frontend code of the plugin. - `x-backend`: Contains the main backend code of the plugin. +- `x-backend-module-`: Contains optional modules related to the backend + plugin. - `x-react`: Contains shared widgets, hooks and similar that both the plugin itself (`x`) and third-party frontend plugins can depend on. - `x-node`: Contains utilities for backends that both the plugin backend itself @@ -61,6 +63,10 @@ We will actively migrate existing packages that are part of a plugin to the `plugins/catalog-common` we might want to do an exception here, as it's a very central package. +We will actively migrate optional features of backend plugins into separate +`x-backend-module-` packages, for example the more specialized processors +in the catalog backend. + The limited set of rules might not be sufficient in the future. If additional packages are required, we will revisit this decision and extend the pattern. diff --git a/docs/assets/software-catalog/service-catalog-home.png b/docs/assets/software-catalog/software-catalog-home.png similarity index 100% rename from docs/assets/software-catalog/service-catalog-home.png rename to docs/assets/software-catalog/software-catalog-home.png diff --git a/docs/auth/index.md b/docs/auth/index.md index 000c57a50c..76cd79b0fd 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -66,7 +66,8 @@ built-in providers: ```diff # packages/app/src/App.tsx -+ import { githubAuthApiRef, SignInProviderConfig, SignInPage } from '@backstage/core'; ++ import { githubAuthApiRef } from '@backstage/core-plugin-api'; ++ import { SignInProviderConfig, SignInPage } from '@backstage/core-components'; + const githubProvider: SignInProviderConfig = { + id: 'github-auth-provider', diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 4209b81279..fbd87d653b 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -14,7 +14,7 @@ to various third party APIs. There are occasions when the user wants to perform actions towards third party services that require authorization via OAuth. Backstage provides standardized [Utility APIs](../api/utility-apis.md) such as the -[GoogleAuthApi](https://github.com/backstage/backstage/blob/master/packages/core-api/src/apis/definitions/auth.ts) +[GoogleAuthApi](https://github.com/backstage/backstage/blob/master/packages/core-plugin-api/src/apis/definitions/auth.ts) for that use-case. Backstage also includes a set of implementations of these APIs that integrate with the [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend) @@ -38,7 +38,7 @@ choose an account to log in with, and accept or reject the request. If the user accepts the login request, a token is issued, and any holder of the token can use it to make authenticated requests towards the third party service. -## OAuth in @backstage/core-api and auth-backend +## OAuth in @backstage/core-app-api and auth-backend The default OAuth implementation in Backstage is based on an OAuth server-side offline access flow, which means that it uses the backend as a helper in order @@ -59,8 +59,8 @@ easier to make authenticated requests inside a plugin. The following describes the OAuth flow implemented by the [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend) and -[DefaultAuthConnector](https://github.com/backstage/backstage/blob/master/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts) -in `@backstage/core-api`. +[DefaultAuthConnector](https://github.com/backstage/backstage/blob/master/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts) +in `@backstage/core-app-api`. Component and APIs can request Access or ID Tokens from any available Auth provider. If there already exists a cached fresh token that covers (at least) diff --git a/docs/auth/using-auth.md b/docs/auth/using-auth.md index fbc6017272..e4f87a12f9 100644 --- a/docs/auth/using-auth.md +++ b/docs/auth/using-auth.md @@ -45,8 +45,8 @@ pieces in place that can be used. #### Identity for Plugin Developers As a plugin developer, there are two main touchpoints for identities: the -`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not -yet existing middleware exported by `@backstage/backend-common`. +`IdentityApi` exported by `@backstage/core-plugin-api` via the `identityApiRef`, +and a not yet existing middleware exported by `@backstage/backend-common`. The `IdentityApi` gives access to the signed-in user's identity in the frontend. It provides access to the user's ID, lightweight profile information, and an ID @@ -61,8 +61,9 @@ https://github.com/backstage/backstage/issues/1435. If you're setting up your own Backstage app, or want to add a new identity provider, there are three touchpoints: the frontend auth APIs in -`@backstage/core-api`, the backend auth providers in `auth-backend`, and the -`SignInPage` component configured in the Backstage app via `createApp`. +`@backstage/core-app-api` and `@backstage/core-plugin-api`, the backend auth +providers in `auth-backend`, and the `SignInPage` component configured in the +Backstage app via `createApp`. The frontend APIs and backend providers are tightly coupled together for each auth provider, and together they implement an e2e auth flow. Only some auth @@ -81,10 +82,10 @@ The final piece of the puzzle is the `SignInPage` component that can be configured as part of the app. Without a sign-in page, Backstage will fall back to a `guest` identity for all users, without any ID token. To enable sign-in, a `SignInPage` needs to be configured, which in turn has to supply a user to the -app. The `@backstage/core` package provides a basic sign-in page that allows -both the user and the app developer to choose between a couple of different -sign-in methods, or to designate a single provider that may also be logged in to -automatically. +app. The `@backstage/core-components` package provides a basic sign-in page that +allows both the user and the app developer to choose between a couple of +different sign-in methods, or to designate a single provider that may also be +logged in to automatically. ## Further Reading diff --git a/docs/cli/commands.md b/docs/cli/commands.md index a99d4cd9d0..ff61e51c75 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -554,9 +554,7 @@ Options: Scope: `root` Validate `@backstage` dependencies within the repo, making sure that there are -no duplicates of packages that might lead to breakages. For example, -`@backstage/core` must not be loaded in twice, so having two different versions -of it installed will cause this command to exit with an error. +no duplicates of packages that might lead to breakages. By supplying the `--fix` flag the command will attempt to fix any conflict that can be resolved by editing `yarn.lock`, but will not attempt to search for diff --git a/docs/conf/reading.md b/docs/conf/reading.md index 6d4b61224d..6ac10a8c8b 100644 --- a/docs/conf/reading.md +++ b/docs/conf/reading.md @@ -112,7 +112,7 @@ example `getString`. These will throw an error if there is no value available. The [ConfigApi](../reference/utility-apis/Config.md) in the frontend is a [UtilityApi](../api/utility-apis.md). It's accessible as usual via the -`configApiRef` exported from `@backstage/core`. +`configApiRef` exported from `@backstage/core-plugin-api`. Depending on the config api in another API is slightly different though, as the `ConfigApi` implementation is supplied via the App itself and not instantiated @@ -123,7 +123,7 @@ for an example of how this wiring is done. For standalone plugin setups in `dev/index.ts`, register a factory with a statically mocked implementation of the config API. Use the `ConfigReader` from `@backstage/config` to create an instance and register it for the `configApiRef` -from `@backstage/core`. +from `@backstage/core-plugin-api`. ## Accessing ConfigApi in Backend Plugins diff --git a/docs/dls/contributing-to-storybook.md b/docs/dls/contributing-to-storybook.md index fff9e51e18..84282289f8 100644 --- a/docs/dls/contributing-to-storybook.md +++ b/docs/dls/contributing-to-storybook.md @@ -25,7 +25,7 @@ component, which are then displayed both visually and with sample code to be copied. When custom Backstage components are created, they are placed in the -`@backstage/core` package and added to the Storybook. +`@backstage/core-components` package and added to the Storybook. There may be times where an existing Material-UI component (in `@material-ui/core`) is sufficient and doesn't need to be wrapped or duplicated. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 45ef28ea2f..068a17249c 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -68,7 +68,7 @@ The base URL to the Kubernetes control plane. Can be found by using the ##### `clusters.\*.name` A name to represent this cluster, this must be unique within the `clusters` -array. Users will see this value in the Service Catalog Kubernetes plugin. +array. Users will see this value in the Software Catalog Kubernetes plugin. ##### `clusters.\*.authProvider` @@ -195,7 +195,7 @@ annotations: #### Labeling Kubernetes components -In order for Kubernetes components to show up in the service catalog as a part +In order for Kubernetes components to show up in the software catalog as a part of an entity, Kubernetes components themselves can have the following label: ```yaml diff --git a/docs/features/kubernetes/index.md b/docs/features/kubernetes/index.md index 26794eca10..159370fbf4 100644 --- a/docs/features/kubernetes/index.md +++ b/docs/features/kubernetes/index.md @@ -2,7 +2,7 @@ id: overview title: Kubernetes sidebar_label: Overview -description: Monitoring Kubernetes based services with the service catalog +description: Monitoring Kubernetes based services with the software catalog --- Kubernetes in Backstage is a tool that's designed around the needs of service diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 7408d69193..c412701499 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -29,7 +29,7 @@ Backstage app with the following contents: ```tsx import React from 'react'; -import { Content, Header, Page } from '@backstage/core'; +import { Content, Header, Page } from '@backstage/core-components'; import { Grid, List, Card, CardContent } from '@material-ui/core'; import { SearchBar, diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 2189b7d799..a502c2aee5 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -1,29 +1,29 @@ --- id: software-catalog-overview -title: Backstage Service Catalog (alpha) +title: Backstage Software Catalog (alpha) sidebar_label: Overview # prettier-ignore -description: The Backstage Service Catalog — actually, a software catalog, since it includes more than just services +description: The Backstage Software Catalog --- -## What is a Service Catalog? +## What is a Software Catalog? -The Backstage Service Catalog — actually, a software catalog, since it includes +The Backstage Software Catalog — actually, a software catalog, since it includes more than just services — is a centralized system that keeps track of ownership and metadata for all the software in your ecosystem (services, websites, libraries, data pipelines, etc). The catalog is built around the concept of [metadata YAML files](descriptor-format.md) stored together with the code, which are then harvested and visualized in Backstage. -![service-catalog](https://backstage.io/blog/assets/6/header.png) +![software-catalog](https://backstage.io/blog/assets/6/header.png) ## How it works -Backstage and the Backstage Service Catalog make it easy for one team to manage +Backstage and the Backstage Software Catalog make it easy for one team to manage 10 services — and makes it possible for your company to manage thousands of them. -More specifically, the Service Catalog enables two main use-cases: +More specifically, the Software Catalog enables two main use-cases: 1. Helping teams manage and maintain the software they own. Teams get a uniform view of all their software; services, libraries, websites, ML models — you @@ -37,11 +37,11 @@ The Software Catalog is available to browse at `/catalog`. If you've followed [Getting Started with Backstage](../../getting-started), you should be able to browse the catalog at `http://localhost:3000`. -![](../../assets/software-catalog/service-catalog-home.png) +![](../../assets/software-catalog/software-catalog-home.png) ## Adding components to the catalog -The source of truth for the components in your service catalog are +The source of truth for the components in your software catalog are [metadata YAML files](descriptor-format.md) stored in source control (GitHub, GitHub Enterprise, GitLab, ...). @@ -104,11 +104,11 @@ them, and do so using their normal Git workflow. ![](../../assets/software-catalog/bsc-edit.png) Once the change has been merged, Backstage will automatically show the updated -metadata in the service catalog after a short while. +metadata in the software catalog after a short while. ## Finding software in the catalog -By default the service catalog shows components owned by the team of the logged +By default the software catalog shows components owned by the team of the logged in user. But you can also switch to _All_ to see all the components across your company's software ecosystem. Basic inline _search_ and _column filtering_ makes it easy to browse a big set of components. @@ -124,7 +124,7 @@ _starring_ of components: ## Integrated tooling through plugins -The service catalog is a great way to organize the infrastructure tools you use +The software catalog is a great way to organize the infrastructure tools you use to manage the software. This is how Backstage creates one developer portal for all your tools. Rather than asking teams to jump between different infrastructure UIs (and incurring additional cognitive overhead each time they diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index ecb7d11be9..d185558949 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -83,7 +83,7 @@ spec: [Template Entity](../software-catalog/descriptor-format.md#kind-template) contains more information about the required fields. -Once we have a `template.yaml` ready, we can then add it to the service catalog +Once we have a `template.yaml` ready, we can then add it to the software catalog for use by the scaffolder. You can add the template files to the catalog through diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 8481e64eac..529376eb89 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -4,8 +4,8 @@ title: Writing Templates description: Details around creating your own custom Software Templates --- -Templates are stored in the **Service Catalog** under a kind `Template`. You can -create your own templates with a small `yaml` definition which describes the +Templates are stored in the **Software Catalog** under a kind `Template`. You +can create your own templates with a small `yaml` definition which describes the template and it's metadata, along with some input variables that your template will need, and then a list of actions which are then executed by the scaffolding service. diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 4dbc6bfa48..aa3851463f 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -56,7 +56,7 @@ For example, adding the theme that we created in the previous section can be done like this: ```ts -import { createApp } from '@backstage/core'; +import { createApp } from '@backstage/core-app-api'; const app = createApp({ apis: ..., diff --git a/docs/getting-started/contributors.md b/docs/getting-started/contributors.md index e33d7a8479..e12c0c5f17 100644 --- a/docs/getting-started/contributors.md +++ b/docs/getting-started/contributors.md @@ -94,7 +94,7 @@ here are some useful ones: ```python yarn start # Start serving the example app, use --check to include type checks and linting -yarn storybook # Start local storybook, useful for working on components in @backstage/core +yarn storybook # Start local storybook, useful for working on components in @backstage/core-components yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 2e568664d9..ac78685a82 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -137,7 +137,7 @@ frontend with `yarn start` in one window, and the backend with It can often be useful to try out changes to the packages in the main Backstage repo within your own app. For example if you want to make modifications to -`@backstage/core` and try them out in your app. +`@backstage/core-plugin-api` and try them out in your app. To link in external packages, add them to your `package.json` and `lerna.json` workspace paths. These can be either relative or absolute paths with or without @@ -147,7 +147,7 @@ globs. For example: "packages": [ "packages/*", "plugins/*", - "../backstage/packages/core", // New path added to work on @backstage/core + "../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api ], ``` @@ -157,9 +157,10 @@ Then reinstall packages to make yarn set up symlinks: yarn install ``` -With this in place you can now modify the `@backstage/core` package within the -main repo, and have those changes be reflected and tested in your app. Simply -run your app using `yarn dev` (or `yarn start` for just frontend) as normal. +With this in place you can now modify the `@backstage/core-plugin-api` package +within the main repo, and have those changes be reflected and tested in your +app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as +normal. Note that for backend packages you need to make sure that linked packages are not dependencies of any non-linked package. If you for example want to work on diff --git a/docs/getting-started/project-structure.md b/docs/getting-started/project-structure.md index 55a92da571..1d5d58c2c9 100644 --- a/docs/getting-started/project-structure.md +++ b/docs/getting-started/project-structure.md @@ -127,18 +127,17 @@ are separated out into their own folder, see further down. used by the backend, we chose to separate `config` and `config-loader` into two different packages. -- [`core/`](https://github.com/backstage/backstage/tree/master/packages/core) - +- [`core-app-api/`](https://github.com/backstage/backstage/tree/master/packages/core-app-api) - + This package contains the core APIs that are used to wire together Backstage + apps. + +- [`core-components/`](https://github.com/backstage/backstage/tree/master/packages/core-components) - This package contains our visual React components, some of which you can find in [plugin examples](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data). - Apart from that it re-exports everything from [`core-api`] so that users only - need to rely on one package. -- [`core-api/`](https://github.com/backstage/backstage/tree/master/packages/core-api) - - This package contains APIs and definitions of such. It is it's own package - because we needed to split our `test-utils` package. It's an implementation - detail that we try to hide from our users, and no one should have to depend on - it directly. +- [`core-plugin-api/`](https://github.com/backstage/backstage/tree/master/packages/core-plugin-api) - + This package contains the core APIs that are used to build Backstage plugins. - [`create-app/`](https://github.com/backstage/backstage/tree/master/packages/create-app) - An CLI to specifically scaffold a new Backstage App. It does so by using a diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 24a4335b8b..836601971c 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -14,19 +14,25 @@ entities that mirror your org setup. ## Installation -The processor that performs the import, `LdapOrgReaderProcessor`, comes -installed with the default setup of Backstage. +1. The processor is not installed by default, therefore you have to add a + dependency to `@backstage/plugin-catalog-backend-module-ldap` to your backend + package. -If you replace the set of processors in your installation using that facility of -the catalog builder class, you can import and add it as follows. +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-catalog-backend-module-ldap +``` -```ts -// Typically in packages/backend/src/plugins/catalog.ts -import { LdapOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; +2. The `LdapOrgReaderProcessor` is not registered by default, so you have to + register it in the catalog plugin: -builder.replaceProcessors( - LdapOrgReaderProcessor.fromConfig(config, { logger }), - // ... +```typescript +// packages/backend/src/plugins/catalog.ts +builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(config, { + logger, + }), ); ``` diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 00d83b43ed..7ea892840c 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -25,7 +25,7 @@ different ways. The following diagram shows how Backstage might look when deployed inside a company which uses the Tech Radar plugin, the Lighthouse plugin, the CircleCI -plugin and the service catalog. +plugin and the software catalog. There are 3 main components in this architecture: @@ -142,7 +142,7 @@ Its architecture looks like this: ![lighthouse plugin backed to microservice and database](../assets/architecture-overview/lighthouse-plugin-architecture.png) -The service catalog in Backstage is another example of a service backed plugin. +The software catalog in Backstage is another example of a service backed plugin. It retrieves a list of services, or "entities", from the Backstage Backend service and renders them in a table for the user. diff --git a/docs/overview/background.md b/docs/overview/background.md index 0c22396e72..00fb5107ea 100644 --- a/docs/overview/background.md +++ b/docs/overview/background.md @@ -22,8 +22,8 @@ Our idea was to centralize and simplify end-to-end software development with an abstraction layer that sits on top of all of our infrastructure and developer tooling. That’s Backstage. -It’s a developer portal powered by a centralized service catalog — with a plugin -architecture that makes it endlessly extensible and customizable. +It’s a developer portal powered by a centralized software catalog — with a +plugin architecture that makes it endlessly extensible and customizable. Manage all your services, software, tooling, and testing in Backstage. Start building a new microservice using an automated template in Backstage. Create, diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index a736126324..0013c99c6b 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -22,7 +22,7 @@ We have divided the project into three high-level _phases_: [UX patterns and components](https://backstage.io/storybook) help ensure a consistent experience between tools. -- 🐢 **Phase 2:** Service Catalog +- 🐢 **Phase 2:** Software Catalog ([alpha released](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)) - With a single catalog, Backstage makes it easy for a team to manage ten services — and makes it possible for your company to manage thousands of them. @@ -120,13 +120,13 @@ Chances are that someone will jump in and help build it. - [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs) - [Plugin marketplace](https://backstage.io/plugins) - [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage) -- [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) +- [Backstage Software Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) - [Backstage Software Templates (alpha)](https://backstage.io/blog/2020/08/05/announcing-backstage-software-templates) - [Make it possible to add custom auth providers](https://backstage.io/blog/2020/07/01/how-to-enable-authentication-in-backstage-using-passport) - [TechDocs v0](https://github.com/backstage/backstage/milestone/15) - CI plugins: CircleCI, Jenkins, GitHub Actions and TravisCI - [Service API documentation](https://github.com/backstage/backstage/pull/1737) -- Backstage Service Catalog can read from: GitHub, GitLab, +- Backstage Software Catalog can read from: GitHub, GitLab, [Bitbucket](https://github.com/backstage/backstage/pull/1938) - Support auth providers: Google, Okta, GitHub, GitLab, [auth0](https://github.com/backstage/backstage/pull/1611), diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 29b9be14b6..1cdde89a78 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -106,16 +106,6 @@ Used to load in static configuration, mainly for use by the CLI and Stability: `1`. Mainly intended for internal use. -### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/) - -The `@backstage/core` and `@backstage/core-api` packages are being phased out -and replaced by other `@backstage/core-*` packages. They are still in use but -will not receive any breaking changes. - -### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) - -Stability: See `@backstage/core` above - ### `core-app-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-app-api/) The APIs used exclusively in the app, such as `createApp` and the system icons. @@ -194,8 +184,9 @@ Stability: `2` ### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) Internal testing utilities that are separated out for usage in -@backstage/core-api. All exports are re-exported by @backstage/test-utils. This -package should not be depended on directly. +@backstage/core-app-api and @backstage/core-plugin-api. All exports are +re-exported by @backstage/test-utils. This package should not be depended on +directly. Stability: See @backstage/test-utils @@ -218,9 +209,6 @@ Stability: `1` ## Plugins -Plugins are rarely marked as stable as the `@backstage/core` plugin API is under -heavy development. - Many backend plugins are split into "REST API" and "TypeScript Interface" sections. The "TypeScript Interface" refers to the API used to integrate the plugin into the backend. diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index ec824a1e11..1d9310541a 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -2,13 +2,13 @@ id: what-is-backstage title: What is Backstage? # prettier-ignore -description: Backstage is an open platform for building developer portals. Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure +description: Backstage is an open platform for building developer portals. Powered by a centralized software catalog, Backstage restores order to your microservices and infrastructure --- -![service-catalog](https://backstage.io/blog/assets/6/header.png) +![software-catalog](https://backstage.io/blog/assets/6/header.png) [Backstage](https://backstage.io/) is an open platform for building developer -portals. Powered by a centralized service catalog, Backstage restores order to +portals. Powered by a centralized software catalog, Backstage restores order to your microservices and infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. @@ -17,7 +17,7 @@ to create a streamlined development environment from end to end. Out of the box, Backstage includes: -- [Backstage Service Catalog](../features/software-catalog/index.md) for +- [Backstage Software Catalog](../features/software-catalog/index.md) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index 52d8b69f7b..44c180fc5e 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -511,10 +511,11 @@ clarify intent. Refer to the following table to formulate the new name: ## Porting Existing Apps The first step of porting any app is to replace the root `Routes` component with -`FlatRoutes` from `@backstage/core`. As opposed to the `Routes` component, -`FlatRoutes` only considers the first level of `Route` components in its -children, and provides any additional children to the outlet of the route. It -also removes the need to append `"/*"` to paths, as it is added automatically. +`FlatRoutes` from `@backstage/core-app-api`. As opposed to the `Routes` +component, `FlatRoutes` only considers the first level of `Route` components in +its children, and provides any additional children to the outlet of the route. +It also removes the need to append `"/*"` to paths, as it is added +automatically. ```diff const AppRoutes = () => ( diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 32d523b2f5..a582556311 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -28,9 +28,9 @@ This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. -## Integrate into the Service Catalog +## Integrate into the Software Catalog If your plugin isn't supposed to live as a standalone page, but rather needs to -be presented as a part of a Service Catalog (e.g. a separate tab or a card on an -"Overview" tab), then check out -[the instruction](integrating-plugin-into-service-catalog.md) on how to do it. +be presented as a part of a Software Catalog (e.g. a separate tab or a card on +an "Overview" tab), then check out +[the instruction](integrating-plugin-into-software-catalog.md) on how to do it. diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-software-catalog.md similarity index 95% rename from docs/plugins/integrating-plugin-into-service-catalog.md rename to docs/plugins/integrating-plugin-into-software-catalog.md index 047b6f14e6..ae7c3189da 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-software-catalog.md @@ -1,7 +1,7 @@ --- -id: integrating-plugin-into-service-catalog -title: Integrate into the Service Catalog -description: How to integrate a plugin into service catalog +id: integrating-plugin-into-software-catalog +title: Integrate into the Software Catalog +description: How to integrate a plugin into software catalog --- > This is an advanced use case and currently is an experimental feature. Expect diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index bfeed58c8f..f61fae0968 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -36,7 +36,7 @@ to avoid import cycles, for example like this: ```tsx /* src/routes.ts */ -import { createRouteRef } from '@backstage/core'; +import { createRouteRef } from '@backstage/core-plugin-api'; // Note: This route ref is for internal use only, don't export it from the plugin export const rootRouteRef = createRouteRef({ @@ -46,11 +46,11 @@ export const rootRouteRef = createRouteRef({ Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our plugin instance with `createPlugin`, as well as create and wrap our routable -extension using `createRoutableExtension` from `@backstage/core`: +extension using `createRoutableExtension` from `@backstage/core-plugin-api`: ```tsx /* src/plugin.ts */ -import { createPlugin, createRouteRef } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core-plugin-api'; import ExampleComponent from './components/ExampleComponent'; // Create a plugin instance and export this from your plugin package diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index f81e3794e5..beffcdb313 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -57,7 +57,10 @@ package.json to declare the plugin dependencies, metadata and scripts. In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin, createRoutableExtension } from '@backstage/core'; +import { + createPlugin, + createRoutableExtension, +} from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index 5bfb4fe096..d550ba10a9 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -11,7 +11,7 @@ can use this to split out logic in your code for manual A/B testing, etc. Here's a code sample: ```typescript -import { createPlugin } from '@backstage/core'; +import { createPlugin } from '@backstage/core-plugin-api'; export default createPlugin({ id: 'plugin-name', @@ -29,7 +29,7 @@ To inspect the state of a feature flag inside your plugin, you can use the ```tsx import React from 'react'; import { Button } from '@material-ui/core'; -import { featureFlagsApiRef, useApi } from '@backstage/core'; +import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; const ExamplePage = () => { const featureFlags = useApi(featureFlagsApiRef); diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index e3df66fa12..6602b0b2cc 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -30,7 +30,7 @@ type PluginHooks = { Showcasing adding a feature flag. ```jsx -import { createPlugin } from '@backstage/core'; +import { createPlugin } from '@backstage/core-plugin-api'; export default createPlugin({ id: 'new-plugin', diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index adefdfa73f..249507ddf0 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -55,10 +55,10 @@ const spotifyAuthApiRef = createApiRef({ Sam realizes that Spotify auth might be useful to others, and that it would be more convenient if it was a part of the Backstage Core. After submitting and merging a Pull Request with the additions to the -`@backstage/plugin-auth-backend` and `@backstage/core` packages, Spotify auth is -now available for everyone to use. Since the Backstage Core team also adds it to -the public demo server, Sam can now get rid of it in the local setup and rely on -the shared development auth providers instead. +`@backstage/plugin-auth-backend` and `@backstage/core-plugin-api` packages, +Spotify auth is now available for everyone to use. Since the Backstage Core team +also adds it to the public demo server, Sam can now get rid of it in the local +setup and rely on the shared development auth providers instead. The only thing left now is making sure that users of the plugin provide Spotify auth in the app. Sam ensures this by adding `spotifyAuthApiRef` to the plugin's @@ -70,7 +70,7 @@ README. This plugin requires the following APIs to function: -- `spotifyAuthApiRef` from `@backstage/core@^1.1.0` +- `spotifyAuthApiRef` from `@@backstage/core-plugin-api@^1.1.0` ``` # 3. The Catalog Awakens diff --git a/docs/tutorials/migrating-away-from-core.md b/docs/tutorials/migrating-away-from-core.md new file mode 100644 index 0000000000..da9d177ccb --- /dev/null +++ b/docs/tutorials/migrating-away-from-core.md @@ -0,0 +1,135 @@ +--- +id: migrating-away-from-core +title: Migrating away from @backstage/core +description: Guide on how to migrate to the new Backstage core libraries. +--- + +The `@backstage/core` package has been split into three separate packages, +`@backstage/core-app-api`, `@backstage/core-plugin-api`, and +`@backstage/core-components`. For more information about the reasoning behind +this change and the naming of the packages, see the +[original RFC](https://github.com/backstage/backstage/issues/4872) and +[initial PR](https://github.com/backstage/backstage/pull/5825). + +The main purpose of the split is to make plugins more decoupled from the app, +and open up for the possibility of combining plugins using many different +versions of the core libraries. This should significantly reduce the maintenance +burden on plugin authors, as well as reduce the impact of breaking changes in +the core APIs. + +## Migration + +At a high level the migration is done by simply replacing usages of +`@backstage/core` with one or more of the three new core libraries. There are a +few breaking changes in the new packages that are listed below, but for most +plugins the migration is a simple replacement. In order to make the migration as +smooth as possible we provide a collection of tools to automate the majority of +the migration effort. + +Below is a list of steps that should get most projects completely migrated, the +order of the steps is a recommendation but not required, so don't worry if you +need to go back to previous steps to fix things. + +### Step 1 - Run codemod + +The first step is to run +[`@backstage/codemods`](https://www.npmjs.com/package/@backstage/codemods) +across your project. This will automatically convert all module imports in your +source code to use one of the three new core packages instead. For example, the +following change might occur: + +```diff +-import { useApi, configApiRef, InfoCard } from '@backstage/core'; ++import { useApi, configApiRef } from '@backstage/core-plugin-api'; ++import { InfoCard } from '@backstage/core-components'; +``` + +In a typical app created with `@backstage/create-app`, you would run the +following: + +```shell +npx @backstage/codemods apply core-imports packages plugins +``` + +The last two arguments, `packages` and `plugins`, are the folders that the +codemod should be applied to. Add or remove folders as needed for your project. + +The codemod might fail for some files because of the missing `IconKey` type in +any of the new packages. This is one of the few breaking changes. To fix, remove +any `IconKey` imports and replace usages of it with the `string` type, see the +breaking changes section below for details. Once usages of `IconKey` type have +been removed, you can re-run the codemod for those files. + +Note that while the codemod tries to stick to using the existing formatting in +your project, it doesn't always manage to do that. If you're using `prettier` to +format the code in your project, it's best to run `prettier --write` on any +files that were changed by the codemod. + +### Step 2 - Update dependencies + +The next step is to update dependencies in your `package.json` files. Any +package that currently depends on `@backstage/core` will need to have it +replaced by one or more of the new packages. The app package should have all +three packages added to `dependencies`, while for plugins and additional non-app +packages, the `@backstage/core-plugin-api` and `@backstage/core-components` +packages should be added to the set of regular `dependencies`, and +`@backstage/core-app-api` should be added to `devDependencies` for usage in +tests. + +A tool that can help out with step is the `plugin:diff` command from the +`@backstage/cli`, it will compare your plugin to the base plugin template and +suggest changes where the plugin deviates. A quick way to get this step done if +you have up-to-date project is to run the following in the project root: + +```bash +# The --yes flag causes all suggested changes to be accepted automatically +yarn diff --yes +``` + +If you do not have the `diff` command set up in `package.json`, you can also +manually execute the following in each plugin folder: + +```bash +yarn backstage-cli plugin:diff --yes +``` + +### Step 3 - Manual review + +At this point your app is either completely or very close to being migrated. Run +type checks with `yarn tsc` to check if you hit any of the breaking changes +below or if there are any other things to fix. It can also be worthwhile +searching for occurrences of `@backstage/core` in the codebase, as that might +find usages in for example `jest` mock calls, which aren't handled by the +codemod. + +As a final step you'll want to boot up the app and take it through any regular +verification step that you have set up for your project. Don't hesitate to open +a GitHub issue, PR, or reach out on Discord if you hit any snags, or if there +are any additional steps or hints that you think should be added to this guide! + +## Breaking Changes + +The following is a list of breaking changes between `@backstage/core` and the +three new core packages. Not that this list may not be exhaustive depending on +when you migrate your app, as new releases of the new core packages may bring +further changes. + +### Removed `IconKey` type + +The `IconKey` type used to be a string union of all known keys used for the app +icons available through `useApp().getSystemIcon(key)`. The type has been removed +since the set of allowed icon keys is no longer constrained, and there is +instead only a guarantee that the app provides a minimum set of icons, but can +provide any icons it wants beyond that. Migration is done by simply replacing +old usages by the `string` type. + +### Constrained `IconComponent` type + +The `IconComponent` type used to allow all of the props from the MUI `SvgIcon`. +This encouraged some bad patterns in open source plugins such as applying colors +to the icons, which in turn hurt the ability to replace the icons with custom +ones. The `IconComponent` type, which is now exported from +`@backstage/core-plugin-api`, now only accepts a `fontSize` prop used to set the +size of the icon. The type is compatible with the MUI `SvgIcon`, but there may +be situations where an icon needs an explicit cast to `IconComponent` in order +to narrow the type. diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 99ba55c19d..e123410fe0 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -72,7 +72,7 @@ Our first modification will be to extract information from the Identity API. ```tsx // Add identityApiRef to the list of imported from core -import { identityApiRef, useApi } from '@backstage/core'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; ``` 3. Adjust the ExampleComponent from inline to block @@ -137,13 +137,8 @@ changes, let's start by wiping this component clean. import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; -import { - Table, - TableColumn, - Progress, - githubAuthApiRef, - useApi, -} from '@backstage/core'; +import { Table, TableColumn, Progress } from '@backstage/core-components'; +import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api'; import { graphql } from '@octokit/graphql'; export const ExampleFetchComponent = () => { diff --git a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md new file mode 100644 index 0000000000..0a4ae572c2 --- /dev/null +++ b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md @@ -0,0 +1,101 @@ +--- +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 +--- + +![Backstage Search platform](assets/21-06-24/backstage-search-platform.png) + +**TLDR;** The new Backstage Search is now available in alpha, ready for you to start building on. A total rethinking of the core search feature in Backstage, it’s more than just a box you type into — it’s a mini platform all by itself. With its composable frontend and extensible backend, you can design and build the search tool that suits your organization’s needs. + +So, you don’t just get an improved out-of-the-box experience for searching whatever is in your software catalog. You can also add support for searching other sources, too. Customize it the way you want and you can search your catalog, your plugins and docs — and even external sources, like Stack Overflow and Confluence — all at once, all right inside Backstage. + +With one query, your teams can find exactly what they’re looking for: anything and everything. + + + +## Search and explore + +Being able to easily explore your ecosystem — to discover software, tools, documentation, and other valuable knowledge — is one of [the three main jobs of Backstage](https://backstage.io/blog/2021/05/20/adopting-backstage#three-jobs-create-manage-explore). Teams should be able to find what other teams have already built, so they can reuse and contribute to components instead of unknowingly duplicating them. Data endpoints should be shared, not siloed away. Services and their APIs should be easily discoverable. Best practices and technical documentation should be easily found. + +Along with the [Backstage Service Catalog](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha), Backstage Search is essential to enabling this discoverability — allowing new hires and old hands alike to explore your infrastructure instead of getting lost inside it. + +We also quickly realized that search looks different from organization to organization. Therefore, we built a search platform that lets you plug in your own search engine, index any information you like, or build a customized search page experience that fits your users’ needs. + +Since finding what you are looking for in Backstage is critical for success, we started by identifying the needs and goals of search. + +## Rethinking search, inside and out + +Spotify’s internal version of Backstage has had some of the features of Backstage Search for a while, and open sourcing them has been top of mind since day one. But we didn’t want to just port our internal version to the open source version. We wanted to take the opportunity to apply what we’ve learned inside Spotify over the last year, address the needs we’ve observed in the community, and ultimately open source not just a search feature but a search platform. We started the process by looking at the [jobs to be done](https://hbr.org/2016/09/know-your-customers-jobs-to-be-done). + +![Backstage Search platform](assets/21-06-24/jobs-to-be-done.png) +_A high-level overview of the process, identifying all the jobs of search._ + +First, we looked at which jobs to be done belonged to the search plugin itself (e.g., “collect documents to index”) and which belonged to the other plugins (e.g., “format documents for indexing”), and then whether those jobs belonged to the frontend (“display results”) or the backend (“schedule indexing”). + +Looking at all these various jobs, we defined four goals for the platform: + +- **Flexibility:** Be search engine–agnostic +- **Simplicity:** Make it easy for content owners to make their content searchable/discoverable +- **Control:** Allow plugin developers to customize their search results components +- **Reusability:** Offer reusable components/APIs that other devs can leverage + +Beginning our journey this way — by identifying the jobs to be done first, then defining the product goals from there — we could make sure that the search platform addressed real needs and improved the search experience for both users and plugin developers. + +This approach not only created a better search tool for the open source community, but for Spotify, as well. So, instead of just open sourcing our internal version of search, we ended up with an even better solution — one that we can all use and build on together, both inside and outside Spotify. + +## Say hello to the Backstage Search platform + +![Backstage Search platform](assets/21-06-24/search-results.png) + +We are now happy and proud to announce our alpha version of the [Backstage Search Platform](https://backstage.io/docs/features/search/architecture), featuring: + +- Bring your own search engine (Flexibility) +- Collators for easily indexing content from plugins and other sources (Simplicity) +- Composable search page experiences (Control, Reusability) +- Customize the look and feel of each search result (Control, Reusability) + +### Bring your own search engine + +By introducing a Search Integration Layer, we have been able to keep the query translation of the search term and filters close to the search engine itself. This makes our search backend less focused on how a set of terms and filters should be translated to fit a certain search engine interface and more focused on querying and retrieving results as well as collecting results to index. + +With the Search Integration Layer, your organization can bring your search engine of choice to Backstage — instead of relying on Backstage to support a specific search engine that might not fit the needs of your organization, either today or in the future. + +But that doesn’t mean “batteries not included”. The current version of Backstage Search ships with Lunr support built-in — and support for ElasticSearch is not very far off. And we hope the number of supported search engines will continue to grow with the community’s help. + +### Collators for easily indexing content from plugins and other sources + +Since Backstage’s functionality comes from its plugins, we wanted the process of making plugin content searchable to be as frictionless as possible. Therefore we decided on a concept we call collators. Collators are responsible for collecting documents to index from a plugin. Your collators live inside your own plugin, but are registered in the Backstage app’s search backend. + +Collators can also be used to index external sources, like Stack Overflow and Confluence. You can watch a demo of how easy it is to extend search with collators [here](https://youtu.be/Z78FFaObTfk?t=339). + +### Composable search page experiences + +Every engineering org has different needs — that is something we have definitely learned over the last year. Your software catalog might be set up differently than ours and therefore your needs for how search results look and how the search filters work will also differ. + +That's why we have put effort into making your search page experience composable to your organization's needs. What do we mean by that? When you adopt Backstage and set up your app, you can set up — or, compose — your search page by using existing components or by creating your own custom ones. + +### Customize the look and feel of each search result + +A good example of the level of customization the platform allows is how list items are displayed in search results. A search result component can be a list, this list can consist of different list items (search results returned from the search engine) — but these list items could look different depending on what the search result returns in terms of its fields. + +Let’s say that for an entity returned from the software catalog maybe the most important information to show is the name, while a result returned from the TechDocs plugin should maybe show the text content as the most important information. This can be customized by creating components (like TechDocsResultListItem or CatalogResultListItem or whatever list item component you want) and configuring them in the app. + +If there is no need to customize your search result list items, the component is there for you to reuse. + +## Getting started with Backstage Search + +We put together [a getting started guide](https://backstage.io/docs/features/search/getting-started) that provides two different ways to set up Backstage Search: + +- Create a new app and get the most out of the search setup right out of the box, or +- Add the new Backstage Search setup to your existing Backstage app. + +Whichever situation you’re in, we have you covered. + +## What’s next? + +We’ve built the foundation for the Backstage Search platform, and we can't wait to see the exciting engines, collators, and components the community builds on the platform. + +You can check out our [project roadmap](https://backstage.io/docs/features/search/search-overview#project-roadmap) in our search documentation or track the progress of our [Beta milestone](https://github.com/backstage/backstage/milestone/27) and [GA milestone](https://github.com/backstage/backstage/milestone/28). + +For any questions, feedback or ideas about the Backstage Search platform, join us in the #search channel on [Discord](https://discord.gg/MUpMjP2)! diff --git a/microsite/blog/assets/21-06-24/backstage-search-platform.png b/microsite/blog/assets/21-06-24/backstage-search-platform.png new file mode 100644 index 0000000000..bb7e828ea9 Binary files /dev/null and b/microsite/blog/assets/21-06-24/backstage-search-platform.png differ diff --git a/microsite/blog/assets/21-06-24/jobs-to-be-done.png b/microsite/blog/assets/21-06-24/jobs-to-be-done.png new file mode 100644 index 0000000000..da86befcc2 Binary files /dev/null and b/microsite/blog/assets/21-06-24/jobs-to-be-done.png differ diff --git a/microsite/blog/assets/21-06-24/search-results.png b/microsite/blog/assets/21-06-24/search-results.png new file mode 100644 index 0000000000..041f86488c Binary files /dev/null and b/microsite/blog/assets/21-06-24/search-results.png differ diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index ec08216367..8e69b7ff6f 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -33,7 +33,7 @@ class Footer extends React.Component { - Service Catalog + Software Catalog Create a Plugin Designing for Backstage diff --git a/microsite/data/plugins/backstage-service-catalog.yaml b/microsite/data/plugins/backstage-software-catalog.yaml similarity index 79% rename from microsite/data/plugins/backstage-service-catalog.yaml rename to microsite/data/plugins/backstage-software-catalog.yaml index 1a788078a5..ae16550297 100644 --- a/microsite/data/plugins/backstage-service-catalog.yaml +++ b/microsite/data/plugins/backstage-software-catalog.yaml @@ -1,10 +1,10 @@ --- -title: Backstage Service Catalog +title: Backstage Software Catalog author: Spotify authorUrl: https://github.com/spotify category: Core Feature description: Manage all your services and software components, all in one place. documentation: https://backstage.io/docs/features/software-catalog/software-catalog-overview -iconUrl: img/backstage-service-catalog.svg +iconUrl: img/backstage-software-catalog.svg npmPackageName: '@backstage/plugin-catalog' order: 1 diff --git a/microsite/package.json b/microsite/package.json index 1894e37d84..eee549fb70 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -19,7 +19,7 @@ "@spotify/prettier-config": "^10.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.3.1", + "prettier": "^2.3.2", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index e3943d2905..7d97641815 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -42,7 +42,7 @@ const Background = props => { To explore the UI and basic features of Backstage firsthand, go to: demo.backstage.io. (Tip: click “All” to view all the example components in the - service catalog.) + software catalog.) diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 45d87daa1d..d7325bb234 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -27,7 +27,7 @@ class Index extends React.Component { An open platform for building developer portals - Powered by a centralized service catalog, Backstage restores + Powered by a centralized software catalog, Backstage restores order to your infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. @@ -102,10 +102,10 @@ class Index extends React.Component { {' '} - Backstage Service Catalog{' '} + Backstage Software Catalog{' '} - Learn more about the service catalog + Learn more about the software catalog ; @@ -294,6 +297,9 @@ export function requestLoggingHandler(logger?: Logger): RequestHandler; // @public export function resolvePackagePath(name: string, ...paths: string[]): string; +// @public +export function resolveSafeChildPath(base: string, path: string): string; + // @public (undocumented) export type RunContainerOptions = { imageName: string; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 04231a6a69..fdab4b4504 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -96,7 +96,7 @@ "http-errors": "^1.7.3", "jest": "^26.0.1", "mock-fs": "^4.13.0", - "msw": "^0.21.2", + "msw": "^0.29.0", "mysql2": "^2.2.5", "recursive-readdir": "^2.2.2", "supertest": "^6.1.3" diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts index efe8e027c8..ffe99fe762 100644 --- a/packages/backend-common/src/paths.ts +++ b/packages/backend-common/src/paths.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isChildPath } from '@backstage/cli-common'; +import { NotAllowedError } from '@backstage/errors'; import { resolve as resolvePath } from 'path'; /** @@ -32,3 +34,27 @@ export function resolvePackagePath(name: string, ...paths: string[]) { return resolvePath(req.resolve(`${name}/package.json`), '..', ...paths); } + +/** + * Resolves a target path from a base path while guaranteeing that the result is + * a path that point to or within the base path. This is useful for resolving + * paths from user input, as it otherwise opens up for vulnerabilities. + * + * @param base The base directory to resolve the path from. + * @param path The target path, relative or absolute + * @returns A path that is guaranteed to point to or within the base path. + */ +export function resolveSafeChildPath(base: string, path: string): string { + const targetPath = resolvePath(base, path); + + if (!isChildPath(base, targetPath)) { + throw new NotAllowedError( + 'Relative path is not allowed to refer to a directory outside its parent', + ); + } + + return targetPath; +} + +// Re-export isChildPath so that backend packages don't need to depend on cli-common +export { isChildPath }; diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index bcbcc19483..2b7c21d4ab 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -61,7 +61,7 @@ describe('AzureUrlReader', () => { ctx.status(200), ctx.json({ url: req.url.toString(), - headers: req.headers.getAllHeaders(), + headers: req.headers.all(), }), ), ), diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index d8bcde2397..cc540cb83c 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -142,7 +142,7 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', (_, res, ctx) => res( ctx.status(200), @@ -366,7 +366,7 @@ describe('BitbucketUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 787ef82f60..0b3288e59c 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -118,7 +118,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/?ref=main', + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 5971264545..9c2e428239 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -90,7 +90,7 @@ describe('GitlabUrlReader', () => { ctx.status(200), ctx.json({ url: req.url.toString(), - headers: req.headers.getAllHeaders(), + headers: req.headers.all(), }), ), ), @@ -182,7 +182,7 @@ describe('GitlabUrlReader', () => { describe('readTree', () => { const archiveBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.tar.gz'), ); const projectGitlabApiResponse = { @@ -205,7 +205,7 @@ describe('GitlabUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), @@ -283,7 +283,7 @@ describe('GitlabUrlReader', () => { }, ), rest.get( - 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), @@ -306,8 +306,8 @@ describe('GitlabUrlReader', () => { const files = await response.files(); expect(files.length).toBe(2); - const mkDocsFile = await files[0].content(); - const indexMarkdownFile = await files[1].content(); + const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); @@ -318,7 +318,7 @@ describe('GitlabUrlReader', () => { 'https://gitlab.com/backstage/mock', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), @@ -331,7 +331,7 @@ describe('GitlabUrlReader', () => { it('returns the wanted files from hosted gitlab', async () => { worker.use( rest.get( - 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', + 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.tar.gz', (_, res, ctx) => res( ctx.status(200), @@ -375,7 +375,7 @@ describe('GitlabUrlReader', () => { 'https://gitlab.com/backstage/mock/tree/main/docs', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'index.md'), 'utf8'), @@ -454,7 +454,7 @@ describe('GitlabUrlReader', () => { describe('search', () => { const archiveBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.tar.gz'), ); const projectGitlabApiResponse = { @@ -471,7 +471,7 @@ describe('GitlabUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 13764a5312..aa215aee47 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -139,7 +139,7 @@ export class GitlabUrlReader implements UrlReader { const archiveGitLabResponse = await fetch( `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, - )}/repository/archive.zip?sha=${branch}`, + )}/repository/archive?sha=${branch}`, getGitLabRequestOptions(this.integration.config), ); if (!archiveGitLabResponse.ok) { @@ -150,7 +150,7 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - return await this.deps.treeResponseFactory.fromZipArchive({ + return await this.deps.treeResponseFactory.fromTarArchive({ stream: (archiveGitLabResponse.body as unknown) as Readable, subpath: filepath, etag: commitSha, diff --git a/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz new file mode 100644 index 0000000000..c078e07e7e Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz differ diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 847924b67b..830edbb600 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.3.14 + +### Patch Changes + +- 45ef515d0: Return entities sorted alphabetically by ref +- Updated dependencies + - @backstage/catalog-model@0.8.4 + ## 0.3.13 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index c366374bf5..24ac72bb67 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.13", + "version": "0.3.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,15 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.8.2", + "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.7.0", + "@backstage/cli": "^0.7.2", "@types/jest": "^26.0.7", - "msw": "^0.21.2" + "msw": "^0.29.0" }, "files": [ "dist" diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 6d43af85ed..4e3d95f99f 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -151,6 +151,26 @@ describe('CatalogClient', () => { expect(response.items).toEqual([]); }); + + it('handles field filtered entities', async () => { + server.use( + rest.get(`${mockBaseUrl}/entities`, (_req, res, ctx) => { + return res(ctx.json([{ apiVersion: '1' }, { apiVersion: '2' }])); + }), + ); + + const response = await client.getEntities( + { + fields: ['apiVersion'], + }, + { token }, + ); + + expect(response.items).toEqual([ + { apiVersion: '1' }, + { apiVersion: '2' }, + ]); + }); }); describe('getLocationById', () => { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 10c282f786..5dfcddde0c 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -92,6 +92,16 @@ export class CatalogClient implements CatalogApi { ); const refCompare = (a: Entity, b: Entity) => { + // in case field filtering is used, these fields might not be part of the response + if ( + a.metadata?.name === undefined || + a.kind === undefined || + b.metadata?.name === undefined || + b.kind === undefined + ) { + return 0; + } + const aRef = stringifyEntityRef(a); const bRef = stringifyEntityRef(b); if (aRef < bRef) { diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 3c4a2e073c..7a6cce0a6b 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/catalog-model +## 0.8.4 + +### Patch Changes + +- 48c9fcd33: Migrated to use the new `@backstage/core-*` packages rather than `@backstage/core`. + ## 0.8.3 ### Patch Changes diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 3477d57e71..1ddf1b89ae 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.8.3", + "version": "0.8.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -40,7 +40,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.7.1", + "@backstage/cli": "^0.7.2", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index bcdc023c22..7141d27f3a 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -7,6 +7,9 @@ // @public export function findPaths(searchDir: string): Paths; +// @public +export function isChildPath(base: string, path: string): boolean; + // @public (undocumented) export type Paths = { ownDir: string; diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index d36314ba35..1f4f17fe9f 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -15,4 +15,5 @@ */ export { findPaths } from './paths'; +export { isChildPath } from './isChildPath'; export type { Paths } from './paths'; diff --git a/packages/cli-common/src/isChildPath.test.ts b/packages/cli-common/src/isChildPath.test.ts new file mode 100644 index 0000000000..4c0d94f07c --- /dev/null +++ b/packages/cli-common/src/isChildPath.test.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 { posix, win32 } from 'path'; + +describe('isChildPath', () => { + it('should check child posix paths', () => { + jest.isolateModules(() => { + jest.setMock('path', posix); + const { isChildPath } = require('./isChildPath'); + + expect(isChildPath('/', '/')).toBe(true); + expect(isChildPath('/x', '/x')).toBe(true); + expect(isChildPath('/x', '/x/y')).toBe(true); + expect(isChildPath('/x', '/x/x')).toBe(true); + expect(isChildPath('/x', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y/z', '/x/y/z')).toBe(true); + expect(isChildPath('/x/a b c/z', '/x/a b c/z')).toBe(true); + expect(isChildPath('/', '/ yz')).toBe(true); + + expect(isChildPath('/x', '/y')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + expect(isChildPath('/x', '/x y')).toBe(false); + expect(isChildPath('/x y', '/x yz')).toBe(false); + expect(isChildPath('/ yz', '/')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + + jest.dontMock('path'); + }); + }); + + it('should check child win32 paths', () => { + jest.isolateModules(() => { + jest.setMock('path', win32); + const { isChildPath } = require('./isChildPath'); + + expect(isChildPath('/x', '/x')).toBe(true); + expect(isChildPath('/x', '/x/y')).toBe(true); + expect(isChildPath('/x', '/x/x')).toBe(true); + expect(isChildPath('/x', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y/z', '/x/y/z')).toBe(true); + expect(isChildPath('Z:', 'Z:')).toBe(true); + expect(isChildPath('C:/', 'c:/')).toBe(true); + expect(isChildPath('C:/x', 'C:/x')).toBe(true); + expect(isChildPath('C:/x', 'c:/x')).toBe(true); + expect(isChildPath('C:/x', 'C:/x/y')).toBe(true); + expect(isChildPath('d:/x', 'D:/x/y')).toBe(true); + + expect(isChildPath('/x', '/y')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + expect(isChildPath('C:/', 'D:/')).toBe(false); + expect(isChildPath('C:/x', 'D:/x')).toBe(false); + expect(isChildPath('D:/x', 'CD:/x')).toBe(false); + expect(isChildPath('D:/x', 'D:/y')).toBe(false); + + jest.dontMock('path'); + }); + }); +}); diff --git a/plugins/todo/src/index.test.ts b/packages/cli-common/src/isChildPath.ts similarity index 53% rename from plugins/todo/src/index.test.ts rename to packages/cli-common/src/isChildPath.ts index 1ca40a8eac..f48c92db32 100644 --- a/plugins/todo/src/index.test.ts +++ b/packages/cli-common/src/isChildPath.ts @@ -14,14 +14,20 @@ * limitations under the License. */ -import * as index from './index'; +import { relative, isAbsolute } from 'path'; -describe('imports', () => { - it('should export known symbols only', () => { - expect(index).toEqual({ - todoPlugin: expect.any(Object), - todoApiRef: expect.any(Object), - EntityTodoContent: expect.any(Function), - }); - }); -}); +/** + * Checks if path is the same as or a child path of base. + */ +export function isChildPath(base: string, path: string): boolean { + const relativePath = relative(base, path); + if (relativePath === '') { + // The same directory + return true; + } + + const outsideBase = relativePath.startsWith('..'); // not outside base + const differentDrive = isAbsolute(relativePath); // on Windows, this means dir is on a different drive from base. + + return !outsideBase && !differentDrive; +} diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts index ba53ef13a5..894060f2e8 100644 --- a/packages/cli-common/src/paths.test.ts +++ b/packages/cli-common/src/paths.test.ts @@ -65,7 +65,7 @@ describe('paths', () => { }); it('findPaths should find mocked package paths', () => { - const mockCwd = resolvePath(__dirname, '../../core'); + const mockCwd = resolvePath(__dirname, '../../config'); const mockDir = resolvePath(__dirname, '../../cli'); const root = resolvePath(__dirname, '../../..'); diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 32d028a32d..87b726600b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/cli +## 0.7.2 + +### Patch Changes + +- 953a7e66f: updated plugin template to generate path equals plugin id for the root page +- 04248b8f9: chore: bump `msw` dependency in `create-plugin` +- e3d31b381: Make the `create-github-app` command disable webhooks by default. +- 8f100db75: chore: bump `@typescript-eslint/eslint-plugin` from 4.26.0 to 4.27.0 +- 95e572305: chore: bump `del` from 5.1.0 to 6.0.0 +- ece2b5dd1: chore: bump `@spotify/eslint-config-typescript` from 9.0.0 to 10.0.0 +- 0ec31e596: chore: bump `@rollup/plugin-node-resolve` from 11.2.1 to 13.0.0 +- 48c9fcd33: Migrated to use the new `@backstage/core-*` packages rather than `@backstage/core`. + ## 0.7.1 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 1d8628e771..73277036c3 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.7.1", + "version": "0.7.2", "private": false, "publishConfig": { "access": "public" @@ -39,8 +39,8 @@ "@octokit/request": "^5.4.12", "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.0.2", - "@rollup/plugin-node-resolve": "^11.2.0", - "@rollup/plugin-yaml": "^2.1.1", + "@rollup/plugin-node-resolve": "^13.0.0", + "@rollup/plugin-yaml": "^3.0.0", "@spotify/eslint-config-base": "^9.0.0", "@spotify/eslint-config-react": "^10.0.0", "@spotify/eslint-config-typescript": "^10.0.0", @@ -61,7 +61,7 @@ "chalk": "^4.0.0", "chokidar": "^3.3.1", "commander": "^6.1.0", - "css-loader": "^3.5.3", + "css-loader": "^5.2.6", "dashify": "^2.0.0", "diff": "^5.0.0", "esbuild": "^0.8.56", @@ -121,10 +121,10 @@ "@backstage/backend-common": "^0.8.3", "@backstage/config": "^0.1.5", "@backstage/core-components": "^0.1.3", - "@backstage/core-plugin-api": "^0.1.2", - "@backstage/core-app-api": "^0.1.2", - "@backstage/dev-utils": "^0.1.17", - "@backstage/test-utils": "^0.1.13", + "@backstage/core-plugin-api": "^0.1.3", + "@backstage/core-app-api": "^0.1.3", + "@backstage/dev-utils": "^0.2.0", + "@backstage/test-utils": "^0.1.14", "@backstage/theme": "^0.2.8", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 2360078a18..aa6a6b4e2e 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { ResolvePlugin } from 'webpack'; -import { isChildPath } from './paths'; +import { isChildPath } from '@backstage/cli-common'; import { LernaPackage } from './types'; // Enables proper resolution of packages when linking in external packages. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 89f677109d..75e94af716 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -22,9 +22,10 @@ import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; import StartServerPlugin from 'start-server-webpack-plugin'; import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; +import { isChildPath } from '@backstage/cli-common'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths, isChildPath } from './paths'; +import { BundlingPaths } from './paths'; import { transforms } from './transforms'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 01fdcdefe5..d465b3010f 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -15,25 +15,8 @@ */ import fs from 'fs-extra'; -import path from 'path'; import { paths } from '../paths'; -/** - * Checks if dir is the same as or a child of base. - */ -export function isChildPath(base: string, dir: string): boolean { - const relativePath = path.relative(base, dir); - if (relativePath === '') { - // The same directory - return true; - } - - const outsideBase = relativePath.startsWith('..'); // not outside base - const differentDrive = path.isAbsolute(relativePath); // on Windows, this means dir is on a different drive from base. - - return !outsideBase && !differentDrive; -} - export type BundlingPathsOptions = { // bundle entrypoint, e.g. 'src/index' entry: string; diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index d041773d3c..8275223f49 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -36,7 +36,7 @@ "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", - "msw": "^0.21.2" + "msw": "^0.29.0" }, "files": [ "dist" diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index f5249e7918..dbba628055 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -44,7 +44,7 @@ "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "msw": "^0.21.2", + "msw": "^0.29.0", "cross-fetch": "^3.0.6" }, "files": [ diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index 524ffc18a1..3b617f4623 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/codemods +## 0.1.3 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.1.3 + - @backstage/core-plugin-api@0.1.3 + ## 0.1.2 Fixed a publish issue, making this package available to the public. diff --git a/packages/codemods/package.json b/packages/codemods/package.json index f97d9cdde0..d9ea7fb1b7 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.2", + "version": "0.1.3", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/.eslintrc.js b/packages/core-api/.eslintrc.js deleted file mode 100644 index d592a653c8..0000000000 --- a/packages/core-api/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - extends: [require.resolve('@backstage/cli/config/eslint')], - rules: { - // TODO: add prop types to JS and remove - 'react/prop-types': 0, - 'jest/expect-expect': 0, - }, -}; diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md deleted file mode 100644 index 2282976d74..0000000000 --- a/packages/core-api/CHANGELOG.md +++ /dev/null @@ -1,382 +0,0 @@ -# @backstage/core-api - -## 0.2.22 - -### Patch Changes - -- 9bca2a252: Improve forwards compatibility with `@backstage/core-app-api` and `@backstage/core-plugin-api` by re-using route reference types and factory methods from `@backstage/core-plugin-api`. -- Updated dependencies [75b8537ce] -- Updated dependencies [da8cba44f] - - @backstage/core-plugin-api@0.1.2 - -## 0.2.21 - -### Patch Changes - -- 0160678b1: Made the `RouteRef*` types compatible with the ones exported from `@backstage/core-plugin-api`. -- Updated dependencies [031ccd45f] -- Updated dependencies [e7c5e4b30] - - @backstage/core-plugin-api@0.1.1 - - @backstage/theme@0.2.8 - -## 0.2.20 - -### Patch Changes - -- d597a50c6: Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined. - -## 0.2.19 - -### Patch Changes - -- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`. - - In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage: - - ```ts - [Symbol.observable]() { - return this; - }, - ``` - -- 65e6c4541: Remove circular dependencies - -## 0.2.18 - -### Patch Changes - -- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 -- 675a569a9: chore: bump `react-use` dependency in all packages - -## 0.2.17 - -### Patch Changes - -- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. -- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. -- Updated dependencies [931b21a12] - - @backstage/theme@0.2.6 - -## 0.2.16 - -### Patch Changes - -- 1279a3325: Introduce a `load-chunk` step in the `BootErrorPage` to show make chunk loading - errors visible to the user. -- 4a4681b1b: Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error. -- b051e770c: Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail. - -## 0.2.15 - -### Patch Changes - -- 76deafd31: Changed the signature of `createRoutableExtension` to include null -- 01ccef4c7: Introduce `useRouteRefParams` to `core-api` to retrieve typed route parameters. -- Updated dependencies [4618774ff] - - @backstage/theme@0.2.5 - -## 0.2.14 - -### Patch Changes - -- a51dc0006: Export `SubRouteRef` type, and allow `SubRouteRef`s to be assigned to `plugin.routes`. -- e7f9b9435: Allow elements to be used multiple times in the app element tree. -- 34ff49b0f: Allow extension components to also return `null` in addition to a `JSX.Element`. -- d88dd219e: Internal refactor to allow for future package splits. As part of this `ApiRef`s are now identified by their ID rather than their reference. -- c8b54c370: Added new Docs Icon to Core Icons -- Updated dependencies [0434853a5] - - @backstage/config@0.1.4 - -## 0.2.13 - -### Patch Changes - -- 13524b80b: Fully deprecate `title` option of `RouteRef`s and introduce `id` instead. -- e74b07578: Fixed a bug where FlatRoutes didn't handle React Fragments properly. -- 6fb4258a8: Add `SubRouteRef`s, which can be used to create a route ref with a fixed path relative to an absolute `RouteRef`. They are useful if you for example have a page that is mounted at a sub route of a routable extension component, and you want other plugins to be able to route to that page. - - For example: - - ```tsx - // routes.ts - const rootRouteRef = createRouteRef({ id: 'root' }); - const detailsRouteRef = createSubRouteRef({ - id: 'root-sub', - parent: rootRouteRef, - path: '/details', - }); - - // plugin.ts - export const myPlugin = createPlugin({ - routes: { - root: rootRouteRef, - details: detailsRouteRef, - } - }) - - export const MyPage = plugin.provide(createRoutableExtension({ - component: () => import('./components/MyPage').then(m => m.MyPage), - mountPoint: rootRouteRef, - })) - - // components/MyPage.tsx - const MyPage = () => ( - - {/* myPlugin.routes.root will take the user to this page */} - }> - - {/* myPlugin.routes.details will take the user to this page */} - }> - - ) - ``` - -- 395885905: Wait for `configApi` to be ready before using `featureFlagsApi` -- Updated dependencies [2089de76b] - - @backstage/theme@0.2.4 - -## 0.2.12 - -### Patch Changes - -- 40c0fdbaa: Added support for optional external route references. By setting `optional: true` when creating an `ExternalRouteRef` it is no longer a requirement to bind the route in the app. If the app isn't bound `useRouteRef` will return `undefined`. -- 2a271d89e: Internal refactor of how component data is access to avoid polluting components and make it possible to bridge across versions. - -## 0.2.11 - -### Patch Changes - -- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched. -- 1407b34c6: More informative error message for missing ApiContext. -- b6c4f485d: Fix error when querying Backstage Identity with SAML authentication -- 3a58084b6: Created separate `AppContext` type to be returned from `useApp` rather than the `BackstageApp` itself. The `AppContext` type includes but deprecates `getPlugins`, `getProvider`, `getRouter`, and `getRoutes`. In addition, the `AppContext` adds a new `getComponents` method which providers access to the app components. -- Updated dependencies [a1f5e6545] - - @backstage/config@0.1.3 - -## 0.2.10 - -### Patch Changes - -- f10950bd2: Minor refactoring of BackstageApp.getSystemIcons to support custom registered - icons. Custom Icons can be added using: - - ```tsx - import AlarmIcon from '@material-ui/icons/Alarm'; - import MyPersonIcon from './MyPerson'; - - const app = createApp({ - icons: { - user: MyPersonIcon // override system icon - alert: AlarmIcon, // Custom icon - }, - }); - ``` - -- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`. - -## 0.2.9 - -### Patch Changes - -- ab0892358: Remove test dependencies from production package list - -## 0.2.8 - -### Patch Changes - -- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. -- 86c3c652a: Deprecate `RouteRef` path parameter and member, and remove deprecated `routeRef.createSubRouteRef`. -- 27f2af935: Delay auth loginPopup close to avoid race condition with callers of authFlowHelpers. - -## 0.2.7 - -### Patch Changes - -- d681db2b5: Fix for GitHub and SAML auth not properly updating session state when already logged in. -- 1dc445e89: Introduce new plugin extension API -- Updated dependencies [1dc445e89] - - @backstage/test-utils@0.1.6 - -## 0.2.6 - -### Patch Changes - -- 7dd2ef7d1: Use auth provider ID to create unique session storage keys for GitHub and SAML Auth. - -## 0.2.5 - -### Patch Changes - -- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. - - This fixes for example the following code: - - ```ts - interface MyApi { - myMethod(): void - } - - const myApiRef = createApiRef({...}); - - createApiFactory({ - api: myApiRef, - deps: {}, - // This should've caused an error, since the empty object does not fully implement MyApi - factory: () => ({}), - }) - ``` - -- d8d5a17da: Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. - - Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. - - Add an as of yet unused `params` option to `createRouteRef`. - -- Updated dependencies [e3bd9fc2f] -- Updated dependencies [e1f4e24ef] -- Updated dependencies [1665ae8bb] -- Updated dependencies [e3bd9fc2f] - - @backstage/config@0.1.2 - - @backstage/test-utils@0.1.5 - - @backstage/theme@0.2.2 - -## 0.2.4 - -### Patch Changes - -- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError - - @backstage/test-utils@0.1.4 - -## 0.2.3 - -### Patch Changes - -- 700a212b4: bug fix: issue 3223 - detect mismatching origin and indicate it in the message at auth failure - -## 0.2.2 - -### Patch Changes - -- 9b9e86f8a: export oidc provider - -## 0.2.1 - -### Patch Changes - -- c5bab94ab: Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the - default scope is configurable when overwriting the Core Api in the app. - - ``` - GithubAuth.create({ - discoveryApi, - oauthRequestApi, - defaultScopes: ['read:user', 'repo'], - }), - ``` - - Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type. - - ``` - export type OAuthApiCreateOptions = AuthApiCreateOptions & { - oauthRequestApi: OAuthRequestApi; - defaultScopes?: string[]; - }; - - export type AuthApiCreateOptions = { - discoveryApi: DiscoveryApi; - environment?: string; - provider?: AuthProvider & { id: string }; - }; - ``` - -- Updated dependencies [4577e377b] - - @backstage/theme@0.2.1 - -## 0.2.0 - -### Minor Changes - -- 819a70229: Add SAML login to backstage - - ![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png) - - ![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) - -- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the - default scope is configurable when overwriting the Core Api in the app. - - ``` - GithubAuth.create({ - discoveryApi, - oauthRequestApi, - defaultScopes: ['read:user', 'repo'], - }), - ``` - -- cbab5bbf8: Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save(). - -### Patch Changes - -- cbbd271c4: Add initial RouteRefRegistry - - Starting out some work to bring routing back and working as part of the work towards finalizing #1536 - - This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this: - - ```jsx - const App = () => ( - - - - {' '} - // catalogRouteRef - - - - - - - // statusRouteRef - - - - - - - - - - // sentryRouteRef - - - - - - - - - - - - - - - - ); - ``` - - As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router. - - The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs. - - The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point. - - We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks. - -- 26e69ab1a: Remove cost insights example client from demo app and export from plugin - Create cost insights dev plugin using example client - Make PluginConfig and dependent types public -- Updated dependencies [ae5983387] -- Updated dependencies [0d4459c08] - - @backstage/theme@0.2.0 - - @backstage/test-utils@0.1.2 diff --git a/packages/core-api/README.md b/packages/core-api/README.md deleted file mode 100644 index 5a6430baa7..0000000000 --- a/packages/core-api/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# @backstage/core-api - -**NOTE: This package is deprecated** - -See the [migration documentation](https://backstage.io/docs/tutorials/migrating-away-from-core) for details on how to move to using the newer packages, [`@backstage/core-app-api`](https://www.npmjs.com/package/@backstage/core-app-api), [`@backstage/core-components`](https://www.npmjs.com/package/@backstage/core-components), and [`@backstage/core-plugin-api`](https://www.npmjs.com/package/@backstage/core-plugin-api) - -This package used to provide the core API used by Backstage plugins and apps. - -## 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/core-api/package.json b/packages/core-api/package.json deleted file mode 100644 index 1eaeeb24b1..0000000000 --- a/packages/core-api/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@backstage/core-api", - "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.22", - "private": false, - "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" - }, - "homepage": "https://backstage.io", - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "packages/core-api" - }, - "keywords": [ - "backstage" - ], - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", - "scripts": { - "build": "backstage-cli build --outputs types,esm", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/config": "^0.1.4", - "@backstage/core-plugin-api": "^0.1.2", - "@backstage/theme": "^0.2.8", - "@material-ui/core": "^4.11.0", - "@material-ui/icons": "^4.9.1", - "@types/react": "^16.9", - "@types/prop-types": "^15.7.3", - "prop-types": "^15.7.2", - "react": "^16.12.0", - "react-router-dom": "6.0.0-beta.0", - "react-use": "^17.2.4", - "zen-observable": "^0.8.15" - }, - "devDependencies": { - "@backstage/cli": "^0.7.0", - "@backstage/test-utils": "^0.1.13", - "@backstage/test-utils-core": "^0.1.1", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^11.2.5", - "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^13.1.8", - "@types/jest": "^26.0.7", - "@types/node": "^14.14.32", - "@types/zen-observable": "^0.8.0", - "cross-fetch": "^3.0.6", - "msw": "^0.21.3" - }, - "files": [ - "dist" - ] -} diff --git a/packages/core-api/src/apis/definitions/AlertApi.ts b/packages/core-api/src/apis/definitions/AlertApi.ts deleted file mode 100644 index 22dd97ee38..0000000000 --- a/packages/core-api/src/apis/definitions/AlertApi.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { createApiRef, ApiRef } from '../system'; -import { Observable } from '../../types'; - -export type AlertMessage = { - message: string; - // Severity will default to success since that is what material ui defaults the value to. - severity?: 'success' | 'info' | 'warning' | 'error'; -}; - -/** - * The alert API is used to report alerts to the app, and display them to the user. - */ - -export type AlertApi = { - /** - * Post an alert for handling by the application. - */ - post(alert: AlertMessage): void; - - /** - * Observe alerts posted by other parts of the application. - */ - alert$(): Observable; -}; - -export const alertApiRef: ApiRef = createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', -}); diff --git a/packages/core-api/src/apis/definitions/AppThemeApi.ts b/packages/core-api/src/apis/definitions/AppThemeApi.ts deleted file mode 100644 index 75a2d6a4ec..0000000000 --- a/packages/core-api/src/apis/definitions/AppThemeApi.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { BackstageTheme } from '@backstage/theme'; -import { Observable } from '../../types'; -import { SvgIconProps } from '@material-ui/core'; - -/** - * Describes a theme provided by the app. - */ -export type AppTheme = { - /** - * ID used to remember theme selections. - */ - id: string; - - /** - * Title of the theme - */ - title: string; - - /** - * Theme variant - */ - variant: 'light' | 'dark'; - - /** - * The specialized MaterialUI theme instance. - */ - theme: BackstageTheme; - - /** - * An Icon for the theme mode setting. - */ - icon?: React.ReactElement; -}; - -/** - * 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. - */ -export type AppThemeApi = { - /** - * Get a list of available themes. - */ - getInstalledThemes(): AppTheme[]; - - /** - * Observe the currently selected theme. A value of undefined means no specific theme has been selected. - */ - activeThemeId$(): Observable; - - /** - * Get the current theme ID. Returns undefined if no specific theme is selected. - */ - getActiveThemeId(): string | undefined; - - /** - * Set a specific theme to use in the app, overriding the default theme selection. - * - * Clear the selection by passing in undefined. - */ - setActiveThemeId(themeId?: string): void; -}; - -export const appThemeApiRef: ApiRef = createApiRef({ - id: 'core.apptheme', - description: 'API Used to configure the app theme, and enumerate options', -}); diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts deleted file mode 100644 index c060d878bf..0000000000 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { Config } from '@backstage/config'; - -/** - * The Config API is used to provide a mechanism to access the - * runtime configuration of the system. - */ -export type ConfigApi = Config; - -export const configApiRef: ApiRef = createApiRef({ - id: 'core.config', - description: 'Used to access runtime configuration', -}); diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts deleted file mode 100644 index 9ae5c8c4f9..0000000000 --- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { 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. - * - * 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. - * - * 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. - */ -export type DiscoveryApi = { - /** - * Returns the HTTP base backend URL for a given plugin, without a trailing slash. - * - * This method must always be called just before making a request, as opposed to - * fetching the URL when constructing an API client. That is to ensure that more - * flexible routing patterns can be supported. - * - * For example, asking for the URL for `auth` may return something - * like `https://backstage.example.com/api/auth` - */ - getBaseUrl(pluginId: string): Promise; -}; - -export const discoveryApiRef: ApiRef = createApiRef({ - id: 'core.discovery', - description: 'Provides service discovery of backend plugins', -}); diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts deleted file mode 100644 index 86ca407718..0000000000 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { Observable } from '../../types'; - -/** - * Mirrors the JavaScript Error class, for the purpose of - * providing documentation and optional fields. - */ -type Error = { - name: string; - message: string; - stack?: string; -}; - -/** - * Provides additional information about an error that was posted to the application. - */ -export type ErrorContext = { - // If set to true, this error should not be displayed to the user. Defaults to false. - hidden?: boolean; -}; - -/** - * The error API is used to report errors to the app, and display them to the user. - * - * 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. - * - * If an error can be displayed inline, e.g. as feedback in a form, that should be - * preferred over relying on this API to display the error. The main use of this API - * for displaying errors should be for asynchronous errors, such as a failing background process. - * - * Even if an error is displayed inline, it should still be reported through this API - * 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. - */ -export type ErrorApi = { - /** - * Post an error for handling by the application. - */ - post(error: Error, context?: ErrorContext): void; - - /** - * Observe errors posted by other parts of the application. - */ - error$(): Observable<{ error: Error; context?: ErrorContext }>; -}; - -export const errorApiRef: ApiRef = createApiRef({ - id: 'core.error', - description: 'Used to report errors and forward them to the app', -}); diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts deleted file mode 100644 index 56fcd7cacf..0000000000 --- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; - -/** - * The feature flags API is used to toggle functionality to users across plugins and Backstage. - * - * 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. - */ - -export type FeatureFlag = { - name: string; - pluginId: string; -}; - -export enum FeatureFlagState { - None = 0, - Active = 1, -} - -/** - * Options to use when saving feature flags. - */ -export type FeatureFlagsSaveOptions = { - /** - * The new feature flag states to save. - */ - states: Record; - - /** - * Whether the saves states should be merged into the existing ones, or replace them. - * - * Defaults to false. - */ - merge?: boolean; -}; - -export type UserFlags = {}; - -export interface FeatureFlagsApi { - /** - * Registers a new feature flag. Once a feature flag has been registered it - * can be toggled by users, and read back to enable or disable features. - */ - registerFlag(flag: FeatureFlag): void; - - /** - * Get a list of all registered flags. - */ - getRegisteredFlags(): FeatureFlag[]; - - /** - * Whether the feature flag with the given name is currently activated for the user. - */ - isActive(name: string): boolean; - - /** - * Save the user's choice of feature flag states. - */ - save(options: FeatureFlagsSaveOptions): void; -} - -export const featureFlagsApiRef: ApiRef = createApiRef({ - id: 'core.featureflags', - description: 'Used to toggle functionality in features across Backstage', -}); diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts deleted file mode 100644 index 222cf12547..0000000000 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { ProfileInfo } from './auth'; - -/** - * The Identity API used to identify and get information about the signed in user. - */ -export type IdentityApi = { - /** - * The ID of the signed in user. This ID is not meant to be presented to the user, but used - * as an opaque string to pass on to backends or use in frontend logic. - * - * TODO: The intention of the user ID is to be able to tie the user to an identity - * that is known by the catalog and/or identity backend. It should for example - * be possible to fetch all owned components using this ID. - */ - getUserId(): string; - - // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. - /** - * The profile of the signed in user. - */ - getProfile(): ProfileInfo; - - /** - * An OpenID Connect ID Token which proves the identity of the signed in user. - * - * The ID token will be undefined if the signed in user does not have a verified - * identity, such as a demo user or mocked user for e2e tests. - */ - getIdToken(): Promise; - - /** - * Sign out the current user - */ - signOut(): Promise; -}; - -export const identityApiRef: ApiRef = createApiRef({ - id: 'core.identity', - description: 'Provides access to the identity of the signed in user', -}); diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts deleted file mode 100644 index 4e9b2cdbc8..0000000000 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 { IconComponent } from '../../icons/types'; -import { Observable } from '../../types'; -import { ApiRef, createApiRef } from '../system'; - -/** - * Information about the auth provider that we're requesting a login towards. - * - * This should be shown to the user so that they can be informed about what login is being requested - * before a popup is shown. - */ -export type AuthProvider = { - /** - * Title for the auth provider, for example "GitHub" - */ - title: string; - - /** - * Icon for the auth provider. - */ - icon: IconComponent; -}; - -/** - * 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. - */ -export type AuthRequesterOptions = { - /** - * Information about the auth provider, which will be forwarded to auth requests. - */ - provider: AuthProvider; - - /** - * Implementation of the auth flow, which will be called synchronously when - * trigger() is called on an auth requests. - */ - onAuthRequest(scopes: Set): Promise; -}; - -/** - * Function used to trigger new auth requests for a set of scopes. - * - * The returned promise will resolve to the same value returned by the onAuthRequest in the - * 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. - */ -export type AuthRequester = ( - scopes: Set, -) => Promise; - -/** - * An pending auth request for a single auth provider. The request will remain in this pending - * state until either reject() or trigger() is called. - * - * 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. - */ -export type PendingAuthRequest = { - /** - * Information about the auth provider, as given in the AuthRequesterOptions - */ - provider: AuthProvider; - - /** - * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". - */ - reject: () => void; - - /** - * Trigger the auth request to continue the auth flow, by for example showing a popup. - * - * Synchronously calls onAuthRequest with all scope currently in the request. - */ - trigger(): Promise; -}; - -/** - * Provides helpers for implemented OAuth login flows within Backstage. - */ -export type OAuthRequestApi = { - /** - * A utility for showing login popups or similar things, and merging together multiple requests for - * different scopes into one request that includes all scopes. - * - * The passed in options provide information about the login provider, and how to handle auth requests. - * - * The returned AuthRequester function is used to request login with new scopes. These requests - * are merged together and forwarded to the auth handler, as soon as a consumer of auth requests - * triggers an auth flow. - * - * See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. - */ - createAuthRequester( - options: AuthRequesterOptions, - ): AuthRequester; - - /** - * Observers pending auth requests. The returned observable will emit all - * current active auth request, at most one for each created auth requester. - * - * Each request has its own info about the login provider, forwarded from the auth requester options. - * - * Depending on user interaction, the request should either be rejected, or used to trigger the auth handler. - * If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError". - * If a auth is triggered, and the auth handler resolves successfully, then all currently pending - * AuthRequester calls will resolve to the value returned by the onAuthRequest call. - */ - authRequest$(): Observable; -}; - -export const oauthRequestApiRef: ApiRef = createApiRef({ - id: 'core.oauthrequest', - description: 'An API for implementing unified OAuth flows in Backstage', -}); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts deleted file mode 100644 index bc243a65f3..0000000000 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { Observable } from '../../types'; -import { ErrorApi } from './ErrorApi'; - -export type StorageValueChange = { - key: string; - newValue?: T; -}; - -export type CreateStorageApiOptions = { - errorApi: ErrorApi; - namespace?: string; -}; - -export interface StorageApi { - /** - * Create a bucket to store data in. - * @param {String} 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. - */ - get(key: string): T | undefined; - - /** - * Remove persistent data. - * - * @param {String} 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. - */ - set(key: string, data: any): Promise; - - /** - * Observe changes on a particular key in the bucket - * @param {String} key Unique key associated with the data - */ - observe$(key: string): Observable>; -} - -export const storageApiRef: ApiRef = createApiRef({ - id: 'core.storage', - description: 'Provides the ability to store data which is unique to the user', -}); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts deleted file mode 100644 index f71aa5df9c..0000000000 --- a/packages/core-api/src/apis/definitions/auth.ts +++ /dev/null @@ -1,347 +0,0 @@ -/* - * 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 { ApiRef, createApiRef } from '../system'; -import { Observable } from '../../types'; - -/** - * This file contains declarations for common interfaces of auth-related APIs. - * The declarations should be used to signal which type of authentication and - * authorization methods each separate auth provider supports. - * - * For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, - * would be declared as follows: - * - * const googleAuthApiRef = createApiRef({ ... }) - */ - -/** - * An array of scopes, or a scope string formatted according to the - * auth provider, which is typically a space separated list. - * - * See the documentation for each auth provider for the list of scopes - * supported by each provider. - */ -export type OAuthScope = string | string[]; - -export type AuthRequestOptions = { - /** - * If this is set to true, the user will not be prompted to log in, - * and an empty response will be returned if there is no existing session. - * - * 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 - */ - optional?: boolean; - - /** - * If this is set to true, the request will bypass the regular oauth login modal - * and open the login popup directly. - * - * The method must be called synchronously from a user action for this to work in all browsers. - * - * @default false - */ - instantPopup?: boolean; -}; - -/** - * 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. - */ -export type OAuthApi = { - /** - * Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows - * you to make requests on behalf of the user, and the copes may grant you broader access, depending - * on the auth provider. - * - * Each auth provider has separate handling of scope, so you need to look at the documentation - * for each one to know what scope you need to request. - * - * This method is cheap and should be called each time an access token is used. Do not for example - * store the access token in React component state, as that could cause the token to expire. Instead - * fetch a new access token for each request. - * - * Be sure to include all required scopes when requesting an access token. When testing your implementation - * it is best to log out the Backstage session and then visit your plugin page directly, as - * you might already have some required scopes in your existing session. Not requesting the correct - * scopes can lead to 403 or other authorization errors, which can be tricky to debug. - * - * If the user has not yet granted access to the provider and the set of requested scopes, the user - * will be prompted to log in. The returned promise will not resolve until the user has - * successfully logged in. The returned promise can be rejected, but only if the user rejects the login request. - */ - getAccessToken( - scope?: OAuthScope, - options?: AuthRequestOptions, - ): Promise; -}; - -/** - * 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. - */ -export type OpenIdConnectApi = { - /** - * Requests an OpenID Connect ID Token. - * - * This method is cheap and should be called each time an ID token is used. Do not for example - * store the id token in React component state, as that could cause the token to expire. Instead - * fetch a new id token for each request. - * - * If the user has not yet logged in to Google inside Backstage, the user will be prompted - * to log in. The returned promise will not resolve until the user has successfully logged in. - * The returned promise can be rejected, but only if the user rejects the login request. - */ - getIdToken(options?: AuthRequestOptions): Promise; -}; - -/** - * This API provides access to profile information of the user from an auth provider. - */ -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. - */ - getProfile(options?: AuthRequestOptions): Promise; -}; - -/** - * This API provides access to the user's identity within Backstage. - * - * 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 - */ -export type BackstageIdentityApi = { - /** - * Get the user's identity within Backstage. This should normally not be called directly, - * use the @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. - */ - getBackstageIdentity( - options?: AuthRequestOptions, - ): Promise; -}; - -export type BackstageIdentity = { - /** - * The backstage user ID. - */ - id: string; - - /** - * An ID token that can be used to authenticate the user within Backstage. - */ - idToken: string; -}; - -/** - * Profile information of the user. - */ -export type ProfileInfo = { - /** - * Email ID. - */ - email?: string; - - /** - * Display name that can be presented to the user. - */ - displayName?: string; - - /** - * URL to an avatar image of the user. - */ - picture?: string; -}; - -/** - * Session state values passed to subscribers of the SessionApi. - */ -export enum SessionState { - SignedIn = 'SignedIn', - SignedOut = 'SignedOut', -} - -/** - * The SessionApi provides basic controls for any auth provider that is tied to a persistent session. - */ -export type SessionApi = { - /** - * Sign in with a minimum set of permissions. - */ - signIn(): Promise; - - /** - * Sign out from the current session. This will reload the page. - */ - signOut(): Promise; - - /** - * Observe the current state of the auth session. Emits the current state on subscription. - */ - sessionState$(): Observable; -}; - -/** - * Provides authentication towards Google APIs and identities. - * - * See 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. - */ -export const googleAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.google', - description: 'Provides authentication towards Google APIs and identities', -}); - -/** - * Provides authentication towards GitHub APIs. - * - * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ - * for a full list of supported scopes. - */ -export const githubAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ - id: 'core.auth.github', - description: 'Provides authentication towards GitHub APIs', -}); - -/** - * Provides authentication towards Okta APIs. - * - * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ - * for a full list of supported scopes. - */ -export const oktaAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.okta', - description: 'Provides authentication towards Okta APIs', -}); - -/** - * Provides authentication towards GitLab APIs. - * - * See 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. - */ -export const gitlabAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ - id: 'core.auth.gitlab', - description: 'Provides authentication towards GitLab APIs', -}); - -/** - * Provides authentication towards Auth0 APIs. - * - * See https://auth0.com/docs/scopes/current/oidc-scopes - * for a full list of supported scopes. - */ -export const auth0AuthApiRef: ApiRef< - OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ - id: 'core.auth.auth0', - description: 'Provides authentication towards Auth0 APIs', -}); - -/** - * Provides authentication towards Microsoft APIs and identities. - * - * 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 - */ -export const microsoftAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.microsoft', - description: 'Provides authentication towards Microsoft APIs and identities', -}); - -/** - * Provides authentication for custom identity providers. - */ -export const oauth2ApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.oauth2', - description: 'Example of how to use oauth2 custom provider', -}); - -/** - * Provides authentication for custom OpenID Connect identity providers. - */ -export const oidcAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.oidc', - description: 'Example of how to use oidc custom provider', -}); - -/** - * Provides authentication for saml based identity providers - */ -export const samlAuthApiRef: ApiRef< - ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ - id: 'core.auth.saml', - description: 'Example of how to use SAML custom provider', -}); - -export const oneloginAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef({ - id: 'core.auth.onelogin', - description: 'Provides authentication towards OneLogin APIs and identities', -}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts deleted file mode 100644 index d4350ddbf6..0000000000 --- a/packages/core-api/src/apis/definitions/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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. - */ - -// This folder contains definitions for all core APIs. -// -// Plugins should rely on these APIs for functionality as much as possible. -// -// If you think some API definition is missing, please open an Issue or send a PR! - -export * from './auth'; - -export * from './AlertApi'; -export * from './AppThemeApi'; -export * from './ConfigApi'; -export * from './DiscoveryApi'; -export * from './ErrorApi'; -export * from './FeatureFlagsApi'; -export * from './IdentityApi'; -export * from './OAuthRequestApi'; -export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts deleted file mode 100644 index 9f7adc260d..0000000000 --- a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 { PublishSubject } from '../../../lib/subjects'; -import { Observable } from '../../../types'; -import { AlertApi, AlertMessage } from '../../definitions'; - -/** - * Base implementation for the AlertApi that simply forwards alerts to consumers. - */ -export class AlertApiForwarder implements AlertApi { - private readonly subject = new PublishSubject(); - - post(alert: AlertMessage) { - this.subject.next(alert); - } - - alert$(): Observable { - return this.subject; - } -} diff --git a/packages/core-api/src/apis/implementations/AlertApi/index.ts b/packages/core-api/src/apis/implementations/AlertApi/index.ts deleted file mode 100644 index d572845809..0000000000 --- a/packages/core-api/src/apis/implementations/AlertApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { AlertApiForwarder } from './AlertApiForwarder'; diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts deleted file mode 100644 index 363e829ac2..0000000000 --- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { AppTheme } from '../../definitions'; -import { AppThemeSelector } from './AppThemeSelector'; - -describe('AppThemeSelector', () => { - it('should should select new themes', async () => { - const selector = new AppThemeSelector([]); - - expect(selector.getInstalledThemes()).toEqual([]); - - const subFn = jest.fn(); - selector.activeThemeId$().subscribe(subFn); - expect(selector.getActiveThemeId()).toBe(undefined); - await 'wait a tick'; - expect(subFn).toHaveBeenLastCalledWith(undefined); - - selector.setActiveThemeId('x'); - expect(subFn).toHaveBeenLastCalledWith('x'); - expect(selector.getActiveThemeId()).toBe('x'); - - selector.setActiveThemeId(undefined); - expect(subFn).toHaveBeenLastCalledWith(undefined); - expect(selector.getActiveThemeId()).toBe(undefined); - }); - - it('should return a new array of themes', () => { - const themes = new Array(); - const selector = new AppThemeSelector(themes); - - expect(selector.getInstalledThemes()).toEqual(themes); - expect(selector.getInstalledThemes()).not.toBe(themes); - }); - - it('should store theme in local storage', async () => { - expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( - undefined, - ); - localStorage.setItem('theme', 'x'); - expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x'); - localStorage.removeItem('theme'); - expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( - undefined, - ); - - const addListenerSpy = jest.spyOn(window, 'addEventListener'); - const selector = AppThemeSelector.createWithStorage([]); - - expect(addListenerSpy).toHaveBeenCalledTimes(1); - expect(addListenerSpy).toHaveBeenCalledWith( - 'storage', - expect.any(Function), - ); - - selector.setActiveThemeId('y'); - await 'wait a tick'; - expect(localStorage.getItem('theme')).toBe('y'); - - selector.setActiveThemeId(undefined); - await 'wait a tick'; - expect(localStorage.getItem('theme')).toBe(null); - - localStorage.setItem('theme', 'z'); - expect(selector.getActiveThemeId()).toBe(undefined); - - const listener = addListenerSpy.mock.calls[0][1] as EventListener; - listener({ key: 'theme' } as StorageEvent); - - expect(selector.getActiveThemeId()).toBe('z'); - }); -}); diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts deleted file mode 100644 index 5856199733..0000000000 --- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 { AppThemeApi, AppTheme } from '../../definitions'; -import { BehaviorSubject } from '../../../lib/subjects'; -import { Observable } from '../../../types'; - -const STORAGE_KEY = 'theme'; - -export class AppThemeSelector implements AppThemeApi { - static createWithStorage(themes: AppTheme[]) { - const selector = new AppThemeSelector(themes); - - if (!window.localStorage) { - return selector; - } - - const initialThemeId = - window.localStorage.getItem(STORAGE_KEY) ?? undefined; - - selector.setActiveThemeId(initialThemeId); - - selector.activeThemeId$().subscribe(themeId => { - if (themeId) { - window.localStorage.setItem(STORAGE_KEY, themeId); - } else { - window.localStorage.removeItem(STORAGE_KEY); - } - }); - - window.addEventListener('storage', event => { - if (event.key === STORAGE_KEY) { - const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; - selector.setActiveThemeId(themeId); - } - }); - - return selector; - } - - private activeThemeId: string | undefined; - private readonly subject = new BehaviorSubject(undefined); - - constructor(private readonly themes: AppTheme[]) {} - - getInstalledThemes(): AppTheme[] { - return this.themes.slice(); - } - - activeThemeId$(): Observable { - return this.subject; - } - - getActiveThemeId(): string | undefined { - return this.activeThemeId; - } - - setActiveThemeId(themeId?: string): void { - this.activeThemeId = themeId; - this.subject.next(themeId); - } -} diff --git a/packages/core-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-api/src/apis/implementations/ConfigApi/index.ts deleted file mode 100644 index 708c1d4573..0000000000 --- a/packages/core-api/src/apis/implementations/ConfigApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { ConfigReader } from '@backstage/config'; diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts deleted file mode 100644 index 9cd666e8a3..0000000000 --- a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 { UrlPatternDiscovery } from './UrlPatternDiscovery'; - -describe('UrlPatternDiscovery', () => { - it('should not require interpolation', async () => { - const discoveryApi = UrlPatternDiscovery.compile('http://example.com'); - await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( - 'http://example.com', - ); - }); - - it('should use a plain pattern', async () => { - const discoveryApi = UrlPatternDiscovery.compile( - 'http://localhost:7000/{{ pluginId }}', - ); - await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( - 'http://localhost:7000/my-plugin', - ); - }); - - it('should allow for multiple interpolation points', async () => { - const discoveryApi = UrlPatternDiscovery.compile( - 'https://{{pluginId }}.example.com/api/{{ pluginId}}', - ); - await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( - 'https://my-plugin.example.com/api/my-plugin', - ); - }); - - it('should validate that the pattern is a valid URL', () => { - expect(() => { - UrlPatternDiscovery.compile('example.com'); - }).toThrow('Invalid discovery URL pattern, Invalid URL: example.com'); - - expect(() => { - UrlPatternDiscovery.compile('http://'); - }).toThrow('Invalid discovery URL pattern, Invalid URL: http://'); - - expect(() => { - UrlPatternDiscovery.compile('abc123'); - }).toThrow('Invalid discovery URL pattern, Invalid URL: abc123'); - - expect(() => { - UrlPatternDiscovery.compile('http://example.com:{{pluginId}}'); - }).toThrow( - 'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId', - ); - - expect(() => { - UrlPatternDiscovery.compile('/{{pluginId}}'); - }).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId'); - - expect(() => { - UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden'); - }).toThrow('Invalid discovery URL pattern, URL must not have a query'); - - expect(() => { - UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden'); - }).toThrow('Invalid discovery URL pattern, URL must not have a hash'); - - expect(() => { - UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/'); - }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); - - expect(() => { - UrlPatternDiscovery.compile('http://localhost/'); - }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); - }); -}); diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts deleted file mode 100644 index 10d3d90c63..0000000000 --- a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 { DiscoveryApi } from '../../definitions/DiscoveryApi'; - -/** - * UrlPatternDiscovery is a lightweight DiscoveryApi implementation. - * It uses a single template string to construct URLs for each plugin. - */ -export class UrlPatternDiscovery implements DiscoveryApi { - /** - * Creates a new UrlPatternDiscovery given a template. The the only - * interpolation done for the template is to replace instances of `{{pluginId}}` - * with the ID of the plugin being requested. - * - * Example pattern: `http://localhost:7000/api/{{ pluginId }}` - */ - static compile(pattern: string): UrlPatternDiscovery { - const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); - - try { - const urlStr = parts.join('pluginId'); - const url = new URL(urlStr); - if (url.hash) { - throw new Error('URL must not have a hash'); - } - if (url.search) { - throw new Error('URL must not have a query'); - } - if (urlStr.endsWith('/')) { - throw new Error('URL must not end with a slash'); - } - } catch (error) { - throw new Error(`Invalid discovery URL pattern, ${error.message}`); - } - - return new UrlPatternDiscovery(parts); - } - - private constructor(private readonly parts: string[]) {} - - async getBaseUrl(pluginId: string): Promise { - return this.parts.join(pluginId); - } -} diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts deleted file mode 100644 index 184346401f..0000000000 --- a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { UrlPatternDiscovery } from './UrlPatternDiscovery'; diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts deleted file mode 100644 index f537205917..0000000000 --- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { AlertApi, ErrorApi, ErrorContext } from '../../definitions'; - -/** - * Decorates an ErrorApi by also forwarding error messages - * to the alertApi with an 'error' severity. - */ -export class ErrorAlerter implements ErrorApi { - constructor( - private readonly alertApi: AlertApi, - private readonly errorApi: ErrorApi, - ) {} - - post(error: Error, context?: ErrorContext) { - if (!context?.hidden) { - this.alertApi.post({ message: error.message, severity: 'error' }); - } - - return this.errorApi.post(error, context); - } - - error$() { - return this.errorApi.error$(); - } -} diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts deleted file mode 100644 index 226c4f5488..0000000000 --- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { PublishSubject } from '../../../lib/subjects'; -import { Observable } from '../../../types'; -import { ErrorApi, ErrorContext } from '../../definitions'; - -/** - * Base implementation for the ErrorApi that simply forwards errors to consumers. - */ -export class ErrorApiForwarder implements ErrorApi { - private readonly subject = new PublishSubject<{ - error: Error; - context?: ErrorContext; - }>(); - - post(error: Error, context?: ErrorContext) { - this.subject.next({ error, context }); - } - - error$(): Observable<{ error: Error; context?: ErrorContext }> { - return this.subject; - } -} diff --git a/packages/core-api/src/apis/implementations/ErrorApi/index.ts b/packages/core-api/src/apis/implementations/ErrorApi/index.ts deleted file mode 100644 index 49e12b4646..0000000000 --- a/packages/core-api/src/apis/implementations/ErrorApi/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { ErrorAlerter } from './ErrorAlerter'; -export { ErrorApiForwarder } from './ErrorApiForwarder'; diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx deleted file mode 100644 index 64ab907d5c..0000000000 --- a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx +++ /dev/null @@ -1,222 +0,0 @@ -/* - * 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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; -import { FeatureFlagState, FeatureFlagsApi } from '../../definitions'; - -describe('FeatureFlags', () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - describe('getFlags', () => { - let featureFlags: FeatureFlagsApi; - - beforeEach(() => { - featureFlags = new LocalStorageFeatureFlags(); - }); - - it('returns no flags', () => { - expect(featureFlags.getRegisteredFlags()).toEqual([]); - }); - - it('loads flags from local storage', () => { - window.localStorage.setItem( - 'featureFlags', - JSON.stringify({ - 'feature-flag-one': 1, - 'feature-flag-two': 1, - 'feature-flag-three': 0, - 'feature-flag-four': 2, - 'feature-flag-five': 'not-valid', - }), - ); - - expect(featureFlags.isActive('feature-flag-one')).toBe(true); - expect(featureFlags.isActive('feature-flag-two')).toBe(true); - expect(featureFlags.isActive('feature-flag-three')).toBe(false); - expect(featureFlags.isActive('feature-flag-four')).toBe(false); - expect(featureFlags.isActive('feature-flag-five')).toBe(false); - }); - - it('sets the correct values', () => { - featureFlags.save({ - states: { - 'feature-flag-zero': FeatureFlagState.Active, - }, - }); - - expect(featureFlags.isActive('feature-flag-zero')).toBe(true); - expect(window.localStorage.getItem('featureFlags')).toEqual( - '{"feature-flag-zero":1}', - ); - }); - - it('deletes the correct values', () => { - window.localStorage.setItem( - 'featureFlags', - JSON.stringify({ - 'feature-flag-one': 1, - 'feature-flag-two': 0, - 'feature-flag-tree': 1, - 'feature-flag-four': 0, - }), - ); - - featureFlags.save({ - states: { - 'feature-flag-one': FeatureFlagState.None, - 'feature-flag-two': FeatureFlagState.Active, - }, - }); - - expect(window.localStorage.getItem('featureFlags')).toEqual( - '{"feature-flag-two":1}', - ); - }); - - it('clears all values', () => { - window.localStorage.setItem( - 'featureFlags', - JSON.stringify({ - 'feature-flag-one': 1, - 'feature-flag-two': 1, - 'feature-flag-three': 0, - }), - ); - - expect(featureFlags.isActive('feature-flag-one')).toBe(true); - expect(featureFlags.isActive('feature-flag-two')).toBe(true); - expect(featureFlags.isActive('feature-flag-three')).toBe(false); - - featureFlags.save({ states: {} }); - - expect(featureFlags.isActive('feature-flag-one')).toBe(false); - expect(featureFlags.isActive('feature-flag-two')).toBe(false); - expect(featureFlags.isActive('feature-flag-three')).toBe(false); - - expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); - }); - }); - - describe('getRegisteredFlags', () => { - let featureFlags: FeatureFlagsApi; - - beforeEach(() => { - featureFlags = new LocalStorageFeatureFlags(); - featureFlags.registerFlag({ - name: 'registered-flag-1', - pluginId: 'plugin-one', - }); - featureFlags.registerFlag({ - name: 'registered-flag-2', - pluginId: 'plugin-one', - }); - featureFlags.registerFlag({ - name: 'registered-flag-3', - pluginId: 'plugin-two', - }); - }); - - it('should return an empty list', () => { - featureFlags = new LocalStorageFeatureFlags(); - expect(featureFlags.getRegisteredFlags()).toEqual([]); - }); - - it('should return an valid list', () => { - expect(featureFlags.getRegisteredFlags()).toEqual([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - ]); - }); - - it('should provide a copy of the list of flags', () => { - const flags = featureFlags.getRegisteredFlags(); - expect(flags).toEqual([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - ]); - flags.splice(2, 1); - expect(flags).toEqual([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - ]); - expect(featureFlags.getRegisteredFlags()).toEqual([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - ]); - }); - - it('should get the correct values', () => { - const getByName = (name: string) => - featureFlags.getRegisteredFlags().find(flag => flag.name === name); - - expect(getByName('registered-flag-0')).toBeUndefined(); - expect(getByName('registered-flag-1')).toEqual({ - name: 'registered-flag-1', - pluginId: 'plugin-one', - }); - expect(getByName('registered-flag-2')).toEqual({ - name: 'registered-flag-2', - pluginId: 'plugin-one', - }); - expect(getByName('registered-flag-3')).toEqual({ - name: 'registered-flag-3', - pluginId: 'plugin-two', - }); - }); - - it('throws an error if length is less than three characters', () => { - expect(() => - featureFlags.registerFlag({ - name: 'ab', - pluginId: 'plugin-three', - }), - ).toThrow(/minimum length of three characters/i); - }); - - it('throws an error if length is greater than 150 characters', () => { - expect(() => - featureFlags.registerFlag({ - name: - 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', - pluginId: 'plugin-three', - }), - ).toThrow(/not exceed 150 characters/i); - }); - - it('throws an error if name does not start with a lowercase letter', () => { - expect(() => - featureFlags.registerFlag({ - name: '123456789', - pluginId: 'plugin-three', - }), - ).toThrow(/start with a lowercase letter/i); - }); - - it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { - expect(() => - featureFlags.registerFlag({ - name: 'Invalid_Feature_Flag', - pluginId: 'plugin-three', - }), - ).toThrow(/only contain lowercase letters, numbers and hyphens/i); - }); - }); -}); diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx deleted file mode 100644 index 3d47e60de8..0000000000 --- a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * 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 { - FeatureFlagState, - FeatureFlagsApi, - FeatureFlag, - FeatureFlagsSaveOptions, -} from '../../definitions'; - -export function validateFlagName(name: string): void { - if (name.length < 3) { - throw new Error( - `The '${name}' feature flag must have a minimum length of three characters.`, - ); - } - - if (name.length > 150) { - throw new Error( - `The '${name}' feature flag must not exceed 150 characters.`, - ); - } - - if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { - throw new Error( - `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + - 'Examples: feature-flag-one, alpha, release-2020', - ); - } -} - -/** - * Create the FeatureFlags implementation based on the API. - */ -export class LocalStorageFeatureFlags implements FeatureFlagsApi { - private registeredFeatureFlags: FeatureFlag[] = []; - private flags?: Map; - - registerFlag(flag: FeatureFlag) { - validateFlagName(flag.name); - this.registeredFeatureFlags.push(flag); - } - - getRegisteredFlags(): FeatureFlag[] { - return this.registeredFeatureFlags.slice(); - } - - isActive(name: string): boolean { - if (!this.flags) { - this.flags = this.load(); - } - return this.flags.get(name) === FeatureFlagState.Active; - } - - save(options: FeatureFlagsSaveOptions): void { - if (!this.flags) { - this.flags = this.load(); - } - if (!options.merge) { - this.flags.clear(); - } - for (const [name, state] of Object.entries(options.states)) { - this.flags.set(name, state); - } - - const enabled = Array.from(this.flags.entries()).filter( - ([, state]) => state === FeatureFlagState.Active, - ); - window.localStorage.setItem( - 'featureFlags', - JSON.stringify(Object.fromEntries(enabled)), - ); - } - - private load(): Map { - try { - const jsonStr = window.localStorage.getItem('featureFlags'); - if (!jsonStr) { - return new Map(); - } - const json = JSON.parse(jsonStr) as unknown; - if (typeof json !== 'object' || json === null || Array.isArray(json)) { - return new Map(); - } - - const entries = Object.entries(json).filter(([name, value]) => { - validateFlagName(name); - return value === FeatureFlagState.Active; - }); - - return new Map(entries); - } catch { - return new Map(); - } - } -} diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts deleted file mode 100644 index fc806c9ae1..0000000000 --- a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts deleted file mode 100644 index d0f137f0be..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * 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 MockOAuthApi from './MockOAuthApi'; -import PowerIcon from '@material-ui/icons/Power'; - -describe('MockOAuthApi', () => { - it('should trigger all requests', async () => { - const authResult = { is: 'done' }; - const mock = new MockOAuthApi(); - - const authHandler1 = jest.fn().mockImplementation(() => authResult); - const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, - onAuthRequest: authHandler1, - }); - - const authHandler2 = jest.fn().mockResolvedValue('other'); - const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, - onAuthRequest: authHandler2, - }); - - const promises = [ - requester1(new Set(['a'])), - requester1(new Set(['b'])), - requester2(new Set(['a', 'b'])), - requester2(new Set(['b', 'c'])), - requester2(new Set(['c', 'a'])), - ]; - - await expect( - Promise.race([Promise.all(promises), 'waiting']), - ).resolves.toBe('waiting'); - - await mock.triggerAll(); - - await expect(Promise.all(promises)).resolves.toEqual([ - authResult, - authResult, - 'other', - 'other', - 'other', - ]); - - expect(authHandler1).toHaveBeenCalledTimes(1); - expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b'])); - expect(authHandler2).toHaveBeenCalledTimes(1); - expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c'])); - }); - - it('should reject all requests', async () => { - const mock = new MockOAuthApi(); - - const authHandler1 = jest.fn(); - const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, - onAuthRequest: authHandler1, - }); - - const authHandler2 = jest.fn(); - const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, - onAuthRequest: authHandler2, - }); - - const promises = [ - requester1(new Set(['a'])), - requester1(new Set(['b'])), - requester2(new Set(['a', 'b'])), - requester2(new Set(['b', 'c'])), - requester2(new Set(['c', 'a'])), - ]; - - await expect( - Promise.race([Promise.all(promises), 'waiting']), - ).resolves.toBe('waiting'); - - await mock.rejectAll(); - - for (const promise of promises) { - await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); - } - - expect(authHandler1).not.toHaveBeenCalled(); - expect(authHandler2).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts deleted file mode 100644 index 7c37d702d6..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { OAuthRequestApi, AuthRequesterOptions } from '../../definitions'; -import { OAuthRequestManager } from './OAuthRequestManager'; - -export default class MockOAuthApi implements OAuthRequestApi { - private readonly real = new OAuthRequestManager(); - - createAuthRequester(options: AuthRequesterOptions) { - return this.real.createAuthRequester(options); - } - - authRequest$() { - return this.real.authRequest$(); - } - - async triggerAll() { - await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - - return new Promise(resolve => { - const subscription = this.authRequest$().subscribe(requests => { - subscription.unsubscribe(); - Promise.all(requests.map(request => request.trigger())).then(() => - resolve(), - ); - }); - }); - } - - async rejectAll() { - await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - - return new Promise(resolve => { - const subscription = this.authRequest$().subscribe(requests => { - subscription.unsubscribe(); - requests.map(request => request.reject()); - resolve(); - }); - }); - } -} diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts deleted file mode 100644 index 20b0cbba67..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { waitFor } from '@testing-library/react'; -import { OAuthPendingRequests } from './OAuthPendingRequests'; - -describe('OAuthPendingRequests', () => { - it('notifies new observers about current state', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - - const input = new Set(['a', 'b']); - target.pending().subscribe({ next, error }); - target.request(input); - - await waitFor(() => expect(next).toBeCalledTimes(2)); - expect(next.mock.calls[0][0].scopes).toBeUndefined(); - expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); - expect(error.mock.calls.length).toBe(0); - }); - - it('resolves requests and notifies observers', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - - const request1 = target.request(new Set(['a'])); - const request2 = target.request(new Set(['a'])); - target.pending().subscribe({ next, error }); - target.resolve(new Set(['a']), 'session1'); - target.resolve(new Set(['a']), 'session2'); - - await expect(request1).resolves.toBe('session1'); - await expect(request2).resolves.toBe('session1'); - expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve - expect(error).toBeCalledTimes(0); - }); - - it('can resolve through the observable', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(pendingRequest => pendingRequest.resolve('done')); - const error = jest.fn(); - - const request1 = target.request(new Set(['a'])); - target.pending().subscribe({ next, error }); - - await expect(request1).resolves.toBe('done'); - expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution - expect(error).toBeCalledTimes(0); - }); - - it('rejects requests and notifies observers only once', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - const rejection = new Error('eek'); - - const request1 = target.request(new Set(['a'])); - const request2 = target.request(new Set(['a'])); - target.pending().subscribe({ next, error }); - target.reject(rejection); - target.resolve(new Set(['a']), 'session'); - - await expect(request1).rejects.toBe(rejection); - await expect(request2).rejects.toBe(rejection); - expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve - expect(error).toBeCalledTimes(0); - }); - - it('can reject through the observable', async () => { - const target = new OAuthPendingRequests(); - const rejection = new Error('nope'); - const next = jest.fn(pendingRequest => pendingRequest.reject(rejection)); - const error = jest.fn(); - - const request1 = target.request(new Set(['a'])); - target.pending().subscribe({ next, error }); - - await expect(request1).rejects.toBe(rejection); - expect(next).toBeCalledTimes(2); - }); -}); diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts deleted file mode 100644 index 9e65ca007f..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 { BehaviorSubject } from '../../../lib/subjects'; -import { Observable } from '../../../types'; - -type RequestQueueEntry = { - scopes: Set; - resolve: (value: ResultType | PromiseLike) => void; - reject: (reason: Error) => void; -}; - -export type PendingRequest = { - scopes: Set | undefined; - resolve: (value: ResultType) => void; - reject: (reason: Error) => void; -}; - -export function hasScopes( - searched: Set, - searchFor: Set, -): boolean { - for (const scope of searchFor) { - if (!searched.has(scope)) { - return false; - } - } - return true; -} - -export function joinScopes( - scopes: Set, - ...moreScopess: Set[] -): Set { - const result = new Set(scopes); - - for (const moreScopes of moreScopess) { - for (const scope of moreScopes) { - result.add(scope); - } - } - - return result; -} - -/** - * The OAuthPendingRequests class is a utility for managing and observing - * a stream of requests for oauth scopes for a single provider, and resolving - * them correctly once requests are fulfilled. - */ -export class OAuthPendingRequests { - private requests: RequestQueueEntry[] = []; - private subject = new BehaviorSubject>( - this.getCurrentPending(), - ); - - request(scopes: Set): Promise { - return new Promise((resolve, reject) => { - this.requests.push({ scopes, resolve, reject }); - - this.subject.next(this.getCurrentPending()); - }); - } - - resolve(scopes: Set, result: ResultType): void { - this.requests = this.requests.filter(request => { - if (hasScopes(scopes, request.scopes)) { - request.resolve(result); - return false; - } - return true; - }); - - this.subject.next(this.getCurrentPending()); - } - - reject(error: Error) { - this.requests.forEach(request => request.reject(error)); - this.requests = []; - - this.subject.next(this.getCurrentPending()); - } - - pending(): Observable> { - return this.subject; - } - - private getCurrentPending(): PendingRequest { - const currentScopes = - this.requests.length === 0 - ? undefined - : this.requests - .slice(1) - .reduce( - (acc, current) => joinScopes(acc, current.scopes), - this.requests[0].scopes, - ); - - return { - scopes: currentScopes, - resolve: (value: ResultType) => { - if (currentScopes) { - this.resolve(currentScopes, value); - } - }, - reject: (reason: Error) => { - if (currentScopes) { - this.reject(reason); - } - }, - }; - } -} diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts deleted file mode 100644 index d8faf90229..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 ProviderIcon from '@material-ui/icons/AcUnit'; -import { OAuthRequestManager } from './OAuthRequestManager'; - -describe('OAuthRequestManager', () => { - it('should forward a requests', async () => { - const manager = new OAuthRequestManager(); - - const reqSpy = jest.fn(); - manager.authRequest$().subscribe(reqSpy); - - const requester = manager.createAuthRequester({ - provider: { - title: 'My Provider', - icon: ProviderIcon, - }, - onAuthRequest: async () => 'hello', - }); - - expect(reqSpy).toHaveBeenCalledTimes(0); - await 'a tick'; - expect(reqSpy).toHaveBeenCalledTimes(2); - expect(reqSpy).toHaveBeenLastCalledWith([]); - - const req = requester(new Set(['my-scope'])); - - expect(reqSpy).toHaveBeenCalledTimes(3); - expect(reqSpy).toHaveBeenLastCalledWith([ - expect.objectContaining({ - reject: expect.any(Function), - trigger: expect.any(Function), - }), - ]); - - await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe( - 'not yet', - ); - - const [request] = reqSpy.mock.calls[2][0]; - request.trigger(); - - await expect(req).resolves.toBe('hello'); - }); -}); diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts deleted file mode 100644 index c8cea322e8..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 { - OAuthRequestApi, - PendingAuthRequest, - AuthRequester, - AuthRequesterOptions, -} from '../../definitions'; -import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; -import { BehaviorSubject } from '../../../lib/subjects'; -import { Observable } from '../../../types'; - -/** - * The OAuthRequestManager is an implementation of the OAuthRequestApi. - * - * The purpose of this class and the API is to read a stream of incoming requests - * of OAuth access tokens from different providers with varying scope, and funnel - * them all together into a single request for each OAuth provider. - */ -export class OAuthRequestManager implements OAuthRequestApi { - private readonly subject = new BehaviorSubject([]); - private currentRequests: PendingAuthRequest[] = []; - private handlerCount = 0; - - createAuthRequester(options: AuthRequesterOptions): AuthRequester { - const handler = new OAuthPendingRequests(); - - const index = this.handlerCount; - this.handlerCount++; - - handler.pending().subscribe({ - next: scopeRequest => { - const newRequests = this.currentRequests.slice(); - const request = this.makeAuthRequest(scopeRequest, options); - if (!request) { - delete newRequests[index]; - } else { - newRequests[index] = request; - } - this.currentRequests = newRequests; - // Convert from sparse array to array of present items only - this.subject.next(newRequests.filter(Boolean)); - }, - }); - - return scopes => { - return handler.request(scopes); - }; - } - - // Converts the pending request and popup options into a popup request that we can forward to subscribers. - private makeAuthRequest( - request: PendingRequest, - options: AuthRequesterOptions, - ): PendingAuthRequest | undefined { - const { scopes } = request; - if (!scopes) { - return undefined; - } - - return { - provider: options.provider, - trigger: async () => { - const result = await options.onAuthRequest(scopes); - request.resolve(result); - }, - reject: () => { - const error = new Error('Login failed, rejected by user'); - error.name = 'RejectedError'; - request.reject(error); - }, - }; - } - - authRequest$(): Observable { - return this.subject; - } -} diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/index.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/index.ts deleted file mode 100644 index 72e42872fd..0000000000 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { OAuthRequestManager } from './OAuthRequestManager'; diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts deleted file mode 100644 index 8551eed4a6..0000000000 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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 { WebStorage } from './WebStorage'; -import { CreateStorageApiOptions, StorageApi } from '../../definitions'; - -describe('WebStorage Storage API', () => { - const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; - const createWebStorage = ( - args?: Partial, - ): StorageApi => { - return WebStorage.create({ - errorApi: mockErrorApi, - ...args, - }); - }; - it('should return undefined for values which are unset', async () => { - const storage = createWebStorage(); - - expect(storage.get('myfakekey')).toBeUndefined(); - }); - - it('should allow the setting and getting of the simple data structures', async () => { - const storage = createWebStorage(); - - await storage.set('myfakekey', 'helloimastring'); - await storage.set('mysecondfakekey', 1234); - await storage.set('mythirdfakekey', true); - expect(storage.get('myfakekey')).toBe('helloimastring'); - expect(storage.get('mysecondfakekey')).toBe(1234); - expect(storage.get('mythirdfakekey')).toBe(true); - }); - - it('should allow setting of complex datastructures', async () => { - const storage = createWebStorage(); - - const mockData = { - something: 'here', - is: [{ super: { complex: [{ but: 'something', why: true }] } }], - }; - - await storage.set('myfakekey', mockData); - - expect(storage.get('myfakekey')).toEqual(mockData); - }); - - it('should subscribe to key changes when setting a new value', async () => { - const storage = createWebStorage(); - - const wrongKeyNextHandler = jest.fn(); - const selectedKeyNextHandler = jest.fn(); - const mockData = { hello: 'im a great new value' }; - - await new Promise(resolve => { - storage.observe$('correctKey').subscribe({ - next: (...args) => { - selectedKeyNextHandler(...args); - resolve(); - }, - }); - - storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); - - storage.set('correctKey', mockData); - }); - - expect(wrongKeyNextHandler).not.toHaveBeenCalled(); - expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); - expect(selectedKeyNextHandler).toHaveBeenCalledWith({ - key: 'correctKey', - newValue: mockData, - }); - }); - - it('should subscribe to key changes when deleting a value', async () => { - const storage = createWebStorage(); - - const wrongKeyNextHandler = jest.fn(); - const selectedKeyNextHandler = jest.fn(); - const mockData = { hello: 'im a great new value' }; - - storage.set('correctKey', mockData); - - await new Promise(resolve => { - storage.observe$('correctKey').subscribe({ - next: (...args) => { - selectedKeyNextHandler(...args); - resolve(); - }, - }); - - storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); - - storage.remove('correctKey'); - }); - - expect(wrongKeyNextHandler).not.toHaveBeenCalled(); - expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); - expect(selectedKeyNextHandler).toHaveBeenCalledWith({ - key: 'correctKey', - newValue: undefined, - }); - }); - - it('should be able to create different buckets for different uses', async () => { - const rootStorage = createWebStorage(); - - const firstStorage = rootStorage.forBucket('userSettings'); - const secondStorage = rootStorage.forBucket('profileSettings'); - const keyName = 'blobby'; - - await firstStorage.set(keyName, 'boop'); - await secondStorage.set(keyName, 'deerp'); - - expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); - expect(firstStorage.get(keyName)).toBe('boop'); - expect(secondStorage.get(keyName)).toBe('deerp'); - }); - - it('should not clash with other namesapces when creating buckets', async () => { - const rootStorage = createWebStorage(); - - // when getting key test2 it will translate to /profile/something/deep/test2 - const firstStorage = rootStorage - .forBucket('profile') - .forBucket('something') - .forBucket('deep'); - // when getting key deep/test2 it will translate to /profile/something/deep/test2 - const secondStorage = rootStorage.forBucket('profile/something'); - - await firstStorage.set('test2', { error: true }); - - expect(secondStorage.get('deep/test2')).toBe(undefined); - }); - - it('should call the error api when the json can not be parsed in local storage', async () => { - const rootStorage = createWebStorage({ - namespace: '/Test/Mock/Thing', - }); - - localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}'); - - const value = rootStorage.get('key'); - - expect(value).toBe(undefined); - expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); - expect(mockErrorApi.post).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error when parsing JSON config from storage for: key', - }), - ); - }); - - it('should return a singleton for the same namespace and same bucket', async () => { - const rootStorage = createWebStorage({ - namespace: '/Test/Mock/Thing/Thing ', - }); - - expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test')); - }); -}); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts deleted file mode 100644 index 1b4f4a88bb..0000000000 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 { - StorageApi, - StorageValueChange, - ErrorApi, - CreateStorageApiOptions, -} from '../../definitions'; -import { Observable } from '../../../types'; -import ObservableImpl from 'zen-observable'; - -const buckets = new Map(); - -export class WebStorage implements StorageApi { - constructor( - private readonly namespace: string, - private readonly errorApi: ErrorApi, - ) {} - - static create(options: CreateStorageApiOptions): WebStorage { - return new WebStorage(options.namespace ?? '', options.errorApi); - } - - get(key: string): T | undefined { - try { - const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); - return storage ?? undefined; - } catch (e) { - this.errorApi.post( - new Error(`Error when parsing JSON config from storage for: ${key}`), - ); - } - - return undefined; - } - - forBucket(name: string): WebStorage { - const bucketPath = `${this.namespace}/${name}`; - if (!buckets.has(bucketPath)) { - buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi)); - } - return buckets.get(bucketPath)!; - } - - async set(key: string, data: T): Promise { - localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); - this.notifyChanges({ key, newValue: data }); - } - - async remove(key: string): Promise { - localStorage.removeItem(this.getKeyName(key)); - this.notifyChanges({ key, newValue: undefined }); - } - - observe$(key: string): Observable> { - return this.observable.filter(({ key: messageKey }) => messageKey === key); - } - - private getKeyName(key: string) { - return `${this.namespace}/${encodeURIComponent(key)}`; - } - - private notifyChanges(message: StorageValueChange) { - for (const subscription of this.subscribers) { - subscription.next(message); - } - } - - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - private readonly observable = new ObservableImpl( - subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }, - ); -} diff --git a/packages/core-api/src/apis/implementations/StorageApi/index.ts b/packages/core-api/src/apis/implementations/StorageApi/index.ts deleted file mode 100644 index 2f941381fd..0000000000 --- a/packages/core-api/src/apis/implementations/StorageApi/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { WebStorage } from './WebStorage'; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts deleted file mode 100644 index 2dac460fbe..0000000000 --- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 Auth0Icon from '@material-ui/icons/AcUnit'; -import { auth0AuthApiRef } from '../../../definitions/auth'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; - -const DEFAULT_PROVIDER = { - id: 'auth0', - title: 'Auth0', - icon: Auth0Icon, -}; - -class Auth0Auth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['openid', `email`, `profile`], - }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - }); - } -} - -export default Auth0Auth; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-api/src/apis/implementations/auth/auth0/index.ts deleted file mode 100644 index 9daed7d13e..0000000000 --- a/packages/core-api/src/apis/implementations/auth/auth0/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as Auth0Auth } from './Auth0Auth'; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts deleted file mode 100644 index 04bfff028d..0000000000 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 GithubAuth from './GithubAuth'; - -describe('GithubAuth', () => { - it('should get access token', async () => { - const getSession = jest - .fn() - .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const githubAuth = new GithubAuth({ getSession } as any); - - expect(await githubAuth.getAccessToken()).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts deleted file mode 100644 index 60ee6a2629..0000000000 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* - * 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 GithubIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GithubSession } from './types'; -import { - OAuthApi, - SessionApi, - SessionState, - ProfileInfo, - BackstageIdentity, - AuthRequestOptions, -} from '../../../definitions/auth'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { - AuthSessionStore, - StaticAuthSessionManager, -} from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; -import { OAuthApiCreateOptions } from '../types'; - -export type GithubAuthResponse = { - providerInfo: { - accessToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - -const DEFAULT_PROVIDER = { - id: 'github', - title: 'GitHub', - icon: GithubIcon, -}; - -class GithubAuth implements OAuthApi, SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['read:user'], - }: OAuthApiCreateOptions) { - const connector = new DefaultAuthConnector({ - discoveryApi, - environment, - provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: GithubAuthResponse): GithubSession { - return { - ...res, - providerInfo: { - accessToken: res.providerInfo.accessToken, - scopes: GithubAuth.normalizeScope(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, - }); - - const sessionManager = new StaticAuthSessionManager({ - connector, - defaultScopes: new Set(defaultScopes), - sessionScopes: (session: GithubSession) => session.providerInfo.scopes, - }); - - const authSessionStore = new AuthSessionStore({ - manager: sessionManager, - storageKey: `${provider.id}Session`, - sessionScopes: (session: GithubSession) => session.providerInfo.scopes, - }); - - return new GithubAuth(authSessionStore); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async signIn() { - await this.getAccessToken(); - } - - async signOut() { - await this.sessionManager.removeSession(); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - async getAccessToken(scope?: string, options?: AuthRequestOptions) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: GithubAuth.normalizeScope(scope), - }); - return session?.providerInfo.accessToken ?? ''; - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } - - static normalizeScope(scope?: string): Set { - if (!scope) { - return new Set(); - } - - const scopeList = Array.isArray(scope) - ? scope - : scope.split(/[\s|,]/).filter(Boolean); - - return new Set(scopeList); - } -} -export default GithubAuth; diff --git a/packages/core-api/src/apis/implementations/auth/github/index.ts b/packages/core-api/src/apis/implementations/auth/github/index.ts deleted file mode 100644 index ee4334f6fc..0000000000 --- a/packages/core-api/src/apis/implementations/auth/github/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 * from './types'; -export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/github/types.ts b/packages/core-api/src/apis/implementations/auth/github/types.ts deleted file mode 100644 index 90573d2429..0000000000 --- a/packages/core-api/src/apis/implementations/auth/github/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 { BackstageIdentity, ProfileInfo } from '../../../definitions'; - -export type GithubSession = { - providerInfo: { - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts deleted file mode 100644 index 3346af9b7c..0000000000 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; -import { UrlPatternDiscovery } from '../../DiscoveryApi'; -import GitlabAuth from './GitlabAuth'; - -const getSession = jest.fn(); - -jest.mock('../../../../lib/AuthSessionManager', () => ({ - ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), - RefreshingAuthSessionManager: class { - getSession = getSession; - }, -})); - -describe('GitlabAuth', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it.each([ - [ - 'read_user api write_repository', - ['read_user', 'api', 'write_repository'], - ], - ['read_repository sudo', ['read_repository', 'sudo']], - ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - const gitlabAuth = GitlabAuth.create({ - oauthRequestApi: new MockOAuthApi(), - discoveryApi: UrlPatternDiscovery.compile('http://example.com'), - }); - - gitlabAuth.getAccessToken(scope); - expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts deleted file mode 100644 index 37a3c89dd6..0000000000 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 GitlabIcon from '@material-ui/icons/AcUnit'; -import { gitlabAuthApiRef } from '../../../definitions/auth'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; - -const DEFAULT_PROVIDER = { - id: 'gitlab', - title: 'GitLab', - icon: GitlabIcon, -}; - -class GitlabAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['read_user'], - }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - }); - } -} -export default GitlabAuth; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts deleted file mode 100644 index 935a11dd54..0000000000 --- a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts deleted file mode 100644 index f8a1c3a1f5..0000000000 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 GoogleAuth from './GoogleAuth'; -import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; -import { UrlPatternDiscovery } from '../../DiscoveryApi'; - -const PREFIX = 'https://www.googleapis.com/auth/'; - -const getSession = jest.fn(); - -jest.mock('../../../../lib/AuthSessionManager', () => ({ - ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), - RefreshingAuthSessionManager: class { - getSession = getSession; - }, -})); - -describe('GoogleAuth', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it.each([ - ['email', [`${PREFIX}userinfo.email`]], - ['profile', [`${PREFIX}userinfo.profile`]], - ['openid', ['openid']], - ['userinfo.email', [`${PREFIX}userinfo.email`]], - [ - 'userinfo.profile email', - [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], - ], - [ - `profile ${PREFIX}userinfo.email`, - [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], - ], - [`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]], - ['a', [`${PREFIX}a`]], - ['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]], - [`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]], - [`${PREFIX}a`, [`${PREFIX}a`]], - - // Some incorrect scopes that we don't try to fix - [`${PREFIX}email`, [`${PREFIX}email`]], - [`${PREFIX}profile`, [`${PREFIX}profile`]], - [`${PREFIX}openid`, [`${PREFIX}openid`]], - ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - const googleAuth = GoogleAuth.create({ - oauthRequestApi: new MockOAuthApi(), - discoveryApi: UrlPatternDiscovery.compile('http://example.com'), - }); - - googleAuth.getAccessToken(scope); - expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts deleted file mode 100644 index 988ef9c2e0..0000000000 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 GoogleIcon from '@material-ui/icons/AcUnit'; -import { googleAuthApiRef } from '../../../definitions/auth'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; - -const DEFAULT_PROVIDER = { - id: 'google', - title: 'Google', - icon: GoogleIcon, -}; - -const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - -class GoogleAuth { - static create({ - discoveryApi, - oauthRequestApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - defaultScopes = [ - 'openid', - `${SCOPE_PREFIX}userinfo.email`, - `${SCOPE_PREFIX}userinfo.profile`, - ], - }: OAuthApiCreateOptions): typeof googleAuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - scopeTransform(scopes: string[]) { - return scopes.map(scope => { - if (scope === 'openid') { - return scope; - } - - if (scope === 'profile' || scope === 'email') { - return `${SCOPE_PREFIX}userinfo.${scope}`; - } - - if (scope.startsWith(SCOPE_PREFIX)) { - return scope; - } - - return `${SCOPE_PREFIX}${scope}`; - }); - }, - }); - } -} -export default GoogleAuth; diff --git a/packages/core-api/src/apis/implementations/auth/google/index.ts b/packages/core-api/src/apis/implementations/auth/google/index.ts deleted file mode 100644 index 96a8699eab..0000000000 --- a/packages/core-api/src/apis/implementations/auth/google/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts deleted file mode 100644 index 9889a33878..0000000000 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 * from './github'; -export * from './gitlab'; -export * from './google'; -export * from './oauth2'; -export * from './okta'; -export * from './saml'; -export * from './auth0'; -export * from './microsoft'; -export * from './onelogin'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts deleted file mode 100644 index 5f0e018add..0000000000 --- a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 MicrosoftIcon from '@material-ui/icons/AcUnit'; -import { microsoftAuthApiRef } from '../../../definitions/auth'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; - -const DEFAULT_PROVIDER = { - id: 'microsoft', - title: 'Microsoft', - icon: MicrosoftIcon, -}; - -class MicrosoftAuth { - static create({ - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - discoveryApi, - defaultScopes = [ - 'openid', - 'offline_access', - 'profile', - 'email', - 'User.Read', - ], - }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - }); - } -} - -export default MicrosoftAuth; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts deleted file mode 100644 index 44bca3d37c..0000000000 --- a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts deleted file mode 100644 index 4f030d1c64..0000000000 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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 OAuth2 from './OAuth2'; - -const theFuture = new Date(Date.now() + 3600000); -const thePast = new Date(Date.now() - 10); - -const PREFIX = 'https://www.googleapis.com/auth/'; - -const scopeTransform = (x: string[]) => x; - -describe('OAuth2', () => { - it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, - }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, - }); - - expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( - 'access-token', - ); - expect(getSession).toBeCalledTimes(1); - expect(getSession.mock.calls[0][0].scopes).toEqual( - new Set(['my-scope', 'my-scope2']), - ); - }); - - it('should transform scopes', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, - }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), - }); - - expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); - expect(getSession.mock.calls[0][0].scopes).toEqual( - new Set(['my-prefix/my-scope']), - ); - }); - - it('should get refreshed id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, - }); - - expect(await oauth2.getIdToken()).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should get optional id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, - }); - - expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should share popup closed errors', async () => { - const error = new Error('NOPE'); - error.name = 'RejectedError'; - const getSession = jest - .fn() - .mockResolvedValueOnce({ - providerInfo: { - accessToken: 'access-token', - expiresAt: theFuture, - scopes: new Set([`${PREFIX}not-enough`]), - }, - }) - .mockRejectedValue(error); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, - }); - - // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check - await expect(oauth2.getAccessToken()).resolves.toBe('access-token'); - - const promise1 = oauth2.getAccessToken('more'); - const promise2 = oauth2.getAccessToken('more'); - await expect(promise1).rejects.toBe(error); - await expect(promise2).rejects.toBe(error); - expect(getSession).toBeCalledTimes(3); - }); - - it('should wait for all session refreshes', async () => { - const initialSession = { - providerInfo: { - idToken: 'token1', - expiresAt: theFuture, - scopes: new Set(), - }, - }; - const getSession = jest - .fn() - .mockResolvedValueOnce(initialSession) - .mockResolvedValue({ - providerInfo: { - idToken: 'token2', - expiresAt: theFuture, - scopes: new Set(), - }, - }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, - }); - - // Grab the expired session first - await expect(oauth2.getIdToken()).resolves.toBe('token1'); - expect(getSession).toBeCalledTimes(1); - - initialSession.providerInfo.expiresAt = thePast; - - const promise1 = oauth2.getIdToken(); - const promise2 = oauth2.getIdToken(); - const promise3 = oauth2.getIdToken(); - await expect(promise1).resolves.toBe('token2'); - await expect(promise2).resolves.toBe('token2'); - await expect(promise3).resolves.toBe('token2'); - expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts deleted file mode 100644 index 10d9792799..0000000000 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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 OAuth2Icon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { Observable } from '../../../../types'; -import { - AuthRequestOptions, - BackstageIdentity, - OAuthApi, - OpenIdConnectApi, - ProfileInfo, - ProfileInfoApi, - SessionState, - SessionApi, - BackstageIdentityApi, -} from '../../../definitions/auth'; -import { OAuth2Session } from './types'; -import { OAuthApiCreateOptions } from '../types'; - -type Options = { - sessionManager: SessionManager; - scopeTransform: (scopes: string[]) => string[]; -}; - -type CreateOptions = OAuthApiCreateOptions & { - scopeTransform?: (scopes: string[]) => string[]; -}; - -export type OAuth2Response = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - -const DEFAULT_PROVIDER = { - id: 'oauth2', - title: 'Your Identity Provider', - icon: OAuth2Icon, -}; - -class OAuth2 - implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = [], - scopeTransform = x => x, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ - discoveryApi, - environment, - provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: OAuth2Response): OAuth2Session { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes( - scopeTransform, - res.providerInfo.scope, - ), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, - }); - - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set(defaultScopes), - sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, - sessionShouldRefresh: (session: OAuth2Session) => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - }, - }); - - return new OAuth2({ sessionManager, scopeTransform }); - } - - private readonly sessionManager: SessionManager; - private readonly scopeTransform: (scopes: string[]) => string[]; - - constructor(options: Options) { - this.sessionManager = options.sessionManager; - this.scopeTransform = options.scopeTransform; - } - - async signIn() { - await this.getAccessToken(); - } - - async signOut() { - await this.sessionManager.removeSession(); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - async getAccessToken( - scope?: string | string[], - options?: AuthRequestOptions, - ) { - const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); - const session = await this.sessionManager.getSession({ - ...options, - scopes: normalizedScopes, - }); - return session?.providerInfo.accessToken ?? ''; - } - - async getIdToken(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.providerInfo.idToken ?? ''; - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } - - private static normalizeScopes( - scopeTransform: (scopes: string[]) => string[], - scopes?: string | string[], - ): Set { - if (!scopes) { - return new Set(); - } - - const scopeList = Array.isArray(scopes) - ? scopes - : scopes.split(/[\s|,]/).filter(Boolean); - - return new Set(scopeTransform(scopeList)); - } -} - -export default OAuth2; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-api/src/apis/implementations/auth/oauth2/index.ts deleted file mode 100644 index 793be515b7..0000000000 --- a/packages/core-api/src/apis/implementations/auth/oauth2/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { default as OAuth2 } from './OAuth2'; -export * from './types'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-api/src/apis/implementations/auth/oauth2/types.ts deleted file mode 100644 index ca34de9f78..0000000000 --- a/packages/core-api/src/apis/implementations/auth/oauth2/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; - -export type OAuth2Session = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts deleted file mode 100644 index 6489b326ff..0000000000 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 OktaAuth from './OktaAuth'; -import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; -import { UrlPatternDiscovery } from '../../DiscoveryApi'; - -const PREFIX = 'okta.'; - -const getSession = jest.fn(); - -jest.mock('../../../../lib/AuthSessionManager', () => ({ - ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), - RefreshingAuthSessionManager: class { - getSession = getSession; - }, -})); - -describe('OktaAuth', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it.each([ - ['openid', ['openid']], - ['profile email', ['profile', 'email']], - [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], - ['groups.read', [`${PREFIX}groups.read`]], - [ - `${PREFIX}groups.manage groups.read, openid`, - [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'], - ], - [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], - - // Some incorrect scopes that we don't try to fix - [`${PREFIX}email`, [`${PREFIX}email`]], - [`${PREFIX}profile`, [`${PREFIX}profile`]], - [`${PREFIX}openid`, [`${PREFIX}openid`]], - ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - const auth = OktaAuth.create({ - oauthRequestApi: new MockOAuthApi(), - discoveryApi: UrlPatternDiscovery.compile('http://example.com'), - }); - - auth.getAccessToken(scope); - expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts deleted file mode 100644 index 3f29a56deb..0000000000 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 OktaIcon from '@material-ui/icons/AcUnit'; -import { oktaAuthApiRef } from '../../../definitions/auth'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; - -const DEFAULT_PROVIDER = { - id: 'okta', - title: 'Okta', - icon: OktaIcon, -}; - -const OKTA_OIDC_SCOPES: Set = new Set([ - 'openid', - 'profile', - 'email', - 'phone', - 'address', - 'groups', - 'offline_access', -]); - -const OKTA_SCOPE_PREFIX: string = 'okta.'; - -class OktaAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = ['openid', 'email', 'profile', 'offline_access'], - }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - scopeTransform(scopes) { - return scopes.map(scope => { - if (OKTA_OIDC_SCOPES.has(scope)) { - return scope; - } - - if (scope.startsWith(OKTA_SCOPE_PREFIX)) { - return scope; - } - - return `${OKTA_SCOPE_PREFIX}${scope}`; - }); - }, - }); - } -} - -export default OktaAuth; diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts deleted file mode 100644 index c20ce4e16b..0000000000 --- a/packages/core-api/src/apis/implementations/auth/okta/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts deleted file mode 100644 index 6d34e63669..0000000000 --- a/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 OneLoginIcon from '@material-ui/icons/AcUnit'; -import { oneloginAuthApiRef } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; -import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; - -const DEFAULT_PROVIDER = { - id: 'onelogin', - title: 'onelogin', - icon: OneLoginIcon, -}; - -const OIDC_SCOPES: Set = new Set([ - 'openid', - 'profile', - 'email', - 'phone', - 'address', - 'groups', - 'offline_access', -]); - -const SCOPE_PREFIX: string = 'onelogin.'; - -class OneLoginAuth { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - }: CreateOptions): typeof oneloginAuthApiRef.T { - return OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes: ['openid', 'email', 'profile', 'offline_access'], - scopeTransform(scopes) { - return scopes.map(scope => { - if (OIDC_SCOPES.has(scope)) { - return scope; - } - - if (scope.startsWith(SCOPE_PREFIX)) { - return scope; - } - - return `${SCOPE_PREFIX}${scope}`; - }); - }, - }); - } -} - -export default OneLoginAuth; diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts deleted file mode 100644 index e1826f17dd..0000000000 --- a/packages/core-api/src/apis/implementations/auth/onelogin/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { default as OneLoginAuth } from './OneLoginAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts deleted file mode 100644 index b8f41cfc18..0000000000 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 SamlIcon from '@material-ui/icons/AcUnit'; -import { DirectAuthConnector } from '../../../../lib/AuthConnector'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { Observable } from '../../../../types'; -import { - ProfileInfo, - BackstageIdentity, - SessionState, - AuthRequestOptions, - ProfileInfoApi, - BackstageIdentityApi, - SessionApi, -} from '../../../definitions/auth'; -import { SamlSession } from './types'; -import { - AuthSessionStore, - StaticAuthSessionManager, -} from '../../../../lib/AuthSessionManager'; -import { AuthApiCreateOptions } from '../types'; - -export type SamlAuthResponse = { - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - -const DEFAULT_PROVIDER = { - id: 'saml', - title: 'SAML', - icon: SamlIcon, -}; - -class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - static create({ - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - }: AuthApiCreateOptions) { - const connector = new DirectAuthConnector({ - discoveryApi, - environment, - provider, - }); - - const sessionManager = new StaticAuthSessionManager({ - connector, - }); - - const authSessionStore = new AuthSessionStore({ - manager: sessionManager, - storageKey: `${provider.id}Session`, - }); - - return new SamlAuth(authSessionStore); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async signIn() { - await this.getBackstageIdentity({}); - } - async signOut() { - await this.sessionManager.removeSession(); - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } -} - -export default SamlAuth; diff --git a/packages/core-api/src/apis/implementations/auth/saml/index.ts b/packages/core-api/src/apis/implementations/auth/saml/index.ts deleted file mode 100644 index 930e6cb115..0000000000 --- a/packages/core-api/src/apis/implementations/auth/saml/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 { default as SamlAuth } from './SamlAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/saml/types.ts b/packages/core-api/src/apis/implementations/auth/saml/types.ts deleted file mode 100644 index 2827c6aa24..0000000000 --- a/packages/core-api/src/apis/implementations/auth/saml/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; - -export type SamlSession = { - userId: string; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/types.ts b/packages/core-api/src/apis/implementations/auth/types.ts deleted file mode 100644 index 14202f4e29..0000000000 --- a/packages/core-api/src/apis/implementations/auth/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions'; - -export type OAuthApiCreateOptions = AuthApiCreateOptions & { - oauthRequestApi: OAuthRequestApi; - defaultScopes?: string[]; -}; - -export type AuthApiCreateOptions = { - discoveryApi: DiscoveryApi; - environment?: string; - provider?: AuthProvider & { id: string }; -}; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts deleted file mode 100644 index 31f01f2bff..0000000000 --- a/packages/core-api/src/apis/implementations/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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. - */ - -// This folder contains implementations for all core APIs. -// -// Plugins should rely on these APIs for functionality as much as possible. - -export * from './auth'; - -export * from './AlertApi'; -export * from './AppThemeApi'; -export * from './ConfigApi'; -export * from './DiscoveryApi'; -export * from './ErrorApi'; -export * from './FeatureFlagsApi'; -export * from './OAuthRequestApi'; -export * from './StorageApi'; diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts deleted file mode 100644 index 05b920c88a..0000000000 --- a/packages/core-api/src/apis/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 * from './system'; -export * from './definitions'; -export * from './implementations'; diff --git a/packages/core-api/src/apis/system/ApiAggregator.test.ts b/packages/core-api/src/apis/system/ApiAggregator.test.ts deleted file mode 100644 index 5a7cd12683..0000000000 --- a/packages/core-api/src/apis/system/ApiAggregator.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 { ApiAggregator } from './ApiAggregator'; -import { createApiRef } from './ApiRef'; -import { ApiRegistry } from './ApiRegistry'; - -describe('ApiAggregator', () => { - const apiARef = createApiRef({ id: 'a', description: '' }); - const apiBRef = createApiRef({ id: 'b', description: '' }); - - it('should forward implementations', () => { - const agg = new ApiAggregator( - ApiRegistry.from([ - [apiARef, 5], - [apiBRef, 10], - ]), - ); - expect(agg.get(apiARef)).toBe(5); - expect(agg.get(apiBRef)).toBe(10); - }); - - it('should return the first implementation', () => { - const agg = new ApiAggregator( - ApiRegistry.from([ - [apiARef, 1], - [apiARef, 2], - ]), - ); - expect(agg.get(apiARef)).toBe(2); - expect(agg.get(apiBRef)).toBe(undefined); - }); -}); diff --git a/packages/core-api/src/apis/system/ApiAggregator.ts b/packages/core-api/src/apis/system/ApiAggregator.ts deleted file mode 100644 index 28b2827d54..0000000000 --- a/packages/core-api/src/apis/system/ApiAggregator.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { ApiRef, ApiHolder } from './types'; - -/** - * An ApiHolder that queries multiple other holders from for - * an Api implementation, returning the first one encountered.. - */ -export class ApiAggregator implements ApiHolder { - private readonly holders: ApiHolder[]; - - constructor(...holders: ApiHolder[]) { - this.holders = holders; - } - - get(apiRef: ApiRef): T | undefined { - for (const holder of this.holders) { - const api = holder.get(apiRef); - if (api) { - return api; - } - } - return undefined; - } -} diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts deleted file mode 100644 index c20d0805b1..0000000000 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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 { ApiFactoryRegistry } from './ApiFactoryRegistry'; -import { createApiRef } from './ApiRef'; - -const aRef = createApiRef({ id: 'a', description: '' }); -const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; -const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; -const bRef = createApiRef({ id: 'b', description: '' }); -const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; -const cRef = createApiRef({ id: 'c', description: '' }); -const cFactory = { api: cRef, deps: {}, factory: () => 'y' }; - -describe('ApiFactoryRegistry', () => { - it('should be empty when created', () => { - const registry = new ApiFactoryRegistry(); - expect(registry.getAllApis()).toEqual(new Set()); - }); - - it('should register a factory', () => { - const registry = new ApiFactoryRegistry(); - expect(registry.register('default', aFactory1)).toBe(true); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.getAllApis()).toEqual(new Set([aRef])); - }); - - it('should prioritize factories based on scope', () => { - const registry = new ApiFactoryRegistry(); - expect(registry.register('default', aFactory1)).toBe(true); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.register('default', aFactory2)).toBe(false); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.register('app', aFactory2)).toBe(true); - expect(registry.get(aRef)).toBe(aFactory2); - expect(registry.register('default', aFactory1)).toBe(false); - expect(registry.get(aRef)).toBe(aFactory2); - expect(registry.register('static', aFactory1)).toBe(true); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.register('static', aFactory2)).toBe(false); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.register('app', aFactory2)).toBe(false); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.getAllApis()).toEqual(new Set([aRef])); - }); - - it('should register multiple factories without conflict', () => { - const registry = new ApiFactoryRegistry(); - expect(registry.register('static', aFactory1)).toBe(true); - expect(registry.register('default', bFactory)).toBe(true); - expect(registry.register('app', cFactory)).toBe(true); - expect(registry.get(aRef)).toBe(aFactory1); - expect(registry.get(bRef)).toBe(bFactory); - expect(registry.get(cRef)).toBe(cFactory); - expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); - }); - - it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => { - const ref1 = createApiRef({ id: 'a', description: 'ref1' }); - const ref2 = createApiRef({ id: 'a', description: 'ref2' }); - - const factory1 = { api: ref1, deps: {}, factory: () => 3 }; - const factory2 = { api: ref2, deps: {}, factory: () => 3 }; - - const registry = new ApiFactoryRegistry(); - expect(registry.register('default', factory1)).toBe(true); - expect(registry.register('default', factory2)).toBe(false); - expect(registry.get(ref1)).toEqual(factory1); - expect(registry.get(ref2)).toEqual(factory1); - expect(registry.getAllApis()).toEqual(new Set([ref1])); - - expect(registry.register('app', factory2)).toBe(true); - expect(registry.get(ref1)).toEqual(factory2); - expect(registry.get(ref2)).toEqual(factory2); - expect(registry.getAllApis()).toEqual(new Set([ref2])); - expect(registry.getAllApis()).not.toEqual(new Set([ref1])); - }); -}); diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts deleted file mode 100644 index 0c37078d42..0000000000 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 { - ApiRef, - ApiFactoryHolder, - ApiFactory, - AnyApiRef, - AnyApiFactory, -} from './types'; - -type ApiFactoryScope = - | 'default' // Default factories registered by core and plugins - | 'app' // Factories registered in the app, overriding default ones - | 'static'; // APIs that can't be overridden, e.g. config - -enum ScopePriority { - default = 10, - app = 50, - static = 100, -} - -type FactoryTuple = { - priority: number; - factory: AnyApiFactory; -}; - -/** - * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables - * registration of API Factories with different scope. - * - * Each scope has an assigned priority, where factories registered with - * higher priority scopes override ones with lower priority. - */ -export class ApiFactoryRegistry implements ApiFactoryHolder { - private readonly factories = new Map(); - - /** - * Register a new API factory. Returns true if the factory was added - * to the registry. - * - * A factory will not be added to the registry if there is already - * an existing factory with the same or higher priority. - */ - register( - scope: ApiFactoryScope, - factory: ApiFactory, - ) { - const priority = ScopePriority[scope]; - const existing = this.factories.get(factory.api.id); - if (existing && existing.priority >= priority) { - return false; - } - - this.factories.set(factory.api.id, { priority, factory }); - return true; - } - - get( - api: ApiRef, - ): ApiFactory | undefined { - const tuple = this.factories.get(api.id); - if (!tuple) { - return undefined; - } - return tuple.factory as ApiFactory; - } - - getAllApis(): Set { - const refs = new Set(); - for (const { factory } of this.factories.values()) { - refs.add(factory.api); - } - return refs; - } -} diff --git a/packages/core-api/src/apis/system/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx deleted file mode 100644 index a212f253ce..0000000000 --- a/packages/core-api/src/apis/system/ApiProvider.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/* - * 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, { Context, useContext } from 'react'; -import { ApiProvider, useApi, withApis } from './ApiProvider'; -import { createApiRef } from './ApiRef'; -import { ApiRegistry } from './ApiRegistry'; -import { render } from '@testing-library/react'; -import { withLogCollector } from '@backstage/test-utils-core'; -import { getGlobalSingleton } from '../../lib/globalObject'; -import { ApiHolder, ApiRef } from './types'; -import { VersionedValue } from '../../lib/versionedValues'; - -describe('ApiProvider', () => { - type Api = () => string; - const apiRef = createApiRef({ id: 'x', description: '' }); - const registry = ApiRegistry.from([[apiRef, () => 'hello']]); - - const MyHookConsumer = () => { - const api = useApi(apiRef); - return

hook message: {api()}

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

hoc message: {getMessage()}

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

hoc message: {getMessage()}

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

hook message: {api()}

; - }; - - it('should provide apis', () => { - const renderedHook = render( - - - , - ); - renderedHook.getByText('hook message: hello'); - }); -}); diff --git a/packages/core-api/src/apis/system/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx deleted file mode 100644 index 40e3cddb72..0000000000 --- a/packages/core-api/src/apis/system/ApiProvider.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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, { - createContext, - useContext, - ReactNode, - PropsWithChildren, - Context, - useMemo, -} from 'react'; -import PropTypes from 'prop-types'; -import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; -import { ApiAggregator } from './ApiAggregator'; -import { - getGlobalSingleton, - getOrCreateGlobalSingleton, -} from '../../lib/globalObject'; -import { - VersionedValue, - createVersionedValueMap, -} from '../../lib/versionedValues'; - -const missingHolderMessage = - 'No ApiProvider available in react context. ' + - 'A common cause of this error is that multiple versions of @backstage/core-api are installed. ' + - `You can check if that is the case using 'yarn backstage-cli versions:check', and can in many cases ` + - `fix the issue either with the --fix flag or using 'yarn backstage-cli versions:bump'`; - -type ApiProviderProps = { - apis: ApiHolder; - children: ReactNode; -}; - -type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined; -const ApiContext = getOrCreateGlobalSingleton('api-context', () => - createContext(undefined), -); - -export const ApiProvider = ({ - apis, - children, -}: PropsWithChildren) => { - const parentHolder = useContext(ApiContext)?.atVersion(1); - const versionedValue = useMemo( - () => - createVersionedValueMap({ - 1: parentHolder ? new ApiAggregator(apis, parentHolder) : apis, - }), - [parentHolder, apis], - ); - - return ; -}; - -ApiProvider.propTypes = { - apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired, - children: PropTypes.node, -}; - -export function useApiHolder(): ApiHolder { - const versionedHolder = useContext( - getGlobalSingleton>('api-context'), - ); - - if (!versionedHolder) { - throw new Error(missingHolderMessage); - } - - const apiHolder = versionedHolder.atVersion(1); - if (!apiHolder) { - throw new Error('ApiContext v1 not available'); - } - - return apiHolder; -} - -export function useApi(apiRef: ApiRef): T { - const apiHolder = useApiHolder(); - - const api = apiHolder.get(apiRef); - if (!api) { - throw new Error(`No implementation available for ${apiRef}`); - } - return api; -} - -export function withApis(apis: TypesToApiRefs) { - return function withApisWrapper

( - WrappedComponent: React.ComponentType

, - ) { - const Hoc = (props: PropsWithChildren>) => { - const apiHolder = useApiHolder(); - - const impls = {} as T; - - for (const key in apis) { - if (apis.hasOwnProperty(key)) { - const ref = apis[key]; - - const api = apiHolder.get(ref); - if (!api) { - throw new Error(`No implementation available for ${ref}`); - } - impls[key] = api; - } - } - - return ; - }; - const displayName = - WrappedComponent.displayName || WrappedComponent.name || 'Component'; - - Hoc.displayName = `withApis(${displayName})`; - - return Hoc; - }; -} diff --git a/packages/core-api/src/apis/system/ApiRef.test.ts b/packages/core-api/src/apis/system/ApiRef.test.ts deleted file mode 100644 index 7cd778634c..0000000000 --- a/packages/core-api/src/apis/system/ApiRef.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 { createApiRef } from './ApiRef'; - -describe('ApiRef', () => { - it('should be created', () => { - const ref = createApiRef({ id: 'abc', description: '123' }); - expect(ref.id).toBe('abc'); - expect(ref.description).toBe('123'); - expect(String(ref)).toBe('apiRef{abc}'); - expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); - }); - - it('should reject invalid ids', () => { - for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) { - expect(createApiRef({ id, description: '123' }).id).toBe(id); - } - - for (const id of [ - '123', - 'ab-3', - 'ab_c', - '.', - '2ac', - 'ab.3a', - '.abc', - 'abc.', - 'ab..s', - '', - '_', - ]) { - expect(() => createApiRef({ id, description: '123' }).id).toThrow( - `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, - ); - } - }); -}); diff --git a/packages/core-api/src/apis/system/ApiRef.ts b/packages/core-api/src/apis/system/ApiRef.ts deleted file mode 100644 index 5a774474d8..0000000000 --- a/packages/core-api/src/apis/system/ApiRef.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 type { ApiRef } from './types'; - -export type ApiRefConfig = { - id: string; - description?: string; -}; - -class ApiRefImpl implements ApiRef { - constructor(private readonly config: ApiRefConfig) { - const valid = config.id - .split('.') - .flatMap(part => part.split('-')) - .every(part => part.match(/^[a-z][a-z0-9]*$/)); - if (!valid) { - throw new Error( - `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, - ); - } - } - - get id(): string { - return this.config.id; - } - - get description() { - return this.config.description; - } - - // Utility for getting type of an api, using `typeof apiRef.T` - get T(): T { - throw new Error(`tried to read ApiRef.T of ${this}`); - } - - toString() { - return `apiRef{${this.config.id}}`; - } -} - -export function createApiRef(config: ApiRefConfig): ApiRef { - return new ApiRefImpl(config); -} diff --git a/packages/core-api/src/apis/system/ApiRegistry.test.ts b/packages/core-api/src/apis/system/ApiRegistry.test.ts deleted file mode 100644 index 66fcb70262..0000000000 --- a/packages/core-api/src/apis/system/ApiRegistry.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 { ApiRegistry } from './ApiRegistry'; -import { createApiRef } from './ApiRef'; - -describe('ApiRegistry', () => { - const x1Ref = createApiRef({ id: 'x1', description: '' }); - const x1DuplicateRef = createApiRef({ id: 'x1', description: '' }); - const x2Ref = createApiRef({ id: 'x2', description: '' }); - - it('should be created', () => { - const registry = ApiRegistry.from([]); - expect(registry.get(x1Ref)).toBe(undefined); - }); - - it('should be created with APIs', () => { - const registry = ApiRegistry.from([ - [x1Ref, 3], - [x2Ref, 'y'], - ]); - expect(registry.get(x1Ref)).toBe(3); - expect(registry.get(x1DuplicateRef)).toBe(3); - expect(registry.get(x2Ref)).toBe('y'); - }); - - it('should be built', () => { - const registry = ApiRegistry.builder().build(); - expect(registry.get(x1Ref)).toBe(undefined); - expect(registry.get(x1DuplicateRef)).toBe(undefined); - }); - - it('should be built with APIs', () => { - const builder = ApiRegistry.builder(); - builder.add(x1Ref, 3); - builder.add(x2Ref, 'y'); - - const registry = builder.build(); - expect(registry.get(x1Ref)).toBe(3); - expect(registry.get(x1DuplicateRef)).toBe(3); - expect(registry.get(x2Ref)).toBe('y'); - }); - - it('should be created with API', () => { - const reg1 = ApiRegistry.with(x1Ref, 3); - const reg2 = reg1.with(x2Ref, 'y'); - const reg3 = reg2.with(x2Ref, 'z'); - const reg4 = reg3.with(x1Ref, 2); - const reg5 = reg3.with(x1DuplicateRef, 4); - - expect(reg1.get(x1Ref)).toBe(3); - expect(reg1.get(x2Ref)).toBe(undefined); - expect(reg2.get(x1Ref)).toBe(3); - expect(reg2.get(x2Ref)).toBe('y'); - expect(reg3.get(x1Ref)).toBe(3); - expect(reg3.get(x2Ref)).toBe('z'); - expect(reg4.get(x1Ref)).toBe(2); - expect(reg4.get(x2Ref)).toBe('z'); - expect(reg5.get(x1Ref)).toBe(4); - expect(reg5.get(x2Ref)).toBe('z'); - }); -}); diff --git a/packages/core-api/src/apis/system/ApiRegistry.ts b/packages/core-api/src/apis/system/ApiRegistry.ts deleted file mode 100644 index 6fcfe03f62..0000000000 --- a/packages/core-api/src/apis/system/ApiRegistry.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 { ApiRef, ApiHolder } from './types'; - -type ApiImpl = readonly [ApiRef, T]; - -class ApiRegistryBuilder { - private apis: [string, unknown][] = []; - - add(api: ApiRef, impl: I): I { - this.apis.push([api.id, impl]); - return impl; - } - - build(): ApiRegistry { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new ApiRegistry(new Map(this.apis)); - } -} - -export class ApiRegistry implements ApiHolder { - static builder() { - return new ApiRegistryBuilder(); - } - - static from(apis: ApiImpl[]) { - return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl]))); - } - - /** - * Creates a new ApiRegistry with a single API implementation. - * - * @param api ApiRef for the API to add - * @param impl Implementation of the API to add - */ - static with(api: ApiRef, impl: T): ApiRegistry { - return new ApiRegistry(new Map([[api.id, impl]])); - } - - constructor(private readonly apis: Map) {} - - /** - * Returns a new ApiRegistry with the provided API added to the existing ones. - * - * @param api ApiRef for the API to add - * @param impl Implementation of the API to add - */ - with(api: ApiRef, impl: T): ApiRegistry { - return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); - } - - get(api: ApiRef): T | undefined { - return this.apis.get(api.id) as T | undefined; - } -} diff --git a/packages/core-api/src/apis/system/ApiResolver.test.ts b/packages/core-api/src/apis/system/ApiResolver.test.ts deleted file mode 100644 index 064a4f8d77..0000000000 --- a/packages/core-api/src/apis/system/ApiResolver.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - * 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 { ApiResolver } from './ApiResolver'; -import { createApiRef } from './ApiRef'; -import { ApiFactoryRegistry } from './ApiFactoryRegistry'; - -const aRef = createApiRef({ id: 'a', description: '' }); -const otherARef = createApiRef({ id: 'a', description: 'other' }); -const bRef = createApiRef({ id: 'b', description: '' }); -const otherBRef = createApiRef({ id: 'b', description: 'other' }); -const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' }); -const otherCRef = createApiRef<{ x: string }>({ - id: 'c', - description: 'other', -}); - -function createRegistry() { - const registry = new ApiFactoryRegistry(); - registry.register('default', { - api: aRef, - deps: {}, - factory: () => 1, - }); - registry.register('default', { - api: bRef, - deps: {}, - factory: () => 'b', - }); - registry.register('default', { - api: cRef, - deps: { b: otherBRef }, - factory: ({ b }) => ({ x: 'x', b }), - }); - return registry; -} - -function createSelfCyclicRegistry() { - const registry = new ApiFactoryRegistry(); - registry.register('default', { - api: aRef, - deps: { a: aRef }, - factory: () => 1, - }); - return registry; -} - -function createShortCyclicRegistry() { - const registry = new ApiFactoryRegistry(); - registry.register('default', { - api: aRef, - deps: { b: bRef }, - factory: () => 1, - }); - registry.register('default', { - api: bRef, - deps: { a: aRef }, - factory: () => 'x', - }); - return registry; -} - -function createShortCyclicRegistryWithOther() { - const registry = new ApiFactoryRegistry(); - registry.register('default', { - api: aRef, - deps: { b: bRef }, - factory: () => 1, - }); - registry.register('default', { - api: otherBRef, - deps: { a: otherARef }, - factory: () => 'x', - }); - return registry; -} - -function createLongCyclicRegistry() { - const registry = new ApiFactoryRegistry(); - registry.register('default', { - api: aRef, - deps: { b: otherBRef }, - factory: () => 1, - }); - registry.register('default', { - api: bRef, - deps: { c: cRef }, - factory: () => 'b', - }); - registry.register('default', { - api: cRef, - deps: { a: aRef }, - factory: () => ({ x: 'x' }), - }); - return registry; -} - -describe('ApiResolver', () => { - it('should be created empty', () => { - const resolver = new ApiResolver(new ApiFactoryRegistry()); - expect(resolver.get(aRef)).toBe(undefined); - expect(resolver.get(bRef)).toBe(undefined); - expect(resolver.get(otherBRef)).toBe(undefined); - expect(resolver.get(cRef)).toBe(undefined); - }); - - it('should instantiate APIs', () => { - const resolver = new ApiResolver(createRegistry()); - expect(resolver.get(aRef)).toBe(1); - expect(resolver.get(otherARef)).toBe(1); - expect(resolver.get(bRef)).toBe('b'); - expect(resolver.get(otherBRef)).toBe('b'); - expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); - expect(resolver.get(cRef)).toBe(resolver.get(otherCRef)); - }); - - it('should detect self dependency cycles', () => { - const resolver = new ApiResolver(createSelfCyclicRegistry()); - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - }); - - it('should detect short dependency cycles', () => { - const resolver = new ApiResolver(createShortCyclicRegistry()); - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => resolver.get(bRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - }); - - it('should detect short dependency cycles with other refs', () => { - const resolver = new ApiResolver(createShortCyclicRegistryWithOther()); - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => resolver.get(bRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => resolver.get(otherARef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => resolver.get(otherBRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - }); - - it('should detect long dependency cycles', () => { - const resolver = new ApiResolver(createLongCyclicRegistry()); - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - // Second call for same ref should still throw - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => resolver.get(bRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => resolver.get(otherBRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => resolver.get(cRef)).toThrow( - 'Circular dependency of api factory for apiRef{c}', - ); - }); - - it('should validate a factory holder', () => { - expect(() => { - ApiResolver.validateFactories(createRegistry(), [ - aRef, - bRef, - otherBRef, - cRef, - ]); - }).not.toThrow(); - }); - - it('should find self cycles with validation', () => { - const self = createSelfCyclicRegistry(); - expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - }); - - it('should find dependency cycles with validation', () => { - const short = createShortCyclicRegistry(); - expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - - const shortOther = createShortCyclicRegistryWithOther(); - expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => - ApiResolver.validateFactories(shortOther, [otherARef]), - ).toThrow('Circular dependency of api factory for apiRef{a}'); - expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => - ApiResolver.validateFactories(shortOther, [otherBRef]), - ).toThrow('Circular dependency of api factory for apiRef{b}'); - - const long = createLongCyclicRegistry(); - expect(() => - ApiResolver.validateFactories(long, long.getAllApis()), - ).toThrow('Circular dependency of api factory for apiRef{a}'); - expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( - 'Circular dependency of api factory for apiRef{c}', - ); - }); - - it('should only call factory func once', () => { - const registry = new ApiFactoryRegistry(); - const factory = jest.fn().mockReturnValue(2); - registry.register('default', { - api: aRef, - deps: {}, - factory, - }); - - const resolver = new ApiResolver(registry); - expect(factory).toHaveBeenCalledTimes(0); - expect(resolver.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - expect(resolver.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - expect(resolver.get(otherARef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/core-api/src/apis/system/ApiResolver.ts b/packages/core-api/src/apis/system/ApiResolver.ts deleted file mode 100644 index 4d69067b43..0000000000 --- a/packages/core-api/src/apis/system/ApiResolver.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 { - ApiRef, - ApiHolder, - ApiFactoryHolder, - AnyApiRef, - TypesToApiRefs, -} from './types'; - -export class ApiResolver implements ApiHolder { - /** - * Validate factories by making sure that each of the apis can be created - * without hitting any circular dependencies. - */ - static validateFactories( - factories: ApiFactoryHolder, - apis: Iterable, - ) { - for (const api of apis) { - const heap = [api]; - const allDeps = new Set(); - - while (heap.length) { - const apiRef = heap.shift()!; - const factory = factories.get(apiRef); - if (!factory) { - continue; - } - - for (const dep of Object.values(factory.deps)) { - if (dep.id === api.id) { - throw new Error(`Circular dependency of api factory for ${api}`); - } - if (!allDeps.has(dep)) { - allDeps.add(dep); - heap.push(dep); - } - } - } - } - } - - private readonly apis = new Map(); - - constructor(private readonly factories: ApiFactoryHolder) {} - - get(ref: ApiRef): T | undefined { - return this.load(ref); - } - - private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { - const impl = this.apis.get(ref.id); - if (impl) { - return impl as T; - } - - const factory = this.factories.get(ref); - if (!factory) { - return undefined; - } - - if (loading.includes(factory.api)) { - throw new Error(`Circular dependency of api factory for ${factory.api}`); - } - - const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); - const api = factory.factory(deps); - this.apis.set(ref.id, api); - return api as T; - } - - private loadDeps( - dependent: ApiRef, - apis: TypesToApiRefs, - loading: AnyApiRef[], - ): T { - const impls = {} as T; - - for (const key in apis) { - if (apis.hasOwnProperty(key)) { - const ref = apis[key]; - - const api = this.load(ref, loading); - if (!api) { - throw new Error( - `No API factory available for dependency ${ref} of dependent ${dependent}`, - ); - } - impls[key] = api; - } - } - - return impls; - } -} diff --git a/packages/core-api/src/apis/system/helpers.ts b/packages/core-api/src/apis/system/helpers.ts deleted file mode 100644 index 8e84dd6c09..0000000000 --- a/packages/core-api/src/apis/system/helpers.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 { ApiRef, ApiFactory, TypesToApiRefs } from './types'; - -/** - * Used to infer types for a standalone ApiFactory that isn't immediately passed - * to another function. - * This function doesn't actually do anything, it's only used to infer types. - */ -export function createApiFactory< - Api, - Impl extends Api, - Deps extends { [name in string]: unknown } ->(factory: ApiFactory): ApiFactory; -export function createApiFactory( - api: ApiRef, - instance: Impl, -): ApiFactory; -export function createApiFactory< - Api, - Impl extends Api, - Deps extends { [name in string]: unknown } ->( - factory: ApiFactory | ApiRef, - instance?: Impl, -): ApiFactory { - if ('id' in factory) { - return { - api: factory, - deps: {} as TypesToApiRefs, - factory: () => instance!, - }; - } - return factory; -} diff --git a/packages/core-api/src/apis/system/index.ts b/packages/core-api/src/apis/system/index.ts deleted file mode 100644 index c9b9fac936..0000000000 --- a/packages/core-api/src/apis/system/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { ApiProvider, useApi, useApiHolder } from './ApiProvider'; -export { ApiRegistry } from './ApiRegistry'; -export { ApiResolver } from './ApiResolver'; -export { ApiFactoryRegistry } from './ApiFactoryRegistry'; -export { createApiRef } from './ApiRef'; -export * from './types'; -export * from './helpers'; diff --git a/packages/core-api/src/apis/system/types.ts b/packages/core-api/src/apis/system/types.ts deleted file mode 100644 index 17ceff4649..0000000000 --- a/packages/core-api/src/apis/system/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 type ApiRef = { - id: string; - description?: string; - T: T; -}; - -export type AnyApiRef = ApiRef; - -export type ApiRefType = T extends ApiRef ? U : never; - -export type TypesToApiRefs = { [key in keyof T]: ApiRef }; - -export type ApiRefsToTypes }> = { - [key in keyof T]: ApiRefType; -}; - -export type ApiHolder = { - get(api: ApiRef): T | undefined; -}; - -export type ApiFactory< - Api, - Impl extends Api, - Deps extends { [name in string]: unknown } -> = { - api: ApiRef; - deps: TypesToApiRefs; - factory(deps: Deps): Impl; -}; - -export type AnyApiFactory = ApiFactory< - unknown, - unknown, - { [key in string]: unknown } ->; - -export type ApiFactoryHolder = { - get( - api: ApiRef, - ): ApiFactory | undefined; -}; diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx deleted file mode 100644 index 5404c111e4..0000000000 --- a/packages/core-api/src/app/App.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -/* - * 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 { - configApiRef, - createApiFactory, - featureFlagsApiRef, - LocalStorageFeatureFlags, -} from '../apis'; -import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { render, screen } from '@testing-library/react'; -import React, { PropsWithChildren } from 'react'; -import { BrowserRouter, Routes } from 'react-router-dom'; -import { createRoutableExtension } from '../extensions'; -import { defaultSystemIcons } from '../icons'; -import { createPlugin } from '../plugin'; -import { useRouteRef } from '../routing/hooks'; -import { - createExternalRouteRef, - createRouteRef, - createSubRouteRef, -} from '../routing'; -import { generateBoundRoutes, PrivateAppImpl } from './App'; - -describe('generateBoundRoutes', () => { - it('runs happy path', () => { - const external = { myRoute: createExternalRouteRef({ id: '1' }) }; - const ref = createRouteRef({ id: 'ref-1' }); - const result = generateBoundRoutes(({ bind }) => { - bind(external, { myRoute: ref }); - }); - - expect(result.get(external.myRoute)).toBe(ref); - }); - - it('throws on unknown keys', () => { - const external = { myRoute: createExternalRouteRef({ id: '2' }) }; - const ref = createRouteRef({ id: 'ref-2' }); - expect(() => - generateBoundRoutes(({ bind }) => { - bind(external, { someOtherRoute: ref } as any); - }), - ).toThrow('Key someOtherRoute is not an existing external route'); - }); -}); - -describe('Integration Test', () => { - const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); - const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); - const subRouteRef1 = createSubRouteRef({ - id: 'sub1', - path: '/sub1', - parent: plugin1RouteRef, - }); - const subRouteRef2 = createSubRouteRef({ - id: 'sub2', - path: '/sub2/:x', - parent: plugin1RouteRef, - }); - const subRouteRef3 = createSubRouteRef({ - id: 'sub3', - path: '/sub3', - parent: plugin2RouteRef, - }); - const subRouteRef4 = createSubRouteRef({ - id: 'sub4', - path: '/sub4/:y', - parent: plugin2RouteRef, - }); - const extRouteRef1 = createExternalRouteRef({ id: 'extRouteRef1' }); - const extRouteRef2 = createExternalRouteRef({ - id: 'extRouteRef2', - params: ['x'], - }); - const extRouteRef3 = createExternalRouteRef({ - id: 'extRouteRef3', - optional: true, - }); - const extRouteRef4 = createExternalRouteRef({ - id: 'extRouteRef4', - optional: true, - params: ['x'], - }); - - const plugin1 = createPlugin({ - id: 'blob', - // Both absolute and sub route refs should be assignable to the plugin routes - routes: { - ref1: plugin1RouteRef, - ref2: plugin2RouteRef, - ref3: subRouteRef1, - }, - externalRoutes: { - extRouteRef1, - extRouteRef2, - extRouteRef3, - extRouteRef4, - }, - }); - - const plugin2 = createPlugin({ - id: 'plugin2', - }); - - const HiddenComponent = plugin2.provide( - createRoutableExtension({ - component: () => Promise.resolve((_: { path?: string }) =>

), - mountPoint: plugin2RouteRef, - }), - ); - - const ExposedComponent = plugin1.provide( - createRoutableExtension({ - component: () => - Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { - const link1 = useRouteRef(plugin1RouteRef); - const link2 = useRouteRef(plugin2RouteRef); - const subLink1 = useRouteRef(subRouteRef1); - const subLink2 = useRouteRef(subRouteRef2); - const subLink3 = useRouteRef(subRouteRef3); - const subLink4 = useRouteRef(subRouteRef4); - const extLink1 = useRouteRef(extRouteRef1); - const extLink2 = useRouteRef(extRouteRef2); - const extLink3 = useRouteRef(extRouteRef3); - const extLink4 = useRouteRef(extRouteRef4); - return ( -
- link1: {link1()} - link2: {link2({ x: 'a' })} - subLink1: {subLink1()} - subLink2: {subLink2({ x: 'a' })} - subLink3: {subLink3({ x: 'b' })} - subLink4: {subLink4({ x: 'c', y: 'd' })} - extLink1: {extLink1()} - extLink2: {extLink2({ x: 'a' })} - extLink3: {extLink3?.() ?? ''} - extLink4: {extLink4?.({ x: 'b' }) ?? ''} -
- ); - }), - mountPoint: plugin1RouteRef, - }), - ); - - const components = { - NotFoundErrorPage: () => null, - BootErrorPage: () => null, - Progress: () => null, - Router: BrowserRouter, - }; - - it('runs happy paths', async () => { - const app = new PrivateAppImpl({ - apis: [], - defaultApis: [], - themes: [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - }, - ], - icons: defaultSystemIcons, - plugins: [], - components, - bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { - extRouteRef1: plugin1RouteRef, - extRouteRef2: plugin2RouteRef, - extRouteRef3: subRouteRef1, - extRouteRef4: plugin2RouteRef, - }); - }, - }); - - const Provider = app.getProvider(); - const Router = app.getRouter(); - - await renderWithEffects( - - - - - - - - , - ); - - expect(screen.getByText('link1: /')).toBeInTheDocument(); - expect(screen.getByText('link2: /foo/a')).toBeInTheDocument(); - expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument(); - expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument(); - expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument(); - expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument(); - expect(screen.getByText('extLink1: /')).toBeInTheDocument(); - expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument(); - expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument(); - expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument(); - - // Plugins should be discovered through element tree - expect(app.getPlugins()).toEqual([plugin1, plugin2]); - }); - - it('runs happy paths without optional routes', async () => { - const app = new PrivateAppImpl({ - apis: [], - defaultApis: [], - themes: [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - }, - ], - icons: defaultSystemIcons, - plugins: [], - components, - bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { - extRouteRef1: plugin1RouteRef, - extRouteRef2: plugin2RouteRef, - }); - }, - }); - - const Provider = app.getProvider(); - const Router = app.getRouter(); - - await renderWithEffects( - - - - - - - - , - ); - - expect(screen.getByText('extLink1: /')).toBeInTheDocument(); - expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); - expect(screen.getByText('extLink3: ')).toBeInTheDocument(); - expect(screen.getByText('extLink4: ')).toBeInTheDocument(); - }); - - it('should wait for the config to load before calling feature flags', async () => { - const storageFlags = new LocalStorageFeatureFlags(); - jest.spyOn(storageFlags, 'registerFlag'); - - const apis = [ - createApiFactory({ - api: featureFlagsApiRef, - deps: { configApi: configApiRef }, - factory() { - return storageFlags; - }, - }), - ]; - - const app = new PrivateAppImpl({ - apis, - defaultApis: [], - themes: [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - }, - ], - icons: defaultSystemIcons, - plugins: [ - createPlugin({ - id: 'test', - register: p => p.featureFlags.register('name'), - }), - ], - components, - bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { - extRouteRef1: plugin1RouteRef, - extRouteRef2: plugin2RouteRef, - }); - }, - }); - - const Provider = app.getProvider(); - const Router = app.getRouter(); - - await renderWithEffects( - - - - - - - - , - ); - - expect(storageFlags.registerFlag).toHaveBeenCalledWith({ - name: 'name', - pluginId: 'test', - }); - }); - - it('should throw some error when the route has duplicate params', () => { - const app = new PrivateAppImpl({ - apis: [], - defaultApis: [], - themes: [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - }, - ], - icons: defaultSystemIcons, - plugins: [], - components, - bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { - extRouteRef1: plugin1RouteRef, - extRouteRef2: plugin2RouteRef, - }); - }, - }); - - const Provider = app.getProvider(); - const Router = app.getRouter(); - const { error: errorLogs } = withLogCollector(() => { - expect(() => - render( - - - - - - - - - , - ), - ).toThrow( - 'Parameter :thing is duplicated in path /test/:thing/some/:thing', - ); - }); - expect(errorLogs).toEqual([ - expect.stringContaining( - 'Parameter :thing is duplicated in path /test/:thing/some/:thing', - ), - expect.stringContaining( - 'The above error occurred in the component', - ), - ]); - }); -}); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx deleted file mode 100644 index 3a5e151466..0000000000 --- a/packages/core-api/src/app/App.tsx +++ /dev/null @@ -1,525 +0,0 @@ -/* - * 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 { Config } from '@backstage/config'; -import React, { - ComponentType, - PropsWithChildren, - ReactElement, - useEffect, - useMemo, - useState, -} from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { - AnyApiFactory, - ApiHolder, - ApiProvider, - ApiRegistry, - AppTheme, - appThemeApiRef, - AppThemeSelector, - configApiRef, - ConfigReader, - LocalStorageFeatureFlags, - useApi, -} from '../apis'; -import { - AppThemeApi, - ConfigApi, - featureFlagsApiRef, - identityApiRef, -} from '../apis/definitions'; -import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; -import { - childDiscoverer, - routeElementDiscoverer, - traverseElementTree, -} from '../extensions/traversal'; -import { IconComponent, IconComponentMap, IconKey } from '../icons'; -import { BackstagePlugin } from '../plugin'; -import { pluginCollector } from '../plugin/collectors'; -import { AnyRoutes } from '../plugin/types'; -import { RouteRef, ExternalRouteRef, SubRouteRef } from '../routing'; -import { - routeObjectCollector, - routeParentCollector, - routePathCollector, -} from '../routing/collectors'; -import { RoutingProvider } from '../routing/hooks'; -import { validateRoutes } from '../routing/validation'; -import { AppContextProvider } from './AppContext'; -import { AppIdentity } from './AppIdentity'; -import { AppThemeProvider } from './AppThemeProvider'; -import { - AppComponents, - AppConfigLoader, - AppContext, - AppOptions, - AppRouteBinder, - BackstageApp, - SignInPageProps, - SignInResult, -} from './types'; - -export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) { - const result = new Map(); - - if (bindRoutes) { - const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => { - for (const [key, value] of Object.entries(targetRoutes)) { - const externalRoute = externalRoutes[key]; - if (!externalRoute) { - throw new Error(`Key ${key} is not an existing external route`); - } - if (!value && !externalRoute.optional) { - throw new Error( - `External route ${key} is required but was undefined`, - ); - } - if (value) { - result.set(externalRoute, value); - } - } - }; - bindRoutes({ bind }); - } - - return result; -} - -type FullAppOptions = { - apis: Iterable; - icons: IconComponentMap; - plugins: BackstagePlugin[]; - components: AppComponents; - themes: AppTheme[]; - configLoader?: AppConfigLoader; - defaultApis: Iterable; - bindRoutes?: AppOptions['bindRoutes']; -}; - -function useConfigLoader( - configLoader: AppConfigLoader | undefined, - components: AppComponents, - appThemeApi: AppThemeApi, -): { api: ConfigApi } | { node: JSX.Element } { - // Keeping this synchronous when a config loader isn't set simplifies tests a lot - const hasConfig = Boolean(configLoader); - const config = useAsync(configLoader || (() => Promise.resolve([]))); - - let noConfigNode = undefined; - - if (hasConfig && config.loading) { - const { Progress } = components; - noConfigNode = ; - } else if (config.error) { - const { BootErrorPage } = components; - noConfigNode = ; - } - - // Before the config is loaded we can't use a router, so exit early - if (noConfigNode) { - return { - node: ( - - {noConfigNode} - - ), - }; - } - - const configReader = ConfigReader.fromConfigs(config.value ?? []); - - return { api: configReader }; -} - -class AppContextImpl implements AppContext { - constructor(private readonly app: PrivateAppImpl) {} - - getPlugins(): BackstagePlugin[] { - // eslint-disable-next-line no-console - console.warn('appContext.getPlugins() is deprecated and will be removed'); - return this.app.getPlugins(); - } - - getSystemIcon(key: IconKey): IconComponent | undefined { - return this.app.getSystemIcon(key); - } - - getComponents(): AppComponents { - return this.app.getComponents(); - } - - getProvider(): React.ComponentType<{}> { - // eslint-disable-next-line no-console - console.warn('appContext.getProvider() is deprecated and will be removed'); - return this.app.getProvider(); - } - - getRouter(): React.ComponentType<{}> { - // eslint-disable-next-line no-console - console.warn('appContext.getRouter() is deprecated and will be removed'); - return this.app.getRouter(); - } - - getRoutes(): JSX.Element[] { - // eslint-disable-next-line no-console - console.warn('appContext.getRoutes() is deprecated and will be removed'); - return this.app.getRoutes(); - } -} - -export class PrivateAppImpl implements BackstageApp { - private apiHolder?: ApiHolder; - private configApi?: ConfigApi; - - private readonly apis: Iterable; - private readonly icons: IconComponentMap; - private readonly plugins: Set>; - private readonly components: AppComponents; - private readonly themes: AppTheme[]; - private readonly configLoader?: AppConfigLoader; - private readonly defaultApis: Iterable; - private readonly bindRoutes: AppOptions['bindRoutes']; - - private readonly identityApi = new AppIdentity(); - - constructor(options: FullAppOptions) { - this.apis = options.apis; - this.icons = options.icons; - this.plugins = new Set(options.plugins); - this.components = options.components; - this.themes = options.themes; - this.configLoader = options.configLoader; - this.defaultApis = options.defaultApis; - this.bindRoutes = options.bindRoutes; - } - - getPlugins(): BackstagePlugin[] { - return Array.from(this.plugins); - } - - getSystemIcon(key: IconKey): IconComponent | undefined { - return this.icons[key]; - } - - getComponents(): AppComponents { - return this.components; - } - - getRoutes(): JSX.Element[] { - const routes = new Array(); - - const { NotFoundErrorPage } = this.components; - - for (const plugin of this.plugins.values()) { - for (const output of plugin.output()) { - switch (output.type) { - case 'legacy-route': { - const { path, component: Component } = output; - routes.push( - } />, - ); - break; - } - case 'route': { - const { target, component: Component } = output; - routes.push( - } - />, - ); - break; - } - case 'legacy-redirect-route': { - const { path, target } = output; - routes.push(); - break; - } - case 'redirect-route': { - const { from, to } = output; - routes.push(); - break; - } - default: - break; - } - } - } - - routes.push( - } - />, - ); - - return routes; - } - - getProvider(): ComponentType<{}> { - const appContext = new AppContextImpl(this); - - const Provider = ({ children }: PropsWithChildren<{}>) => { - const appThemeApi = useMemo( - () => AppThemeSelector.createWithStorage(this.themes), - [], - ); - - const { routePaths, routeParents, routeObjects } = useMemo(() => { - const result = traverseElementTree({ - root: children, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - collectedPlugins: pluginCollector, - }, - }); - - validateRoutes(result.routePaths, result.routeParents); - - // TODO(Rugvip): Restructure the public API so that we can get an immediate view of - // the app, rather than having to wait for the provider to render. - // For now we need to push the additional plugins we find during - // collection and then make sure we initialize things afterwards. - result.collectedPlugins.forEach(plugin => this.plugins.add(plugin)); - this.verifyPlugins(this.plugins); - - // Initialize APIs once all plugins are available - this.getApiHolder(); - - return result; - }, [children]); - - const loadedConfig = useConfigLoader( - this.configLoader, - this.components, - appThemeApi, - ); - - const hasConfigApi = 'api' in loadedConfig; - if (hasConfigApi) { - const { api } = loadedConfig as { api: Config }; - this.configApi = api; - } - - useEffect(() => { - if (hasConfigApi) { - const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; - - for (const plugin of this.plugins.values()) { - for (const output of plugin.output()) { - switch (output.type) { - case 'feature-flag': { - featureFlagsApi.registerFlag({ - name: output.name, - pluginId: plugin.getId(), - }); - break; - } - default: - break; - } - } - } - } - }, [hasConfigApi, loadedConfig]); - - if ('node' in loadedConfig) { - // Loading or error - return loadedConfig.node; - } - - return ( - - - - - {children} - - - - - ); - }; - return Provider; - } - - getRouter(): ComponentType<{}> { - const { - Router: RouterComponent, - SignInPage: SignInPageComponent, - } = this.components; - - // This wraps the sign-in page and waits for sign-in to be completed before rendering the app - const SignInPageWrapper = ({ - component: Component, - children, - }: { - component: ComponentType; - children: ReactElement; - }) => { - const [result, setResult] = useState(); - - if (result) { - this.identityApi.setSignInResult(result); - return children; - } - - return ; - }; - - const AppRouter = ({ children }: PropsWithChildren<{}>) => { - const configApi = useApi(configApiRef); - - let { pathname } = new URL( - configApi.getOptionalString('app.baseUrl') ?? '/', - 'http://dummy.dev', // baseUrl can be specified as just a path - ); - if (pathname.endsWith('/')) { - pathname = pathname.replace(/\/$/, ''); - } - - // If the app hasn't configured a sign-in page, we just continue as guest. - if (!SignInPageComponent) { - this.identityApi.setSignInResult({ - userId: 'guest', - profile: { - email: 'guest@example.com', - displayName: 'Guest', - }, - }); - - return ( - - - {children}} /> - - - ); - } - - return ( - - - - {children}} /> - - - - ); - }; - - return AppRouter; - } - - private getApiHolder(): ApiHolder { - if (this.apiHolder) { - return this.apiHolder; - } - - const registry = new ApiFactoryRegistry(); - - registry.register('static', { - api: appThemeApiRef, - deps: {}, - factory: () => AppThemeSelector.createWithStorage(this.themes), - }); - registry.register('static', { - api: configApiRef, - deps: {}, - factory: () => { - if (!this.configApi) { - throw new Error( - 'Tried to access config API before config was loaded', - ); - } - return this.configApi; - }, - }); - registry.register('static', { - api: identityApiRef, - deps: {}, - factory: () => this.identityApi, - }); - - // It's possible to replace the feature flag API, but since we must have at least - // one implementation we add it here directly instead of through the defaultApis. - registry.register('default', { - api: featureFlagsApiRef, - deps: {}, - factory: () => new LocalStorageFeatureFlags(), - }); - for (const factory of this.defaultApis) { - registry.register('default', factory); - } - - for (const plugin of this.plugins) { - for (const factory of plugin.getApis()) { - if (!registry.register('default', factory)) { - throw new Error( - `Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${ - factory.api - }`, - ); - } - } - } - - for (const factory of this.apis) { - if (!registry.register('app', factory)) { - throw new Error( - `Duplicate or forbidden API factory for ${factory.api} in app`, - ); - } - } - - ApiResolver.validateFactories(registry, registry.getAllApis()); - - this.apiHolder = new ApiResolver(registry); - - return this.apiHolder; - } - - /** - * @deprecated - */ - verify() {} - - private verifyPlugins(plugins: Iterable) { - const pluginIds = new Set(); - - for (const plugin of plugins) { - const id = plugin.getId(); - if (pluginIds.has(id)) { - throw new Error(`Duplicate plugin found '${id}'`); - } - pluginIds.add(id); - } - } -} diff --git a/packages/core-api/src/app/AppContext.test.tsx b/packages/core-api/src/app/AppContext.test.tsx deleted file mode 100644 index 526b397130..0000000000 --- a/packages/core-api/src/app/AppContext.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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, Context } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { VersionedValue } from '../lib/versionedValues'; -import { getGlobalSingleton } from '../lib/globalObject'; -import { AppContext as AppContextV1 } from './types'; -import { AppContextProvider } from './AppContext'; - -describe('v1 consumer', () => { - const AppContext = getGlobalSingleton< - Context> - >('app-context'); - - function useMockAppV1(): AppContextV1 { - const impl = useContext(AppContext)?.atVersion(1); - if (!impl) { - throw new Error('no impl'); - } - return impl; - } - - it('should provide an app context', () => { - const mockContext: AppContextV1 = { - getComponents: jest.fn(), - getSystemIcon: jest.fn(), - getPlugins: jest.fn(), - getProvider: jest.fn(), - getRouter: jest.fn(), - getRoutes: jest.fn(), - }; - - const renderedHook = renderHook(() => useMockAppV1(), { - wrapper: ({ children }) => ( - - ), - }); - const result = renderedHook.result.current; - - expect(mockContext.getComponents).toHaveBeenCalledTimes(0); - result.getComponents(); - expect(mockContext.getComponents).toHaveBeenCalledTimes(1); - - expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(0); - result.getSystemIcon('icon'); - expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1); - expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon'); - - expect(mockContext.getPlugins).toHaveBeenCalledTimes(0); - result.getPlugins(); - expect(mockContext.getPlugins).toHaveBeenCalledTimes(1); - - expect(mockContext.getProvider).toHaveBeenCalledTimes(0); - result.getProvider(); - expect(mockContext.getProvider).toHaveBeenCalledTimes(1); - - expect(mockContext.getRouter).toHaveBeenCalledTimes(0); - result.getRouter(); - expect(mockContext.getRouter).toHaveBeenCalledTimes(1); - - expect(mockContext.getRoutes).toHaveBeenCalledTimes(0); - result.getRoutes(); - expect(mockContext.getRoutes).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/core-api/src/app/AppContext.tsx b/packages/core-api/src/app/AppContext.tsx deleted file mode 100644 index ab2d2d7861..0000000000 --- a/packages/core-api/src/app/AppContext.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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, { - createContext, - PropsWithChildren, - useContext, - Context, - useMemo, -} from 'react'; -import { - VersionedValue, - createVersionedValueMap, -} from '../lib/versionedValues'; -import { - getGlobalSingleton, - getOrCreateGlobalSingleton, -} from '../lib/globalObject'; -import { AppContext as AppContextV1 } from './types'; - -type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined; -const AppContext = getOrCreateGlobalSingleton('app-context', () => - createContext(undefined), -); - -type Props = { - appContext: AppContextV1; -}; - -export const AppContextProvider = ({ - appContext, - children, -}: PropsWithChildren) => { - const versionedValue = useMemo( - () => createVersionedValueMap({ 1: appContext }), - [appContext], - ); - - return ; -}; - -export const useApp = (): AppContextV1 => { - const versionedContext = useContext( - getGlobalSingleton>('app-context'), - ); - if (!versionedContext) { - throw new Error('No app context available'); - } - const appContext = versionedContext.atVersion(1); - if (!appContext) { - throw new Error('AppContext v1 not available'); - } - return appContext; -}; diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts deleted file mode 100644 index 7dd87de392..0000000000 --- a/packages/core-api/src/app/AppIdentity.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { IdentityApi, ProfileInfo } from '../apis'; -import { SignInResult } from './types'; - -/** - * Implementation of the connection between the App-wide IdentityApi - * and sign-in page. - */ -export class AppIdentity implements IdentityApi { - private hasIdentity = false; - private userId?: string; - private profile?: ProfileInfo; - private idTokenFunc?: () => Promise; - private signOutFunc?: () => Promise; - - getUserId(): string { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi userId before app was loaded', - ); - } - return this.userId!; - } - - getProfile(): ProfileInfo { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi profile before app was loaded', - ); - } - return this.profile!; - } - - async getIdToken(): Promise { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi idToken before app was loaded', - ); - } - return this.idTokenFunc?.(); - } - - async signOut(): Promise { - if (!this.hasIdentity) { - throw new Error( - 'Tried to access IdentityApi signOutFunc before app was loaded', - ); - } - await this.signOutFunc?.(); - location.reload(); - } - - // This is indirectly called by the sign-in page to continue into the app. - setSignInResult(result: SignInResult) { - if (this.hasIdentity) { - return; - } - if (!result.userId) { - throw new Error('Invalid sign-in result, userId not set'); - } - if (!result.profile) { - throw new Error('Invalid sign-in result, profile not set'); - } - this.hasIdentity = true; - this.userId = result.userId; - this.profile = result.profile; - this.idTokenFunc = result.getIdToken; - this.signOutFunc = result.signOut; - } -} diff --git a/packages/core-api/src/app/AppThemeProvider.tsx b/packages/core-api/src/app/AppThemeProvider.tsx deleted file mode 100644 index b6a88e6fcc..0000000000 --- a/packages/core-api/src/app/AppThemeProvider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; -import { ThemeProvider, CssBaseline } from '@material-ui/core'; -import { useApi, appThemeApiRef, AppTheme } from '../apis'; -import { useObservable } from 'react-use'; - -// This tries to find the most accurate match, but also falls back to less -// accurate results in order to avoid errors. -function resolveTheme( - themeId: string | undefined, - shouldPreferDark: boolean, - themes: AppTheme[], -) { - if (themeId !== undefined) { - const selectedTheme = themes.find(theme => theme.id === themeId); - if (selectedTheme) { - return selectedTheme; - } - } - - if (shouldPreferDark) { - const darkTheme = themes.find(theme => theme.variant === 'dark'); - if (darkTheme) { - return darkTheme; - } - } - - const lightTheme = themes.find(theme => theme.variant === 'light'); - if (lightTheme) { - return lightTheme; - } - - return themes[0]; -} - -const useShouldPreferDarkTheme = () => { - const mediaQuery = useMemo( - () => window.matchMedia('(prefers-color-scheme: dark)'), - [], - ); - const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches); - - useEffect(() => { - const listener = (event: MediaQueryListEvent) => { - setPrefersDark(event.matches); - }; - mediaQuery.addListener(listener); - return () => { - mediaQuery.removeListener(listener); - }; - }, [mediaQuery]); - - return shouldPreferDark; -}; - -export function AppThemeProvider({ children }: PropsWithChildren<{}>) { - const appThemeApi = useApi(appThemeApiRef); - const themeId = useObservable( - appThemeApi.activeThemeId$(), - appThemeApi.getActiveThemeId(), - ); - - // Browser feature detection won't change over time, so ignore lint rule - const shouldPreferDark = Boolean(window.matchMedia) - ? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks - : false; - - const appTheme = resolveTheme( - themeId, - shouldPreferDark, - appThemeApi.getInstalledThemes(), - ); - if (!appTheme) { - throw new Error('App has no themes'); - } - - return ( - - {children} - - ); -} diff --git a/packages/core-api/src/app/index.ts b/packages/core-api/src/app/index.ts deleted file mode 100644 index 56e0800809..0000000000 --- a/packages/core-api/src/app/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { useApp } from './AppContext'; -export * from './types'; diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts deleted file mode 100644 index a3eb6f423f..0000000000 --- a/packages/core-api/src/app/types.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* - * 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 { ComponentType } from 'react'; -import { IconComponent, IconComponentMap, IconKey } from '../icons/types'; -import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; -import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types'; -import { AnyApiFactory } from '../apis/system'; -import { AppTheme, ProfileInfo } from '../apis/definitions'; -import { AppConfig } from '@backstage/config'; - -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 AppComponents = { - NotFoundErrorPage: ComponentType<{}>; - BootErrorPage: ComponentType; - Progress: ComponentType<{}>; - Router: ComponentType<{}>; - - /** - * An optional sign-in page that will be rendered instead of the AppRouter at startup. - * - * If a sign-in page is set, it will always be shown before the app, and it is up - * to the sign-in page to handle e.g. saving of login methods for subsequent visits. - * - * The sign-in page will be displayed until it has passed up a result to the parent, - * and which point the AppRouter and all of its children will be rendered instead. - */ - SignInPage?: ComponentType; -}; - -/** - * A function that loads in the App config that will be accessible via the ConfigApi. - * - * If multiple config objects are returned in the array, values in the earlier configs - * will override later ones. - */ -export type AppConfigLoader = () => Promise; - -/** - * Extracts a union of the keys in a map whose value extends the given type - */ -type KeysWithType = { - [key in keyof Obj]: Obj[key] extends Type ? key : never; -}[keyof Obj]; - -/** - * Takes a map Map required values and makes all keys matching Keys optional - */ -type PartialKeys< - Map extends { [name in string]: any }, - Keys extends keyof Map -> = Partial> & Required>; - -/** - * Creates a map of target routes with matching parameters based on a map of external routes. - */ -type TargetRouteMap = { - [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< - infer Params, - any - > - ? RouteRef | SubRouteRef - : never; -}; - -export type AppRouteBinder = ( - externalRoutes: ExternalRoutes, - targetRoutes: PartialKeys< - TargetRouteMap, - KeysWithType> - >, -) => void; - -export type AppOptions = { - /** - * A collection of ApiFactories to register in the application to either - * add add new ones, or override factories provided by default or by plugins. - */ - apis?: Iterable; - - /** - * Supply icons to override the default ones. - */ - icons?: IconComponentMap; - - /** - * A list of all plugins to include in the app. - */ - plugins?: BackstagePlugin[]; - - /** - * Supply components to the app to override the default ones. - */ - components?: Partial; - - /** - * Themes provided as a part of the app. By default two themes are included, one - * light variant of the default backstage theme, and one dark. - * - * This is the default config: - * - * ``` - * [{ - * id: 'light', - * title: 'Light Theme', - * variant: 'light', - * theme: lightTheme, - * icon: , - * }, { - * id: 'dark', - * title: 'Dark Theme', - * variant: 'dark', - * theme: darkTheme, - * icon: , - * }] - * ``` - */ - themes?: AppTheme[]; - - /** - * A function that loads in App configuration that will be accessible via - * the ConfigApi. - * - * Defaults to an empty config. - * - * TODO(Rugvip): Omitting this should instead default to loading in configuration - * that was packaged by the backstage-cli and default docker container boot script. - */ - configLoader?: AppConfigLoader; - - /** - * A function that is used to register associations between cross-plugin route - * references, enabling plugins to navigate between each other. - * - * The `bind` function that is passed in should be used to bind all external - * routes of all used plugins. - * - * ```ts - * bindRoutes({ bind }) { - * bind(docsPlugin.externalRoutes, { - * homePage: managePlugin.routes.managePage, - * }) - * bind(homePagePlugin.externalRoutes, { - * settingsPage: settingsPlugin.routes.settingsPage, - * }) - * } - * ``` - */ - bindRoutes?(context: { bind: AppRouteBinder }): void; -}; - -export type BackstageApp = { - /** - * Returns all plugins registered for the app. - */ - getPlugins(): BackstagePlugin[]; - - /** - * Get a common or custom icon for this app. - */ - getSystemIcon(key: IconKey): IconComponent | undefined; - - /** - * Provider component that should wrap the Router created with getRouter() - * and any other components that need to be within the app context. - */ - getProvider(): ComponentType<{}>; - - /** - * Router component that should wrap the App Routes create with getRoutes() - * and any other components that should only be available while signed in. - */ - getRouter(): ComponentType<{}>; - - /** - * Routes component that contains all routes for plugin pages in the app. - * - * @deprecated Registering routes in plugins is deprecated and this method will be removed. - */ - getRoutes(): JSX.Element[]; -}; - -export type AppContext = { - /** - * @deprecated Will be removed - */ - getPlugins(): BackstagePlugin[]; - - /** - * Get a common or custom icon for this app. - */ - getSystemIcon(key: IconKey): IconComponent | undefined; - - /** - * Get the components registered for various purposes in the app. - */ - getComponents(): AppComponents; - - /** - * @deprecated Will be removed - */ - getProvider(): ComponentType<{}>; - - /** - * @deprecated Will be removed - */ - getRouter(): ComponentType<{}>; - - /** - * @deprecated Will be removed - */ - getRoutes(): JSX.Element[]; -}; diff --git a/packages/core-api/src/extensions/componentData.test.tsx b/packages/core-api/src/extensions/componentData.test.tsx deleted file mode 100644 index 808ab08cf1..0000000000 --- a/packages/core-api/src/extensions/componentData.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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 { attachComponentData, getComponentData } from './componentData'; - -describe('elementData', () => { - it('should attach a single piece of data', () => { - const data = { foo: 'bar' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data); - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data); - }); - - it('should attach several distinct pieces of data', () => { - const data1 = { foo: 'bar' }; - const data2 = { test: 'value' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data1); - attachComponentData(Component, 'second', data2); - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data1); - expect(getComponentData(element, 'second')).toBe(data2); - }); - - it('returns undefined for missing data', () => { - const data = { foo: 'bar' }; - const Component1 = () => null; - const Component2 = () => null; - attachComponentData(Component2, 'my-data', data); - - const element1 = ; - const element2 = ; - expect(getComponentData(element1, 'missing')).toBeUndefined(); - expect(getComponentData(element2, 'missing')).toBeUndefined(); - }); - - it('should throw when attempting to overwrite data', () => { - const data = { foo: 'bar' }; - const MyComponent = () => null; - attachComponentData(MyComponent, 'my-data', data); - expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow( - 'Attempted to attach duplicate data "my-data" to component "MyComponent"', - ); - }); - - describe('works across versions', () => { - function getDataSymbol() { - const Component = () => null; - attachComponentData(Component, 'my-data', {}); - const [symbol] = Object.getOwnPropertySymbols(Component); - return symbol; - } - - it('should should be able to get data from older versions', () => { - const symbol = getDataSymbol(); - - const data = { foo: 'bar' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data); - - const element = ; - expect((element as any).type[symbol].map.get('my-data')).toBe(data); - }); - - it('should should be able to attach data for older versions', () => { - const symbol = getDataSymbol(); - - const data = { foo: 'bar' }; - const Component = () => null; - (Component as any)[symbol] = { - map: new Map([['my-data', data]]), - }; - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data); - }); - - it('should be able to get data from newer versions', () => { - const data = { foo: 'bar' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data); - - const element = ; - const container = (global as any)[ - '__@backstage/component-data-store__' - ].get(element.type); - expect(container.map.get('my-data')).toBe(data); - }); - - it('should should be able to attach data for newer versions', () => { - const data = { foo: 'bar' }; - const Component = () => null; - (global as any)['__@backstage/component-data-store__'].set(Component, { - map: new Map([['my-data', data]]), - }); - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data); - }); - }); -}); diff --git a/packages/core-api/src/extensions/componentData.tsx b/packages/core-api/src/extensions/componentData.tsx deleted file mode 100644 index d4975d9eef..0000000000 --- a/packages/core-api/src/extensions/componentData.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 { ComponentType, ReactNode } from 'react'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; - -// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x -const DATA_KEY = Symbol('backstage-component-data'); - -type ComponentWithData

= ComponentType

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

( - component: ComponentType

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

; - - let container = store.get(component) || dataComponent[DATA_KEY]; - if (!container) { - container = { map: new Map() }; - store.set(component, container); - dataComponent[DATA_KEY] = container; - } - - if (container.map.has(type)) { - const name = component.displayName || component.name; - throw new Error( - `Attempted to attach duplicate data "${type}" to component "${name}"`, - ); - } - - container.map.set(type, data); -} - -export function getComponentData( - node: ReactNode, - type: string, -): T | undefined { - if (!node) { - return undefined; - } - - const component = (node as MaybeComponentNode).type; - if (!component) { - return undefined; - } - - const container = store.get(component) || component[DATA_KEY]; - if (!container) { - return undefined; - } - - return container.map.get(type) as T | undefined; -} diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx deleted file mode 100644 index bd649bc0f8..0000000000 --- a/packages/core-api/src/extensions/extensions.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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 { createPlugin } from '../plugin'; -import { createRouteRef } from '../routing'; -import { getComponentData } from './componentData'; -import { - createComponentExtension, - createReactExtension, - createRoutableExtension, -} from './extensions'; - -const plugin = createPlugin({ - id: 'my-plugin', -}); - -describe('extensions', () => { - it('should create a react extension with component data', () => { - const Component = () =>

; - - const extension = createReactExtension({ - component: { - sync: Component, - }, - data: { - myData: { foo: 'bar' }, - }, - }); - - const ExtensionComponent = plugin.provide(extension); - const element = ; - - expect(getComponentData(element, 'core.plugin')).toBe(plugin); - expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' }); - }); - - it('should create react extensions of different types', () => { - const Component = () =>
; - const routeRef = createRouteRef({ path: '/foo', title: 'Foo' }); - - const extension1 = createComponentExtension({ - component: { - sync: Component, - }, - }); - - const extension2 = createRoutableExtension({ - component: () => Promise.resolve(Component), - mountPoint: routeRef, - }); - - const ExtensionComponent1 = plugin.provide(extension1); - const ExtensionComponent2 = plugin.provide(extension2); - - const element1 = ; - const element2 = ; - - expect(getComponentData(element1, 'core.plugin')).toBe(plugin); - expect(getComponentData(element2, 'core.plugin')).toBe(plugin); - expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); - }); -}); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx deleted file mode 100644 index bca4c25f97..0000000000 --- a/packages/core-api/src/extensions/extensions.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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, { lazy, Suspense } from 'react'; -import { useApp } from '../app'; -import { BackstagePlugin, Extension } from '../plugin/types'; -import { RouteRef, useRouteRef } from '../routing'; -import { attachComponentData } from './componentData'; - -type ComponentLoader = - | { - lazy: () => Promise; - } - | { - 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. -export function createRoutableExtension< - T extends (props: any) => JSX.Element | null ->(options: { - component: () => Promise; - mountPoint: RouteRef; -}): Extension { - const { component, mountPoint } = options; - return createReactExtension({ - component: { - lazy: () => - component().then( - InnerComponent => { - const RoutableExtensionWrapper: any = (props: any) => { - // Validate that the routing is wired up correctly in the App.tsx - try { - useRouteRef(mountPoint); - } catch (error) { - if (error?.message.startsWith('No path for ')) { - throw new Error( - `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + - 'Routable extension components may not be rendered by other components and must be ' + - 'directly available as an element within the App provider component.', - ); - } - throw error; - } - return ; - }; - - const componentName = - (InnerComponent as { displayName?: string }).displayName || - InnerComponent.name || - 'LazyComponent'; - - RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`; - - return RoutableExtensionWrapper as T; - }, - error => { - const RoutableExtensionWrapper: any = (_: any) => { - const app = useApp(); - const { BootErrorPage } = app.getComponents(); - - return ; - }; - return RoutableExtensionWrapper; - }, - ), - }, - data: { - 'core.mountPoint': mountPoint, - }, - }); -} - -// 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. -export function createComponentExtension< - T extends (props: any) => JSX.Element | null ->(options: { component: ComponentLoader }): Extension { - const { component } = options; - return createReactExtension({ component }); -} - -// 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. -export function createReactExtension< - T extends (props: any) => JSX.Element | null ->(options: { - component: ComponentLoader; - data?: Record; -}): Extension { - const { data = {} } = options; - - let Component: T; - if ('lazy' in options.component) { - const lazyLoader = options.component.lazy; - Component = (lazy(() => - lazyLoader().then(component => ({ default: component })), - ) as unknown) as T; - } else { - Component = options.component.sync; - } - const componentName = - (Component as { displayName?: string }).displayName || - Component.name || - 'Component'; - - return { - expose(plugin: BackstagePlugin) { - const Result: any = (props: any) => ( - - - - ); - - attachComponentData(Result, 'core.plugin', plugin); - for (const [key, value] of Object.entries(data)) { - attachComponentData(Result, key, value); - } - - Result.displayName = `Extension(${componentName})`; - return Result; - }, - }; -} diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts deleted file mode 100644 index 914d76aebf..0000000000 --- a/packages/core-api/src/extensions/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 { attachComponentData, getComponentData } from './componentData'; -export { - createReactExtension, - createRoutableExtension, - createComponentExtension, -} from './extensions'; diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx deleted file mode 100644 index 0acc51fb27..0000000000 --- a/packages/core-api/src/extensions/traversal.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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, { Children, isValidElement } from 'react'; -import { - childDiscoverer, - createCollector, - traverseElementTree, -} from './traversal'; - -describe('discovery', () => { - it('should collect element names', () => { - const root = ( -
-
-

Title

-

Text

-
-
-
-

Title

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

Title

-

Text

-
-
-
-

Title

- Text -
-
- ); - - const { names } = traverseElementTree({ - root, - discoverers: [ - el => - Children.toArray(el.props.children).flatMap(child => - isValidElement(child) ? child?.props?.children : [], - ), - ], - collectors: { - names: createCollector( - () => Array(), - (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }, - ), - }, - }); - - expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); - }); -}); diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts deleted file mode 100644 index 79f44523b3..0000000000 --- a/packages/core-api/src/extensions/traversal.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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 { isValidElement, ReactNode, ReactElement, Children } from 'react'; - -export type Discoverer = (element: ReactElement) => ReactNode; - -export type Collector = () => { - accumulator: Result; - visit( - accumulator: Result, - element: ReactElement, - parent: ReactElement | undefined, - context: Context, - ): Context; -}; - -/** - * A function that allows you to traverse a tree of React elements using - * varying methods to discover child nodes and collect data along the way. - */ -export function traverseElementTree(options: { - root: ReactNode; - discoverers: Discoverer[]; - collectors: { [name in keyof Results]: Collector }; -}): Results { - const collectors: { - [name in string]: ReturnType>; - } = {}; - - // Bootstrap all collectors, initializing the accumulators and providing the visitor function - for (const name in options.collectors) { - if (options.collectors.hasOwnProperty(name)) { - collectors[name] = options.collectors[name](); - } - } - - // Internal representation of an element in the tree that we're iterating over - type QueueItem = { - node: ReactNode; - parent: ReactElement | undefined; - contexts: { [name in string]: unknown }; - }; - - const queue = [ - { - node: Children.toArray(options.root), - parent: undefined, - contexts: {}, - } as QueueItem, - ]; - - while (queue.length !== 0) { - const { node, parent, contexts } = queue.shift()!; - - // While the parent and the element we pass on to collectors and discoverers - // have been validated and are known to be React elements, the child nodes - // emitted by the discoverers are not. - Children.forEach(node, element => { - if (!isValidElement(element)) { - return; - } - - const nextContexts: QueueItem['contexts'] = {}; - - // Collectors populate their result data using the current node, and compute - // context for the next iteration - for (const name in collectors) { - if (collectors.hasOwnProperty(name)) { - const collector = collectors[name]; - - nextContexts[name] = collector.visit( - collector.accumulator, - element, - parent, - contexts[name], - ); - } - } - - // Discoverers provide ways to continue the traversal from the current element - for (const discoverer of options.discoverers) { - const children = discoverer(element); - if (children) { - queue.push({ - node: children, - parent: element, - contexts: nextContexts, - }); - } - } - }); - } - - return Object.fromEntries( - Object.entries(collectors).map(([name, c]) => [name, c.accumulator]), - ) as Results; -} - -export function createCollector( - accumulatorFactory: () => Result, - visit: ReturnType>['visit'], -): Collector { - return () => ({ accumulator: accumulatorFactory(), visit }); -} - -export function childDiscoverer(element: ReactElement): ReactNode { - return element.props?.children; -} - -export function routeElementDiscoverer(element: ReactElement): ReactNode { - if (element.props?.path && element.props?.element) { - return element.props?.element; - } - return undefined; -} diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx deleted file mode 100644 index c90ee5cfc4..0000000000 --- a/packages/core-api/src/icons/icons.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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 { SvgIconProps } from '@material-ui/core'; -import MuiMenuBookIcon from '@material-ui/icons/MenuBook'; -import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; -import MuiChatIcon from '@material-ui/icons/Chat'; -import MuiDashboardIcon from '@material-ui/icons/Dashboard'; -import MuiEmailIcon from '@material-ui/icons/Email'; -import MuiGitHubIcon from '@material-ui/icons/GitHub'; -import MuiHelpIcon from '@material-ui/icons/Help'; -import MuiPeopleIcon from '@material-ui/icons/People'; -import MuiPersonIcon from '@material-ui/icons/Person'; -import MuiWarningIcon from '@material-ui/icons/Warning'; -import MuiDocsIcon from '@material-ui/icons/Description'; - -import React from 'react'; -import { useApp } from '../app/AppContext'; -import { IconComponent, IconComponentMap, SystemIconKey } from './types'; - -export const defaultSystemIcons: IconComponentMap = { - brokenImage: MuiBrokenImageIcon, - // To be confirmed: see https://github.com/backstage/backstage/issues/4970 - catalog: MuiMenuBookIcon, - chat: MuiChatIcon, - dashboard: MuiDashboardIcon, - email: MuiEmailIcon, - github: MuiGitHubIcon, - group: MuiPeopleIcon, - help: MuiHelpIcon, - user: MuiPersonIcon, - warning: MuiWarningIcon, - docs: MuiDocsIcon, -}; - -const overridableSystemIcon = (key: SystemIconKey): IconComponent => { - const Component = (props: SvgIconProps) => { - const app = useApp(); - const Icon = app.getSystemIcon(key); - return Icon ? : ; - }; - return Component; -}; - -export const BrokenImageIcon = overridableSystemIcon('brokenImage'); -export const ChatIcon = overridableSystemIcon('chat'); -export const DashboardIcon = overridableSystemIcon('dashboard'); -export const EmailIcon = overridableSystemIcon('email'); -export const GitHubIcon = overridableSystemIcon('github'); -export const GroupIcon = overridableSystemIcon('group'); -export const HelpIcon = overridableSystemIcon('help'); -export const UserIcon = overridableSystemIcon('user'); -export const WarningIcon = overridableSystemIcon('warning'); -export const DocsIcon = overridableSystemIcon('docs'); diff --git a/packages/core-api/src/icons/index.ts b/packages/core-api/src/icons/index.ts deleted file mode 100644 index 10045c4513..0000000000 --- a/packages/core-api/src/icons/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 * from './icons'; -export * from './types'; diff --git a/packages/core-api/src/icons/types.ts b/packages/core-api/src/icons/types.ts deleted file mode 100644 index bfaf9c5122..0000000000 --- a/packages/core-api/src/icons/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { ComponentType } from 'react'; -import { SvgIconProps } from '@material-ui/core'; - -export type SystemIconKey = - | 'brokenImage' - | 'chat' - | 'dashboard' - | 'email' - | 'github' - | 'group' - | 'help' - | 'user' - | 'warning' - | 'docs'; - -export type IconComponent = ComponentType; -export type IconKey = SystemIconKey | string; -export type IconComponentMap = { [key in IconKey]: IconComponent }; diff --git a/packages/core-api/src/index.ts b/packages/core-api/src/index.ts deleted file mode 100644 index 9d4b2f770a..0000000000 --- a/packages/core-api/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 * from './public'; -import * as privateExports from './private'; - -export default privateExports; diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts deleted file mode 100644 index 524a0c5709..0000000000 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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 ProviderIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from './DefaultAuthConnector'; -import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; -import * as loginPopup from '../loginPopup'; -import { UrlPatternDiscovery } from '../../apis'; -import { msw } from '@backstage/test-utils'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; - -const defaultOptions = { - discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), - environment: 'production', - provider: { - id: 'my-provider', - title: 'My Provider', - icon: ProviderIcon, - }, - oauthRequestApi: new MockOAuthApi(), - sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ - ...res, - scopes: new Set(res.scopes.split(' ')), - expiresAt: new Date(Date.now() + expiresInSeconds * 1000), - }), -}; - -describe('DefaultAuthConnector', () => { - const server = setupServer(); - msw.setupDefaultHandlers(server); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should refresh a session', async () => { - server.use( - rest.get('*', (_req, res, ctx) => - res( - ctx.json({ - idToken: 'mock-id-token', - accessToken: 'mock-access-token', - scopes: 'a b c', - expiresInSeconds: '60', - }), - ), - ), - ); - - const helper = new DefaultAuthConnector(defaultOptions); - const session = await helper.refreshSession(); - expect(session.idToken).toBe('mock-id-token'); - expect(session.accessToken).toBe('mock-access-token'); - expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); - expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000); - expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000); - }); - - it('should handle failure to refresh session', async () => { - server.use( - rest.get('*', (_req, res, ctx) => - res(ctx.status(500, 'Error: Network NOPE')), - ), - ); - - const helper = new DefaultAuthConnector(defaultOptions); - await expect(helper.refreshSession()).rejects.toThrow( - 'Auth refresh request failed, Error: Network NOPE', - ); - }); - - it('should handle failure response when refreshing session', async () => { - server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); - - const helper = new DefaultAuthConnector(defaultOptions); - await expect(helper.refreshSession()).rejects.toThrow( - 'Auth refresh request failed, NOPE', - ); - }); - - it('should fail if popup was rejected', async () => { - const mockOauth = new MockOAuthApi(); - const helper = new DefaultAuthConnector({ - ...defaultOptions, - oauthRequestApi: mockOauth, - }); - const promise = helper.createSession({ scopes: new Set(['a', 'b']) }); - await mockOauth.rejectAll(); - await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); - }); - - it('should create a session', async () => { - const mockOauth = new MockOAuthApi(); - const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') - .mockResolvedValue({ - idToken: 'my-id-token', - accessToken: 'my-access-token', - scopes: 'a b', - expiresInSeconds: 3600, - }); - const helper = new DefaultAuthConnector({ - ...defaultOptions, - oauthRequestApi: mockOauth, - }); - - const sessionPromise = helper.createSession({ - scopes: new Set(['a', 'b']), - }); - - await mockOauth.triggerAll(); - - expect(popupSpy).toBeCalledTimes(1); - expect(popupSpy.mock.calls[0][0]).toMatchObject({ - url: - 'http://my-host/api/auth/my-provider/start?scope=a%20b&env=production', - }); - - await expect(sessionPromise).resolves.toEqual({ - idToken: 'my-id-token', - accessToken: 'my-access-token', - scopes: expect.any(Set), - expiresAt: expect.any(Date), - }); - }); - - it('should instantly show popup if option is set', async () => { - const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') - .mockResolvedValue('my-session'); - const helper = new DefaultAuthConnector({ - ...defaultOptions, - oauthRequestApi: new MockOAuthApi(), - sessionTransform: str => str, - }); - - const sessionPromise = helper.createSession({ - scopes: new Set(), - instantPopup: true, - }); - - await expect(sessionPromise).resolves.toBe('my-session'); - - expect(popupSpy).toBeCalledTimes(1); - }); - - it('should use join func to join scopes', async () => { - const mockOauth = new MockOAuthApi(); - const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') - .mockResolvedValue({ scopes: '' }); - const helper = new DefaultAuthConnector({ - ...defaultOptions, - joinScopes: scopes => `-${[...scopes].join('')}-`, - oauthRequestApi: mockOauth, - }); - - helper.createSession({ scopes: new Set(['a', 'b']) }); - - await mockOauth.triggerAll(); - - expect(popupSpy).toBeCalledTimes(1); - expect(popupSpy.mock.calls[0][0]).toMatchObject({ - url: - 'http://my-host/api/auth/my-provider/start?scope=-ab-&env=production', - }); - }); -}); diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts deleted file mode 100644 index 41a281dc6c..0000000000 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * 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 { - AuthRequester, - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../apis/definitions'; -import { showLoginPopup } from '../loginPopup'; -import { AuthConnector, CreateSessionOptions } from './types'; - -type Options = { - /** - * DiscoveryApi instance used to locate the auth backend endpoint. - */ - discoveryApi: DiscoveryApi; - /** - * Environment hint passed on to auth backend, for example 'production' or 'development' - */ - environment: string; - /** - * Information about the auth provider to be shown to the user. - * The ID Must match the backend auth plugin configuration, for example 'google'. - */ - provider: AuthProvider & { id: string }; - /** - * API used to instantiate an auth requester. - */ - oauthRequestApi: OAuthRequestApi; - /** - * Function used to join together a set of scopes, defaults to joining with a space character. - */ - joinScopes?: (scopes: Set) => string; - /** - * Function used to transform an auth response into the session type. - */ - sessionTransform?(response: any): AuthSession | Promise; -}; - -function defaultJoinScopes(scopes: Set) { - return [...scopes].join(' '); -} - -/** - * DefaultAuthConnector is the default auth connector in Backstage. It talks to the - * backend auth plugin through the standardized API, and requests user permission - * via the OAuthRequestApi. - */ -export class DefaultAuthConnector - implements AuthConnector { - private readonly discoveryApi: DiscoveryApi; - private readonly environment: string; - private readonly provider: AuthProvider & { id: string }; - private readonly joinScopesFunc: (scopes: Set) => string; - private readonly authRequester: AuthRequester; - private readonly sessionTransform: (response: any) => Promise; - - constructor(options: Options) { - const { - discoveryApi, - environment, - provider, - joinScopes = defaultJoinScopes, - oauthRequestApi, - sessionTransform = id => id, - } = options; - - this.authRequester = oauthRequestApi.createAuthRequester({ - provider, - onAuthRequest: scopes => this.showPopup(scopes), - }); - - this.discoveryApi = discoveryApi; - this.environment = environment; - this.provider = provider; - this.joinScopesFunc = joinScopes; - this.sessionTransform = sessionTransform; - } - - async createSession(options: CreateSessionOptions): Promise { - if (options.instantPopup) { - return this.showPopup(options.scopes); - } - return this.authRequester(options.scopes); - } - - async refreshSession(): Promise { - const res = await fetch( - await this.buildUrl('/refresh', { optional: true }), - { - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }, - ).catch(error => { - throw new Error(`Auth refresh request failed, ${error}`); - }); - - if (!res.ok) { - const error: any = new Error( - `Auth refresh request failed, ${res.statusText}`, - ); - error.status = res.status; - throw error; - } - - const authInfo = await res.json(); - - if (authInfo.error) { - const error = new Error(authInfo.error.message); - if (authInfo.error.name) { - error.name = authInfo.error.name; - } - throw error; - } - return await this.sessionTransform(authInfo); - } - - async removeSession(): Promise { - const res = await fetch(await this.buildUrl('/logout'), { - method: 'POST', - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }).catch(error => { - throw new Error(`Logout request failed, ${error}`); - }); - - if (!res.ok) { - const error: any = new Error(`Logout request failed, ${res.statusText}`); - error.status = res.status; - throw error; - } - } - - private async showPopup(scopes: Set): Promise { - const scope = this.joinScopesFunc(scopes); - const popupUrl = await this.buildUrl('/start', { scope }); - - const payload = await showLoginPopup({ - url: popupUrl, - name: `${this.provider.title} Login`, - origin: new URL(popupUrl).origin, - width: 450, - height: 730, - }); - - return await this.sessionTransform(payload); - } - - private async buildUrl( - path: string, - query?: { [key: string]: string | boolean | undefined }, - ): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('auth'); - const queryString = this.buildQueryString({ - ...query, - env: this.environment, - }); - - return `${baseUrl}/${this.provider.id}${path}${queryString}`; - } - - private buildQueryString(query?: { - [key: string]: string | boolean | undefined; - }): string { - if (!query) { - return ''; - } - - const queryString = Object.entries(query) - .map(([key, value]) => { - if (typeof value === 'string') { - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - } else if (value) { - return encodeURIComponent(key); - } - return undefined; - }) - .filter(Boolean) - .join('&'); - - if (!queryString) { - return ''; - } - return `?${queryString}`; - } -} diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts deleted file mode 100644 index ea3704f7f9..0000000000 --- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 { AuthProvider, DiscoveryApi } from '../../apis/definitions'; -import { showLoginPopup } from '../loginPopup'; - -type Options = { - discoveryApi: DiscoveryApi; - environment?: string; - provider: AuthProvider & { id: string }; -}; -export class DirectAuthConnector { - private readonly discoveryApi: DiscoveryApi; - private readonly environment: string | undefined; - private readonly provider: AuthProvider & { id: string }; - - constructor(options: Options) { - const { discoveryApi, environment, provider } = options; - - this.discoveryApi = discoveryApi; - this.environment = environment; - this.provider = provider; - } - - async createSession(): Promise { - const popupUrl = await this.buildUrl('/start'); - const payload = await showLoginPopup({ - url: popupUrl, - name: `${this.provider.title} Login`, - origin: new URL(popupUrl).origin, - width: 450, - height: 730, - }); - - return { - ...payload, - id: payload.profile.email, - }; - } - - async refreshSession(): Promise {} - - async removeSession(): Promise { - const res = await fetch(await this.buildUrl('/logout'), { - method: 'POST', - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }).catch(error => { - throw new Error(`Logout request failed, ${error}`); - }); - - if (!res.ok) { - const error: any = new Error(`Logout request failed, ${res.statusText}`); - error.status = res.status; - throw error; - } - } - - private async buildUrl(path: string): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('auth'); - return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; - } -} diff --git a/packages/core-api/src/lib/AuthConnector/MockAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/MockAuthConnector.test.ts deleted file mode 100644 index 0758c0ba29..0000000000 --- a/packages/core-api/src/lib/AuthConnector/MockAuthConnector.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { MockAuthConnector, mockAccessToken } from './MockAuthConnector'; - -describe('MockAuthConnector', () => { - it('should return mock tokens', async () => { - const helper = new MockAuthConnector(); - - await expect(helper.createSession()).resolves.toEqual({ - accessToken: mockAccessToken, - expiresAt: expect.any(Date), - scopes: expect.any(String), - }); - - await expect(helper.refreshSession()).resolves.toEqual({ - accessToken: mockAccessToken, - expiresAt: expect.any(Date), - scopes: expect.any(String), - }); - }); -}); diff --git a/packages/core-api/src/lib/AuthConnector/MockAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/MockAuthConnector.ts deleted file mode 100644 index db978e7018..0000000000 --- a/packages/core-api/src/lib/AuthConnector/MockAuthConnector.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { AuthConnector } from './types'; - -export const mockAccessToken = 'mock-access-token'; - -type MockSession = { - accessToken: string; - expiresAt: Date; - scopes: string; -}; - -const defaultMockSession: MockSession = { - accessToken: mockAccessToken, - expiresAt: new Date(), - scopes: 'profile email', -}; - -export class MockAuthConnector implements AuthConnector { - constructor(private readonly mockSession: MockSession = defaultMockSession) {} - - async createSession() { - return this.mockSession; - } - - async refreshSession() { - return this.mockSession; - } - - async removeSession() {} -} diff --git a/packages/core-api/src/lib/AuthConnector/index.ts b/packages/core-api/src/lib/AuthConnector/index.ts deleted file mode 100644 index 047583f3ac..0000000000 --- a/packages/core-api/src/lib/AuthConnector/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 { DefaultAuthConnector } from './DefaultAuthConnector'; -export { DirectAuthConnector } from './DirectAuthConnector'; -export * from './types'; diff --git a/packages/core-api/src/lib/AuthConnector/types.ts b/packages/core-api/src/lib/AuthConnector/types.ts deleted file mode 100644 index 464a2ed627..0000000000 --- a/packages/core-api/src/lib/AuthConnector/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 type CreateSessionOptions = { - scopes: Set; - instantPopup?: boolean; -}; - -/** - * An AuthConnector is responsible for realizing auth session actions - * by for example communicating with a backend or interacting with the user. - */ -export type AuthConnector = { - createSession(options: CreateSessionOptions): Promise; - refreshSession(): Promise; - removeSession(): Promise; -}; diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts deleted file mode 100644 index 4ceadd51ba..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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 { AuthSessionStore } from './AuthSessionStore'; -import { SessionManager } from './types'; - -const defaultOptions = { - storageKey: 'my-key', - sessionScopes: (session: string) => new Set(session.split(' ')), -}; - -class LocalStorage { - private store: Record = {}; - - getItem(key: string) { - return this.store[key] || null; - } - setItem(key: string, value: string) { - this.store[key] = value.toString(); - } - removeItem(key: string) { - delete this.store[key]; - } -} - -class MockManager implements SessionManager { - setSession = jest.fn(); - getSession = jest.fn(); - removeSession = jest.fn(); - sessionState$ = jest.fn(); -} - -describe('GheAuth AuthSessionStore', () => { - beforeEach(() => { - delete (window as any).localStorage; - (window as any).localStorage = new LocalStorage(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should load session', async () => { - localStorage.setItem('my-key', '"a b c"'); - - const manager = new MockManager(); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - - await expect(store.getSession({})).resolves.toBe('a b c'); - expect(manager.getSession).not.toHaveBeenCalled(); - expect(manager.setSession).toHaveBeenCalledWith('a b c'); - }); - - it('should not use session without enough scope', async () => { - localStorage.setItem('my-key', '"a b c"'); - - const manager = new MockManager(); - manager.getSession.mockResolvedValue('a b c d'); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - - await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe( - 'a b c d', - ); - expect(manager.getSession).toHaveBeenCalledTimes(1); - expect(manager.setSession).not.toHaveBeenCalled(); - }); - - it('should not use expired session', async () => { - localStorage.setItem('my-key', '"a b c"'); - - const manager = new MockManager(); - manager.getSession.mockResolvedValue('123'); - const store = new AuthSessionStore({ - manager, - ...defaultOptions, - sessionShouldRefresh: () => true, - }); - - await expect(store.getSession({})).resolves.toBe('123'); - expect(manager.getSession).toHaveBeenCalledTimes(1); - expect(manager.setSession).not.toHaveBeenCalled(); - }); - - it('should not load missing session', async () => { - const manager = new MockManager(); - manager.getSession.mockResolvedValue('123'); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - - await expect(store.getSession({})).resolves.toBe('123'); - expect(manager.getSession).toHaveBeenCalledTimes(1); - expect(manager.setSession).not.toHaveBeenCalled(); - - expect(localStorage.getItem('my-key')).toBe('"123"'); - }); - - it('should ignore bad session values', async () => { - localStorage.setItem('my-key', 'derp'); - - const manager = new MockManager(); - manager.getSession.mockResolvedValue('123'); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - - await expect(store.getSession({})).resolves.toBe('123'); - expect(manager.getSession).toHaveBeenCalledTimes(1); - expect(manager.setSession).not.toHaveBeenCalled(); - }); - - it('should clear session', () => { - localStorage.setItem('my-key', '"a b c"'); - - const manager = new MockManager(); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - store.removeSession(); - - expect(localStorage.getItem('my-key')).toBe(null); - expect(manager.removeSession).toHaveBeenCalled(); - }); - - it('should forward sessionState calls', () => { - const manager = new MockManager(); - const store = new AuthSessionStore({ manager, ...defaultOptions }); - store.sessionState$(); - expect(manager.sessionState$).toHaveBeenCalled(); - }); -}); diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts deleted file mode 100644 index 057a70e58d..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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 { - SessionManager, - MutableSessionManager, - SessionScopesFunc, - SessionShouldRefreshFunc, - GetSessionOptions, -} from './types'; -import { SessionScopeHelper } from './common'; - -type Options = { - /** The connector used for acting on the auth session */ - manager: MutableSessionManager; - /** Storage key to use to store sessions */ - storageKey: string; - /** Used to get the scope of the session */ - sessionScopes?: SessionScopesFunc; - /** Used to check if the session needs to be refreshed, defaults to never refresh */ - sessionShouldRefresh?: SessionShouldRefreshFunc; -}; - -/** - * AuthSessionStore decorates another SessionManager with a functionality - * to store the session in local storage. - * - * Session is serialized to JSON with special support for following types: Set. - */ -export class AuthSessionStore implements SessionManager { - private readonly manager: MutableSessionManager; - private readonly storageKey: string; - private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; - private readonly helper: SessionScopeHelper; - - constructor(options: Options) { - const { - manager, - storageKey, - sessionScopes, - sessionShouldRefresh = () => false, - } = options; - - this.manager = manager; - this.storageKey = storageKey; - this.sessionShouldRefreshFunc = sessionShouldRefresh; - this.helper = new SessionScopeHelper({ - sessionScopes, - defaultScopes: new Set(), - }); - } - - async getSession(options: GetSessionOptions): Promise { - const { scopes } = options; - const session = this.loadSession(); - - if (this.helper.sessionExistsAndHasScope(session, scopes)) { - const shouldRefresh = this.sessionShouldRefreshFunc(session!); - - if (!shouldRefresh) { - this.manager.setSession(session!); - return session!; - } - } - - const newSession = await this.manager.getSession(options); - this.saveSession(newSession); - return newSession; - } - - async removeSession() { - localStorage.removeItem(this.storageKey); - await this.manager.removeSession(); - } - - sessionState$() { - return this.manager.sessionState$(); - } - - private loadSession(): T | undefined { - try { - const sessionJson = localStorage.getItem(this.storageKey); - if (sessionJson) { - const session = JSON.parse(sessionJson, (_key, value) => { - if (value?.__type === 'Set') { - return new Set(value.__value); - } - return value; - }); - return session; - } - - return undefined; - } catch (error) { - localStorage.removeItem(this.storageKey); - return undefined; - } - } - - private saveSession(session: T | undefined) { - if (session === undefined) { - localStorage.removeItem(this.storageKey); - } else { - localStorage.setItem( - this.storageKey, - JSON.stringify(session, (_key, value) => { - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value), - }; - } - return value; - }), - ); - } - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts deleted file mode 100644 index 99a10002c3..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; -import { SessionState } from '../../apis'; - -const defaultOptions = { - sessionScopes: (session: { scopes: Set }) => session.scopes, - sessionShouldRefresh: (session: { expired: boolean }) => session.expired, -}; - -describe('RefreshingAuthSessionManager', () => { - it('should save result from createSession', async () => { - const createSession = jest.fn().mockResolvedValue({ expired: false }); - const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const removeSession = jest.fn(); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession, removeSession }, - ...defaultOptions, - } as any); - const stateSubscriber = jest.fn(); - manager.sessionState$().subscribe(stateSubscriber); - - await Promise.resolve(); // Wait a tick for observer to post a value - - expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]); - await manager.getSession({}); - expect(createSession).toBeCalledTimes(1); - - expect(stateSubscriber.mock.calls).toEqual([ - [SessionState.SignedOut], - [SessionState.SignedIn], - ]); - await manager.getSession({}); - expect(createSession).toBeCalledTimes(1); - - expect(refreshSession).toBeCalledTimes(1); - expect(stateSubscriber.mock.calls).toEqual([ - [SessionState.SignedOut], - [SessionState.SignedIn], - ]); - - expect(removeSession).toHaveBeenCalledTimes(0); - await manager.removeSession(); - expect(removeSession).toHaveBeenCalledTimes(1); - expect(stateSubscriber.mock.calls).toEqual([ - [SessionState.SignedOut], - [SessionState.SignedIn], - [SessionState.SignedOut], - ]); - }); - - it('should ask consent only if scopes have changed', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession }, - ...defaultOptions, - } as any); - - createSession.mockResolvedValue({ - scopes: new Set(['a']), - expired: false, - }); - await manager.getSession({ scopes: new Set(['a']) }); - expect(createSession).toBeCalledTimes(1); - - await manager.getSession({ scopes: new Set(['a']) }); - expect(createSession).toBeCalledTimes(1); - - await manager.getSession({ scopes: new Set(['b']) }); - expect(createSession).toBeCalledTimes(2); - }); - - it('should check for session expiry', async () => { - const createSession = jest.fn(); - const refreshSession = jest - .fn() - .mockRejectedValueOnce(new Error('NOPE')) - .mockResolvedValue({ scopes: new Set(['a']) }); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession }, - ...defaultOptions, - } as any); - - createSession.mockResolvedValue({ - scopes: new Set(['a']), - expired: true, - }); - - await manager.getSession({ scopes: new Set(['a']) }); - expect(createSession).toBeCalledTimes(1); - expect(refreshSession).toBeCalledTimes(1); - - await manager.getSession({ scopes: new Set(['a']) }); - expect(createSession).toBeCalledTimes(1); - expect(refreshSession).toBeCalledTimes(2); - }); - - it('should handle user closed popup', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession }, - ...defaultOptions, - } as any); - - createSession.mockRejectedValueOnce(new Error('some error')); - await expect( - manager.getSession({ scopes: new Set(['a']) }), - ).rejects.toThrow('some error'); - }); - - it('should not get optional session', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession }, - ...defaultOptions, - } as any); - - expect(await manager.getSession({ optional: true })).toBe(undefined); - expect(createSession).toBeCalledTimes(0); - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should forward option to instantly show auth popup and not attempt refresh', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const manager = new RefreshingAuthSessionManager({ - connector: { createSession, refreshSession }, - ...defaultOptions, - } as any); - - expect(await manager.getSession({ instantPopup: true })).toBe(undefined); - expect(createSession).toBeCalledTimes(1); - expect(createSession).toHaveBeenCalledWith({ - scopes: new Set(), - instantPopup: true, - }); - expect(refreshSession).toBeCalledTimes(0); - }); - - it('should remove session straight away', async () => { - const removeSession = jest.fn(); - const manager = new RefreshingAuthSessionManager({ - connector: { removeSession }, - ...defaultOptions, - } as any); - - await manager.removeSession(); - expect(removeSession).toHaveBeenCalled(); - expect(await manager.getSession({ optional: true })).toBe(undefined); - }); -}); diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts deleted file mode 100644 index d31f5e29bf..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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 { - SessionManager, - SessionScopesFunc, - SessionShouldRefreshFunc, - GetSessionOptions, -} from './types'; -import { AuthConnector } from '../AuthConnector'; -import { SessionScopeHelper, hasScopes } from './common'; -import { SessionStateTracker } from './SessionStateTracker'; - -type Options = { - /** The connector used for acting on the auth session */ - connector: AuthConnector; - /** Used to get the scope of the session */ - sessionScopes: SessionScopesFunc; - /** Used to check if the session needs to be refreshed */ - sessionShouldRefresh: SessionShouldRefreshFunc; - /** The default scopes that should always be present in a session, defaults to none. */ - defaultScopes?: Set; -}; - -/** - * RefreshingAuthSessionManager manages an underlying session that has - * and expiration time and needs to be refreshed periodically. - */ -export class RefreshingAuthSessionManager implements SessionManager { - private readonly connector: AuthConnector; - private readonly helper: SessionScopeHelper; - private readonly sessionScopesFunc: SessionScopesFunc; - private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; - private readonly stateTracker = new SessionStateTracker(); - - private refreshPromise?: Promise; - private currentSession: T | undefined; - - constructor(options: Options) { - const { - connector, - defaultScopes = new Set(), - sessionScopes, - sessionShouldRefresh, - } = options; - - this.connector = connector; - this.sessionScopesFunc = sessionScopes; - this.sessionShouldRefreshFunc = sessionShouldRefresh; - this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); - } - - async getSession(options: GetSessionOptions): Promise { - if ( - this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) - ) { - const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); - if (!shouldRefresh) { - return this.currentSession!; - } - - try { - const refreshedSession = await this.collapsedSessionRefresh(); - const currentScopes = this.sessionScopesFunc(this.currentSession!); - const refreshedScopes = this.sessionScopesFunc(refreshedSession); - if (hasScopes(refreshedScopes, currentScopes)) { - this.currentSession = refreshedSession; - } - return refreshedSession; - } catch (error) { - if (options.optional) { - return undefined; - } - throw error; - } - } - - // The user may still have a valid refresh token in their cookies. Attempt to - // initiate a fresh session through the backend using that refresh token. - // - // We skip this check if an instant login popup is requested, as we need to - // stay in a synchronous call stack from the user interaction. The downside - // is that that the user will sometimes be requested to log in even if they - // already had an existing session. - if (!this.currentSession && !options.instantPopup) { - try { - const newSession = await this.collapsedSessionRefresh(); - this.currentSession = newSession; - // The session might not have the scopes requested so go back and check again - return this.getSession(options); - } catch { - // If the refresh attempt fails we assume we don't have a session, so continue to create one. - } - } - - // If we continue here we will show a popup, so exit if this is an optional session request. - if (options.optional) { - return undefined; - } - - // We can call authRequester multiple times, the returned session will contain all requested scopes. - this.currentSession = await this.connector.createSession({ - ...options, - scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), - }); - this.stateTracker.setIsSignedIn(true); - return this.currentSession; - } - - async removeSession() { - this.currentSession = undefined; - await this.connector.removeSession(); - this.stateTracker.setIsSignedIn(false); - } - - sessionState$() { - return this.stateTracker.sessionState$(); - } - - private async collapsedSessionRefresh(): Promise { - if (this.refreshPromise) { - return this.refreshPromise; - } - - this.refreshPromise = this.connector.refreshSession(); - - try { - const session = await this.refreshPromise; - this.stateTracker.setIsSignedIn(true); - return session; - } finally { - delete this.refreshPromise; - } - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts deleted file mode 100644 index 525cfd7313..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { SessionState } from '../../apis/definitions'; -import { Observable } from '../../types'; -import { BehaviorSubject } from '../subjects'; - -export class SessionStateTracker { - private readonly subject = new BehaviorSubject( - SessionState.SignedOut, - ); - - private signedIn: boolean = false; - - setIsSignedIn(isSignedIn: boolean) { - if (this.signedIn !== isSignedIn) { - this.signedIn = isSignedIn; - this.subject.next( - this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, - ); - } - } - - sessionState$(): Observable { - return this.subject; - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts deleted file mode 100644 index 26f489a1b4..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 { StaticAuthSessionManager } from './StaticAuthSessionManager'; - -const defaultOptions = { - sessionScopes: (session: string) => new Set(session.split(' ')), -}; - -describe('StaticAuthSessionManager', () => { - const baseConnector = { - refreshSession() { - throw new Error('refreshSession should not be called'); - }, - removeSession() { - throw new Error('removeSession should not be called'); - }, - }; - - it('should get session by creating session once', async () => { - const createSession = jest.fn().mockResolvedValue('my-session'); - const manager = new StaticAuthSessionManager({ - connector: { createSession, ...baseConnector }, - ...defaultOptions, - }); - - expect(createSession).toHaveBeenCalledTimes(0); - await expect(manager.getSession({})).resolves.toBe('my-session'); - expect(createSession).toHaveBeenCalledTimes(1); - await expect(manager.getSession({})).resolves.toBe('my-session'); - expect(createSession).toHaveBeenCalledTimes(1); - }); - - it('should fail to get session if user rejects the request', async () => { - const createSession = jest.fn().mockRejectedValue(new Error('NOPE')); - const manager = new StaticAuthSessionManager({ - connector: { createSession, ...baseConnector }, - ...defaultOptions, - }); - - expect(createSession).toHaveBeenCalledTimes(0); - await expect(manager.getSession({})).rejects.toThrow('NOPE'); - expect(createSession).toHaveBeenCalledTimes(1); - await expect(manager.getSession({ optional: true })).resolves.toBe( - undefined, - ); - }); - - it('should only request auth once for same scopes', async () => { - const createSession = jest - .fn() - .mockImplementation(({ scopes }) => [...scopes].join(' ')); - const manager = new StaticAuthSessionManager({ - connector: { createSession, ...baseConnector }, - ...defaultOptions, - }); - - expect(createSession).toHaveBeenCalledTimes(0); - await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( - 'a', - ); - expect(createSession).toHaveBeenCalledTimes(1); - await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( - 'a', - ); - expect(createSession).toHaveBeenCalledTimes(1); - await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe( - 'a b', - ); - expect(createSession).toHaveBeenCalledTimes(2); - }); - - it('should remove session and reload', async () => { - const removeSession = jest.fn(); - const manager = new StaticAuthSessionManager({ - connector: { removeSession }, - ...defaultOptions, - } as any); - - await manager.removeSession(); - expect(removeSession).toHaveBeenCalled(); - expect(await manager.getSession({ optional: true })).toBe(undefined); - }); -}); diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts deleted file mode 100644 index b7940d88c4..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { MutableSessionManager, GetSessionOptions } from './types'; -import { AuthConnector } from '../AuthConnector'; -import { SessionScopeHelper } from './common'; -import { SessionStateTracker } from './SessionStateTracker'; - -type Options = { - /** The connector used for acting on the auth session */ - connector: AuthConnector; - /** Used to get the scope of the session */ - sessionScopes?: (session: T) => Set; - /** The default scopes that should always be present in a session, defaults to none. */ - defaultScopes?: Set; -}; - -/** - * StaticAuthSessionManager manages an underlying session that does not expire. - */ -export class StaticAuthSessionManager implements MutableSessionManager { - private readonly connector: AuthConnector; - private readonly helper: SessionScopeHelper; - private readonly stateTracker = new SessionStateTracker(); - - private currentSession: T | undefined; - - constructor(options: Options) { - const { connector, defaultScopes = new Set(), sessionScopes } = options; - - this.connector = connector; - this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); - } - - setSession(session: T | undefined): void { - this.currentSession = session; - this.stateTracker.setIsSignedIn(Boolean(session)); - } - - async getSession(options: GetSessionOptions): Promise { - if ( - this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) - ) { - return this.currentSession; - } - - // If we continue here we will show a popup, so exit if this is an optional session request. - if (options.optional) { - return undefined; - } - - // We can call authRequester multiple times, the returned session will contain all requested scopes. - this.currentSession = await this.connector.createSession({ - ...options, - scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), - }); - this.stateTracker.setIsSignedIn(true); - return this.currentSession; - } - - async removeSession() { - this.currentSession = undefined; - await this.connector.removeSession(); - this.stateTracker.setIsSignedIn(false); - } - - sessionState$() { - return this.stateTracker.sessionState$(); - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/common.ts b/packages/core-api/src/lib/AuthSessionManager/common.ts deleted file mode 100644 index 002b6c616b..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/common.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { SessionScopesFunc } from './types'; - -export function hasScopes( - searched: Set, - searchFor: Set, -): boolean { - for (const scope of searchFor) { - if (!searched.has(scope)) { - return false; - } - } - return true; -} - -type ScopeHelperOptions = { - sessionScopes: SessionScopesFunc | undefined; - defaultScopes?: Set; -}; - -export class SessionScopeHelper { - constructor(private readonly options: ScopeHelperOptions) {} - - sessionExistsAndHasScope( - session: T | undefined, - scopes?: Set, - ): boolean { - if (!session) { - return false; - } - if (!scopes) { - return true; - } - if (this.options.sessionScopes === undefined) { - return true; - } - const sessionScopes = this.options.sessionScopes(session); - return hasScopes(sessionScopes, scopes); - } - - getExtendedScope(session: T | undefined, scopes?: Set) { - const newScope = new Set(this.options.defaultScopes); - if (session && this.options.sessionScopes !== undefined) { - const sessionScopes = this.options.sessionScopes(session); - for (const scope of sessionScopes) { - newScope.add(scope); - } - } - if (scopes) { - for (const scope of scopes) { - newScope.add(scope); - } - } - return newScope; - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts deleted file mode 100644 index 85ef2013e9..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; -export { StaticAuthSessionManager } from './StaticAuthSessionManager'; -export { AuthSessionStore } from './AuthSessionStore'; -export * from './types'; diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts deleted file mode 100644 index a58f838037..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 { Observable } from '../../types'; -import { SessionState } from '../../apis/definitions'; - -export type GetSessionOptions = { - optional?: boolean; - instantPopup?: boolean; - scopes?: Set; -}; - -/** - * A sessions manager keeps track of the current session and makes sure that - * multiple simultaneous requests for sessions with different scope are handled - * in a correct way. - */ -export type SessionManager = { - getSession(options: GetSessionOptions): Promise; - - removeSession(): Promise; - - sessionState$(): Observable; -}; - -/** - * An extension of the session manager where the session can also be pushed from the manager. - */ -export interface MutableSessionManager extends SessionManager { - setSession(session: T | undefined): void; -} - -/** - * A function called to determine the scopes of a session. - */ -export type SessionScopesFunc = (session: T) => Set; - -/** - * A function called to determine whether it's time for a session to refresh. - * - * This should return true before the session expires, for example, if a session - * expires after 60 minutes, you could return true if the session is older than 45 minutes. - */ -export type SessionShouldRefreshFunc = (session: T) => boolean; diff --git a/packages/core-api/src/lib/globalObject.test.ts b/packages/core-api/src/lib/globalObject.test.ts deleted file mode 100644 index a658b253a6..0000000000 --- a/packages/core-api/src/lib/globalObject.test.ts +++ /dev/null @@ -1,90 +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 { - getGlobalSingleton, - getOrCreateGlobalSingleton, - setGlobalSingleton, -} from './globalObject'; - -const anyGlobal = global as any; - -describe('getGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should return an existing value', () => { - const myThing = {}; - const myOtherThing = {}; - - anyGlobal['__@backstage/my-thing__'] = myThing; - expect(getGlobalSingleton('my-thing')).toBe(myThing); - expect(getGlobalSingleton('my-thing')).toBe(myThing); - anyGlobal['__@backstage/my-thing__'] = myOtherThing; - expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); - }); - - it('should throw if the value is not set', () => { - expect(() => getGlobalSingleton('my-thing')).toThrow( - 'Global my-thing is not set', - ); - }); -}); - -describe('getOrCreateGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should return an existing value', () => { - const myThing = {}; - anyGlobal['__@backstage/my-thing__'] = myThing; - - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); - }); - - it('should should create a new value', () => { - const myNewThing = {}; - - expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); - expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( - myNewThing, - ); - expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); - }); -}); - -describe('setGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should set a global value', () => { - setGlobalSingleton('my-thing', 'global value'); - - expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); - }); - - it('should throw if global value is set', () => { - anyGlobal['__@backstage/my-thing__'] = 'already defined'; - expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( - 'Global my-thing is already se', - ); - }); -}); diff --git a/packages/core-api/src/lib/globalObject.ts b/packages/core-api/src/lib/globalObject.ts deleted file mode 100644 index ad70a61110..0000000000 --- a/packages/core-api/src/lib/globalObject.ts +++ /dev/null @@ -1,73 +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. - */ - -// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 -function getGlobalObject() { - if (typeof window !== 'undefined' && window.Math === Math) { - return window; - } - if (typeof self !== 'undefined' && self.Math === Math) { - return self; - } - // eslint-disable-next-line no-new-func - return Function('return this')(); -} - -const globalObject = getGlobalObject(); - -const makeKey = (id: string) => `__@backstage/${id}__`; - -/** - * Used to provide a global singleton value, failing if it is already set. - */ -export function setGlobalSingleton(id: string, value: unknown): void { - const key = makeKey(id); - if (key in globalObject) { - throw new Error(`Global ${id} is already set`); // TODO some sort of special build err - } - globalObject[key] = value; -} - -/** - * Used to access a global singleton value, failing if it is not already set. - */ -export function getGlobalSingleton(id: string): T { - const key = makeKey(id); - if (!(key in globalObject)) { - throw new Error(`Global ${id} is not set`); // TODO some sort of special build err - } - - return globalObject[key]; -} - -/** - * Serializes access to a global singleton value, with the first caller creating the value. - */ -export function getOrCreateGlobalSingleton( - id: string, - supplier: () => T, -): T { - const key = makeKey(id); - - let value = globalObject[key]; - if (value) { - return value; - } - - value = supplier(); - globalObject[key] = value; - return value; -} diff --git a/packages/core-api/src/lib/index.ts b/packages/core-api/src/lib/index.ts deleted file mode 100644 index 1327aab4c2..0000000000 --- a/packages/core-api/src/lib/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 * from './subjects'; -export * from './loginPopup'; -export * from './AuthConnector'; -export * from './AuthSessionManager'; diff --git a/packages/core-api/src/lib/loginPopup.test.ts b/packages/core-api/src/lib/loginPopup.test.ts deleted file mode 100644 index 1eb7c3f8b9..0000000000 --- a/packages/core-api/src/lib/loginPopup.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* - * 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 { showLoginPopup } from './loginPopup'; - -describe('showLoginPopup', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should show an auth popup', async () => { - const popupMock = { closed: false }; - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue(popupMock as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const payloadPromise = showLoginPopup({ - url: - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - name: 'test-popup', - origin: 'my-origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(openSpy.mock.calls[0][0]).toBe( - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - ); - expect(openSpy.mock.calls[0][1]).toBe('test-popup'); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - listener({} as MessageEvent); - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - // None of these should be accepted - listener({ source: popupMock } as MessageEvent); - listener({ origin: 'my-origin' } as MessageEvent); - listener({ data: { type: 'authorization_response' } } as MessageEvent); - listener({ - source: popupMock, - origin: 'my-origin', - data: {}, - } as MessageEvent); - listener({ - source: popupMock, - origin: 'my-origin', - data: { type: 'not-auth-result', response: {} }, - } as MessageEvent); - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - const myResponse = {}; - - // This should be accepted as a valid sessions response - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'authorization_response', - response: myResponse, - }, - } as MessageEvent); - - await expect(payloadPromise).resolves.toBe(myResponse); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should fail if popup returns error', async () => { - const popupMock = { closed: false }; - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue(popupMock as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const payloadPromise = showLoginPopup({ - url: 'url', - name: 'name', - origin: 'my-origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'authorization_response', - error: { - message: 'NOPE', - name: 'NopeError', - }, - }, - } as MessageEvent); - - await expect(payloadPromise).rejects.toThrow({ - name: 'NopeError', - message: 'NOPE', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should fail if popup is closed', async () => { - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue({ closed: false } as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - - const payloadPromise = showLoginPopup({ - url: 'url', - name: 'name', - origin: 'origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - listener({ - source: popupMock, - origin: 'origin', - data: { - type: 'config_info', - targetOrigin: 'http://localhost', - }, - } as MessageEvent); - - setTimeout(() => { - popupMock.closed = true; - }, 150); - await expect(payloadPromise).rejects.toThrow( - 'Login failed, popup was closed', - ); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should indicate if origin does not match', async () => { - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue({ closed: false } as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - - const payloadPromise = showLoginPopup({ - url: 'url', - name: 'name', - origin: 'origin', - }); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - listener({ - source: popupMock, - origin: 'origin', - data: { - type: 'config_info', - targetOrigin: 'http://differenthost', - }, - } as MessageEvent); - - setTimeout(() => { - popupMock.closed = true; - }, 150); - await expect(payloadPromise).rejects.toThrow( - 'Login failed, Incorrect app origin, expected http://differenthost', - ); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); -}); diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts deleted file mode 100644 index b6c14d60c9..0000000000 --- a/packages/core-api/src/lib/loginPopup.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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. - */ - -/** - * Options used to open a login popup. - */ -export type LoginPopupOptions = { - /** - * The URL that the auth popup should point to - */ - url: string; - - /** - * The name of the popup, as in second argument to window.open - */ - name: string; - - /** - * The origin of the final popup page that will post a message to this window. - */ - origin: string; - - /** - * The width of the popup in pixels, defaults to 500 - */ - width?: number; - - /** - * The height of the popup in pixels, defaults to 700 - */ - height?: number; -}; - -type AuthResult = - | { - type: 'authorization_response'; - response: unknown; - } - | { - type: 'authorization_response'; - error: { - name: string; - message: string; - }; - }; - -/** - * Show a popup pointing to a URL that starts an auth flow. Implementing the receiving - * end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00 - * - * The redirect handler of the flow should use postMessage to communicate back - * to the app window. The message posted to the app must match the AuthResult type. - * - * The returned promise resolves to the response of the message that was posted from the auth popup. - */ -export function showLoginPopup(options: LoginPopupOptions): Promise { - return new Promise((resolve, reject) => { - const width = options.width || 500; - const height = options.height || 700; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; - - const popup = window.open( - options.url, - options.name, - `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, - ); - - let targetOrigin = ''; - - if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - const error = new Error('Failed to open auth popup.'); - error.name = 'PopupRejectedError'; - reject(error); - return; - } - - const messageListener = (event: MessageEvent) => { - if (event.source !== popup) { - return; - } - if (event.origin !== options.origin) { - return; - } - const { data } = event; - - if (data.type === 'config_info') { - targetOrigin = data.targetOrigin; - return; - } - - if (data.type !== 'authorization_response') { - return; - } - const authResult = data as AuthResult; - - if ('error' in authResult) { - const error = new Error(authResult.error.message); - error.name = authResult.error.name; - // TODO: proper error type - // error.extra = authResult.error.extra; - reject(error); - } else { - resolve(authResult.response); - } - done(); - }; - - const intervalId = setInterval(() => { - if (popup.closed) { - const errMessage = `Login failed, ${ - targetOrigin && targetOrigin !== window.location.origin - ? `Incorrect app origin, expected ${targetOrigin}` - : 'popup was closed' - }`; - const error = new Error(errMessage); - error.name = 'PopupClosedError'; - reject(error); - done(); - } - }, 100); - - function done() { - window.removeEventListener('message', messageListener); - clearInterval(intervalId); - } - - window.addEventListener('message', messageListener); - }); -} diff --git a/packages/core-api/src/lib/subjects.test.ts b/packages/core-api/src/lib/subjects.test.ts deleted file mode 100644 index eab5757898..0000000000 --- a/packages/core-api/src/lib/subjects.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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 { PublishSubject, BehaviorSubject } from './subjects'; - -function observerSpy() { - return { - next: jest.fn(), - error: jest.fn(), - complete: jest.fn(), - }; -} - -describe('PublishSubject', () => { - it('should be completed', async () => { - const subj = new PublishSubject(); - subj.complete(); - - const spy = observerSpy(); - subj.subscribe(spy); - await 'a tick'; - - expect(spy.next).not.toHaveBeenCalled(); - expect(spy.error).not.toHaveBeenCalled(); - expect(spy.complete).toHaveBeenCalledTimes(1); - - expect(() => subj.next(1)).toThrow('PublishSubject is closed'); - expect(() => subj.error(new Error())).toThrow('PublishSubject is closed'); - expect(() => subj.complete()).toThrow('PublishSubject is closed'); - - expect(subj.closed).toBe(true); - }); - - it('should forward values to all subscribers', async () => { - const subj = new PublishSubject(); - - const spy1 = observerSpy(); - const spy2 = observerSpy(); - subj.subscribe(spy1); - const sub = subj.subscribe(spy2); - - subj.next(42); - - expect(spy1.next).toHaveBeenCalledTimes(1); - expect(spy1.next).toHaveBeenCalledWith(42); - expect(spy2.next).toHaveBeenCalledTimes(1); - expect(spy2.next).toHaveBeenCalledWith(42); - - sub.unsubscribe(); - - subj.next(1337); - - expect(spy1.next).toHaveBeenCalledTimes(2); - expect(spy1.next).toHaveBeenCalledWith(1337); - expect(spy2.next).toHaveBeenCalledTimes(1); - - expect(spy1.error).not.toHaveBeenCalled(); - expect(spy2.error).not.toHaveBeenCalled(); - expect(spy1.complete).not.toHaveBeenCalled(); - expect(spy2.complete).not.toHaveBeenCalled(); - - expect(subj.closed).toBe(false); - }); - - it('should forward errors', async () => { - const subj = new PublishSubject(); - - const spy1 = observerSpy(); - subj.subscribe(spy1); - - const error = new Error('NOPE'); - subj.error(error); - expect(spy1.error).toHaveBeenCalledWith(error); - expect(spy1.next).not.toHaveBeenCalled(); - expect(spy1.complete).not.toHaveBeenCalled(); - - const spy2 = observerSpy(); - subj.subscribe(spy2); - await 'a tick'; - expect(spy2.error).toHaveBeenCalledWith(error); - expect(spy2.next).not.toHaveBeenCalled(); - expect(spy2.complete).not.toHaveBeenCalled(); - - expect(subj.closed).toBe(true); - }); -}); - -describe('BehaviorSubject', () => { - it('should be completed', async () => { - const subj = new BehaviorSubject(0); - subj.complete(); - - const next = jest.fn(); - const complete = jest.fn(); - subj.subscribe({ next, complete }); - await 'a tick'; - - expect(complete).toHaveBeenCalledTimes(1); - - expect(() => subj.next(1)).toThrow('BehaviorSubject is closed'); - expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed'); - expect(() => subj.complete()).toThrow('BehaviorSubject is closed'); - - expect(subj.closed).toBe(true); - }); - - it('should forward values to all subscribers', async () => { - const subj = new BehaviorSubject(0); - - const obs1 = jest.fn(); - const obs2 = jest.fn(); - subj.subscribe(obs1); - const sub = subj.subscribe(obs2); - await 'a tick'; - - expect(obs1).toHaveBeenCalledTimes(1); - expect(obs1).toHaveBeenCalledWith(0); - expect(obs2).toHaveBeenCalledTimes(1); - expect(obs2).toHaveBeenCalledWith(0); - - subj.next(42); - - expect(obs1).toHaveBeenCalledTimes(2); - expect(obs1).toHaveBeenCalledWith(42); - expect(obs2).toHaveBeenCalledTimes(2); - expect(obs2).toHaveBeenCalledWith(42); - - sub.unsubscribe(); - - subj.next(1337); - - expect(obs1).toHaveBeenCalledTimes(3); - expect(obs1).toHaveBeenCalledWith(1337); - expect(obs2).toHaveBeenCalledTimes(2); - - expect(subj.closed).toBe(false); - }); - - it('should forward errors', async () => { - const subj = new BehaviorSubject(0); - - const spy1 = observerSpy(); - subj.subscribe(spy1); - await 'a tick'; - - expect(spy1.error).not.toHaveBeenCalled(); - expect(spy1.next).toHaveBeenCalledWith(0); - - const error = new Error('NOPE'); - subj.error(error); - expect(spy1.error).toHaveBeenCalledWith(error); - expect(spy1.next).toHaveBeenCalledWith(0); - expect(spy1.next).toHaveBeenCalledTimes(1); - expect(spy1.complete).not.toHaveBeenCalled(); - - const spy2 = observerSpy(); - subj.subscribe(spy2); - await 'a tick'; - expect(spy2.error).toHaveBeenCalledWith(error); - expect(spy2.next).not.toHaveBeenCalled(); - expect(spy2.complete).not.toHaveBeenCalled(); - - expect(subj.closed).toBe(true); - }); -}); diff --git a/packages/core-api/src/lib/subjects.ts b/packages/core-api/src/lib/subjects.ts deleted file mode 100644 index 4b60596bd9..0000000000 --- a/packages/core-api/src/lib/subjects.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* - * 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 { Observable } from '../types'; -import ObservableImpl from 'zen-observable'; - -// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects. -// If we add a more complete Observables library they should be replaced. - -/** - * A basic implementation of ReactiveX publish subjects. - * - * A subject is a convenient way to create an observable when you want - * to fan out a single value to all subscribers. - * - * See http://reactivex.io/documentation/subject.html - */ -export class PublishSubject - implements Observable, ZenObservable.SubscriptionObserver { - private isClosed = false; - private terminatingError?: Error; - - private readonly observable = new ObservableImpl(subscriber => { - if (this.isClosed) { - if (this.terminatingError) { - subscriber.error(this.terminatingError); - } else { - subscriber.complete(); - } - return () => {}; - } - - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); - - private readonly subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - [Symbol.observable]() { - return this; - } - - get closed() { - return this.isClosed; - } - - next(value: T) { - if (this.isClosed) { - throw new Error('PublishSubject is closed'); - } - this.subscribers.forEach(subscriber => subscriber.next(value)); - } - - error(error: Error) { - if (this.isClosed) { - throw new Error('PublishSubject is closed'); - } - this.isClosed = true; - this.terminatingError = error; - this.subscribers.forEach(subscriber => subscriber.error(error)); - } - - complete() { - if (this.isClosed) { - throw new Error('PublishSubject is closed'); - } - this.isClosed = true; - this.subscribers.forEach(subscriber => subscriber.complete()); - } - - subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; - subscribe( - onNext: (value: T) => void, - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription; - subscribe( - onNext: ZenObservable.Observer | ((value: T) => void), - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription { - const observer = - typeof onNext === 'function' - ? { - next: onNext, - error: onError, - complete: onComplete, - } - : onNext; - - return this.observable.subscribe(observer); - } -} - -/** - * A basic implementation of ReactiveX behavior subjects. - * - * A subject is a convenient way to create an observable when you want - * to fan out a single value to all subscribers. - * - * The BehaviorSubject will emit the most recently emitted value or error - * whenever a new observer subscribes to the subject. - * - * See http://reactivex.io/documentation/subject.html - */ -export class BehaviorSubject - implements Observable, ZenObservable.SubscriptionObserver { - private isClosed = false; - private currentValue: T; - private terminatingError?: Error; - - constructor(value: T) { - this.currentValue = value; - } - - private readonly observable = new ObservableImpl(subscriber => { - if (this.isClosed) { - if (this.terminatingError) { - subscriber.error(this.terminatingError); - } else { - subscriber.complete(); - } - return () => {}; - } - - subscriber.next(this.currentValue); - - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); - - private readonly subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - [Symbol.observable]() { - return this; - } - - get closed() { - return this.isClosed; - } - - next(value: T) { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.currentValue = value; - this.subscribers.forEach(subscriber => subscriber.next(value)); - } - - error(error: Error) { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.isClosed = true; - this.terminatingError = error; - this.subscribers.forEach(subscriber => subscriber.error(error)); - } - - complete() { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.isClosed = true; - this.subscribers.forEach(subscriber => subscriber.complete()); - } - - subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; - subscribe( - onNext: (value: T) => void, - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription; - subscribe( - onNext: ZenObservable.Observer | ((value: T) => void), - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription { - const observer = - typeof onNext === 'function' - ? { - next: onNext, - error: onError, - complete: onComplete, - } - : onNext; - - return this.observable.subscribe(observer); - } -} diff --git a/packages/core-api/src/lib/versionedValues.test.ts b/packages/core-api/src/lib/versionedValues.test.ts deleted file mode 100644 index 19f9c8f349..0000000000 --- a/packages/core-api/src/lib/versionedValues.test.ts +++ /dev/null @@ -1,40 +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 { createVersionedValueMap } from './versionedValues'; - -describe('createVersionedValueMap', () => { - it('should be empty', () => { - const map = createVersionedValueMap({}); - - // @ts-expect-error - expect(map.atVersion(1 as any)).toBe(undefined); - }); - - it('should access values by version', () => { - const map = createVersionedValueMap({ 1: 'v1', 2: 'v2' }); - - expect(map.atVersion(1)).toBe('v1'); - expect(map.atVersion(2)).toBe('v2'); - - // @ts-expect-error - expect(map.atVersion(0)).toBe(undefined); - // @ts-expect-error - expect(map.atVersion(NaN)).toBe(undefined); - // @ts-expect-error - expect(map.atVersion(Infinity)).toBe(undefined); - }); -}); diff --git a/packages/core-api/src/lib/versionedValues.ts b/packages/core-api/src/lib/versionedValues.ts deleted file mode 100644 index 3d0a4a41ae..0000000000 --- a/packages/core-api/src/lib/versionedValues.ts +++ /dev/null @@ -1,40 +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. - */ - -/** - * The versioned value interface is a container for a set of values that - * can be looked up by version. It is intended to be used as a container - * for values that can be versioned independently of package versions. - */ -export type VersionedValue = { - atVersion( - version: Version, - ): Versions[Version] | undefined; -}; - -/** - * Creates a container for a map of versioned values that implements VersionedValue. - */ -export function createVersionedValueMap< - Versions extends { [version: number]: any } ->(versions: Versions): VersionedValue { - Object.freeze(versions); - return { - atVersion(version) { - return versions[version]; - }, - }; -} diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx deleted file mode 100644 index 8ccdb267ba..0000000000 --- a/packages/core-api/src/plugin/Plugin.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * 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 { - PluginConfig, - PluginOutput, - BackstagePlugin, - Extension, - AnyRoutes, - AnyExternalRoutes, -} from './types'; -import { AnyApiFactory } from '../apis'; - -export class PluginImpl< - Routes extends AnyRoutes, - ExternalRoutes extends AnyExternalRoutes -> implements BackstagePlugin { - private storedOutput?: PluginOutput[]; - - constructor(private readonly config: PluginConfig) {} - - getId(): string { - return this.config.id; - } - - getApis(): Iterable { - return this.config.apis ?? []; - } - - get routes(): Routes { - return this.config.routes ?? ({} as Routes); - } - - get externalRoutes(): ExternalRoutes { - return this.config.externalRoutes ?? ({} as ExternalRoutes); - } - - output(): PluginOutput[] { - if (this.storedOutput) { - return this.storedOutput; - } - if (!this.config.register) { - return []; - } - - const outputs = new Array(); - - this.config.register({ - router: { - addRoute(target, component, options) { - outputs.push({ - type: 'route', - target, - component, - options, - }); - }, - }, - featureFlags: { - register(name) { - outputs.push({ type: 'feature-flag', name }); - }, - }, - }); - - this.storedOutput = outputs; - return this.storedOutput; - } - - provide(extension: Extension): T { - return extension.expose(this); - } - - toString() { - return `plugin{${this.config.id}}`; - } -} - -export function createPlugin< - Routes extends AnyRoutes = {}, - ExternalRoutes extends AnyExternalRoutes = {} ->( - config: PluginConfig, -): BackstagePlugin { - return new PluginImpl(config); -} diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx deleted file mode 100644 index 8c0ba2bc9e..0000000000 --- a/packages/core-api/src/plugin/collectors.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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, { PropsWithChildren } from 'react'; -import { createRouteRef } from '../routing'; -import { createPlugin } from './Plugin'; -import { - createRoutableExtension, - createComponentExtension, -} from '../extensions'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { - traverseElementTree, - childDiscoverer, - routeElementDiscoverer, -} from '../extensions/traversal'; -import { pluginCollector } from './collectors'; - -const mockConfig = () => ({ path: '/foo', title: 'Foo' }); -const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( - <>{children} -); - -const pluginA = createPlugin({ id: 'my-plugin-a' }); -const pluginB = createPlugin({ id: 'my-plugin-b' }); -const pluginC = createPlugin({ id: 'my-plugin-c' }); - -const ref1 = createRouteRef(mockConfig()); -const ref2 = createRouteRef(mockConfig()); - -const Extension1 = pluginA.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref1, - }), -); -const Extension2 = pluginB.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref2, - }), -); -const Extension3 = pluginA.provide( - createComponentExtension({ component: { sync: MockComponent } }), -); -const Extension4 = pluginB.provide( - createComponentExtension({ component: { sync: MockComponent } }), -); -const Extension5 = pluginC.provide( - createComponentExtension({ component: { sync: MockComponent } }), -); - -describe('collection', () => { - it('should collect the plugins', () => { - const root = ( - - - -
- -
-
- {[]} - Some text here shouldn't be a problem -
- {null} -
- -
- - {false} - {true} - {0} -
- -
- } /> -
- - - ); - - const { plugins } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - plugins: pluginCollector, - }, - }); - - expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); - }); -}); diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts deleted file mode 100644 index 7e021beb24..0000000000 --- a/packages/core-api/src/plugin/collectors.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 { BackstagePlugin } from './types'; -import { getComponentData } from '../extensions'; -import { createCollector } from '../extensions/traversal'; - -export const pluginCollector = createCollector( - () => new Set>(), - (acc, node) => { - const plugin = getComponentData>( - node, - 'core.plugin', - ); - if (plugin) { - acc.add(plugin); - } - }, -); diff --git a/packages/core-api/src/plugin/index.ts b/packages/core-api/src/plugin/index.ts deleted file mode 100644 index 54903ecf17..0000000000 --- a/packages/core-api/src/plugin/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 { createPlugin } from './Plugin'; -export type { - BackstagePlugin, - Extension, - FeatureFlagOutput, - FeatureFlagsHooks, - LegacyRedirectRouteOutput, - LegacyRouteOutput, - PluginConfig, - PluginHooks, - PluginOutput, - RedirectRouteOutput, - RouteOptions, - RouteOutput, - RoutePath, - RouterHooks, -} from './types'; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts deleted file mode 100644 index a00897da43..0000000000 --- a/packages/core-api/src/plugin/types.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 { ComponentType } from 'react'; -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 -export type LegacyRouteOutput = { - type: 'legacy-route'; - path: RoutePath; - component: ComponentType<{}>; - options?: RouteOptions; -}; - -export type RouteOutput = { - type: 'route'; - target: RouteRef; - component: ComponentType<{}>; - options?: RouteOptions; -}; - -export type RedirectRouteOutput = { - type: 'redirect-route'; - from: RouteRef; - to: RouteRef; - options?: RouteOptions; -}; - -export type LegacyRedirectRouteOutput = { - type: 'legacy-redirect-route'; - path: RoutePath; - target: RoutePath; - options?: RouteOptions; -}; - -export type FeatureFlagOutput = { - type: 'feature-flag'; - name: string; -}; - -export type PluginOutput = - | LegacyRouteOutput - | RouteOutput - | LegacyRedirectRouteOutput - | RedirectRouteOutput - | FeatureFlagOutput; - -export type Extension = { - expose(plugin: BackstagePlugin): T; -}; - -export type AnyRoutes = { [name: string]: RouteRef | SubRouteRef }; - -export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; - -export type BackstagePlugin< - Routes extends AnyRoutes = {}, - ExternalRoutes extends AnyExternalRoutes = {} -> = { - getId(): string; - output(): PluginOutput[]; - getApis(): Iterable; - provide(extension: Extension): T; - routes: Routes; - externalRoutes: ExternalRoutes; -}; - -export type PluginConfig< - Routes extends AnyRoutes, - ExternalRoutes extends AnyExternalRoutes -> = { - id: string; - apis?: Iterable; - register?(hooks: PluginHooks): void; - routes?: Routes; - externalRoutes?: ExternalRoutes; -}; - -export type PluginHooks = { - /** - * @deprecated All router hooks have been deprecated - */ - router: RouterHooks; - featureFlags: FeatureFlagsHooks; -}; - -export type RouterHooks = { - /** - * @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins - */ - addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, - ): void; -}; - -export type FeatureFlagsHooks = { - register(name: string): void; -}; diff --git a/packages/core-api/src/private.ts b/packages/core-api/src/private.ts deleted file mode 100644 index 56a7546925..0000000000 --- a/packages/core-api/src/private.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { PrivateAppImpl } from './app/App'; diff --git a/packages/core-api/src/public.ts b/packages/core-api/src/public.ts deleted file mode 100644 index 28b078357d..0000000000 --- a/packages/core-api/src/public.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 * from './apis'; -export * from './app'; -export * from './extensions'; -export * from './icons'; -export * from './plugin'; -export * from './routing'; -export * from './types'; diff --git a/packages/core-api/src/routing/ExternalRouteRef.test.ts b/packages/core-api/src/routing/ExternalRouteRef.test.ts deleted file mode 100644 index 458f7d9426..0000000000 --- a/packages/core-api/src/routing/ExternalRouteRef.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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 { AnyParams, ExternalRouteRef } from './types'; -import { createExternalRouteRef, isExternalRouteRef } from './ExternalRouteRef'; -import { isSubRouteRef } from './SubRouteRef'; -import { isRouteRef } from './RouteRef'; - -describe('ExternalRouteRef', () => { - it('should be created', () => { - const routeRef: ExternalRouteRef = createExternalRouteRef({ - id: 'my-route-ref', - }); - expect(routeRef.params).toEqual([]); - expect(routeRef.optional).toBe(false); - expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}'); - expect(isRouteRef(routeRef)).toBe(false); - expect(isSubRouteRef(routeRef)).toBe(false); - expect(isExternalRouteRef(routeRef)).toBe(true); - - expect(isRouteRef({} as ExternalRouteRef)).toBe(false); - }); - - it('should be created as optional', () => { - const routeRef: ExternalRouteRef<{ - x: string; - y: string; - }> = createExternalRouteRef({ - id: 'my-other-route-ref', - params: [], - optional: true, - }); - expect(routeRef.params).toEqual([]); - expect(routeRef.optional).toEqual(true); - }); - - it('should be created with params', () => { - const routeRef: ExternalRouteRef<{ - x: string; - y: string; - }> = createExternalRouteRef({ - id: 'my-other-route-ref', - params: ['x', 'y'], - }); - expect(routeRef.params).toEqual(['x', 'y']); - expect(routeRef.optional).toEqual(false); - }); - - it('should be created as optional with params', () => { - const routeRef: ExternalRouteRef<{ - x: string; - y: string; - }> = createExternalRouteRef({ - id: 'my-other-route-ref', - params: ['x', 'y'], - optional: true, - }); - expect(routeRef.params).toEqual(['x', 'y']); - expect(routeRef.optional).toEqual(true); - }); - - it('should properly infer and validate parameter types and assignments', () => { - function validateType( - _ref: ExternalRouteRef, - ) {} - - const _1 = createExternalRouteRef({ id: '1', params: ['notX'] }); - // @ts-expect-error - validateType<{ x: string }, any>(_1); - validateType<{ notX: string }, any>(_1); - - const _2 = createExternalRouteRef({ - id: '2', - params: ['x'], - optional: true, - }); - // @ts-expect-error - validateType(_2); - validateType<{ x: string }, true>(_2); - - const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); - // @ts-expect-error - validateType<{ x: string }, any>(_3); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead - validateType<{ x: string; y: string; z: string }, any>(_3); - validateType<{ x: string; y: string }, false>(_3); - - const _4 = createExternalRouteRef({ id: '4', params: [] }); - // @ts-expect-error - validateType<{ x: string }, any>(_4); - validateType(_4); - - const _5 = createExternalRouteRef({ id: '5' }); - // @ts-expect-error - validateType<{ x: string }, any>(_5); - validateType(_5); - - const _6 = createExternalRouteRef({ id: '6', optional: true }); - // @ts-expect-error - validateType(_6); - validateType(_6); - - // To avoid complains about missing expectations and unused vars - expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String)); - }); -}); diff --git a/packages/core-api/src/routing/ExternalRouteRef.ts b/packages/core-api/src/routing/ExternalRouteRef.ts deleted file mode 100644 index 0bc5809449..0000000000 --- a/packages/core-api/src/routing/ExternalRouteRef.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 { - RouteRef, - SubRouteRef, - ExternalRouteRef, - routeRefType, - AnyParams, -} from './types'; - -export { createExternalRouteRef } from '@backstage/core-plugin-api'; - -export function isExternalRouteRef< - Params extends AnyParams, - Optional extends boolean ->( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): routeRef is ExternalRouteRef { - return routeRef[routeRefType] === 'external'; -} diff --git a/packages/core-api/src/routing/FlatRoutes.test.tsx b/packages/core-api/src/routing/FlatRoutes.test.tsx deleted file mode 100644 index 20ee0db235..0000000000 --- a/packages/core-api/src/routing/FlatRoutes.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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, RenderResult } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; -import { AppContext } from '../app'; -import { AppContextProvider } from '../app/AppContext'; -import { FlatRoutes } from './FlatRoutes'; - -function makeRouteRenderer(node: ReactNode) { - let rendered: RenderResult | undefined = undefined; - return (path: string) => { - const content = ( - ({ - NotFoundErrorPage: () => <>Not Found, - }), - } as unknown) as AppContext - } - > - - - ); - if (rendered) { - rendered.unmount(); - rendered.rerender(content); - } else { - rendered = render(content); - } - return rendered; - }; -} - -describe('FlatRoutes', () => { - it('renders some routes', () => { - const renderRoute = makeRouteRenderer( - - a} /> - b} /> - , - ); - expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); - expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); - expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); - expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); - expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); - }); - - it('is not sensitive to ordering and overlapping routes', () => { - // The '/*' suffixes here are intentional and will be ignored by FlatRoutes - const routes = ( - <> - a-1} /> - a} /> - a-2} /> - - ); - const renderRoute = makeRouteRenderer({routes}); - expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); - expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); - expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); - renderRoute('').unmount(); - - // This uses regular Routes from react-router, not that a-2 renders a, which is the behavior we're working around - const renderBadRoute = makeRouteRenderer({routes}); - expect(renderBadRoute('/a').getByText('a')).toBeInTheDocument(); - expect(renderBadRoute('/a-1').getByText('a-1')).toBeInTheDocument(); - expect(renderBadRoute('/a-2').getByText('a')).toBeInTheDocument(); - }); - - it('renders children straight as outlets', () => { - const MyPage = () => { - return <>Outlet: {useOutlet()}; - }; - - // The '/*' suffixes here are intentional and will be ignored by FlatRoutes - const routes = ( - <> - }> - a - - }> - a-b - - }> - b - - - ); - const renderRoute = makeRouteRenderer({routes}); - expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); - expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); - expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); - }); -}); diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx deleted file mode 100644 index 9280e183be..0000000000 --- a/packages/core-api/src/routing/FlatRoutes.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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, { ReactNode, Children, isValidElement, Fragment } from 'react'; -import { useRoutes } from 'react-router-dom'; -import { useApp } from '../app'; - -type RouteObject = { - path: string; - element: JSX.Element; - children?: RouteObject[]; -}; - -// Similar to the same function from react-router, this collects routes from the -// children, but only the first level of routes -function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { - return Children.toArray(childrenNode).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - const { children } = child.props; - - if (child.type === Fragment) { - return createRoutesFromChildren(children); - } - - let path = child.props.path as string | undefined; - - // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone - if (path === '') { - return []; - } - path = path?.replace(/\/\*$/, '') ?? '/'; - - return [ - { - path, - element: child, - children: children && [ - { - path: '/*', - element: children, - }, - ], - }, - ]; - }); -} - -type FlatRoutesProps = { - children: ReactNode; -}; - -export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { - const app = useApp(); - const { NotFoundErrorPage } = app.getComponents(); - const routes = createRoutesFromChildren(props.children) - // Routes are sorted to work around a bug where prefixes are unexpectedly matched - .sort((a, b) => b.path.localeCompare(a.path)) - // We make sure all routes have '/*' appended, except '/' - .map(obj => { - obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; - return obj; - }); - - // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop - routes.push({ - element: , - path: '/*', - }); - - return useRoutes(routes); -}; diff --git a/packages/core-api/src/routing/RouteRef.test.ts b/packages/core-api/src/routing/RouteRef.test.ts deleted file mode 100644 index 92d218eb5b..0000000000 --- a/packages/core-api/src/routing/RouteRef.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 { AnyParams, RouteRef } from './types'; -import { createRouteRef, isRouteRef } from './RouteRef'; -import { isSubRouteRef } from './SubRouteRef'; -import { isExternalRouteRef } from './ExternalRouteRef'; -import MyIcon from '@material-ui/icons/AcUnit'; - -describe('RouteRef', () => { - it('should be created', () => { - const routeRef: RouteRef = createRouteRef({ - id: 'my-route-ref', - }); - expect(routeRef.params).toEqual([]); - expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}'); - expect(isRouteRef(routeRef)).toBe(true); - expect(isSubRouteRef(routeRef)).toBe(false); - expect(isExternalRouteRef(routeRef)).toBe(false); - - expect(isRouteRef({} as RouteRef)).toBe(false); - }); - - it('should be created with params', () => { - const routeRef: RouteRef<{ - x: string; - y: string; - }> = createRouteRef({ - id: 'my-other-route-ref', - params: ['x', 'y'], - }); - expect(routeRef.params).toEqual(['x', 'y']); - }); - - it('should properly infer and validate parameter types and assignments', () => { - function validateType(_ref: RouteRef) {} - - const _1 = createRouteRef({ id: '1', params: ['x'] }); - // @ts-expect-error - validateType<{ y: string }>(_1); - // @ts-expect-error - validateType(_1); - validateType<{ x: string }>(_1); - - const _2 = createRouteRef({ id: '2', params: ['x', 'y'] }); - // @ts-expect-error - validateType<{ x: string }>(_2); - // @ts-expect-error - validateType(_2); - // @ts-expect-error - validateType<{ x: string; z: string }>(_2); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead - validateType<{ x: string; y: string; z: string }>(_2); - validateType<{ x: string; y: string }>(_2); - - const _3 = createRouteRef({ id: '3', params: [] }); - // @ts-expect-error - validateType<{ x: string }>(_3); - validateType(_3); - - const _4 = createRouteRef({ id: '4' }); - // @ts-expect-error - validateType<{ x: string }>(_4); - validateType(_4); - - // To avoid complains about missing expectations and unused vars - expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); - }); - - it('should support deprecated access', () => { - const routeRef = createRouteRef({ - title: 'My Ref', - path: '/my-path', - icon: MyIcon, - }); - expect(routeRef.title).toBe('My Ref'); - expect(routeRef.path).toBe('/my-path'); - expect(routeRef.icon).toBe(MyIcon); - expect(String(routeRef)).toBe('routeRef{type=absolute,id=My Ref}'); - }); -}); diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts deleted file mode 100644 index 21f28eba86..0000000000 --- a/packages/core-api/src/routing/RouteRef.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { - RouteRef, - SubRouteRef, - ExternalRouteRef, - routeRefType, - AnyParams, - ParamKeys, -} from './types'; -import { IconComponent } from '../icons/types'; - -export { createRouteRef } from '@backstage/core-plugin-api'; - -// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused -export type RouteRefConfig = { - params?: ParamKeys; - path?: string; - icon?: IconComponent; - title: string; -}; - -export function isRouteRef( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): routeRef is RouteRef { - return routeRef[routeRefType] === 'absolute'; -} diff --git a/packages/core-api/src/routing/RouteResolver.test.ts b/packages/core-api/src/routing/RouteResolver.test.ts deleted file mode 100644 index 901c30dfc1..0000000000 --- a/packages/core-api/src/routing/RouteResolver.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * 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 { createRouteRef } from './RouteRef'; -import { createSubRouteRef } from './SubRouteRef'; -import { createExternalRouteRef } from './ExternalRouteRef'; -import { RouteResolver } from './RouteResolver'; -import { ExternalRouteRef, RouteRef, SubRouteRef } from './types'; -import { MATCH_ALL_ROUTE } from './collectors'; - -const element = () => null; -const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] }; - -const ref1 = createRouteRef({ id: 'rr1' }); -const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); -const ref3 = createRouteRef({ id: 'rr3', params: ['y'] }); -const subRef1 = createSubRouteRef({ - id: 'srr1', - parent: ref1, - path: '/foo', -}); -const subRef2 = createSubRouteRef({ - id: 'srr2', - parent: ref1, - path: '/foo/:a', -}); -const subRef3 = createSubRouteRef({ - id: 'srr3', - parent: ref2, - path: '/bar', -}); -const subRef4 = createSubRouteRef({ - id: 'srr4', - parent: ref2, - path: '/bar/:a', -}); -const externalRef1 = createExternalRouteRef({ id: 'err1' }); -const externalRef2 = createExternalRouteRef({ - id: 'err2', - optional: true, -}); -const externalRef3 = createExternalRouteRef({ id: 'err3', params: ['x'] }); -const externalRef4 = createExternalRouteRef({ - id: 'err4', - optional: true, - params: ['x'], -}); - -describe('RouteResolver', () => { - it('should not resolve anything with an empty resolver', () => { - const r = new RouteResolver(new Map(), new Map(), [], new Map()); - - expect(r.resolve(ref1, '/')?.()).toBe(undefined); - expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); - expect(r.resolve(subRef1, '/')?.()).toBe(undefined); - expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined); - expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); - expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); - expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); - expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); - expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); - expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); - }); - - it('should resolve an absolute route', () => { - const r = new RouteResolver( - new Map([[ref1, '/my-route']]), - new Map(), - [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], - new Map(), - ); - - expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); - expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); - expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); - expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); - expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); - expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); - expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); - expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); - expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); - expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); - }); - - it('should resolve an absolute route with a param and with a parent', () => { - const r = new RouteResolver( - new Map([ - [ref1, '/my-route'], - [ref2, '/my-parent/:x'], - ]), - new Map([[ref2, ref1]]), - [ - { - routeRefs: new Set([ref2]), - path: '/my-parent/:x', - ...rest, - children: [ - MATCH_ALL_ROUTE, - { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, - ], - }, - ], - new Map([ - [externalRef1, ref1], - [externalRef3, ref2], - [externalRef4, subRef3], - ]), - ); - - expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); - expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x'); - expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); - expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); - expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe( - '/my-route/my-parent/3x/bar', - ); - expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe( - '/my-route/my-parent/4x/bar/4a', - ); - expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route'); - expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); - expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe( - '/my-route/my-parent/5x', - ); - expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe( - '/my-route/my-parent/6x/bar', - ); - }); - - it('should resolve the most specific match', () => { - const r = new RouteResolver( - new Map([ - [ref1, '/deep'], - [ref2, '/root/:x'], - [ref3, '/sub/:y'], - ]), - new Map([ - [ref3, ref2], - [ref1, ref3], - ]), - [ - { - routeRefs: new Set([ref2]), - path: '/root/:x', - ...rest, - children: [ - MATCH_ALL_ROUTE, - { - routeRefs: new Set([ref3]), - path: '/sub/:y', - ...rest, - children: [ - MATCH_ALL_ROUTE, - { - routeRefs: new Set([ref1]), - path: '/deep', - ...rest, - }, - ], - }, - ], - }, - ], - new Map(), - ); - - expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x'); - expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y'); - - expect(() => r.resolve(ref1, '/')?.()).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(() => r.resolve(ref1, '/root/x')?.()).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep'); - // Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here - expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe( - '/root/x/sub/y/deep', - ); - }); - - it('should resolve an absolute route with multiple parents', () => { - const r = new RouteResolver( - new Map([ - [ref1, '/my-route'], - [ref2, '/my-parent/:x'], - [ref3, '/my-grandparent/:y'], - ]), - new Map([ - [ref1, ref2], - [ref2, ref3], - ]), - [ - { - routeRefs: new Set([ref3]), - path: '/my-grandparent/:y', - ...rest, - children: [ - MATCH_ALL_ROUTE, - { - routeRefs: new Set([ref2]), - path: '/my-parent/:x', - ...rest, - children: [ - MATCH_ALL_ROUTE, - { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, - ], - }, - ], - }, - ], - new Map([ - [externalRef1, ref1], - [externalRef3, ref2], - [externalRef4, subRef3], - ]), - ); - - const l = '/my-grandparent/my-y/my-parent/my-x'; - expect(r.resolve(ref1, l)?.()).toBe( - '/my-grandparent/my-y/my-parent/my-x/my-route', - ); - expect(() => r.resolve(ref1, '/')?.()).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe( - '/my-grandparent/my-y/my-parent/1x', - ); - expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe( - '/my-grandparent/my-y/my-parent/1x', - ); - expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(subRef1, l)?.()).toBe( - '/my-grandparent/my-y/my-parent/my-x/my-route/foo', - ); - expect(() => r.resolve(subRef1, '/')?.()).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe( - '/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a', - ); - expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe( - '/my-grandparent/my-y/my-parent/3x/bar', - ); - expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe( - '/my-grandparent/my-y/my-parent/3x/bar', - ); - expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe( - '/my-grandparent/my-y/my-parent/4x/bar/4a', - ); - expect( - r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }), - ).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a'); - expect(r.resolve(externalRef1, l)?.()).toBe( - '/my-grandparent/my-y/my-parent/my-x/my-route', - ); - expect(() => r.resolve(externalRef1, '/')?.()).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(externalRef2, l)?.()).toBe(undefined); - expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe( - '/my-grandparent/my-y/my-parent/5x', - ); - expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe( - '/my-grandparent/my-y/my-parent/6x/bar', - ); - expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow( - /^Cannot route.*with parent.*as it has parameters$/, - ); - }); -}); diff --git a/packages/core-api/src/routing/RouteResolver.ts b/packages/core-api/src/routing/RouteResolver.ts deleted file mode 100644 index 4d4096a026..0000000000 --- a/packages/core-api/src/routing/RouteResolver.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - * 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 { generatePath, matchRoutes } from 'react-router-dom'; -import { - AnyRouteRef, - BackstageRouteObject, - RouteRef, - ExternalRouteRef, - AnyParams, - SubRouteRef, - routeRefType, - RouteFunc, -} from './types'; -import { isRouteRef } from './RouteRef'; -import { isSubRouteRef } from './SubRouteRef'; -import { isExternalRouteRef } from './ExternalRouteRef'; - -// Joins a list of paths together, avoiding trailing and duplicate slashes -function joinPaths(...paths: string[]): string { - const normalized = paths.join('/').replace(/\/\/+/g, '/'); - if (normalized !== '/' && normalized.endsWith('/')) { - return normalized.slice(0, -1); - } - return normalized; -} - -/** - * Resolves the absolute route ref that our target route ref is pointing pointing to, as well - * as the relative target path. - * - * Returns an undefined target ref if one could not be fully resolved. - */ -function resolveTargetRef( - anyRouteRef: AnyRouteRef, - routePaths: Map, - routeBindings: Map, -): readonly [RouteRef | undefined, string] { - // First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append. - // For sub routes it will be the parent path, while for external routes it will be the bound route. - let targetRef: RouteRef; - let subRoutePath = ''; - if (isRouteRef(anyRouteRef)) { - targetRef = anyRouteRef; - } else if (isSubRouteRef(anyRouteRef)) { - targetRef = anyRouteRef.parent; - subRoutePath = anyRouteRef.path; - } else if (isExternalRouteRef(anyRouteRef)) { - const resolvedRoute = routeBindings.get(anyRouteRef); - if (!resolvedRoute) { - return [undefined, '']; - } - if (isRouteRef(resolvedRoute)) { - targetRef = resolvedRoute; - } else if (isSubRouteRef(resolvedRoute)) { - targetRef = resolvedRoute.parent; - subRoutePath = resolvedRoute.path; - } else { - throw new Error( - `ExternalRouteRef was bound to invalid target, ${resolvedRoute}`, - ); - } - } else if (anyRouteRef[routeRefType]) { - throw new Error( - `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, - ); - } else { - throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`); - } - - // Bail if no absolute path could be resolved - if (!targetRef) { - return [undefined, '']; - } - - // Find the path that our target route is bound to - const resolvedPath = routePaths.get(targetRef); - if (!resolvedPath) { - return [undefined, '']; - } - - // SubRouteRefs join the path from the parent route with its own path - const targetPath = joinPaths(resolvedPath, subRoutePath); - return [targetRef, targetPath]; -} - -/** - * Resolves the complete base path for navigating to the target RouteRef. - */ -function resolveBasePath( - targetRef: RouteRef, - sourceLocation: Parameters[1], - routePaths: Map, - routeParents: Map, - routeObjects: BackstageRouteObject[], -) { - // While traversing the app element tree we build up the routeObjects structure - // used here. It is the same kind of structure that react-router creates, with the - // addition that associated route refs are stored throughout the tree. This lets - // us look up all route refs that can be reached from our source location. - // Because of the similar route object structure, we can use `matchRoutes` from - // react-router to do the lookup of our current location. - const match = matchRoutes(routeObjects, sourceLocation) ?? []; - - // While we search for a common routing root between our current location and - // the target route, we build a list of all route refs we find that we need - // to traverse to reach the target. - const refDiffList = Array(); - - let matchIndex = -1; - for ( - let targetSearchRef: RouteRef | undefined = targetRef; - targetSearchRef; - targetSearchRef = routeParents.get(targetSearchRef) - ) { - // The match contains a list of all ancestral route refs present at our current location - // Starting at the desired target ref and traversing back through its parents, we search - // for a target ref that is present in the match for our current location. When a match - // is found it means we have found a common base to resolve the route from. - matchIndex = match.findIndex(m => - (m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!), - ); - if (matchIndex !== -1) { - break; - } - - // Every time we move a step up in the ancestry of the target ref, we add the current ref - // to the diff list, which ends up being the list of route refs to traverse form the common base - // in order to reach our target. - refDiffList.unshift(targetSearchRef); - } - - // If our target route is present in the initial match we need to construct the final path - // from the parent of the matched route segment. That's to allow the caller of the route - // function to supply their own params. - if (refDiffList.length === 0) { - matchIndex -= 1; - } - - // This is the part of the route tree that the target and source locations have in common. - // We re-use the existing pathname directly along with all params. - const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; - - // This constructs the mid section of the path using paths resolved from all route refs - // we need to traverse to reach our target except for the very last one. None of these - // paths are allowed to require any parameters, as the caller would have no way of knowing - // what parameters those are. - const diffPath = joinPaths( - ...refDiffList.slice(0, -1).map(ref => { - const path = routePaths.get(ref); - if (!path) { - throw new Error(`No path for ${ref}`); - } - if (path.includes(':')) { - throw new Error( - `Cannot route to ${targetRef} with parent ${ref} as it has parameters`, - ); - } - return path; - }), - ); - - return parentPath + diffPath; -} - -export class RouteResolver { - constructor( - private readonly routePaths: Map, - private readonly routeParents: Map, - private readonly routeObjects: BackstageRouteObject[], - private readonly routeBindings: Map< - ExternalRouteRef, - RouteRef | SubRouteRef - >, - ) {} - - resolve( - anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, - sourceLocation: Parameters[1], - ): RouteFunc | undefined { - // First figure out what our target absolute ref is, as well as our target path. - const [targetRef, targetPath] = resolveTargetRef( - anyRouteRef, - this.routePaths, - this.routeBindings, - ); - if (!targetRef) { - return undefined; - } - - // Next we figure out the base path, which is the combination of the common parent path - // between our current location and our target location, as well as the additional path - // that is the difference between the parent path and the base of our target location. - const basePath = resolveBasePath( - targetRef, - sourceLocation, - this.routePaths, - this.routeParents, - this.routeObjects, - ); - - const routeFunc: RouteFunc = (...[params]) => { - return basePath + generatePath(targetPath, params); - }; - return routeFunc; - } -} diff --git a/packages/core-api/src/routing/SubRouteRef.test.ts b/packages/core-api/src/routing/SubRouteRef.test.ts deleted file mode 100644 index 0a12b2a153..0000000000 --- a/packages/core-api/src/routing/SubRouteRef.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * 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 { AnyParams, SubRouteRef } from './types'; -import { createSubRouteRef, isSubRouteRef } from './SubRouteRef'; -import { createRouteRef, isRouteRef } from './RouteRef'; -import { isExternalRouteRef } from './ExternalRouteRef'; - -const parent = createRouteRef({ id: 'parent' }); -const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); - -describe('SubRouteRef', () => { - it('should be created', () => { - const routeRef: SubRouteRef = createSubRouteRef({ - parent, - id: 'my-route-ref', - path: '/foo', - }); - expect(routeRef.path).toBe('/foo'); - expect(routeRef.parent).toBe(parent); - expect(routeRef.params).toEqual([]); - expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}'); - expect(isRouteRef(routeRef)).toBe(false); - expect(isSubRouteRef(routeRef)).toBe(true); - expect(isExternalRouteRef(routeRef)).toBe(false); - - expect(isRouteRef({} as SubRouteRef)).toBe(false); - }); - - it('should be created with params', () => { - const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({ - parent, - id: 'my-other-route-ref', - path: '/foo/:bar', - }); - expect(routeRef.path).toBe('/foo/:bar'); - expect(routeRef.parent).toBe(parent); - expect(routeRef.params).toEqual(['bar']); - }); - - it('should be created with merged params', () => { - const routeRef: SubRouteRef<{ - x: string; - y: string; - z: string; - }> = createSubRouteRef({ - parent: parentX, - id: 'my-other-route-ref', - path: '/foo/:y/:z', - }); - expect(routeRef.path).toBe('/foo/:y/:z'); - expect(routeRef.parent).toBe(parentX); - expect(routeRef.params).toEqual(['x', 'y', 'z']); - }); - - it('should be created with params from parent', () => { - const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({ - parent: parentX, - id: 'my-other-route-ref', - path: '/foo/bar', - }); - expect(routeRef.path).toBe('/foo/bar'); - expect(routeRef.parent).toBe(parentX); - expect(routeRef.params).toEqual(['x']); - }); - - it.each([ - ['foo', "SubRouteRef path must start with '/', got 'foo'"], - [':foo', "SubRouteRef path must start with '/', got ':foo'"], - ['', "SubRouteRef path must start with '/', got ''"], - ['/', "SubRouteRef path must not end with '/', got '/'"], - ['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"], - ['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'], - ['/:/foo', "SubRouteRef path has invalid param, got ''"], - ['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"], - ['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"], - ])('should throw if path is invalid, %s', (path, message) => { - expect(() => - createSubRouteRef({ path, parent: parentX, id: path }), - ).toThrow(message); - }); - - it('should properly infer and parse path parameters', () => { - function validateType(_ref: SubRouteRef) {} - - const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); - // @ts-expect-error - validateType<{ x: string }>(_1); - validateType(_1); - - const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); - // @ts-expect-error - validateType(_2); - // @ts-expect-error - validateType<{ x: string; z: string }>(_2); - // @ts-expect-error - validateType<{ y: string }>(_2); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead - validateType<{ x: string; y: string; z: string }>(_2); - validateType<{ x: string; y: string }>(_2); - - const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); - // @ts-expect-error - validateType(_3); - // @ts-expect-error - validateType<{ y: string }>(_3); - validateType<{ x: string }>(_3); - - const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); - // @ts-expect-error - validateType(_4); - // @ts-expect-error - validateType<{ x: string; z: string }>(_4); - // @ts-expect-error - validateType<{ y: string }>(_4); - validateType<{ x: string; y: string }>(_4); - - // To avoid complains about missing expectations and unused vars - expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); - }); -}); diff --git a/packages/core-api/src/routing/SubRouteRef.ts b/packages/core-api/src/routing/SubRouteRef.ts deleted file mode 100644 index b64bce9d8a..0000000000 --- a/packages/core-api/src/routing/SubRouteRef.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { - AnyParams, - ExternalRouteRef, - RouteRef, - routeRefType, - SubRouteRef, -} from './types'; - -export { createSubRouteRef } from '@backstage/core-plugin-api'; - -export function isSubRouteRef( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): routeRef is SubRouteRef { - return routeRef[routeRefType] === 'sub'; -} diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx deleted file mode 100644 index 21ef219555..0000000000 --- a/packages/core-api/src/routing/collectors.test.tsx +++ /dev/null @@ -1,374 +0,0 @@ -/* - * 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, { PropsWithChildren } from 'react'; -import { - routePathCollector, - routeParentCollector, - routeObjectCollector, -} from './collectors'; - -import { - traverseElementTree, - childDiscoverer, - routeElementDiscoverer, -} from '../extensions/traversal'; -import { createRouteRef } from './RouteRef'; -import { createPlugin } from '../plugin'; -import { attachComponentData, createRoutableExtension } from '../extensions'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { RouteRef } from './types'; - -const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( - <>{children} -); - -const plugin = createPlugin({ id: 'my-plugin' }); - -const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); -const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); -const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); -const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); -const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' }); -const refOrder = [ref1, ref2, ref3, ref4, ref5]; - -const Extension1 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref1, - }), -); -const Extension2 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref2, - }), -); -const Extension3 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref3, - }), -); -const Extension4 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref4, - }), -); -const Extension5 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref5, - }), -); - -const AggregationComponent = ({ - children, -}: PropsWithChildren<{ - path: string; -}>) => <>{children}; - -attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); - -function sortedEntries(map: Map): [RouteRef, T][] { - return Array.from(map).sort( - ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), - ); -} - -function routeObj( - path: string, - refs: RouteRef[], - children: any[] = [], - type: 'mounted' | 'gathered' = 'mounted', -) { - return { - path: path, - caseSensitive: false, - element: type, - routeRefs: new Set(refs), - children: [ - { - path: '/*', - caseSensitive: false, - element: 'match-all', - routeRefs: new Set(), - }, - ...children, - ], - }; -} - -describe('discovery', () => { - it('should collect routes', () => { - const list = [ -
, -
, -
- -
, - ]; - - const root = ( - - - -
- -
-
- Some text here shouldn't be a problem -
- {null} -
- -
- - {false} - {list} - {true} - {0} -
- -
- } /> -
- - - ); - - const { routes, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - expect(sortedEntries(routes)).toEqual([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]); - expect(sortedEntries(routeParents)).toEqual([ - [ref1, undefined], - [ref2, ref1], - [ref3, ref2], - [ref4, undefined], - [ref5, ref1], - ]); - expect(routeObjects).toEqual([ - routeObj( - '/foo', - [ref1], - [ - routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), - routeObj('/blop', [ref5]), - ], - ), - routeObj('/divsoup', [ref4]), - ]); - }); - - it('should handle all react router Route patterns', () => { - const root = ( - - - - - - - - } - /> - }> - } /> - - - - - ); - - const { routes, routeParents } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - }, - }); - expect(sortedEntries(routes)).toEqual([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]); - expect(sortedEntries(routeParents)).toEqual([ - [ref1, undefined], - [ref2, ref1], - [ref3, undefined], - [ref4, ref3], - [ref5, ref3], - ]); - }); - - it('should use the route aggregator key to bind child routes to the same path', () => { - const root = ( - - - - -
- -
- HELLO -
- - - - - - - -
-
- ); - - const { routes, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - expect(sortedEntries(routes)).toEqual([ - [ref1, '/foo'], - [ref2, '/foo'], - [ref3, '/bar'], - [ref4, '/baz'], - [ref5, '/baz'], - ]); - expect(sortedEntries(routeParents)).toEqual([ - [ref1, undefined], - [ref2, undefined], - [ref3, undefined], - [ref4, ref3], - [ref5, ref3], - ]); - expect(routeObjects).toEqual([ - routeObj('/foo', [ref1, ref2], [], 'gathered'), - routeObj( - '/bar', - [ref3], - [routeObj('/baz', [ref4, ref5], [], 'gathered')], - ), - ]); - }); - - it('should use the route aggregator but stop when encountering explicit path', () => { - const root = ( - - - - - - - - - - - - - - - ); - - const { routes, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - expect(sortedEntries(routes)).toEqual([ - [ref1, '/foo'], - [ref2, '/bar'], - [ref3, '/baz'], - [ref4, '/blop'], - [ref5, '/bar'], - ]); - expect(sortedEntries(routeParents)).toEqual([ - [ref1, undefined], - [ref2, ref1], - [ref3, ref1], - [ref4, ref3], - [ref5, ref1], - ]); - expect(routeObjects).toEqual([ - routeObj( - '/foo', - [ref1], - [ - routeObj( - '/bar', - [ref2, ref5], - [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], - 'gathered', - ), - ], - ), - ]); - }); - - it('should stop gathering mount points after encountering explicit path', () => { - const root = ( - - - - - - - - - - - - ); - - expect(() => { - traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - }, - }); - }).toThrow('Mounted routable extension must have a path'); - }); -}); diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx deleted file mode 100644 index 7db51e5014..0000000000 --- a/packages/core-api/src/routing/collectors.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * 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 { isValidElement, ReactElement, ReactNode } from 'react'; -import { BackstageRouteObject, RouteRef } from '../routing/types'; -import { getComponentData } from '../extensions'; -import { createCollector } from '../extensions/traversal'; - -function getMountPoint(node: ReactElement): RouteRef | undefined { - const element: ReactNode = node.props?.element; - - let routeRef = getComponentData(node, 'core.mountPoint'); - if (!routeRef && isValidElement(element)) { - routeRef = getComponentData(element, 'core.mountPoint'); - } - - return routeRef; -} - -export const routePathCollector = createCollector( - () => new Map(), - (acc, node, parent, ctxPath: string | undefined) => { - // The context path is used during mount point gathering to assign the same path - // to all discovered mount points - let currentCtxPath = ctxPath; - - if (parent?.props.element === node) { - return currentCtxPath; - } - - // Start gathering mount points when we encounter a mount point gathering flag - if (getComponentData(node, 'core.gatherMountPoints')) { - const path: string | undefined = node.props?.path; - if (!path) { - throw new Error('Mount point gatherer must have a path'); - } - currentCtxPath = path; - } - - const routeRef = getMountPoint(node); - if (routeRef) { - let path: string | undefined = node.props?.path; - // If we're gathering mount points we use the context path as out path, unless - // the element has its own path, in which case we use that instead and stop gathering - if (currentCtxPath) { - if (path) { - currentCtxPath = undefined; - } else { - path = currentCtxPath; - } - } - if (!path) { - throw new Error('Mounted routable extension must have a path'); - } - acc.set(routeRef, path); - } - return currentCtxPath; - }, -); - -export const routeParentCollector = createCollector( - () => new Map(), - (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { - if (parent?.props.element === node) { - return parentRouteRef; - } - - let nextParent = parentRouteRef; - - const routeRef = getMountPoint(node); - if (routeRef) { - // "sticky" route ref is when we've encountered a mount point gatherer, and we want a - // mount points beneath it to have the same parent, regardless of internal structure - if (parentRouteRef && 'sticky' in parentRouteRef) { - acc.set(routeRef, parentRouteRef.sticky); - - // When we encounter a mount point with an explicit path, we stop gathering - // mount points withing the children and remove the sticky state - if (node.props?.path) { - nextParent = routeRef; - } else { - nextParent = parentRouteRef; - } - } else { - acc.set(routeRef, parentRouteRef); - nextParent = routeRef; - } - } - - // Mount point gatherers are marked as "sticky" - if (getComponentData(node, 'core.gatherMountPoints')) { - return { sticky: nextParent }; - } - - return nextParent; - }, -); - -// We always add a child that matches all subroutes but without any route refs. This makes -// sure that we're always able to match each route no matter how deep the navigation goes. -// The route resolver then takes care of selecting the most specific match in order to find -// mount points that are as deep in the routing tree as possible. -export const MATCH_ALL_ROUTE: BackstageRouteObject = { - caseSensitive: false, - path: '/*', - element: 'match-all', // These elements aren't used, so we add in a bit of debug information - routeRefs: new Set(), -}; - -export const routeObjectCollector = createCollector( - () => Array(), - (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { - const parentChildren = parentObj?.children ?? acc; - if (parent?.props.element === node) { - return parentObj; - } - - const path: string | undefined = node.props?.path; - const caseSensitive: boolean = Boolean(node.props?.caseSensitive); - - const routeRef = getMountPoint(node); - if (routeRef) { - if (path) { - const newObject: BackstageRouteObject = { - caseSensitive, - path, - element: 'mounted', - routeRefs: new Set([routeRef]), - children: [MATCH_ALL_ROUTE], - }; - parentChildren.push(newObject); - return newObject; - } - - parentObj?.routeRefs.add(routeRef); - } - - const isGatherer = getComponentData( - node, - 'core.gatherMountPoints', - ); - if (isGatherer) { - if (!path) { - throw new Error('Mount point gatherer must have a path'); - } - const newObject: BackstageRouteObject = { - caseSensitive, - path, - element: 'gathered', - routeRefs: new Set(), - children: [MATCH_ALL_ROUTE], - }; - parentChildren.push(newObject); - return newObject; - } - - return parentObj; - }, -); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx deleted file mode 100644 index 3d723053b0..0000000000 --- a/packages/core-api/src/routing/hooks.test.tsx +++ /dev/null @@ -1,405 +0,0 @@ -/* - * 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 } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; -import React, { - Context, - PropsWithChildren, - ReactElement, - useContext, -} from 'react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { createRoutableExtension } from '../extensions'; -import { - childDiscoverer, - routeElementDiscoverer, - traverseElementTree, -} from '../extensions/traversal'; -import { getGlobalSingleton } from '../lib/globalObject'; -import { VersionedValue } from '../lib/versionedValues'; -import { createPlugin } from '../plugin'; -import { - routeObjectCollector, - routeParentCollector, - routePathCollector, -} from './collectors'; -import { createExternalRouteRef } from './ExternalRouteRef'; -import { RoutingProvider, useRouteRef, useRouteRefParams } from './hooks'; -import { createRouteRef, RouteRefConfig } from './RouteRef'; -import { RouteResolver } from './RouteResolver'; -import { AnyRouteRef, ExternalRouteRef, RouteFunc, RouteRef } from './types'; -import { validateRoutes } from './validation'; - -const mockConfig = (extra?: Partial>) => ({ - path: '/unused', - title: 'Unused', - ...extra, -}); -const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( - <>{children} -); - -const plugin = createPlugin({ id: 'my-plugin' }); - -const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); -const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); -const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); -const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); -const ref5 = createRouteRef({ - ...mockConfig({ path: '/wat5' }), - params: ['x'], -}); -const eRefA = createExternalRouteRef({ id: '1' }); -const eRefB = createExternalRouteRef({ id: '2' }); -const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); -const eRefD = createExternalRouteRef({ id: '4', optional: true }); -const eRefE = createExternalRouteRef({ - id: '5', - optional: true, - params: ['z'], -}); - -const MockRouteSource = (props: { - path?: string; - name: string; - routeRef: AnyRouteRef; - params?: T; -}) => { - try { - const routeFunc = useRouteRef(props.routeRef as any) as - | RouteFunc - | undefined; - return ( -
- Path at {props.name}: {routeFunc?.(props.params) ?? ''} -
- ); - } catch (ex) { - return ( -
- Error at {props.name}: {ex.message} -
- ); - } -}; - -const Extension1 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref1, - }), -); -const Extension2 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockRouteSource), - mountPoint: ref2, - }), -); -const Extension3 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref3, - }), -); -const Extension4 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockRouteSource), - mountPoint: ref4, - }), -); -const Extension5 = plugin.provide( - createRoutableExtension({ - component: () => Promise.resolve(MockComponent), - mountPoint: ref5, - }), -); - -function withRoutingProvider( - root: ReactElement, - routeBindings: [ExternalRouteRef, RouteRef][] = [], -) { - const { routePaths, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - - return ( - - {root} - - ); -} - -describe('discovery', () => { - it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { - const root = ( - - - - - - - - - - - - - - - ); - - const rendered = render( - withRoutingProvider(root, [ - [eRefA, ref3], - [eRefB, ref1], - [eRefC, ref2], - [eRefD, ref1], - ]), - ); - - await expect( - rendered.findByText('Path at inside: /foo/bar'), - ).resolves.toBeInTheDocument(); - expect( - rendered.getByText('Path at insideExternal: /baz'), - ).toBeInTheDocument(); - expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); - expect( - rendered.getByText('Path at outsideExternal1: /foo'), - ).toBeInTheDocument(); - expect( - rendered.getByText('Path at outsideExternal2: /foo/bar'), - ).toBeInTheDocument(); - expect( - rendered.getByText('Path at outsideExternal3: /foo'), - ).toBeInTheDocument(); - expect( - rendered.getByText('Path at outsideExternal4: '), - ).toBeInTheDocument(); - }); - - it('should handle routeRefs with parameters', async () => { - const root = ( - - - - - - - - - ); - - const rendered = render(withRoutingProvider(root)); - - await expect( - rendered.findByText('Path at inside: /foo/bar/bleb'), - ).resolves.toBeInTheDocument(); - expect( - rendered.getByText('Path at outside: /foo/bar/blob'), - ).toBeInTheDocument(); - }); - - it('should handle relative routing within parameterized routePaths', async () => { - const root = ( - - - - - - - - - - - - - ); - - const rendered = render(withRoutingProvider(root)); - - await expect( - rendered.findByText('Path at inside: /foo/blob/baz'), - ).resolves.toBeInTheDocument(); - }); - - it('should throw errors for routing to other routeRefs with unsupported parameters', () => { - const root = ( - - - - - - - - - - - ); - - const rendered = render(withRoutingProvider(root)); - - expect( - rendered.getByText( - `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, - ), - ).toBeInTheDocument(); - expect( - rendered.getByText( - `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, - ), - ).toBeInTheDocument(); - }); - - it('should handle relative routing of parameterized routePaths with duplicate param names', () => { - const root = ( - - - - - - - - ); - - const { routePaths, routeParents } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - }, - }); - - expect(() => validateRoutes(routePaths, routeParents)).toThrow( - 'Parameter :id is duplicated in path /foo/:id/bar/:id', - ); - }); -}); - -describe('v1 consumer', () => { - const RoutingContext = getGlobalSingleton< - Context> - >('routing-context'); - - function useMockRouteRefV1( - routeRef: AnyRouteRef, - location: string, - ): RouteFunc | undefined { - const resolver = useContext(RoutingContext)?.atVersion(1); - if (!resolver) { - throw new Error('no impl'); - } - return resolver.resolve(routeRef, location); - } - - it('should resolve routes', () => { - const routeRef1 = createRouteRef({ id: 'ref1' }); - const routeRef2 = createRouteRef({ id: 'ref2' }); - const routeRef3 = createRouteRef({ id: 'ref3', params: ['x'] }); - - const renderedHook = renderHook( - ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), - { - initialProps: { - routeRef: routeRef1 as AnyRouteRef, - }, - wrapper: ({ children }) => ( - , string>([ - [routeRef2, '/foo'], - [routeRef3, '/bar/:x'], - ]) - } - routeParents={new Map()} - routeObjects={[]} - routeBindings={new Map()} - children={children} - /> - ), - }, - ); - - expect(renderedHook.result.current).toBe(undefined); - renderedHook.rerender({ routeRef: routeRef2 }); - expect(renderedHook.result.current?.()).toBe('/foo'); - renderedHook.rerender({ routeRef: routeRef3 }); - expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x'); - }); -}); - -describe('useRouteRefParams', () => { - it('should provide types params', () => { - const routeRef = createRouteRef({ - id: 'ref1', - params: ['a', 'b'], - }); - - const Page = () => { - const params: { a: string; b: string } = useRouteRefParams(routeRef); - - return ( -
- {params.a} - {params.b} -
- ); - }; - - const { getByText } = render( - - - - - - - , - ); - - expect(getByText('foo')).toBeInTheDocument(); - expect(getByText('bar')).toBeInTheDocument(); - }); -}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx deleted file mode 100644 index d498027efb..0000000000 --- a/packages/core-api/src/routing/hooks.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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, { - createContext, - ReactNode, - useContext, - useMemo, - Context, -} from 'react'; -import { useLocation, useParams } from 'react-router-dom'; -import { - BackstageRouteObject, - RouteRef, - ExternalRouteRef, - AnyParams, - SubRouteRef, - RouteFunc, -} from './types'; -import { RouteResolver } from './RouteResolver'; -import { - VersionedValue, - createVersionedValueMap, -} from '../lib/versionedValues'; -import { - getGlobalSingleton, - getOrCreateGlobalSingleton, -} from '../lib/globalObject'; - -type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; -const RoutingContext = getOrCreateGlobalSingleton('routing-context', () => - createContext(undefined), -); - -export function useRouteRef( - routeRef: ExternalRouteRef, -): Optional extends true ? RouteFunc | undefined : RouteFunc; -export function useRouteRef( - routeRef: RouteRef | SubRouteRef, -): RouteFunc; -export function useRouteRef( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): RouteFunc | undefined { - const sourceLocation = useLocation(); - const versionedContext = useContext( - getGlobalSingleton>('routing-context'), - ); - const resolver = versionedContext?.atVersion(1); - const routeFunc = useMemo( - () => resolver && resolver.resolve(routeRef, sourceLocation), - [resolver, routeRef, sourceLocation], - ); - - if (!versionedContext) { - throw new Error('useRouteRef used outside of routing context'); - } - if (!resolver) { - throw new Error('RoutingContext v1 not available'); - } - - const isOptional = 'optional' in routeRef && routeRef.optional; - if (!routeFunc && !isOptional) { - throw new Error(`No path for ${routeRef}`); - } - - return routeFunc; -} - -type ProviderProps = { - routePaths: Map; - routeParents: Map; - routeObjects: BackstageRouteObject[]; - routeBindings: Map; - children: ReactNode; -}; - -export const RoutingProvider = ({ - routePaths, - routeParents, - routeObjects, - routeBindings, - children, -}: ProviderProps) => { - const resolver = new RouteResolver( - routePaths, - routeParents, - routeObjects, - routeBindings, - ); - - const versionedValue = createVersionedValueMap({ 1: resolver }); - return ( - - {children} - - ); -}; - -export function useRouteRefParams( - _routeRef: RouteRef | SubRouteRef, -): Params { - return useParams() as Params; -} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts deleted file mode 100644 index 835568c313..0000000000 --- a/packages/core-api/src/routing/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 { createExternalRouteRef } from './ExternalRouteRef'; -export { FlatRoutes } from './FlatRoutes'; -export { useRouteRef, useRouteRefParams } from './hooks'; -export { createRouteRef } from './RouteRef'; -export type { RouteRefConfig } from './RouteRef'; -export { createSubRouteRef } from './SubRouteRef'; -export type { - AbsoluteRouteRef, - ConcreteRoute, - ExternalRouteRef, - MutableRouteRef, - RouteRef, - SubRouteRef, -} from './types'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts deleted file mode 100644 index 5ecb64c7fe..0000000000 --- a/packages/core-api/src/routing/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 { - RouteRef, - SubRouteRef, - ExternalRouteRef, -} from '@backstage/core-plugin-api'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; - -export type { RouteRef, SubRouteRef, ExternalRouteRef }; - -export type AnyParams = { [param in string]: string } | undefined; -export type ParamKeys = keyof Params extends never - ? [] - : (keyof Params)[]; -export type OptionalParams< - Params extends { [param in string]: string } -> = 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. -export type RouteFunc = ( - ...[params]: Params extends undefined ? readonly [] : readonly [Params] -) => string; - -type RouteRefType = Exclude< - keyof RouteRef, - 'params' | 'path' | 'title' | 'icon' ->; -export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( - 'route-ref-type', - () => Symbol('route-ref-type'), -); - -export type AnyRouteRef = - | RouteRef - | SubRouteRef - | ExternalRouteRef; - -// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release -/** @deprecated */ -export type ConcreteRoute = {}; -/** @deprecated */ -export type AbsoluteRouteRef = RouteRef<{}>; -/** @deprecated */ -export type MutableRouteRef = RouteRef<{}>; - -// A duplicate of the react-router RouteObject, but with routeRef added -export interface BackstageRouteObject { - caseSensitive: boolean; - children?: BackstageRouteObject[]; - element: React.ReactNode; - path: string; - routeRefs: Set; -} diff --git a/packages/core-api/src/routing/validation.ts b/packages/core-api/src/routing/validation.ts deleted file mode 100644 index 51078a0130..0000000000 --- a/packages/core-api/src/routing/validation.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 { AnyRouteRef } from './types'; - -export function validateRoutes( - routePaths: Map, - routeParents: Map, -) { - const notLeafRoutes = new Set(routeParents.values()); - notLeafRoutes.delete(undefined); - - for (const route of routeParents.keys()) { - if (notLeafRoutes.has(route)) { - continue; - } - - let currentRouteRef: AnyRouteRef | undefined = route; - - let fullPath = ''; - while (currentRouteRef) { - const path = routePaths.get(currentRouteRef); - if (!path) { - throw new Error(`No path for ${currentRouteRef}`); - } - fullPath = `${path}${fullPath}`; - currentRouteRef = routeParents.get(currentRouteRef); - } - - const params = fullPath.match(/:(\w+)/g); - if (params) { - for (let j = 0; j < params.length; j++) { - for (let i = j + 1; i < params.length; i++) { - if (params[i] === params[j]) { - throw new Error( - `Parameter ${params[i]} is duplicated in path ${fullPath}`, - ); - } - } - } - } - } -} diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts deleted file mode 100644 index c1d649f2ad..0000000000 --- a/packages/core-api/src/setupTests.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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'; -import 'cross-fetch/polyfill'; diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts deleted file mode 100644 index fff4cb1515..0000000000 --- a/packages/core-api/src/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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. - */ - -/** - * This file contains non-react related core types used throughout Backstage. - */ - -/** - * Observer interface for consuming an Observer, see TC39. - */ -export type Observer = { - next?(value: T): void; - error?(error: Error): void; - complete?(): void; -}; - -/** - * Subscription returned when subscribing to an Observable, see TC39. - */ -export type Subscription = { - /** - * Cancels the subscription - */ - unsubscribe(): void; - - /** - * Value indicating whether the subscription is closed. - */ - readonly closed: boolean; -}; - -// Declares the global well-known Symbol.observable -// We get the actual runtime polyfill from zen-observable -declare global { - interface SymbolConstructor { - readonly observable: symbol; - } -} - -/** - * Observable sequence of values and errors, see TC39. - * - * 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. - */ -export type Observable = { - [Symbol.observable](): Observable; - - /** - * Subscribes to this observable to start receiving new values. - */ - subscribe(observer: Observer): Subscription; - subscribe( - onNext?: (value: T) => void, - onError?: (error: Error) => void, - onComplete?: () => void, - ): Subscription; -}; diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index 4d7b7541d0..c96fbb5aba 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/core-app-api +## 0.1.3 + +### Patch Changes + +- dc3e7ce68: Introducing new UnhandledErrorForwarder installed by default. For catching unhandled promise rejections, you can override the API to align with general error handling. +- 5f4339b8c: Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API +- Updated dependencies + - @backstage/core-plugin-api@0.1.3 + ## 0.1.2 ### Patch Changes diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index ce85078a88..8cb18cf1ce 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -200,6 +200,9 @@ export { ConfigReader } // @public export function createApp(options?: AppOptions): PrivateAppImpl; +// @public +export const defaultConfigLoader: AppConfigLoader; + // @public export class ErrorAlerter implements ErrorApi { constructor(alertApi: AlertApi, errorApi: ErrorApi); diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 9298a53ea4..42db08a136 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.2", + "version": "0.1.3", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core-components": "^0.1.2", "@backstage/config": "^0.1.3", - "@backstage/core-plugin-api": "^0.1.2", + "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,8 +44,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.0", - "@backstage/test-utils": "^0.1.13", + "@backstage/cli": "^0.7.2", + "@backstage/test-utils": "^0.1.14", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", @@ -55,7 +55,7 @@ "@types/node": "^14.14.32", "@types/zen-observable": "^0.8.0", "cross-fetch": "^3.0.6", - "msw": "^0.21.3" + "msw": "^0.29.0" }, "files": [ "dist", diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 4bf27851b2..ab82774cad 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { createApp } from './createApp'; +export { createApp, defaultConfigLoader } from './createApp'; export * from './types'; diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts deleted file mode 100644 index 1d17e78402..0000000000 --- a/packages/core-app-api/src/index.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 * as index from '.'; - -describe('index', () => { - it('exports the app api', () => { - expect(index).toEqual({ - // Public API - createApp: expect.any(Function), - ApiProvider: expect.any(Function), - // TODO(Rugvip): Figure out if we need these - ApiFactoryRegistry: expect.any(Function), - ApiResolver: expect.any(Function), - ApiRegistry: expect.any(Function), - - // Components - FlatRoutes: expect.any(Function), - - // Utility API Implementations - AlertApiForwarder: expect.any(Function), - AppThemeSelector: expect.any(Function), - Auth0Auth: expect.any(Function), - ConfigReader: expect.any(Function), - ErrorAlerter: expect.any(Function), - ErrorApiForwarder: expect.any(Function), - UnhandledErrorForwarder: expect.any(Function), - FeatureFlagged: expect.any(Function), - GithubAuth: expect.any(Function), - GitlabAuth: expect.any(Function), - GoogleAuth: expect.any(Function), - LocalStorageFeatureFlags: expect.any(Function), - MicrosoftAuth: expect.any(Function), - OAuth2: expect.any(Function), - OAuthRequestManager: expect.any(Function), - OktaAuth: expect.any(Function), - OneLoginAuth: expect.any(Function), - SamlAuth: expect.any(Function), - UrlPatternDiscovery: expect.any(Function), - WebStorage: expect.any(Function), - }); - }); -}); diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 5c0442f906..89f7421d2a 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -262,7 +262,7 @@ export function DependencyGraph({ diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx index d88e190910..bab831e905 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx @@ -43,7 +43,7 @@ export const Children = () => ( used to hide/expose stack traces for warnings, like this example:
SyntaxError: Error transforming - /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx: + /home/user/github/backstage/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx: Unexpected token (42:16) at unexpected (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15) at tsParseMaybeAssignWithJSX diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.tsx index c90874fba0..37ae5b2c62 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.tsx @@ -107,6 +107,7 @@ export const WarningPanel = (props: Props) => { } diff --git a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx index 1dbeeca8c6..f8adb81a2a 100644 --- a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx +++ b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx @@ -17,7 +17,13 @@ import React from 'react'; import { ErrorBoundary } from './ErrorBoundary'; -import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { + MockErrorApi, + renderInTestApp, + withLogCollector, +} from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { errorApiRef } from '@backstage/core-plugin-api'; type BombProps = { shouldThrow?: boolean; @@ -35,28 +41,33 @@ 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 { rerender, queryByRole, getByRole, getByText, } = await renderInTestApp( - - - , + + + + + , ); expect(queryByRole('alert')).not.toBeInTheDocument(); expect(getByText(/working component/i)).toBeInTheDocument(); rerender( - - - , + + + + + , ); expect(getByRole('alert')).toBeInTheDocument(); - expect(getByText(/something went wrong here/i)).toBeInTheDocument(); + expect(getByText(/something went wrong/i)).toBeInTheDocument(); }); expect(error).toEqual([ diff --git a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.tsx b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.tsx index 4134808311..b8dfd7ff3a 100644 --- a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.tsx @@ -15,9 +15,16 @@ */ import React, { ComponentClass, Component, ErrorInfo } from 'react'; +import { Button } from '../../components/Button'; +import { ErrorPanel } from '../../components/ErrorPanel'; -type Props = { - slackChannel?: string; +type SlackChannel = { + name: string; + href?: string; +}; + +export type ErrorBoundaryProps = { + slackChannel?: string | SlackChannel; onError?: (error: Error, errorInfo: string) => null; }; @@ -26,28 +33,30 @@ type State = { errorInfo?: ErrorInfo; }; -type EProps = { - error?: Error; - slackChannel?: string; - children?: React.ReactNode; -}; +const SlackLink = (props: { slackChannel?: string | SlackChannel }) => { + const { slackChannel } = props; + + if (!slackChannel) { + return null; + } else if (typeof slackChannel === 'string') { + return <>Please contact {slackChannel} for help.; + } else if (!slackChannel.href) { + return <>Please contact {slackChannel.name} for help.; + } -const Error = ({ slackChannel }: EProps) => { return ( -
- Something went wrong here.{' '} - {slackChannel && <>Please contact {slackChannel} for help.} -
+ ); }; export const ErrorBoundary: ComponentClass< - Props, + ErrorBoundaryProps, State -> = class ErrorBoundary extends Component { - constructor(props: Props) { +> = class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { super(props); - this.state = { error: undefined, errorInfo: undefined, @@ -61,13 +70,17 @@ export const ErrorBoundary: ComponentClass< } render() { - const { slackChannel } = this.props; - const { error, errorInfo } = this.state; + const { slackChannel, children } = this.props; + const { error } = this.state; - if (!errorInfo) { - return this.props.children; + if (!error) { + return children; } - return ; + return ( + + + + ); } }; diff --git a/packages/core-components/src/layout/ErrorBoundary/index.ts b/packages/core-components/src/layout/ErrorBoundary/index.ts index 47c1b2bace..a0640bd5ef 100644 --- a/packages/core-components/src/layout/ErrorBoundary/index.ts +++ b/packages/core-components/src/layout/ErrorBoundary/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export type { ErrorBoundaryProps } from './ErrorBoundary'; export { ErrorBoundary } from './ErrorBoundary'; diff --git a/packages/core-components/src/layout/InfoCard/InfoCard.tsx b/packages/core-components/src/layout/InfoCard/InfoCard.tsx index 9a64bfa6f3..763038388b 100644 --- a/packages/core-components/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core-components/src/layout/InfoCard/InfoCard.tsx @@ -26,7 +26,7 @@ import { makeStyles, } from '@material-ui/core'; import classNames from 'classnames'; -import { ErrorBoundary } from '../ErrorBoundary'; +import { ErrorBoundary, ErrorBoundaryProps } from '../ErrorBoundary'; import { BottomLink, BottomLinkProps } from '../BottomLink'; const useStyles = makeStyles(theme => ({ @@ -95,8 +95,8 @@ export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem'; * You can custom style an InfoCard with the 'style' (outer container) and 'cardStyle' (inner container) * styles. * - * The InfoCard serves as an error boundary. As a result, if you provide a 'slackChannel' property this - * specifies the channel to display in the error component that is displayed if an error occurs + * The InfoCard serves as an error boundary. As a result, if you provide an 'errorBoundaryProps' property this + * specifies the extra information to display in the error component that is displayed if an error occurs * in any descendent components. * * By default the InfoCard has no custom layout of its children, but is treated as a block element. A @@ -112,7 +112,9 @@ type Props = { subheader?: ReactNode; divider?: boolean; deepLink?: BottomLinkProps; + /** @deprecated Use errorBoundaryProps instead */ slackChannel?: string; + errorBoundaryProps?: ErrorBoundaryProps; variant?: InfoCardVariants; style?: object; cardStyle?: object; @@ -133,7 +135,8 @@ export const InfoCard = ({ subheader, divider = true, deepLink, - slackChannel = '#backstage', + slackChannel, + errorBoundaryProps, variant, children, headerStyle, @@ -169,9 +172,12 @@ export const InfoCard = ({ }); } + const errProps: ErrorBoundaryProps = + errorBoundaryProps || (slackChannel ? { slackChannel } : {}); + return ( - + {title && ( ({ root: { @@ -52,7 +52,9 @@ const BoldHeader = withStyles(theme => ({ }))(CardHeader); type Props = { + /** @deprecated Use errorBoundaryProps instead */ slackChannel?: string; + errorBoundaryProps?: ErrorBoundaryProps; children?: ReactElement[]; onChange?: (event: React.ChangeEvent<{}>, value: number | string) => void; title?: string; @@ -61,7 +63,8 @@ type Props = { }; const TabbedCard = ({ - slackChannel = '#backstage', + slackChannel, + errorBoundaryProps, children, title, deepLink, @@ -87,9 +90,12 @@ const TabbedCard = ({ }); } + const errProps: ErrorBoundaryProps = + errorBoundaryProps || (slackChannel ? { slackChannel } : {}); + return ( - + {title && } { - const ApiRef = Object.getPrototypeOf(createApiRef({ id: 'x' })).constructor; - - it('exports the plugin api', () => { - expect(index).toEqual({ - FeatureFlagState: { - 0: 'None', - 1: 'Active', - Active: 1, - None: 0, - }, - SessionState: expect.any(Object), - attachComponentData: expect.any(Function), - createApiFactory: expect.any(Function), - createComponentExtension: expect.any(Function), - createPlugin: expect.any(Function), - createReactExtension: expect.any(Function), - createRoutableExtension: expect.any(Function), - - getComponentData: expect.any(Function), - useApi: expect.any(Function), - useApiHolder: expect.any(Function), - useApp: expect.any(Function), - useElementFilter: expect.any(Function), - useRouteRef: expect.any(Function), - useRouteRefParams: expect.any(Function), - withApis: expect.any(Function), - - createApiRef: expect.any(Function), - createExternalRouteRef: expect.any(Function), - createRouteRef: expect.any(Function), - createSubRouteRef: expect.any(Function), - - alertApiRef: expect.any(ApiRef), - appThemeApiRef: expect.any(ApiRef), - auth0AuthApiRef: expect.any(ApiRef), - configApiRef: expect.any(ApiRef), - discoveryApiRef: expect.any(ApiRef), - errorApiRef: expect.any(ApiRef), - featureFlagsApiRef: expect.any(ApiRef), - githubAuthApiRef: expect.any(ApiRef), - gitlabAuthApiRef: expect.any(ApiRef), - googleAuthApiRef: expect.any(ApiRef), - identityApiRef: expect.any(ApiRef), - microsoftAuthApiRef: expect.any(ApiRef), - oauth2ApiRef: expect.any(ApiRef), - oauthRequestApiRef: expect.any(ApiRef), - oidcAuthApiRef: expect.any(ApiRef), - oktaAuthApiRef: expect.any(ApiRef), - oneloginAuthApiRef: expect.any(ApiRef), - samlAuthApiRef: expect.any(ApiRef), - storageApiRef: expect.any(ApiRef), - }); - }); -}); diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js deleted file mode 100644 index d592a653c8..0000000000 --- a/packages/core/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - extends: [require.resolve('@backstage/cli/config/eslint')], - rules: { - // TODO: add prop types to JS and remove - 'react/prop-types': 0, - 'jest/expect-expect': 0, - }, -}; diff --git a/packages/core/.npmrc b/packages/core/.npmrc deleted file mode 100644 index 214c29d139..0000000000 --- a/packages/core/.npmrc +++ /dev/null @@ -1 +0,0 @@ -registry=https://registry.npmjs.org/ diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md deleted file mode 100644 index 359573ef01..0000000000 --- a/packages/core/CHANGELOG.md +++ /dev/null @@ -1,508 +0,0 @@ -# @backstage/core - -## 0.7.13 - -### Patch Changes - -- d4644f592: Use the Backstage `Link` component in the `Button` - -## 0.7.12 - -### Patch Changes - -- 1cf1d351f: Export `CheckboxTree` as we have a storybook for it -- Updated dependencies [e7c5e4b30] -- Updated dependencies [0160678b1] - - @backstage/theme@0.2.8 - - @backstage/core-api@0.2.21 - -## 0.7.11 - -### Patch Changes - -- cc592248b: SignInPage: Show login page while pop-up is being displayed when `auto` prop is set. -- Updated dependencies [d597a50c6] - - @backstage/core-api@0.2.20 - -## 0.7.10 - -### Patch Changes - -- 65e6c4541: Remove circular dependencies -- 5da6a561d: Fix a bug where users are asked to log-in on every page refresh. This is specific to people with only one sign-in provider in their SignInPage component. -- Updated dependencies [61c3f927c] -- Updated dependencies [65e6c4541] - - @backstage/core-api@0.2.19 - -## 0.7.9 - -### Patch Changes - -- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 -- 889d89b6e: Fix state persisted in the URL make search input in the table toolbar lose their - focus. -- 3f988cb63: Add count of older messages when multiple messages exist in AlertDisplay -- 675a569a9: chore: bump `react-use` dependency in all packages -- Updated dependencies [062bbf90f] -- Updated dependencies [675a569a9] - - @backstage/core-api@0.2.18 - -## 0.7.8 - -### Patch Changes - -- f65adcde7: Fix some transitive dependency warnings in yarn -- 80888659b: Bump react-hook-form version to be the same for the entire project. -- Updated dependencies [7b8272fb7] -- Updated dependencies [d8b81fd28] - - @backstage/theme@0.2.7 - - @backstage/config@0.1.5 - -## 0.7.7 - -### Patch Changes - -- 9afcac5af: Allow passing NavLinkProps to SidebarItem component to use in NavLink -- e0c9ed759: Add `if` prop to `EntityLayout.Route` to conditionally render tabs -- 6eaecbd81: Improve owner example value in `MissingAnnotationEmptyState`. - -## 0.7.6 - -### Patch Changes - -- 94da20976: Sort the table filter options by name. -- d8cc7e67a: Exposing Material UI extension point for tabs to be able to add additional information to them -- 99fbef232: Adding Headings for Accessibility on the Scaffolder Plugin -- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. -- 937ed39ce: Exported SignInProviderConfig to strongly type SignInPage providers -- 9a9e7a42f: Adding close button on support menu -- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. -- Updated dependencies [ab07d77f6] -- Updated dependencies [931b21a12] -- Updated dependencies [50ce875a0] - - @backstage/core-api@0.2.17 - - @backstage/theme@0.2.6 - -## 0.7.5 - -### Patch Changes - -- d0d1c2f7b: Pass `inverse` prop to Gauge from GaugeCard -- 5cafcf452: add debounce time attribute for apis-docs for search, giving more time to the users when they are typing. -- 86a95ba67: exposes undocumented `PageTheme` -- e27cb6c45: Don't use a drag & drop cursor when clicking on disabled `IconLinkVertical`. - -## 0.7.4 - -### Patch Changes - -- 1279a3325: Introduce a `load-chunk` step in the `BootErrorPage` to show make chunk loading - errors visible to the user. -- 4a4681b1b: Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error. -- b051e770c: Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail. -- 98dd5da71: Add support for multiple links to post-scaffold task summary page -- Updated dependencies [1279a3325] -- Updated dependencies [4a4681b1b] -- Updated dependencies [b051e770c] - - @backstage/core-api@0.2.16 - -## 0.7.3 - -### Patch Changes - -- fcc3ada24: Reuse ResponseErrorList for non ResponseErrors -- 4618774ff: Changed color for Add Item, Support & Choose buttons with low contrast/readability in dark mode -- df59930b3: Fix PropTypes error with OverflowTooltip component -- Updated dependencies [76deafd31] -- Updated dependencies [01ccef4c7] -- Updated dependencies [4618774ff] - - @backstage/core-api@0.2.15 - - @backstage/theme@0.2.5 - -## 0.7.2 - -### Patch Changes - -- 8686eb38c: Add a `ResponseErrorPanel` to render `ResponseError` from `@backstage/errors` -- 9ca0e4009: use local version of lowerCase and upperCase methods -- 34ff49b0f: Allow extension components to also return `null` in addition to a `JSX.Element`. -- Updated dependencies [a51dc0006] -- Updated dependencies [e7f9b9435] -- Updated dependencies [0434853a5] -- Updated dependencies [34ff49b0f] -- Updated dependencies [d88dd219e] -- Updated dependencies [c8b54c370] - - @backstage/core-api@0.2.14 - - @backstage/config@0.1.4 - -## 0.7.1 - -### Patch Changes - -- ff4d666ab: Add support for passing a fetch function instead of data to Table `data` prop. -- 2089de76b: Deprecated `ItemCard`. Added `ItemCardGrid` and `ItemCardHeader` instead, that can be used to compose functionality around regular Material-UI `Card` components instead. -- dc1fc92c8: Add support for non external URI's in the Link component to `to` prop. For example `Slack -- Updated dependencies [13524b80b] -- Updated dependencies [e74b07578] -- Updated dependencies [6fb4258a8] -- Updated dependencies [2089de76b] -- Updated dependencies [395885905] - - @backstage/core-api@0.2.13 - - @backstage/theme@0.2.4 - -## 0.7.0 - -### Minor Changes - -- 4c049a1a1: - Adds onClick and other props to IconLinkVertical; - - - Allows TriggerButton component to render when pager duty key is missing; - - Refactors TriggerButton and PagerDutyCard not to have shared state; - - Removes the `action` prop of the IconLinkVertical component while adding `onClick`. - - Instead of having an action including a button with onClick, now the whole component can be clickable making it easier to implement and having a better UX. - - Before: - - ```ts - const myLink: IconLinkVerticalProps = { - label: 'Click me', - action: - - - - Secondary Button: - Used for actions that cancel, skip, and in general perform negative - functions, etc. -
-
color="secondary" variant="contained"
-
- - -
- - - Tertiary Button: - Used commonly in a ButtonGroup and when the button function itself is - not a primary function on a page. -
-
color="default" variant="outlined"
-
- - -
- - ); -}; - -export const ButtonLinks = () => { - const routeRef = createRouteRef({ - path: '/hello', - title: 'Hi there!', - }); - - const handleClick = () => { - return 'Your click worked!'; - }; - - return ( - <> - - { - // TODO: Refactor to use new routing mechanisms - } - - -   has props for both Material-UI's component as well as for - react-router-dom's Route object. - - - - -   links to a statically defined route. In general, this should be - avoided. - - - - - View URL - -   links to a defined URL using Material-UI's Button. - - - - - Trigger Event - -   triggers an onClick event using Material-UI's Button. - - - - ); -}; diff --git a/packages/core/src/components/Button/Button.test.tsx b/packages/core/src/components/Button/Button.test.tsx deleted file mode 100644 index e3a1074e6f..0000000000 --- a/packages/core/src/components/Button/Button.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 { render, fireEvent, act } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { Button } from './Button'; -import { Route, Routes } from 'react-router'; - -describe(' - , - ), - ); - - expect(() => getByText(testString)).toThrow(); - await act(async () => { - fireEvent.click(getByText(buttonLabel)); - }); - expect(getByText(testString)).toBeInTheDocument(); - }); -}); diff --git a/packages/core/src/components/Button/Button.tsx b/packages/core/src/components/Button/Button.tsx deleted file mode 100644 index 6dd593d776..0000000000 --- a/packages/core/src/components/Button/Button.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 { - Button as MaterialButton, - ButtonProps as MaterialButtonProps, -} from '@material-ui/core'; -import React from 'react'; -import { Link, LinkProps } from '../Link'; - -type Props = MaterialButtonProps & Omit; - -/** - * Thin wrapper on top of material-ui's Button component - * Makes the Button to utilise react-router - */ -export const Button = React.forwardRef((props, ref) => ( - -)); diff --git a/packages/core/src/components/Button/index.ts b/packages/core/src/components/Button/index.ts deleted file mode 100644 index e2aa3aff98..0000000000 --- a/packages/core/src/components/Button/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { Button } from './Button'; diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx deleted file mode 100644 index 48da8b92b2..0000000000 --- a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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, { useState } from 'react'; -import { CheckboxTree } from './CheckboxTree'; - -const CHECKBOX_TREE_ITEMS = [ - { - label: 'Generic subcategory name 1', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, - { - label: 'Generic subcategory name 2', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, - { - label: 'Generic subcategory name 3', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, -]; - -export default { - title: 'Inputs/CheckboxTree', - component: CheckboxTree, -}; - -export const Default = () => ( - {}} - label="default" - subCategories={CHECKBOX_TREE_ITEMS} - /> -); - -export const DynamicTree = () => { - function generateTree(showMore: boolean = false) { - const t = [ - { - label: 'Show more', - options: [], - }, - ]; - - if (showMore) { - t.push({ - label: 'More', - options: [], - }); - } - - return t; - } - - const [tree, setTree] = useState(generateTree()); - - return ( - { - setTree(generateTree(state.some(c => c.category === 'Show more'))); - }} - label="default" - subCategories={tree} - /> - ); -}; diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx deleted file mode 100644 index 6c84a311d2..0000000000 --- a/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 { fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { CheckboxTree } from './CheckboxTree'; - -const CHECKBOX_TREE_ITEMS = [ - { - label: 'Generic subcategory name 1', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, -]; - -const minProps = { - onChange: jest.fn(), - label: 'Default', - subCategories: CHECKBOX_TREE_ITEMS, -}; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText, getByTestId } = render(); - - expect(getByText('Generic subcategory name 1')).toBeInTheDocument(); - const checkbox = await getByTestId('expandable'); - - // Simulate click on expandable arrow - fireEvent.click(checkbox); - - // Simulate click on option - const option = getByText('Option 1'); - expect(getByText('Option 1')).toBeInTheDocument(); - fireEvent.click(option); - expect(minProps.onChange).toHaveBeenCalled(); - }); -}); diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx deleted file mode 100644 index 7e7155143b..0000000000 --- a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx +++ /dev/null @@ -1,358 +0,0 @@ -/* - * 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. - */ -/* eslint-disable guard-for-in */ -import { - Checkbox, - Collapse, - List, - ListItem, - ListItemIcon, - ListItemText, - Typography, -} from '@material-ui/core'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; -import produce from 'immer'; -import { isEqual } from 'lodash'; -import React, { useEffect, useReducer } from 'react'; -import { usePrevious } from 'react-use'; - -type IndexedObject = { - [key: string]: T; -}; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - width: '100%', - minWidth: 10, - maxWidth: 360, - backgroundColor: 'transparent', - '&:hover': { - backgroundColor: 'transparent', - }, - '&:active': { - animation: 'none', - transform: 'none', - }, - }, - nested: { - paddingLeft: theme.spacing(5), - height: '32px', - '&:hover': { - backgroundColor: 'transparent', - }, - }, - listItemIcon: { - minWidth: 10, - }, - listItem: { - '&:hover': { - backgroundColor: 'transparent', - }, - }, - text: { - '& span, & svg': { - fontWeight: 'normal', - fontSize: 14, - }, - }, - }), -); - -/* SUB_CATEGORY */ - -type SubCategory = { - label: string; - isChecked?: boolean; - isOpen?: boolean; - options?: Option[]; -}; - -type SubCategoryWithIndexedOptions = { - label: string; - isChecked?: boolean; - isOpen?: boolean; - options: IndexedObject