diff --git a/.changeset/blue-keys-do.md b/.changeset/blue-keys-do.md new file mode 100644 index 0000000000..c933bac0f2 --- /dev/null +++ b/.changeset/blue-keys-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: Replace default plugin extension and plugin ids to be `app` instead of `root`. diff --git a/.changeset/calm-cups-rule.md b/.changeset/calm-cups-rule.md new file mode 100644 index 0000000000..48cb8a2012 --- /dev/null +++ b/.changeset/calm-cups-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Fix wrong `alpha` support in dynamic plugins support: the `alpha` sub-package should not be required for the dynamic plugins to be loaded under the new backend system. diff --git a/.changeset/calm-items-double.md b/.changeset/calm-items-double.md new file mode 100644 index 0000000000..3db8ad57d9 --- /dev/null +++ b/.changeset/calm-items-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': minor +--- + +Allow to force the creation of a pull request from a forked repository diff --git a/.changeset/chilled-ways-wave.md b/.changeset/chilled-ways-wave.md new file mode 100644 index 0000000000..68e4d1eb7d --- /dev/null +++ b/.changeset/chilled-ways-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +fix for: converting circular structure to JSON error diff --git a/.changeset/dry-vans-kiss.md b/.changeset/dry-vans-kiss.md new file mode 100644 index 0000000000..5d11df989c --- /dev/null +++ b/.changeset/dry-vans-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Simple typo fix in the fetch:template action example on the word 'skeleton'. diff --git a/.changeset/eight-hounds-dream.md b/.changeset/eight-hounds-dream.md new file mode 100644 index 0000000000..8bf945d684 --- /dev/null +++ b/.changeset/eight-hounds-dream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr': patch +--- + +Fix alignment of text in `AdrSearchResultListItem`. Update size and font to match other `SearchResultListItem`. diff --git a/.changeset/fair-mirrors-hammer.md b/.changeset/fair-mirrors-hammer.md new file mode 100644 index 0000000000..3884835d11 --- /dev/null +++ b/.changeset/fair-mirrors-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +use `CatalogClient.getEntitiesByRefs()` to reduce the number of backend requests from plugin `catalog-graph` diff --git a/.changeset/four-mugs-try.md b/.changeset/four-mugs-try.md new file mode 100644 index 0000000000..11fdf783d4 --- /dev/null +++ b/.changeset/four-mugs-try.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': minor +--- + +**BREAKING**: Removed the `SidebarIntro` component as it was providing instructions for features that do not exist, along with `IntroCard`. If you were relying on this component and want to keep using it you can refer to the original implementations of [`SidebarIntro`](https://github.com/backstage/backstage/blob/80f2413334ed9b221ec3c2b7c22fa737ad8d8885/packages/core-components/src/layout/Sidebar/Intro.tsx#L149) and [`IntroCard`](https://github.com/backstage/backstage/blob/80f2413334ed9b221ec3c2b7c22fa737ad8d8885/packages/core-components/src/layout/Sidebar/Intro.tsx#L100). diff --git a/.changeset/funny-buttons-sip.md b/.changeset/funny-buttons-sip.md new file mode 100644 index 0000000000..c455b8a31b --- /dev/null +++ b/.changeset/funny-buttons-sip.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Move the `NODE_OPTIONS` messaging for `--no-node-snapshot` to the `SecureTemplater` in order to get better messaging at runtime diff --git a/.changeset/gorgeous-pumas-draw.md b/.changeset/gorgeous-pumas-draw.md new file mode 100644 index 0000000000..2e94c61c63 --- /dev/null +++ b/.changeset/gorgeous-pumas-draw.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-analytics-module-newrelic-browser': minor +'@backstage/plugin-analytics-module-ga4': minor +'@backstage/plugin-analytics-module-ga': minor +'@backstage/core-compat-api': minor +--- + +Add support to the new analytics api. diff --git a/.changeset/green-flies-draw.md b/.changeset/green-flies-draw.md new file mode 100644 index 0000000000..6b967feceb --- /dev/null +++ b/.changeset/green-flies-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Upgrade jest diff --git a/.changeset/hot-paws-tap.md b/.changeset/hot-paws-tap.md new file mode 100644 index 0000000000..30d8b396ba --- /dev/null +++ b/.changeset/hot-paws-tap.md @@ -0,0 +1,7 @@ +--- +'@backstage/catalog-model': patch +'@backstage/test-utils': patch +'@backstage/plugin-explore': patch +--- + +Fix wording in API reference diff --git a/.changeset/large-moons-speak.md b/.changeset/large-moons-speak.md new file mode 100644 index 0000000000..efd94c5df3 --- /dev/null +++ b/.changeset/large-moons-speak.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +Add API to get location by entity diff --git a/.changeset/nice-carrots-dream.md b/.changeset/nice-carrots-dream.md new file mode 100644 index 0000000000..3b57dfd9e0 --- /dev/null +++ b/.changeset/nice-carrots-dream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +Decreased number of teams fetched by GraphQL Query responsible for fetching Teams and Members in organization, due to timeouts when running against big organizations diff --git a/.changeset/ninety-rules-sneeze.md b/.changeset/ninety-rules-sneeze.md new file mode 100644 index 0000000000..bea9d00f7e --- /dev/null +++ b/.changeset/ninety-rules-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Minor performance improvement for `queryEntities` when the limit is 0. diff --git a/.changeset/old-papayas-shave.md b/.changeset/old-papayas-shave.md new file mode 100644 index 0000000000..1372b9ce85 --- /dev/null +++ b/.changeset/old-papayas-shave.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Wrap the root element with the analytics context to ensure it always exists for all extensions. diff --git a/.changeset/orange-walls-complain.md b/.changeset/orange-walls-complain.md new file mode 100644 index 0000000000..477d42d913 --- /dev/null +++ b/.changeset/orange-walls-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add line clamping to `CatalogSearchResultListItem` diff --git a/.changeset/quick-penguins-refuse.md b/.changeset/quick-penguins-refuse.md new file mode 100644 index 0000000000..5d3165114b --- /dev/null +++ b/.changeset/quick-penguins-refuse.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Added `ScaffolderPageContextMenu` to `ActionsPage`, `ListTaskPage`, and `TemplateEditorPage` so that you can more easily navigate between these pages diff --git a/.changeset/quick-ties-stare.md b/.changeset/quick-ties-stare.md new file mode 100644 index 0000000000..19eb50feed --- /dev/null +++ b/.changeset/quick-ties-stare.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-common': patch +--- + +Support `token` in `readTree`, `readUrl` and `search` diff --git a/.changeset/quiet-donkeys-punch.md b/.changeset/quiet-donkeys-punch.md new file mode 100644 index 0000000000..a375ed148c --- /dev/null +++ b/.changeset/quiet-donkeys-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-react': patch +--- + +Extracted common dialog component. diff --git a/.changeset/selfish-mails-scream.md b/.changeset/selfish-mails-scream.md new file mode 100644 index 0000000000..7d1149f27a --- /dev/null +++ b/.changeset/selfish-mails-scream.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': patch +--- + +fix: mkdocs parameter casing diff --git a/.changeset/shaggy-trainers-rule.md b/.changeset/shaggy-trainers-rule.md new file mode 100644 index 0000000000..4140a400e1 --- /dev/null +++ b/.changeset/shaggy-trainers-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Throw a more specific exception `NotImplementedError` when an API implementation cannot be found. diff --git a/.changeset/six-jobs-sin.md b/.changeset/six-jobs-sin.md new file mode 100644 index 0000000000..307190ae81 --- /dev/null +++ b/.changeset/six-jobs-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Internal updates to auto-generated files. diff --git a/.changeset/smart-numbers-call.md b/.changeset/smart-numbers-call.md new file mode 100644 index 0000000000..24a2053259 --- /dev/null +++ b/.changeset/smart-numbers-call.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +The default `ComponentsApi` implementation now uses the `ComponentRef` ID as the component key, rather than the reference instance. This fixes a bug where duplicate installations of `@backstage/frontend-plugin-api` would break the app. diff --git a/.changeset/strong-lobsters-hide.md b/.changeset/strong-lobsters-hide.md new file mode 100644 index 0000000000..7b51355eb7 --- /dev/null +++ b/.changeset/strong-lobsters-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +App component extensions are no longer wrapped in an `ExtensionBoundary`, allowing them to inherit the outer context instead. diff --git a/.changeset/sweet-ravens-glow.md b/.changeset/sweet-ravens-glow.md new file mode 100644 index 0000000000..045f7edbf2 --- /dev/null +++ b/.changeset/sweet-ravens-glow.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration': minor +'@backstage/backend-common': patch +--- + +Fix rate limit detection by looking for HTTP status code 429 and updating the header `x-ratelimit-remaining` to look for in case of a 403 code is returned diff --git a/.changeset/tasty-feet-cheat.md b/.changeset/tasty-feet-cheat.md new file mode 100644 index 0000000000..1e93439fa6 --- /dev/null +++ b/.changeset/tasty-feet-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Fixes an issue where comments would be duplicated in the template. Also, removes a header with the title and version of the OpenAPI spec from generated code. diff --git a/.changeset/ten-trainers-cough.md b/.changeset/ten-trainers-cough.md new file mode 100644 index 0000000000..53a82def90 --- /dev/null +++ b/.changeset/ten-trainers-cough.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-node': minor +--- + +Support providing an overriding token for `fetch:template`, `fetch:plain` and `fetch:file` when interacting with upstream integrations diff --git a/.changeset/tender-flowers-collect.md b/.changeset/tender-flowers-collect.md new file mode 100644 index 0000000000..dfbf96a52c --- /dev/null +++ b/.changeset/tender-flowers-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Updated the styling for tags to avoid word break. diff --git a/.changeset/twelve-hounds-know.md b/.changeset/twelve-hounds-know.md new file mode 100644 index 0000000000..3c95c71ddb --- /dev/null +++ b/.changeset/twelve-hounds-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +Use more distinguishable icons for link (`Link`) and text output (`Description`). diff --git a/.changeset/twelve-pens-rescue.md b/.changeset/twelve-pens-rescue.md new file mode 100644 index 0000000000..9d40fb2851 --- /dev/null +++ b/.changeset/twelve-pens-rescue.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-app-api': patch +'@backstage/core-compat-api': patch +--- + +Allow external route refs in the new system to have a `defaultTarget` pointing to a route that it'll resolve to by default if no explicit bindings were made by the adopter. diff --git a/.changeset/twelve-weeks-march.md b/.changeset/twelve-weeks-march.md new file mode 100644 index 0000000000..4d9fff5b34 --- /dev/null +++ b/.changeset/twelve-weeks-march.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Fix issue where `ui:schema` was replaced with an empty object if `dependencies` is defined diff --git a/.changeset/two-geese-explain.md b/.changeset/two-geese-explain.md new file mode 100644 index 0000000000..12ae307cfe --- /dev/null +++ b/.changeset/two-geese-explain.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed an issue that would cause an invalid `__backstage-autodetected-plugins__.js` to be written when using experimental module discovery. diff --git a/.changeset/wise-flies-laugh.md b/.changeset/wise-flies-laugh.md new file mode 100644 index 0000000000..4b31b4e9ba --- /dev/null +++ b/.changeset/wise-flies-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Added filter support for HomePageVisitedByType in order to enable filtering entities from the list diff --git a/.changeset/young-ladybugs-decide.md b/.changeset/young-ladybugs-decide.md new file mode 100644 index 0000000000..53284cbc37 --- /dev/null +++ b/.changeset/young-ladybugs-decide.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-node': patch +--- + +Add option to configure nunjucks with the `trimBlocks` and `lstripBlocks` options in the fetch:template action diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 73dd5f8c57..d8dd0d9a6a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -66,6 +66,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/upload-sarif@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 with: sarif_file: results.sarif diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 245ef401fa..ddf6d2cf28 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -58,6 +58,6 @@ jobs: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} NODE_OPTIONS: --max-old-space-size=7168 - name: Upload Snyk report - uses: github/codeql-action/upload-sarif@v3.23.1 + uses: github/codeql-action/upload-sarif@v3.23.2 with: sarif_file: snyk.sarif diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 6fbf516c08..df0dd80118 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -55,7 +55,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3.23.1 + uses: github/codeql-action/init@v3.23.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -66,7 +66,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3.23.1 + uses: github/codeql-action/autobuild@v3.23.2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -80,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.23.1 + uses: github/codeql-action/analyze@v3.23.2 diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index 395dbe0b7f..802247341f 100644 --- a/.github/workflows/verify_e2e-windows.yml +++ b/.github/workflows/verify_e2e-windows.yml @@ -61,7 +61,7 @@ jobs: python-version: '3.10' - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v1.3.1 + uses: microsoft/setup-msbuild@v1.3.2 - name: Setup gyp env run: | diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml index 1c3a5fd619..49a0c76df3 100644 --- a/.github/workflows/verify_storybook.yml +++ b/.github/workflows/verify_storybook.yml @@ -51,7 +51,7 @@ jobs: - run: yarn build-storybook - - uses: chromaui/action@5f2cdb26c04c0364f5c8ca3fc41627a83d6ffc8a # v10 + - uses: chromaui/action@05a82adb1e6919df177f54777e81a2ef3e312323 # v10 with: token: ${{ secrets.GITHUB_TOKEN }} # projectToken intentionally shared to allow collaborators to run Chromatic on forks diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 143b22c8b6..5b188770b4 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -21,7 +21,7 @@ jobs: env: CI: true - NODE_OPTIONS: --max-old-space-size=4096 + NODE_OPTIONS: ${{ matrix.node-version == '20.x' && '--max-old-space-size=4096 --no-node-snapshot' || '--max-old-space-size=4096' }} INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md new file mode 100644 index 0000000000..cc076acae1 --- /dev/null +++ b/beps/0003-auth-architecture-evolution/README.md @@ -0,0 +1,142 @@ +--- +title: Auth Architecture Evolution +status: provisional +authors: + - '@Rugvip' +owners: + - '@backstage/maintainers' +project-areas: + - core +creation-date: 2024-01-28 +--- + +# BEP: Auth Architecture Evolution + + + +[**Discussion Issue**](https://github.com/backstage/backstage/issues/NNNNN) + +- [Summary](#summary) +- [Motivation](#motivation) + - [Goals](#goals) + - [Non-Goals](#non-goals) +- [Proposal](#proposal) +- [Design Details](#design-details) +- [Release Plan](#release-plan) +- [Dependencies](#dependencies) +- [Alternatives](#alternatives) + +## Summary + +This proposal outlines a new architecture for authenticating users and services in Backstage. It adds built-in access restriction to Backstage instances that protects them from outside access, and enables more granular access control for inter-plugin communication and access from external services. + +It proposes a new `AuthService` interface that handles all user and service authentication and token management available to plugins, as well as a new `HttpAuthService` interface that is a higher level service used to protect endpoints of plugin routers. The `auth-backend` will also be extended to support issuing of user tokens with a reduced scope for cookie-based authentication of requests. + +The changes to the service-to-service auth are aimed to be the minimum needed to get the necessary interfaces in place, and will rely on the existing symmetrical keys for now. + +## Motivation + +This proposal aims to address several of the points in the [Auth Meta issue](https://github.com/backstage/backstage/issues/15999), with the overarching goal being to replace the existing [API request authentication](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/authenticate-api-requests.md) tutorial in `contrib/` with a more robust and secure built-in solution. The tutorial exists for two purposes: to add authentication of API requests as part of using the permission system in Backstage, and to protect a Backstage instance from external access. It does a fairly good job of the former, although we want to avoid placing user tokens in cookies, but it does a quite poor job of the latter, which we want to fix. + +A secondary goal is to do this work before stabilizing the APIs in the new Backend system, as it will have some impact on how plugin backends are built. This will inevitably also lead to the need to improve the way that service-to-service auth is handled in Backstage, although that is not the primary goal of this work. + +By providing protection of Backstage instances out of the box we drastically reduce both the barrier of entry as well as security risks for Backstage adopters. It will no longer be a requirement to either set up protection of your Backstage instance or not do so and risk exposing your instance to malicious actors. It also drastically reduces the complexity of enabling the permission system of Backstage, since access restrictions are already built-in. + +### Goals + +The following goals are the primary focus of this BEP: + +- Built-in protection of Backstage instances such that it is safe to deploy Backstage directly towards the internet. + - Protection of the frontend app bundle from being accessed by unauthenticated users. + - Basic rate limiting of non-authenticated requests. + - Cookie-based authentication of requests for static assets that protects against CSRF attacks and does not unnecessarily expose user tokens. +- Basic improvements to the service-to-service auth service interfaces such that we are confident that we do not need to break them in the near future. + - If possible we will keep using the existing symmetrical keys that are used today, but it is likely that we will need to break compatibility of existing tokens. + - Encapsulation of user credentials in upstream service requests, avoiding the pattern where backend plugins re-use the user token directly for their outgoing requests. + +### Non-Goals + +- No advanced rate limiting or other protection against DDoS attacks. If this is a concern then adopters should still use other external technologies to protect access to their Backstage instance. +- As part of the immediate work we will only add as much support for service-to-service auth as is needed for a stable API, and not necessarily make it very capable from the start. + +## Proposal + +Two new backend service interfaces are introduced to support these new features. The new `AuthService` is a low-level service that encapsulates authentication and creation of bearer tokens for all types of Backstage identities, including user, service, and services making requests on behalf of users. The new `HttpAuthService` is a higher level service that lets your create [express middleware](https://expressjs.com/en/guide/using-middleware.html) to protect endpoints, as well as access the credentials and identity of incoming requests. These new services replace the existing `IdentityService` and `TokenManagerService` + +The proposed design leaves the decision for how different endpoints are protected to the implementation of the plugin backends themselves. This includes whether particular routes should allow anonymous access, access from users authenticated via a cookie, or perhaps only allow access from other plugin backends and external services. This means that integrators do not need to - and do not have the ability to - configure access controls of individual endpoints, except for what the permission system already provides, and what is made available through static configuration or extension points. + +In order to allow for cookie-based authentication of incoming user requests, the `auth` plugin backend is extended to be able to issue user tokens with reduced scope, which in turn integrate with the new `AuthService` and `HttpAuthService`. The ability to use cookie auth for requests is an opt-in per route and is only be permitted for read methods (`GET`, `HEAD`, `OPTIONS`). The actual implementation of cookie-based flows will be up to each plugin, but with significant help from the new auth service interfaces. + +For service-to-service communication we will move away from reusing user tokens in upstream requests. We will instead implement an "On-Behalf-Of" flow where incoming user credentials are encapsulated in a service token for the upstream request. In line with this the new auth service interfaces will aim to make it difficult to directly forward credentials from incoming requests, and instead encourage that plugin backends issue new service credentials for upstream requests. + +## Design Details + +### AuthService (WIP) + +The new `AuthService` interface is defined as follows: + +```ts +export type BackstageCredentials = { + token: string; + + user?: { + userEntityRef: string; + ownershipEntityRefs: string[]; + }; + + service?: { + id: string; + }; +}; + +export interface AuthService { + authenticate(token: string): Promise; + + issueToken(credentials: BackstageCredentials): Promise<{ token: string }>; +} +``` + +TODO: add usage patterns for this service + +### HttpAuthService (WIP) + +The new `HttpAuthService` interface is defined as follows: + +```ts +type AuthTypes = 'user' | 'user-cookie' | 'service' | 'unauthorized'; + +export interface HttpAuthServiceMiddlewareOptions { + allow: AuthTypes[]; +} + +export interface HttpAuthService { + createHttpPluginRouterMiddleware(): Handler; + + middleware(options?: HttpAuthServiceMiddlewareOptions): Handler; + + credentials( + req: Request, + options?: HttpAuthServiceMiddlewareOptions, + ): BackstageCredentials; + + requestHeaders(credentials: BackstageCredentials): Record; + + issueUserCookie(res: Response): Promise; +} +``` + +TODO: add usage patterns for this service + +## Release Plan + +The existing `IdentityService` and `TokenManagerService` will be deprecated and instead implemented in terms of the new `AuthService`. + +The release plan for the `HttpAuthService` is TBD, but is likely to be shipped as a no-op for plugins using the old backend system. + +## Dependencies + +No significant dependencies have been identified for this work, although any future security audits of Backstage are considered dependent on this work. + +## Alternatives + +An alternative to built-in protection from external access would be to keep relying on external mechanisms to protect access to Backstage. We feel that this is a suboptimal solution since it adds complexity to the adoption of Backstage, and increases the risk of misconfiguration and security breaches. Regardless of whether we add built-in protection or not the ability to protect API endpoints needs to be addressed in some way, since it is a requirement for the permission system to work. This means that the extra steps to ensure protection out of the box are fairly minimal when looking at just the delta for protecting API access. diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index 3ab3c0df96..46f32e9064 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -474,6 +474,34 @@ Response type is JSON, on the form ] ``` +### `GET /locations/{id}` + +Gets a location by it's location ID. + +Response type is JSON, on the form + +```json +{ + "id": "b9784c38-7118-472f-9e22-5638fc73bab0", + "target": "https://git.example.com/example-project/example-repository/blob/main/catalog-info.yaml", + "type": "url" +} +``` + +### `GET /locations/by-entity/{kind}/{namespace}/{name}` + +Gets a location referring to a given entity. + +Response type is JSON, on the form + +```json +{ + "id": "b9784c38-7118-472f-9e22-5638fc73bab0", + "target": "https://git.example.com/example-project/example-repository/blob/main/catalog-info.yaml", + "type": "url" +} +``` + ### `POST /locations` Adds a location to be ingested by the catalog. diff --git a/docs/frontend-system/architecture/03-extensions.md b/docs/frontend-system/architecture/03-extensions.md index 6d86415817..46bfa3d86f 100644 --- a/docs/frontend-system/architecture/03-extensions.md +++ b/docs/frontend-system/architecture/03-extensions.md @@ -114,7 +114,7 @@ const extension = createExtension({ ### Extension Data Uniqueness -Note that the key used in the output map, in this case `element`, is only used internally within the definition of the extension itself. That actual identifier for the data when consumed by other extensions is the ID of the reference, in this case `core.reactElement`. This means that you can not output multiple different values for the same extension data reference, as they would conflict with each other. That in turn makes overly generic extension data references a bad idea, for example a generic "string" type. Instead create separate references for each type of data that you want to share. +Note that the key used in the output map, in this case `element`, is only used internally within the definition of the extension itself. That actual identifier for the data when consumed by other extensions is the ID of the reference, in this case [`core.reactElement`](https://github.com/backstage/backstage/blob/916da47e8abdb880877daa18881eb8fdbb33e70a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts#L23). This means that you can not output multiple different values for the same extension data reference, as they would conflict with each other. That in turn makes overly generic extension data references a bad idea, for example a generic "string" type. Instead create separate references for each type of data that you want to share. ```tsx const extension = createExtension({ @@ -160,7 +160,7 @@ const extension = createExtension({ ## Extension Inputs -The Extension Data can be passed up to other extensions through their extension inputs. Similar to the outputs seen before, let's create an example an extension with a extension input: +The Extension Data can be passed up to other extensions through their extension inputs. Similar to the outputs seen before, let's create an example of an extension with a extension input: ```tsx const navigationExtension = createExtension({ @@ -254,7 +254,7 @@ With the `inputs` not only the `output` of an extensions item is passed to the e ## Extension Configuration -With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefor we created a possibility to configure each extension individually through config. The extension config schema is created using the [`zod`](https://zod.dev/) library, which in addition to TypeScript type checking also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: +With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using the [`zod`](https://zod.dev/) library, which in addition to TypeScript type checking also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: ```tsx const navigationExtension = createExtension({ @@ -297,7 +297,7 @@ app: With creating an extension by using `createExtension(...)` you have the advantage that the extension can be anything in your Backstage application. We realised that this comes with the trade-off of having to repeat boilerplate code for similar building blocks. Here extension creators come into play for covering common building blocks in Backstage like pages using `createPageExtension`, themes using the `createThemeExtension` or items for the navigation using `createNavItemExtension`. -If we follow the example from above all items of the navigation have similarities, like they all want to be attached to the same extension with the same input as well as rendering the same navigation item component. Therefor `createExtension` can be abstracted for this use case to `createNavItemExtension` and if we add the extension to the app it will end up in the right place & looks like we expect a navigation item to look. +If we follow the example from above all items of the navigation have similarities, like they all want to be attached to the same extension with the same input as well as rendering the same navigation item component. Therefore `createExtension` can be abstracted for this use case to `createNavItemExtension` and if we add the extension to the app it will end up in the right place & looks like we expect a navigation item to look. ```tsx export const HomeNavIcon = createNavItemExtension({ diff --git a/docs/frontend-system/architecture/06-utility-apis.md b/docs/frontend-system/architecture/06-utility-apis.md index fe714793d7..49e5854b65 100644 --- a/docs/frontend-system/architecture/06-utility-apis.md +++ b/docs/frontend-system/architecture/06-utility-apis.md @@ -14,7 +14,7 @@ Utility APIs are pieces of standalone functionality, interfaces that can be requ A common example of a utility API is a client interface to interact with the backend part of a plugin, such as the catalog client. Any frontend plugin can then request an implementation of that interface to make requests through. -The following diagram shows a hypothetical application, which depends on two plugins and also provides some extra overrides. Note that both the plugins and the core framework provide utility APIs, and that they depended on each other. The app also chooses to use its overrides mechanism to supply a replacement implementation of one API, which takes precedence over the default one. Thus, all consumers of that API will be sure to get that new implementation provided to them. +The following diagram shows a hypothetical application, which depends on two plugins and also provides some extra overrides. Note that both the plugins and the core framework provide utility APIs, and that they depend on each other. The app also chooses to use its overrides mechanism to supply a replacement implementation of one API, which takes precedence over the default one. Thus, all consumers of that API will be sure to get that new implementation provided to them. ![frontend system utility apis diagram](../../assets/frontend-system/architecture-utility-apis.drawio.svg) diff --git a/docs/frontend-system/architecture/07-routes.md b/docs/frontend-system/architecture/07-routes.md index 11586573be..7fb053aa56 100644 --- a/docs/frontend-system/architecture/07-routes.md +++ b/docs/frontend-system/architecture/07-routes.md @@ -66,7 +66,7 @@ export default createPlugin({ }); ``` -In the example above we associated the `indexRouteRef` with the `catalogIndexPage` extension and provided both the route ref and page via the Catalog plugin. So, When this plugin is installed in the app, the index page will become associated with the newly created `RouteRef`, making it possible to use the route ref to navigate the page extension. +In the example above we associated the `indexRouteRef` with the `catalogIndexPage` extension and provided both the route ref and page via the Catalog plugin. So, when this plugin is installed in the app, the index page will become associated with the newly created `RouteRef`, making it possible to use the route ref to navigate the page extension. It may seem unclear why we configure the `routes` option when creating a plugin as the route has already been passed to the extension. We do that to make it possible for other plugins to route to our page, which is explained in detail in the [binding routes](#binding-external-route-references) section. diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index c6621d6584..fa29918311 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -70,7 +70,7 @@ describe('Entity details component', () => { await renderInTestApp( - + , ); diff --git a/docs/overview/threat-model.md b/docs/overview/threat-model.md index a5adf8025d..442d51985c 100644 --- a/docs/overview/threat-model.md +++ b/docs/overview/threat-model.md @@ -52,7 +52,7 @@ As part of signing in with an identity resolver, a Backstage Token is issued con The token is used to prove the identity of the user within the Backstage system, and is used throughout Backstage plugins to control access. It is important that the ownership resolution logic is consistent across the entire Backstage ecosystem, with no possibility of misinterpreting the ownership information. -For cross backend communication the Backstage Token is typically forwarded or in strict backend to backend communication without a user party the backend itself issues a service token based on a pre-shared secret which is then validated on the receiving end. There are no unique service identities tied to these tokens at this point, meaning the tokens can be used across all services in a Backstage installation, this is something that we aim to improve in the future. +For cross-backend communication, the Backstage Token is typically forwarded or, in strict backend-to-backend communication without a user party, the backend itself issues a service token based on a pre-shared secret which is then validated on the receiving end. There are no unique service identities tied to these tokens at this point, meaning the tokens can be used across all services in a Backstage installation. This is something that we aim to improve in the future. Backstage also supports authentication through a proxy where the user identity is read from the incoming request from the proxy, which has been decorated by an authenticating reverse proxy such as [AWS ALB](https://aws.amazon.com/elasticloadbalancing/application-load-balancer/). The following proxy auth providers verify the signature of incoming requests, and are therefore safe to deploy with direct access by users: `awsAlb`, `cfAccess`, and `gcpIap`. Providers like `oauth2Proxy` does not verify the incoming request and can therefore be spoofed by a malicious internal user to supply the `auth` backend with forged identity information. It’s therefore highly recommended to restrict access to the `oauth2Proxy` endpoints, or use a different provider. diff --git a/docs/releases/v1.22.0.md b/docs/releases/v1.22.0.md index e5ce345ccc..9f9295fb96 100644 --- a/docs/releases/v1.22.0.md +++ b/docs/releases/v1.22.0.md @@ -39,7 +39,7 @@ Contributed by [@davidfestal](https://github.com/davidfestal) in [#18862](https: ### New Scaffolder action `gitlab:issues:create` -You can now create GitHub issues in your scaffolder flows! Contributed by [@elaine-mattos](https://github.com/elaine-mattos) in [#21929](https://github.com/backstage/backstage/pull/21929) +You can now create GitLab issues in your scaffolder flows! Contributed by [@elaine-mattos](https://github.com/elaine-mattos) in [#21929](https://github.com/backstage/backstage/pull/21929) ### New Scaffolder action `gitlab:repo:push` diff --git a/docs/tutorials/react18-migration.md b/docs/tutorials/react18-migration.md index 2d3b3a961a..c11bc83a7e 100644 --- a/docs/tutorials/react18-migration.md +++ b/docs/tutorials/react18-migration.md @@ -21,9 +21,9 @@ To switch a project to React 18, there are generally three changes that need to // highlight-remove-next-line "@types/react": "^17", // highlight-remove-next-line - "@types/react": "^17", + "@types/react-dom": "^17", // highlight-add-next-line - "@types/react-dom": "^18", + "@types/react": "^18", // highlight-add-next-line "@types/react-dom": "^18", }, diff --git a/microsite/data/plugins/api-spectral-linter.yaml b/microsite/data/plugins/api-spectral-linter.yaml index 292279e324..5f1682c71b 100644 --- a/microsite/data/plugins/api-spectral-linter.yaml +++ b/microsite/data/plugins/api-spectral-linter.yaml @@ -4,7 +4,7 @@ author: dweber019 authorUrl: https://github.com/dweber019 category: Linting description: API Spectral Linter is a quality assurance tool that checks the compliance of API's specifications Spectral rule sets. -documentation: https://github.com/dweber019/backstage-plugin-api-docs-spectral-linter -iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugin-api-docs-spectral-linter/main/plugins/api-docs-spectral-linter/docs/pluginIcon.png +documentation: https://github.com/dweber019/backstage-plugins/tree/main/plugins/api-docs-spectral-linter +iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugins/main/plugins/api-docs-spectral-linter/docs/pluginIcon.png npmPackageName: '@dweber019/backstage-plugin-api-docs-spectral-linter' addedDate: '2023-03-27' diff --git a/microsite/data/plugins/api-wsdl.yaml b/microsite/data/plugins/api-wsdl.yaml index 6523531f1d..219227c509 100644 --- a/microsite/data/plugins/api-wsdl.yaml +++ b/microsite/data/plugins/api-wsdl.yaml @@ -4,7 +4,7 @@ author: dweber019 authorUrl: https://github.com/dweber019 category: Discovery description: An API docs extension to render WSDL / SOAP web services human readable. -documentation: https://github.com/dweber019/backstage-plugin-api-docs-module-wsdl -iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugin-api-docs-module-wsdl/main/docs/logo.png +documentation: https://github.com/dweber019/backstage-plugins/tree/main/plugins/api-docs-module-wsdl +iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugins/main/plugins/api-docs-module-wsdl/docs/logo.png npmPackageName: '@dweber019/backstage-plugin-api-docs-module-wsdl' addedDate: '2023-12-22' diff --git a/microsite/data/plugins/end-of-life.yaml b/microsite/data/plugins/end-of-life.yaml index d54a6bc5f9..ddf4b26341 100644 --- a/microsite/data/plugins/end-of-life.yaml +++ b/microsite/data/plugins/end-of-life.yaml @@ -4,7 +4,7 @@ author: dweber019 authorUrl: https://github.com/dweber019 category: Quality description: Display end of life data for entities from endoflife.data -documentation: https://github.com/dweber019/backstage-plugin-endoflife -iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugin-endoflife/main/plugins/endoflife/docs/pluginIcon.png +documentation: https://github.com/dweber019/backstage-plugins/tree/main/plugins/endoflife +iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugins/main/plugins/endoflife/docs/pluginIcon.png npmPackageName: '@dweber019/backstage-plugin-endoflife' addedDate: '2024-01-18' diff --git a/microsite/data/plugins/tips.yaml b/microsite/data/plugins/tips.yaml index e58ad0fb46..03327f3e41 100644 --- a/microsite/data/plugins/tips.yaml +++ b/microsite/data/plugins/tips.yaml @@ -4,7 +4,7 @@ author: dweber019 authorUrl: https://github.com/dweber019 category: Discovery description: The tips plugin will show the end user some helpful information for entities. -documentation: https://github.com/dweber019/backstage-plugin-tips -iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugin-tips/main/plugins/tips/docs/pluginIcon.png +documentation: https://github.com/dweber019/backstage-plugins/tree/main/plugins/tips +iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugins/main/plugins/tips/docs/pluginIcon.png npmPackageName: '@dweber019/backstage-plugin-tips' addedDate: '2023-10-08' diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 5c92ba5e69..50465ec651 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -2701,90 +2701,90 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-arm64@npm:1.3.105" +"@swc/core-darwin-arm64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-arm64@npm:1.3.107" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-x64@npm:1.3.105" +"@swc/core-darwin-x64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-x64@npm:1.3.107" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.105" +"@swc/core-linux-arm-gnueabihf@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.107" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-gnu@npm:1.3.105" +"@swc/core-linux-arm64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-musl@npm:1.3.105" +"@swc/core-linux-arm64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-gnu@npm:1.3.105" +"@swc/core-linux-x64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-musl@npm:1.3.105" +"@swc/core-linux-x64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-musl@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-arm64-msvc@npm:1.3.105" +"@swc/core-win32-arm64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.107" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-ia32-msvc@npm:1.3.105" +"@swc/core-win32-ia32-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.107" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-x64-msvc@npm:1.3.105" +"@swc/core-win32-x64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.107" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/core@npm:^1.3.46": - version: 1.3.105 - resolution: "@swc/core@npm:1.3.105" + version: 1.3.107 + resolution: "@swc/core@npm:1.3.107" dependencies: - "@swc/core-darwin-arm64": 1.3.105 - "@swc/core-darwin-x64": 1.3.105 - "@swc/core-linux-arm-gnueabihf": 1.3.105 - "@swc/core-linux-arm64-gnu": 1.3.105 - "@swc/core-linux-arm64-musl": 1.3.105 - "@swc/core-linux-x64-gnu": 1.3.105 - "@swc/core-linux-x64-musl": 1.3.105 - "@swc/core-win32-arm64-msvc": 1.3.105 - "@swc/core-win32-ia32-msvc": 1.3.105 - "@swc/core-win32-x64-msvc": 1.3.105 + "@swc/core-darwin-arm64": 1.3.107 + "@swc/core-darwin-x64": 1.3.107 + "@swc/core-linux-arm-gnueabihf": 1.3.107 + "@swc/core-linux-arm64-gnu": 1.3.107 + "@swc/core-linux-arm64-musl": 1.3.107 + "@swc/core-linux-x64-gnu": 1.3.107 + "@swc/core-linux-x64-musl": 1.3.107 + "@swc/core-win32-arm64-msvc": 1.3.107 + "@swc/core-win32-ia32-msvc": 1.3.107 + "@swc/core-win32-x64-msvc": 1.3.107 "@swc/counter": ^0.1.1 "@swc/types": ^0.1.5 peerDependencies: @@ -2813,7 +2813,7 @@ __metadata: peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 5baa880bc92748ef4845d9c65eba5d6dd01adaa673854e20a5116f5e267c12180db50e563cf3c34a415772b9742d021176a9d9a91065c190ef6f54fefe85728c + checksum: 0dccff50461fb8c0f4af053b70e555c91386cb07aa7657a7328d58e397d15640723587549416d8fa7dcc073ad11b39318146bd50ec4a82345ce2ce39c7ba4c00 languageName: node linkType: hard diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 9cc3507fa4..a79bb866db 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -3,7 +3,6 @@ app: packages: 'all' # ✨ routes: bindings: - pages.pageX: pages.pageX catalog.viewTechDoc: techdocs.docRoot catalog.createComponent: catalog-import.importPage diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index c5069f99c3..826040a6cb 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -27,7 +27,9 @@ import { Route, Routes } from 'react-router-dom'; const indexRouteRef = createRouteRef(); const page1RouteRef = createRouteRef(); -export const externalPageXRouteRef = createExternalRouteRef(); +export const externalPageXRouteRef = createExternalRouteRef({ + defaultTarget: 'pages.pageX', +}); export const pageXRouteRef = createRouteRef(); // const page2RouteRef = createSubRouteRef({ // id: 'page2', diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 5e6d30f519..f07ada0cc3 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -46,7 +46,7 @@ const treeResponseFactory = DefaultReadTreeResponseFactory.create({ const mockCredentialsProvider = { getCredentials: jest.fn().mockResolvedValue({ headers: {} }), -} as unknown as GithubCredentialsProvider; +} satisfies GithubCredentialsProvider; const githubProcessor = new GithubUrlReader( new GithubIntegration( @@ -105,7 +105,7 @@ describe('GithubUrlReader', () => { otherheader: 'something', }; - (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + mockCredentialsProvider.getCredentials.mockResolvedValue({ headers: mockHeaders, }); @@ -146,7 +146,7 @@ describe('GithubUrlReader', () => { otherheader: 'something', }; - (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + mockCredentialsProvider.getCredentials.mockResolvedValue({ headers: mockHeaders, }); @@ -182,7 +182,7 @@ describe('GithubUrlReader', () => { otherheader: 'something', }; - (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + mockCredentialsProvider.getCredentials.mockResolvedValue({ headers: mockHeaders, }); @@ -241,7 +241,7 @@ describe('GithubUrlReader', () => { }); it('should return etag and last-modified from the response', async () => { - (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + mockCredentialsProvider.getCredentials.mockResolvedValue({ headers: { Authorization: 'bearer blah', }, @@ -271,6 +271,33 @@ describe('GithubUrlReader', () => { expect(response.etag).toBe('foo'); expect(response.lastModifiedAt).toEqual(new Date('2021-01-01T00:00:00Z')); }); + + it('should override the token if its provided', async () => { + expect.assertions(1); + + mockCredentialsProvider.getCredentials.mockResolvedValue({ + headers: { + Authorization: 'bearer blah', + }, + }); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + 'Bearer overridentoken', + ); + return res(ctx.status(200)); + }, + ), + ); + + await gheProcessor.readUrl( + 'https://github.com/backstage/mock/tree/blob/main', + { token: 'overridentoken' }, + ); + }); }); /* @@ -431,7 +458,7 @@ describe('GithubUrlReader', () => { otherheader: 'something', }; - (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + mockCredentialsProvider.getCredentials.mockResolvedValue({ headers: mockHeaders, }); @@ -463,6 +490,44 @@ describe('GithubUrlReader', () => { ); }); + it('should override the token when provided', async () => { + expect.assertions(1); + + const mockHeaders = { + Authorization: 'bearer blah', + }; + + mockCredentialsProvider.getCredentials.mockResolvedValue({ + headers: mockHeaders, + }); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + 'Bearer overridentoken', + ); + + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ); + }, + ), + ); + + await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main', + { token: 'overridentoken' }, + ); + }); + it('includes the subdomain in the github url', async () => { const response = await gheProcessor.readTree( 'https://ghe.github.com/backstage/mock/tree/main/docs', @@ -905,6 +970,34 @@ describe('GithubUrlReader', () => { await runTests(gheProcessor, 'https://ghe.github.com'); }); + it('passes through a token for the search request', async () => { + expect.assertions(1); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees/etag123abc', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + 'Bearer overridentoken', + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + truncated: true, + tree: [], + } as Partial), + ); + }, + ), + ); + + await gheProcessor.search( + `https://ghe.github.com/backstage/mock/tree/main/**/*`, + { token: 'overridentoken' }, + ); + }); + // eslint-disable-next-line jest/expect-expect it('succeeds on ghe when going via readTree', async () => { worker.use( diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 67f6a975c1..b49945013d 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -92,13 +92,31 @@ export class GithubUrlReader implements UrlReader { return response.buffer(); } + private getCredentials = async ( + url: string, + options?: { token?: string }, + ): Promise => { + if (options?.token) { + return { + headers: { + Authorization: `Bearer ${options.token}`, + }, + type: 'token', + token: options.token, + }; + } + + return await this.deps.credentialsProvider.getCredentials({ + url, + }); + }; + async readUrl( url: string, options?: ReadUrlOptions, ): Promise { - const credentials = await this.deps.credentialsProvider.getCredentials({ - url, - }); + const credentials = await this.getCredentials(url, options); + const ghUrl = getGithubFileFetchUrl( url, this.integration.config, @@ -141,9 +159,7 @@ export class GithubUrlReader implements UrlReader { } const { filepath } = parseGitUrl(url); - const { headers } = await this.deps.credentialsProvider.getCredentials({ - url, - }); + const { headers } = await this.getCredentials(url, options); return this.doReadTree( repoDetails.repo.archive_url, @@ -169,9 +185,7 @@ export class GithubUrlReader implements UrlReader { } const { filepath } = parseGitUrl(url); - const { headers } = await this.deps.credentialsProvider.getCredentials({ - url, - }); + const { headers } = await this.getCredentials(url, options); const files = await this.doSearch( url, @@ -333,10 +347,7 @@ export class GithubUrlReader implements UrlReader { // GitHub returns a 403 response with a couple of headers indicating rate // limit status. See more in the GitHub docs: // https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting - if ( - response.status === 403 && - response.headers.get('X-RateLimit-Remaining') === '0' - ) { + if (this.integration.parseRateLimitInfo(response).isRateLimited) { message += ' (rate limit exceeded)'; } diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.test.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.test.ts index e52eee87c4..1abd7c234c 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.test.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.test.ts @@ -484,6 +484,37 @@ Please add '${mockDir.resolve( ], }, }, + { + name: "alpha manifest preferred but skipped because the `alpha` sub-directory doesn't exist", + preferAlpha: true, + fileSystem: { + backstageRoot: { + 'dist-dynamic': { + 'test-backend-plugin': { + 'package.json': JSON.stringify({ + name: 'test-backend-plugin-dynamic', + version: '0.0.0', + main: 'dist/index.cjs.js', + backstage: { role: 'backend-plugin' }, + }), + }, + }, + }, + }, + expectedPluginPackages: [ + { + location: url.pathToFileURL( + mockDir.resolve('backstageRoot/dist-dynamic/test-backend-plugin'), + ), + manifest: { + name: 'test-backend-plugin-dynamic', + version: '0.0.0', + main: 'dist/index.cjs.js', + backstage: { role: 'backend-plugin' }, + }, + }, + ], + }, { name: 'invalid alpha package.json', preferAlpha: true, diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index 25e0d614dd..af1144f019 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import { ScannedPluginPackage, ScannedPluginManifest } from './types'; import * as fs from 'fs/promises'; -import { Stats, lstatSync } from 'fs'; +import { Stats, lstatSync, existsSync } from 'fs'; import * as chokidar from 'chokidar'; import * as path from 'path'; import * as url from 'url'; @@ -178,22 +178,24 @@ export class PluginScanner { if (platform === 'node') { if (this.preferAlpha) { const pluginHomeAlpha = path.resolve(pluginHome, 'alpha'); - if ((await fs.lstat(pluginHomeAlpha)).isDirectory()) { - const backstage = scannedPlugin.manifest.backstage; - try { - scannedPlugin = await this.scanDir(pluginHomeAlpha); - } catch (e) { - this.logger.error( - `failed to load dynamic plugin manifest from '${pluginHomeAlpha}'`, - e, + if (existsSync(pluginHomeAlpha)) { + if ((await fs.lstat(pluginHomeAlpha)).isDirectory()) { + const backstage = scannedPlugin.manifest.backstage; + try { + scannedPlugin = await this.scanDir(pluginHomeAlpha); + } catch (e) { + this.logger.error( + `failed to load dynamic plugin manifest from '${pluginHomeAlpha}'`, + e, + ); + continue; + } + scannedPlugin.manifest.backstage = backstage; + } else { + this.logger.warn( + `skipping '${pluginHomeAlpha}' since it is not a directory`, ); - continue; } - scannedPlugin.manifest.backstage = backstage; - } else { - this.logger.warn( - `skipping '${pluginHomeAlpha}' since it is not a directory`, - ); } } } diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 7807dd96c6..3b3d28cc27 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -316,6 +316,7 @@ export type ReadTreeOptions = { ): boolean; etag?: string; signal?: AbortSignal; + token?: string; }; // @public @@ -343,6 +344,7 @@ export type ReadUrlOptions = { etag?: string; lastModifiedAfter?: Date; signal?: AbortSignal; + token?: string; }; // @public @@ -390,6 +392,7 @@ export interface SchedulerService extends PluginTaskScheduler {} export type SearchOptions = { etag?: string; signal?: AbortSignal; + token?: string; }; // @public diff --git a/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts b/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts index c189b4a339..734dcd9902 100644 --- a/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts +++ b/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts @@ -92,6 +92,18 @@ export type ReadUrlOptions = { * Not all reader implementations may take this field into account. */ signal?: AbortSignal; + + /** + * An optional token to use for authentication when reading the resources. + * + * @remarks + * + * By default all URL Readers will use the integrations config which is supplied + * when creating the Readers. Sometimes it might be desireable to use the already + * created URLReaders but with a different token, maybe that's supplied by the user + * at runtime. + */ + token?: string; }; /** @@ -179,6 +191,18 @@ export type ReadTreeOptions = { * Not all reader implementations may take this field into account. */ signal?: AbortSignal; + + /** + * An optional token to use for authentication when reading the resources. + * + * @remarks + * + * By default all URL Readers will use the integrations config which is supplied + * when creating the Readers. Sometimes it might be desireable to use the already + * created URLReaders but with a different token, maybe that's supplied by the user + * at runtime. + */ + token?: string; }; /** @@ -281,6 +305,18 @@ export type SearchOptions = { * Not all reader implementations may take this field into account. */ signal?: AbortSignal; + + /** + * An optional token to use for authentication when reading the resources. + * + * @remarks + * + * By default all URL Readers will use the integrations config which is supplied + * when creating the Readers. Sometimes it might be desireable to use the already + * created URLReaders but with a different token, maybe that's supplied by the user + * at runtime. + */ + token?: string; }; /** diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 22feceabec..b1dd2b9188 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -50,6 +50,10 @@ export interface CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise; + getLocationByEntity( + entityRef: string | CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise; getLocationById( id: string, options?: CatalogRequestOptions, @@ -120,6 +124,10 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise; + getLocationByEntity( + entityRef: CompoundEntityRef | string, + options?: CatalogRequestOptions, + ): Promise; getLocationById( id: string, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index b8212fde96..c36b3d96b5 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -617,6 +617,71 @@ describe('CatalogClient', () => { }); }); + describe('getLocationByEntity', () => { + const defaultResponse = { + data: { + kind: 'c', + namespace: 'ns', + name: 'n', + }, + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/locations/by-entity/c/ns/n`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + }); + + it('should locations from correct endpoint', async () => { + const response = await client.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { token }, + ); + expect(response).toEqual(defaultResponse); + }); + + it('forwards authorization token', async () => { + expect.assertions(1); + + server.use( + rest.get( + `${mockBaseUrl}/locations/by-entity/c/ns/n`, + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json(defaultResponse)); + }, + ), + ); + + await client.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { token }, + ); + }); + + it('skips authorization header if token is omitted', async () => { + expect.assertions(1); + + server.use( + rest.get( + `${mockBaseUrl}/locations/by-entity/c/ns/n`, + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBeNull(); + return res(ctx.json(defaultResponse)); + }, + ), + ); + + await client.getLocationByEntity({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + }); + describe('validateEntity', () => { it('returns valid false when validation fails', async () => { server.use( diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 0d6bd6b926..07fedd0e9c 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -88,6 +88,21 @@ export class CatalogClient implements CatalogApi { ); } + /** + * {@inheritdoc CatalogApi.getLocationByEntity} + */ + async getLocationByEntity( + entityRef: CompoundEntityRef | string, + options?: CatalogRequestOptions, + ): Promise { + return await this.requestOptional( + await this.apiClient.getLocationByEntity( + { path: parseEntityRef(entityRef) }, + options, + ), + ); + } + /** * {@inheritdoc CatalogApi.getEntities} */ diff --git a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts index 65b8699370..14fc73ba83 100644 --- a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts +++ b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { DiscoveryApi } from '../types/discovery'; import { FetchApi } from '../types/fetch'; import crossFetch from 'cross-fetch'; @@ -461,6 +464,42 @@ export class DefaultApiClient { }); } + /** + * Get a location for entity. + * @param kind + * @param namespace + * @param name + */ + public async getLocationByEntity( + // @ts-ignore + request: { + path: { + kind: string; + namespace: string; + name: string; + }; + }, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/locations/by-entity/{kind}/{namespace}/{name}`; + + const uri = parser.parse(uriTemplate).expand({ + kind: request.path.kind, + namespace: request.path.namespace, + name: request.path.name, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'GET', + }); + } + /** * Get all locations */ diff --git a/packages/catalog-client/src/generated/apis/index.ts b/packages/catalog-client/src/generated/apis/index.ts index ad2f72c5b2..51dcca33fe 100644 --- a/packages/catalog-client/src/generated/apis/index.ts +++ b/packages/catalog-client/src/generated/apis/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/catalog-client/src/generated/index.ts b/packages/catalog-client/src/generated/index.ts index 0cf3e9cc49..bb399e97a0 100644 --- a/packages/catalog-client/src/generated/index.ts +++ b/packages/catalog-client/src/generated/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/catalog-client/src/generated/models/AnalyzeLocationEntityField.model.ts b/packages/catalog-client/src/generated/models/AnalyzeLocationEntityField.model.ts index a1c1e1f669..48d2c897a7 100644 --- a/packages/catalog-client/src/generated/models/AnalyzeLocationEntityField.model.ts +++ b/packages/catalog-client/src/generated/models/AnalyzeLocationEntityField.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface AnalyzeLocationEntityField { /** * A text to show to the user to inform about the choices made. Like, it could say \"Found a CODEOWNERS file that covers this target, so we suggest leaving this field empty; which would currently make it owned by X\" where X is taken from the codeowners file. diff --git a/packages/catalog-client/src/generated/models/AnalyzeLocationExistingEntity.model.ts b/packages/catalog-client/src/generated/models/AnalyzeLocationExistingEntity.model.ts index 5ede0fb60b..7a1417145e 100644 --- a/packages/catalog-client/src/generated/models/AnalyzeLocationExistingEntity.model.ts +++ b/packages/catalog-client/src/generated/models/AnalyzeLocationExistingEntity.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { Entity } from '../models/Entity.model'; import { LocationSpec } from '../models/LocationSpec.model'; diff --git a/packages/catalog-client/src/generated/models/AnalyzeLocationGenerateEntity.model.ts b/packages/catalog-client/src/generated/models/AnalyzeLocationGenerateEntity.model.ts index 886aac05d9..3e33c59330 100644 --- a/packages/catalog-client/src/generated/models/AnalyzeLocationGenerateEntity.model.ts +++ b/packages/catalog-client/src/generated/models/AnalyzeLocationGenerateEntity.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { AnalyzeLocationEntityField } from '../models/AnalyzeLocationEntityField.model'; import { RecursivePartialEntity } from '../models/RecursivePartialEntity.model'; diff --git a/packages/catalog-client/src/generated/models/AnalyzeLocationRequest.model.ts b/packages/catalog-client/src/generated/models/AnalyzeLocationRequest.model.ts index e8e9542f5a..15e9b96e2c 100644 --- a/packages/catalog-client/src/generated/models/AnalyzeLocationRequest.model.ts +++ b/packages/catalog-client/src/generated/models/AnalyzeLocationRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { LocationInput } from '../models/LocationInput.model'; export interface AnalyzeLocationRequest { diff --git a/packages/catalog-client/src/generated/models/AnalyzeLocationResponse.model.ts b/packages/catalog-client/src/generated/models/AnalyzeLocationResponse.model.ts index bf60aa8018..e22d45627a 100644 --- a/packages/catalog-client/src/generated/models/AnalyzeLocationResponse.model.ts +++ b/packages/catalog-client/src/generated/models/AnalyzeLocationResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { AnalyzeLocationExistingEntity } from '../models/AnalyzeLocationExistingEntity.model'; import { AnalyzeLocationGenerateEntity } from '../models/AnalyzeLocationGenerateEntity.model'; diff --git a/packages/catalog-client/src/generated/models/CreateLocation201Response.model.ts b/packages/catalog-client/src/generated/models/CreateLocation201Response.model.ts index d090d80bf2..367e701af8 100644 --- a/packages/catalog-client/src/generated/models/CreateLocation201Response.model.ts +++ b/packages/catalog-client/src/generated/models/CreateLocation201Response.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { Entity } from '../models/Entity.model'; import { Location } from '../models/Location.model'; diff --git a/packages/catalog-client/src/generated/models/CreateLocationRequest.model.ts b/packages/catalog-client/src/generated/models/CreateLocationRequest.model.ts index 6ef950072e..badddbf60d 100644 --- a/packages/catalog-client/src/generated/models/CreateLocationRequest.model.ts +++ b/packages/catalog-client/src/generated/models/CreateLocationRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface CreateLocationRequest { target: string; type: string; diff --git a/packages/catalog-client/src/generated/models/EntitiesBatchResponse.model.ts b/packages/catalog-client/src/generated/models/EntitiesBatchResponse.model.ts index d09b2cc086..3275a7b6e7 100644 --- a/packages/catalog-client/src/generated/models/EntitiesBatchResponse.model.ts +++ b/packages/catalog-client/src/generated/models/EntitiesBatchResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { NullableEntity } from '../models/NullableEntity.model'; export interface EntitiesBatchResponse { diff --git a/packages/catalog-client/src/generated/models/EntitiesQueryResponse.model.ts b/packages/catalog-client/src/generated/models/EntitiesQueryResponse.model.ts index 10d4747caf..9950dd98d1 100644 --- a/packages/catalog-client/src/generated/models/EntitiesQueryResponse.model.ts +++ b/packages/catalog-client/src/generated/models/EntitiesQueryResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntitiesQueryResponsePageInfo } from '../models/EntitiesQueryResponsePageInfo.model'; import { Entity } from '../models/Entity.model'; diff --git a/packages/catalog-client/src/generated/models/EntitiesQueryResponsePageInfo.model.ts b/packages/catalog-client/src/generated/models/EntitiesQueryResponsePageInfo.model.ts index 8a7d5b39bb..4d2a0af058 100644 --- a/packages/catalog-client/src/generated/models/EntitiesQueryResponsePageInfo.model.ts +++ b/packages/catalog-client/src/generated/models/EntitiesQueryResponsePageInfo.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface EntitiesQueryResponsePageInfo { /** * The cursor for the next batch of entities. diff --git a/packages/catalog-client/src/generated/models/Entity.model.ts b/packages/catalog-client/src/generated/models/Entity.model.ts index 59ef9bc741..f4a30fbf66 100644 --- a/packages/catalog-client/src/generated/models/Entity.model.ts +++ b/packages/catalog-client/src/generated/models/Entity.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityMeta } from '../models/EntityMeta.model'; import { EntityRelation } from '../models/EntityRelation.model'; diff --git a/packages/catalog-client/src/generated/models/EntityAncestryResponse.model.ts b/packages/catalog-client/src/generated/models/EntityAncestryResponse.model.ts index 08076fcf36..4e0ead7d3b 100644 --- a/packages/catalog-client/src/generated/models/EntityAncestryResponse.model.ts +++ b/packages/catalog-client/src/generated/models/EntityAncestryResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityAncestryResponseItemsInner } from '../models/EntityAncestryResponseItemsInner.model'; export interface EntityAncestryResponse { diff --git a/packages/catalog-client/src/generated/models/EntityAncestryResponseItemsInner.model.ts b/packages/catalog-client/src/generated/models/EntityAncestryResponseItemsInner.model.ts index 7713c21295..22b59e201f 100644 --- a/packages/catalog-client/src/generated/models/EntityAncestryResponseItemsInner.model.ts +++ b/packages/catalog-client/src/generated/models/EntityAncestryResponseItemsInner.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { Entity } from '../models/Entity.model'; export interface EntityAncestryResponseItemsInner { diff --git a/packages/catalog-client/src/generated/models/EntityFacet.model.ts b/packages/catalog-client/src/generated/models/EntityFacet.model.ts index 3872091d77..aa637501f4 100644 --- a/packages/catalog-client/src/generated/models/EntityFacet.model.ts +++ b/packages/catalog-client/src/generated/models/EntityFacet.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface EntityFacet { value: string; count: number; diff --git a/packages/catalog-client/src/generated/models/EntityFacetsResponse.model.ts b/packages/catalog-client/src/generated/models/EntityFacetsResponse.model.ts index 0ff37e4f2f..33aa505a64 100644 --- a/packages/catalog-client/src/generated/models/EntityFacetsResponse.model.ts +++ b/packages/catalog-client/src/generated/models/EntityFacetsResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityFacet } from '../models/EntityFacet.model'; export interface EntityFacetsResponse { diff --git a/packages/catalog-client/src/generated/models/EntityLink.model.ts b/packages/catalog-client/src/generated/models/EntityLink.model.ts index f52f9f2df0..9de21c1b10 100644 --- a/packages/catalog-client/src/generated/models/EntityLink.model.ts +++ b/packages/catalog-client/src/generated/models/EntityLink.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * A link to external information that is related to the entity. */ diff --git a/packages/catalog-client/src/generated/models/EntityMeta.model.ts b/packages/catalog-client/src/generated/models/EntityMeta.model.ts index cb0fd28fdc..10cda7f226 100644 --- a/packages/catalog-client/src/generated/models/EntityMeta.model.ts +++ b/packages/catalog-client/src/generated/models/EntityMeta.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityLink } from '../models/EntityLink.model'; /** @@ -21,6 +24,7 @@ import { EntityLink } from '../models/EntityLink.model'; */ export interface EntityMeta { [key: string]: any; + /** * A list of external hyperlinks related to the entity. */ diff --git a/packages/catalog-client/src/generated/models/EntityRelation.model.ts b/packages/catalog-client/src/generated/models/EntityRelation.model.ts index 1107f7563c..14f7d0fab3 100644 --- a/packages/catalog-client/src/generated/models/EntityRelation.model.ts +++ b/packages/catalog-client/src/generated/models/EntityRelation.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * A relation of a specific type to another entity in the catalog. */ diff --git a/packages/catalog-client/src/generated/models/ErrorError.model.ts b/packages/catalog-client/src/generated/models/ErrorError.model.ts index 9dbfa22813..a9d3b11fdd 100644 --- a/packages/catalog-client/src/generated/models/ErrorError.model.ts +++ b/packages/catalog-client/src/generated/models/ErrorError.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface ErrorError { name: string; message: string; diff --git a/packages/catalog-client/src/generated/models/ErrorRequest.model.ts b/packages/catalog-client/src/generated/models/ErrorRequest.model.ts index e6306b87f5..be62627814 100644 --- a/packages/catalog-client/src/generated/models/ErrorRequest.model.ts +++ b/packages/catalog-client/src/generated/models/ErrorRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface ErrorRequest { method: string; url: string; diff --git a/packages/catalog-client/src/generated/models/ErrorResponse.model.ts b/packages/catalog-client/src/generated/models/ErrorResponse.model.ts index 1f55c34b4a..008f8b7348 100644 --- a/packages/catalog-client/src/generated/models/ErrorResponse.model.ts +++ b/packages/catalog-client/src/generated/models/ErrorResponse.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface ErrorResponse { statusCode: number; } diff --git a/packages/catalog-client/src/generated/models/GetEntitiesByRefsRequest.model.ts b/packages/catalog-client/src/generated/models/GetEntitiesByRefsRequest.model.ts index bdda7c2fa6..07c6500c9f 100644 --- a/packages/catalog-client/src/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/packages/catalog-client/src/generated/models/GetEntitiesByRefsRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; diff --git a/packages/catalog-client/src/generated/models/GetLocations200ResponseInner.model.ts b/packages/catalog-client/src/generated/models/GetLocations200ResponseInner.model.ts index 134306cce7..94c40d2233 100644 --- a/packages/catalog-client/src/generated/models/GetLocations200ResponseInner.model.ts +++ b/packages/catalog-client/src/generated/models/GetLocations200ResponseInner.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { Location } from '../models/Location.model'; export interface GetLocations200ResponseInner { diff --git a/packages/catalog-client/src/generated/models/Location.model.ts b/packages/catalog-client/src/generated/models/Location.model.ts index 27e47a5a25..456b76ec7b 100644 --- a/packages/catalog-client/src/generated/models/Location.model.ts +++ b/packages/catalog-client/src/generated/models/Location.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * Entity location for a specific entity. */ diff --git a/packages/catalog-client/src/generated/models/LocationInput.model.ts b/packages/catalog-client/src/generated/models/LocationInput.model.ts index 8de7e3e6c9..41537aaded 100644 --- a/packages/catalog-client/src/generated/models/LocationInput.model.ts +++ b/packages/catalog-client/src/generated/models/LocationInput.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface LocationInput { type: string; target: string; diff --git a/packages/catalog-client/src/generated/models/LocationSpec.model.ts b/packages/catalog-client/src/generated/models/LocationSpec.model.ts index 80db2cadf2..cd9eb284f7 100644 --- a/packages/catalog-client/src/generated/models/LocationSpec.model.ts +++ b/packages/catalog-client/src/generated/models/LocationSpec.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * Holds the entity location information. */ diff --git a/packages/catalog-client/src/generated/models/ModelError.model.ts b/packages/catalog-client/src/generated/models/ModelError.model.ts index 989e755c9a..7bfa165493 100644 --- a/packages/catalog-client/src/generated/models/ModelError.model.ts +++ b/packages/catalog-client/src/generated/models/ModelError.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,16 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { ErrorError } from '../models/ErrorError.model'; import { ErrorRequest } from '../models/ErrorRequest.model'; import { ErrorResponse } from '../models/ErrorResponse.model'; export interface ModelError { [key: string]: any; + error: ErrorError; request?: ErrorRequest; response: ErrorResponse; diff --git a/packages/catalog-client/src/generated/models/NullableEntity.model.ts b/packages/catalog-client/src/generated/models/NullableEntity.model.ts index 36aff86ba8..b4344a3bfb 100644 --- a/packages/catalog-client/src/generated/models/NullableEntity.model.ts +++ b/packages/catalog-client/src/generated/models/NullableEntity.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityMeta } from '../models/EntityMeta.model'; import { EntityRelation } from '../models/EntityRelation.model'; diff --git a/packages/catalog-client/src/generated/models/RecursivePartialEntity.model.ts b/packages/catalog-client/src/generated/models/RecursivePartialEntity.model.ts index 8de7824550..db5ac31de3 100644 --- a/packages/catalog-client/src/generated/models/RecursivePartialEntity.model.ts +++ b/packages/catalog-client/src/generated/models/RecursivePartialEntity.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { RecursivePartialEntityMeta } from '../models/RecursivePartialEntityMeta.model'; import { RecursivePartialEntityRelation } from '../models/RecursivePartialEntityRelation.model'; diff --git a/packages/catalog-client/src/generated/models/RecursivePartialEntityMeta.model.ts b/packages/catalog-client/src/generated/models/RecursivePartialEntityMeta.model.ts index b21cc1bb21..93e849ac99 100644 --- a/packages/catalog-client/src/generated/models/RecursivePartialEntityMeta.model.ts +++ b/packages/catalog-client/src/generated/models/RecursivePartialEntityMeta.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityLink } from '../models/EntityLink.model'; export interface RecursivePartialEntityMeta { diff --git a/packages/catalog-client/src/generated/models/RecursivePartialEntityMetaAllOf.model.ts b/packages/catalog-client/src/generated/models/RecursivePartialEntityMetaAllOf.model.ts index 5111cd7f9f..7e06368102 100644 --- a/packages/catalog-client/src/generated/models/RecursivePartialEntityMetaAllOf.model.ts +++ b/packages/catalog-client/src/generated/models/RecursivePartialEntityMetaAllOf.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { EntityLink } from '../models/EntityLink.model'; /** diff --git a/packages/catalog-client/src/generated/models/RecursivePartialEntityRelation.model.ts b/packages/catalog-client/src/generated/models/RecursivePartialEntityRelation.model.ts index 50635b69f4..9179add222 100644 --- a/packages/catalog-client/src/generated/models/RecursivePartialEntityRelation.model.ts +++ b/packages/catalog-client/src/generated/models/RecursivePartialEntityRelation.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * A relation of a specific type to another entity in the catalog. */ diff --git a/packages/catalog-client/src/generated/models/RefreshEntityRequest.model.ts b/packages/catalog-client/src/generated/models/RefreshEntityRequest.model.ts index 573523385b..c5ab4c38eb 100644 --- a/packages/catalog-client/src/generated/models/RefreshEntityRequest.model.ts +++ b/packages/catalog-client/src/generated/models/RefreshEntityRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + /** * Options for requesting a refresh of entities in the catalog. */ diff --git a/packages/catalog-client/src/generated/models/ValidateEntity400Response.model.ts b/packages/catalog-client/src/generated/models/ValidateEntity400Response.model.ts index 0031b2edc2..7cd5044782 100644 --- a/packages/catalog-client/src/generated/models/ValidateEntity400Response.model.ts +++ b/packages/catalog-client/src/generated/models/ValidateEntity400Response.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,9 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** import { ValidateEntity400ResponseErrorsInner } from '../models/ValidateEntity400ResponseErrorsInner.model'; export interface ValidateEntity400Response { diff --git a/packages/catalog-client/src/generated/models/ValidateEntity400ResponseErrorsInner.model.ts b/packages/catalog-client/src/generated/models/ValidateEntity400ResponseErrorsInner.model.ts index 6223df27af..5617ee00bb 100644 --- a/packages/catalog-client/src/generated/models/ValidateEntity400ResponseErrorsInner.model.ts +++ b/packages/catalog-client/src/generated/models/ValidateEntity400ResponseErrorsInner.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,13 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface ValidateEntity400ResponseErrorsInner { [key: string]: any; + name: string; message: string; } diff --git a/packages/catalog-client/src/generated/models/ValidateEntityRequest.model.ts b/packages/catalog-client/src/generated/models/ValidateEntityRequest.model.ts index d5ce1be5c9..152f28cf85 100644 --- a/packages/catalog-client/src/generated/models/ValidateEntityRequest.model.ts +++ b/packages/catalog-client/src/generated/models/ValidateEntityRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ * limitations under the License. */ +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + export interface ValidateEntityRequest { location: string; entity: { [key: string]: any }; diff --git a/packages/catalog-client/src/generated/models/index.ts b/packages/catalog-client/src/generated/models/index.ts index 5a93dbb33a..d6c81becb5 100644 --- a/packages/catalog-client/src/generated/models/index.ts +++ b/packages/catalog-client/src/generated/models/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/catalog-client/src/generated/pluginId.ts b/packages/catalog-client/src/generated/pluginId.ts index c8c8ce1ca3..b45379f0b0 100644 --- a/packages/catalog-client/src/generated/pluginId.ts +++ b/packages/catalog-client/src/generated/pluginId.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 5285bda2f7..ecf2be7f44 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -629,6 +629,17 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * Gets a location associated with an entity. + * + * @param entityRef - A complete entity ref, either on string or compound form + * @param options - Additional options + */ + getLocationByEntity( + entityRef: string | CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise; + /** * Validate entity and its location. * diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts index 4d545c9d35..69f9aae1c4 100644 --- a/packages/catalog-model/src/kinds/relations.ts +++ b/packages/catalog-model/src/kinds/relations.ts @@ -114,7 +114,7 @@ export const RELATION_CHILD_OF = 'childOf'; export const RELATION_MEMBER_OF = 'memberOf'; /** - * A relation from a group to its member, typcally a user in a group. Reversed direction of + * A relation from a group to its member, typically a user in a group. Reversed direction of * {@link RELATION_MEMBER_OF}. * * @public diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ca7d675d3..edf03167ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,7 +62,7 @@ "@swc/core": "^1.3.46", "@swc/helpers": "^0.5.0", "@swc/jest": "^0.2.22", - "@types/jest": "^29.0.0", + "@types/jest": "^29.5.11", "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.7.2", @@ -100,7 +100,7 @@ "handlebars": "^4.7.3", "html-webpack-plugin": "^5.3.1", "inquirer": "^8.2.0", - "jest": "^29.0.2", + "jest": "^29.7.0", "jest-css-modules": "^2.1.0", "jest-environment-jsdom": "^29.0.2", "jest-runtime": "^29.0.2", @@ -112,6 +112,7 @@ "node-libs-browser": "^2.2.1", "npm-packlist": "^5.0.0", "ora": "^5.3.0", + "p-queue": "^6.6.2", "postcss": "^8.1.0", "process": "^0.11.10", "react-dev-utils": "^12.0.0-next.60", diff --git a/packages/cli/src/lib/bundler/packageDetection.ts b/packages/cli/src/lib/bundler/packageDetection.ts index 3aa0955c06..0c357fe79e 100644 --- a/packages/cli/src/lib/bundler/packageDetection.ts +++ b/packages/cli/src/lib/bundler/packageDetection.ts @@ -18,6 +18,7 @@ import { BackstagePackageJson } from '@backstage/cli-node'; import { Config, ConfigReader } from '@backstage/config'; import chokidar from 'chokidar'; import fs from 'fs-extra'; +import PQueue from 'p-queue'; import { join as joinPath, resolve as resolvePath } from 'path'; import { paths as cliPaths } from '../paths'; @@ -104,6 +105,9 @@ async function detectPackages( }); } +// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites +const writeQueue = new PQueue({ concurrency: 1 }); + async function writeDetectedPackagesModule( pkgs: { name: string; export?: string; import: string }[], ) { @@ -116,13 +120,15 @@ async function writeDetectedPackagesModule( ) .join(','); - await fs.writeFile( - joinPath( - cliPaths.targetRoot, - 'node_modules', - `${DETECTED_MODULES_MODULE_NAME}.js`, + await writeQueue.add(() => + fs.writeFile( + joinPath( + cliPaths.targetRoot, + 'node_modules', + `${DETECTED_MODULES_MODULE_NAME}.js`, + ), + `window['__@backstage/discovered__'] = { modules: [${requirePackageScript}] };`, ), - `window['__@backstage/discovered__'] = { modules: [${requirePackageScript}] };`, ); } diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index a7aae2b5b9..be80ca50e9 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -3,6 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; @@ -51,6 +55,20 @@ export function convertLegacyRouteRefs< [KName in keyof TRefs]: ToNewRouteRef; }; +// @public +export class MultipleAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; + static fromApis( + actualApis: (AnalyticsApi | AnalyticsApi_2)[], + ): MultipleAnalyticsApi; +} + +// @public +export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { + // (undocumented) + captureEvent(_event: AnalyticsEvent | AnalyticsEvent_2): void; +} + // @public export type ToNewRouteRef = T extends RouteRef diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts new file mode 100644 index 0000000000..8095ee0020 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MultipleAnalyticsApi } from './MultipleAnalyticsApi'; + +describe('MultipleAnalyticsApi', () => { + const analyticsApiOne = { captureEvent: jest.fn() }; + const analyticsApiTwo = { captureEvent: jest.fn() }; + const multipleApis = MultipleAnalyticsApi.fromApis([ + analyticsApiOne, + analyticsApiTwo, + ]); + + const event = { + action: 'navivate', + subject: '/path', + context: { + extension: 'App', + pluginId: 'plugin', + routeRef: 'unknown', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards events to all apis', () => { + // When an event is captured + multipleApis.captureEvent(event); + + // Then both underlying APIs should have received the event + expect(analyticsApiOne.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiOne.captureEvent).toHaveBeenCalledWith(event); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event); + }); + + it('forwards events to all apis even if one throws an error', () => { + // Given one underlying API that throws on capture + analyticsApiOne.captureEvent.mockImplementation(() => { + throw new Error('!!!'); + }); + + // When an event is captured + multipleApis.captureEvent(event); + + // Then the other underlying API should have still received the event + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts new file mode 100644 index 0000000000..888474877d --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyicsEvent, +} from '@backstage/frontend-plugin-api'; + +/** + * An implementation of the AnalyticsApi that can be used to forward analytics + * events to multiple concrete implementations. + * + * @public + * + * @example + * + * ```jsx + * createApiFactory({ + * api: analyticsApiRef, + * deps: { configApi: configApiRef, identityApi: identityApiRef, storageApi: storageApiRef }, + * factory: ({ configApi, identityApi, storageApi }) => + * MultipleAnalyticsApi.fromApis([ + * VendorAnalyticsApi.fromConfig(configApi, { identityApi }), + * CustomAnalyticsApi.fromConfig(configApi, { identityApi, storageApi }), + * ]), + * }); + * ``` + */ +export class MultipleAnalyticsApi implements AnalyticsApi, NewAnalyicsApi { + private constructor( + private readonly actualApis: (AnalyticsApi | NewAnalyicsApi)[], + ) {} + + /** + * Create an AnalyticsApi implementation from an array of concrete + * implementations. + * + * @example + * + * ```jsx + * MultipleAnalyticsApi.fromApis([ + * SomeAnalyticsApi.fromConfig(configApi), + * new CustomAnalyticsApi(), + * ]); + * ``` + */ + static fromApis(actualApis: (AnalyticsApi | NewAnalyicsApi)[]) { + return new MultipleAnalyticsApi(actualApis); + } + + /** + * Forward the event to all configured analytics API implementations. + */ + captureEvent(event: AnalyticsEvent | NewAnalyicsEvent): void { + this.actualApis.forEach(analyticsApi => { + try { + analyticsApi.captureEvent(event as AnalyticsEvent & NewAnalyicsEvent); + } catch { + /* ignored */ + } + }); + } +} diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts new file mode 100644 index 0000000000..ad57258fdd --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyicsEvent, +} from '@backstage/frontend-plugin-api'; + +/** + * Base implementation for the AnalyticsApi that does nothing. + * + * @public + */ +export class NoOpAnalyticsApi implements AnalyticsApi, NewAnalyicsApi { + captureEvent(_event: AnalyticsEvent | NewAnalyicsEvent): void {} +} diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..c9e2008516 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { MultipleAnalyticsApi } from './MultipleAnalyticsApi'; +export { NoOpAnalyticsApi } from './NoOpAnalyticsApi'; diff --git a/packages/core-compat-api/src/apis/implementations/index.ts b/packages/core-compat-api/src/apis/implementations/index.ts new file mode 100644 index 0000000000..82fc567355 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './AnalyticsApi'; diff --git a/packages/core-compat-api/src/apis/index.ts b/packages/core-compat-api/src/apis/index.ts new file mode 100644 index 0000000000..dd4cc2a930 --- /dev/null +++ b/packages/core-compat-api/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './implementations'; diff --git a/packages/core-compat-api/src/convertLegacyRouteRef.ts b/packages/core-compat-api/src/convertLegacyRouteRef.ts index 36311dc081..a5375e0692 100644 --- a/packages/core-compat-api/src/convertLegacyRouteRef.ts +++ b/packages/core-compat-api/src/convertLegacyRouteRef.ts @@ -199,6 +199,10 @@ export function convertLegacyRouteRef( getDescription() { return legacyRefStr; }, + getDefaultTarget() { + // TODO(freben): These are not yet supported in the old system; just returning undefined for now + return undefined; + }, setId(id: string) { newRef.setId(id); }, diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index b632bf8a57..88e1892eac 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -15,6 +15,8 @@ */ export * from './compatWrapper'; +export * from './apis'; + export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyRouteRef, diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index cca7017089..b5d54dd606 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -593,11 +593,6 @@ export type InfoCardClassKey = // @public (undocumented) export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem'; -// Warning: (ae-forgotten-export) The symbol "IntroCardProps" needs to be exported by the entry point index.d.ts -// -// @public -export function IntroCard(props: IntroCardProps): React_2.JSX.Element; - // Warning: (ae-forgotten-export) The symbol "ItemCardProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ItemCard" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -953,19 +948,6 @@ export interface SidebarGroupProps extends BottomNavigationActionProps { to?: string; } -// Warning: (ae-missing-release-tag) "SidebarIntro" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export function SidebarIntro(_props: {}): React_2.JSX.Element | null; - -// @public (undocumented) -export type SidebarIntroClassKey = - | 'introCard' - | 'introDismiss' - | 'introDismissLink' - | 'introDismissText' - | 'introDismissIcon'; - // Warning: (ae-forgotten-export) The symbol "SidebarItemProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SidebarItem" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/core-components/src/layout/Sidebar/Intro.tsx b/packages/core-components/src/layout/Sidebar/Intro.tsx deleted file mode 100644 index 6f75f096fb..0000000000 --- a/packages/core-components/src/layout/Sidebar/Intro.tsx +++ /dev/null @@ -1,199 +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 Box from '@material-ui/core/Box'; -import Collapse from '@material-ui/core/Collapse'; -import IconButton from '@material-ui/core/IconButton'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import CloseIcon from '@material-ui/icons/Close'; -import { useLocalStorageValue } from '@react-hookz/web'; -import React, { useContext, useState } from 'react'; - -import { - SIDEBAR_INTRO_LOCAL_STORAGE, - SidebarConfig, - SidebarConfigContext, -} from './config'; -import { SidebarDivider } from './Items'; -import { useSidebarOpenState } from './SidebarOpenStateContext'; - -/** @public */ -export type SidebarIntroClassKey = - | 'introCard' - | 'introDismiss' - | 'introDismissLink' - | 'introDismissText' - | 'introDismissIcon'; - -const useStyles = makeStyles( - theme => ({ - introCard: props => ({ - color: '#b5b5b5', - // XXX (@koroeskohr): should I be using a Mui theme variable? - fontSize: 12, - width: props.sidebarConfig.drawerWidthOpen, - marginTop: theme.spacing(2.25), - marginBottom: theme.spacing(1.5), - paddingLeft: props.sidebarConfig.iconPadding, - paddingRight: props.sidebarConfig.iconPadding, - }), - introDismiss: { - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - marginTop: theme.spacing(1.5), - }, - introDismissLink: { - color: '#dddddd', - display: 'flex', - alignItems: 'center', - marginBottom: theme.spacing(0.5), - '&:hover': { - color: theme.palette.linkHover, - transition: theme.transitions.create('color', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.shortest, - }), - }, - }, - introDismissText: { - fontSize: '0.7rem', - fontWeight: 'bold', - textTransform: 'uppercase', - letterSpacing: 1, - }, - introDismissIcon: { - width: 18, - height: 18, - marginRight: theme.spacing(1.5), - }, - }), - { name: 'BackstageSidebarIntro' }, -); - -type IntroCardProps = { - text: string; - onClose: () => void; -}; - -/** - * Closable card with information from Navigation Sidebar - * - * @public - * - */ - -export function IntroCard(props: IntroCardProps) { - const { sidebarConfig } = useContext(SidebarConfigContext); - const classes = useStyles({ sidebarConfig }); - const { text, onClose } = props; - const handleClose = () => onClose(); - - return ( - - {text} - - - - - Dismiss - - - - - ); -} - -type SidebarIntroLocalStorage = { - starredItemsDismissed: boolean; - recentlyViewedItemsDismissed: boolean; -}; - -type SidebarIntroCardProps = { - text: string; - onDismiss: () => void; -}; - -const SidebarIntroCard = (props: SidebarIntroCardProps) => { - const { text, onDismiss } = props; - const [collapsing, setCollapsing] = useState(false); - const startDismissing = () => { - setCollapsing(true); - }; - return ( - - - - ); -}; - -const starredIntroText = `Fun fact! As you explore all the awesome plugins in Backstage, you can actually pin them to this side nav. -Keep an eye out for the little star icon (⭐) next to the plugin name and give it a click!`; -const recentlyViewedIntroText = - 'And your recently viewed plugins will pop up here!'; - -export function SidebarIntro(_props: {}) { - const { isOpen } = useSidebarOpenState(); - const defaultValue = { - starredItemsDismissed: false, - recentlyViewedItemsDismissed: false, - }; - const { value: dismissedIntro, set: setDismissedIntro } = - useLocalStorageValue(SIDEBAR_INTRO_LOCAL_STORAGE); - - const { starredItemsDismissed, recentlyViewedItemsDismissed } = - dismissedIntro ?? {}; - - const dismissStarred = () => { - setDismissedIntro(state => ({ - ...defaultValue, - ...state, - starredItemsDismissed: true, - })); - }; - const dismissRecentlyViewed = () => { - setDismissedIntro(state => ({ - ...defaultValue, - ...state, - recentlyViewedItemsDismissed: true, - })); - }; - - if (!isOpen) { - return null; - } - - return ( - <> - {!starredItemsDismissed && ( - <> - - - - )} - {!recentlyViewedItemsDismissed && ( - - )} - - ); -} diff --git a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx index 342a9b2a29..aef837b646 100644 --- a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx @@ -34,7 +34,6 @@ import { SidebarSearchField, SidebarSpace, } from './Items'; -import { SidebarIntro } from './Intro'; import { SidebarSubmenu } from './SidebarSubmenu'; import { SidebarSubmenuItem } from './SidebarSubmenuItem'; @@ -65,7 +64,6 @@ export const SampleSidebar = () => ( - @@ -104,7 +102,6 @@ export const SampleScalableSidebar = () => ( - diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index 4c91ea6a8f..b614d50feb 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -44,8 +44,6 @@ export type { SidebarSpacerClassKey, SidebarDividerClassKey, } from './Items'; -export { IntroCard, SidebarIntro } from './Intro'; -export type { SidebarIntroClassKey } from './Intro'; export { SIDEBAR_INTRO_LOCAL_STORAGE, sidebarConfig } from './config'; export type { SidebarOptions, SubmenuOptions } from './config'; export { diff --git a/packages/core-components/src/overridableComponents.ts b/packages/core-components/src/overridableComponents.ts index 750303e4df..85e9dc96a8 100644 --- a/packages/core-components/src/overridableComponents.ts +++ b/packages/core-components/src/overridableComponents.ts @@ -83,7 +83,6 @@ import { SidebarSpaceClassKey, SidebarSpacerClassKey, SidebarDividerClassKey, - SidebarIntroClassKey, SidebarItemClassKey, SidebarPageClassKey, CustomProviderClassKey, @@ -157,7 +156,6 @@ type BackstageComponentsNameToClassKey = { BackstageSidebarSpace: SidebarSpaceClassKey; BackstageSidebarSpacer: SidebarSpacerClassKey; BackstageSidebarDivider: SidebarDividerClassKey; - BackstageSidebarIntro: SidebarIntroClassKey; BackstageSidebarItem: SidebarItemClassKey; BackstageSidebarPage: SidebarPageClassKey; BackstageCustomProvider: CustomProviderClassKey; diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index d676260227..68d5ad68c3 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/core-plugin-api/src/apis/system/useApi.tsx b/packages/core-plugin-api/src/apis/system/useApi.tsx index 03c4fa33ac..2bb1532525 100644 --- a/packages/core-plugin-api/src/apis/system/useApi.tsx +++ b/packages/core-plugin-api/src/apis/system/useApi.tsx @@ -17,6 +17,7 @@ import React, { PropsWithChildren } from 'react'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { useVersionedContext } from '@backstage/version-bridge'; +import { NotImplementedError } from '@backstage/errors'; /** * React hook for retrieving {@link ApiHolder}, an API catalog. @@ -26,12 +27,12 @@ import { useVersionedContext } from '@backstage/version-bridge'; export function useApiHolder(): ApiHolder { const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context'); if (!versionedHolder) { - throw new Error('API context is not available'); + throw new NotImplementedError('API context is not available'); } const apiHolder = versionedHolder.atVersion(1); if (!apiHolder) { - throw new Error('ApiContext v1 not available'); + throw new NotImplementedError('ApiContext v1 not available'); } return apiHolder; } @@ -47,7 +48,7 @@ export function useApi(apiRef: ApiRef): T { const api = apiHolder.get(apiRef); if (!api) { - throw new Error(`No implementation available for ${apiRef}`); + throw new NotImplementedError(`No implementation available for ${apiRef}`); } return api; } @@ -73,7 +74,9 @@ export function withApis(apis: TypesToApiRefs) { const api = apiHolder.get(ref); if (!api) { - throw new Error(`No implementation available for ${ref}`); + throw new NotImplementedError( + `No implementation available for ${ref}`, + ); } impls[key] = api; } diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx new file mode 100644 index 0000000000..7d62103727 --- /dev/null +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { + coreExtensionData, + createComponentExtension, + createComponentRef, + createExtension, + createExtensionOverrides, +} from '@backstage/frontend-plugin-api'; +import { resolveAppNodeSpecs } from '../../../tree/resolveAppNodeSpecs'; +import { resolveAppTree } from '../../../tree/resolveAppTree'; +import { App } from '../../../extensions/App'; +import { DefaultComponentsApi } from './DefaultComponentsApi'; +import { render, screen } from '@testing-library/react'; +import { instantiateAppNodeTree } from '../../../tree/instantiateAppNodeTree'; + +const testRefA = createComponentRef({ id: 'test.a' }); +const testRefB1 = createComponentRef({ id: 'test.b' }); +const testRefB2 = createComponentRef({ id: 'test.b' }); + +const baseOverrides = createExtensionOverrides({ + extensions: [ + App, + createExtension({ + namespace: 'app', + name: 'root', + attachTo: { id: 'app', input: 'root' }, + output: { + element: coreExtensionData.reactElement, + }, + factory() { + return { + element:
root
, + }; + }, + }), + ], +}); + +describe('DefaultComponentsApi', () => { + it('should provide components', () => { + const tree = resolveAppTree( + 'app', + resolveAppNodeSpecs({ + features: [ + baseOverrides, + createExtensionOverrides({ + extensions: [ + createComponentExtension({ + ref: testRefA, + loader: { sync: () => () =>
test.a
}, + }), + ], + }), + ], + }), + ); + instantiateAppNodeTree(tree.root); + const api = DefaultComponentsApi.fromTree(tree); + + const ComponentA = api.getComponent(testRefA); + render(); + + expect(screen.getByText('test.a')).toBeInTheDocument(); + }); + + it('should key extension refs by ID', () => { + const tree = resolveAppTree( + 'app', + resolveAppNodeSpecs({ + features: [ + baseOverrides, + createExtensionOverrides({ + extensions: [ + createComponentExtension({ + ref: testRefB1, + loader: { sync: () => () =>
test.b
}, + }), + ], + }), + ], + }), + ); + instantiateAppNodeTree(tree.root); + const api = DefaultComponentsApi.fromTree(tree); + + const ComponentB1 = api.getComponent(testRefB1); + const ComponentB2 = api.getComponent(testRefB2); + + expect(ComponentB1).toBe(ComponentB2); + + render(); + + expect(screen.getByText('test.b')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts similarity index 58% rename from packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts rename to packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts index 4d8bc47e24..572b6fc3c4 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts @@ -15,7 +15,12 @@ */ import { ComponentType } from 'react'; -import { ComponentRef, ComponentsApi } from '@backstage/frontend-plugin-api'; +import { + AppTree, + ComponentRef, + ComponentsApi, + createComponentExtension, +} from '@backstage/frontend-plugin-api'; /** * Implementation for the {@linkComponentApi} @@ -23,14 +28,29 @@ import { ComponentRef, ComponentsApi } from '@backstage/frontend-plugin-api'; * @internal */ export class DefaultComponentsApi implements ComponentsApi { - #components: Map, ComponentType>; + #components: Map>; - constructor(components: Map, any>) { + static fromTree(tree: AppTree) { + const componentEntries = tree.root.edges.attachments + .get('components') + ?.reduce((map, e) => { + const data = e.instance?.getData( + createComponentExtension.componentDataRef, + ); + if (data) { + map.set(data.ref.id, data.impl); + } + return map; + }, new Map()); + return new DefaultComponentsApi(componentEntries ?? new Map()); + } + + constructor(components: Map) { this.#components = components; } getComponent(ref: ComponentRef): ComponentType { - const impl = this.#components.get(ref); + const impl = this.#components.get(ref.id); if (!impl) { throw new Error(`No implementation found for component ref ${ref}`); } diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts index f04059c54f..18604f15de 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { DefaultComponentsApi } from './ComponentsApi'; +export { DefaultComponentsApi } from './DefaultComponentsApi'; diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/packages/frontend-app-api/src/extensions/App.tsx index 751a255fa7..82e7600178 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/packages/frontend-app-api/src/extensions/App.tsx @@ -14,7 +14,9 @@ * limitations under the License. */ +import React from 'react'; import { + ExtensionBoundary, coreExtensionData, createApiExtension, createComponentExtension, @@ -50,9 +52,13 @@ export const App = createExtension({ output: { root: coreExtensionData.reactElement, }, - factory({ inputs }) { + factory({ node, inputs }) { return { - root: inputs.root.output.element, + root: ( + + {inputs.root.output.element} + + ), }; }, }); diff --git a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx index 0e66d0e50d..935449b697 100644 --- a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx +++ b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx @@ -211,8 +211,8 @@ describe('RouteTracker', () => { action: 'navigate', attributes: {}, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, subject: '/not-routable-extension', value: undefined, @@ -221,8 +221,8 @@ describe('RouteTracker', () => { action: 'click', attributes: undefined, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, subject: 'test', value: undefined, diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts index d80a02e34d..9b150eb127 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts @@ -117,4 +117,48 @@ describe('resolveRouteBindings', () => { "Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route", ); }); + + it('can have default targets, but at the lowest priority', () => { + const source = createExternalRouteRef({ defaultTarget: 'target1' }); + const target1 = createRouteRef(); + const target2 = createRouteRef(); + const routesById = { + routes: new Map([ + ['target1', target1], + ['target2', target2], + ]), + externalRoutes: new Map([['source', source]]), + }; + + // defaultTarget wins only if no bind or config matches + let result = resolveRouteBindings( + () => {}, + new ConfigReader({}), + routesById, + ); + + expect(result.get(source)).toBe(target1); + + // config wins over defaultTarget + result = resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { source: 'target2' } } }, + }), + routesById, + ); + + expect(result.get(source)).toBe(target2); + + // bind wins over defaultTarget + result = resolveRouteBindings( + ({ bind }) => { + bind({ a: source }, { a: target2 }); + }, + new ConfigReader({}), + routesById, + ); + + expect(result.get(source)).toBe(target2); + }); }); diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts index 18ea9f9bd8..49e3b1a1be 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -22,6 +22,8 @@ import { import { RouteRefsById } from './collectRouteIds'; import { Config } from '@backstage/config'; import { JsonObject } from '@backstage/types'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; /** * Extracts a union of the keys in a map whose value extends the given type @@ -82,6 +84,7 @@ export function resolveRouteBindings( ): Map { const result = new Map(); + // Perform callback bindings first with highest priority if (bindRoutes) { const bind: CreateAppRouteBinder = ( externalRoutes, @@ -105,37 +108,50 @@ export function resolveRouteBindings( bindRoutes({ bind }); } - const bindingsConfig = config.getOptionalConfig('app.routes.bindings'); - if (!bindingsConfig) { - return result; + // Then perform config based bindings with lower priority + const bindings = config + .getOptionalConfig('app.routes.bindings') + ?.get(); + if (bindings) { + for (const [externalRefId, targetRefId] of Object.entries(bindings)) { + if (typeof targetRefId !== 'string' || targetRefId === '') { + throw new Error( + `Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string`, + ); + } + + const externalRef = routesById.externalRoutes.get(externalRefId); + if (!externalRef) { + throw new Error( + `Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`, + ); + } + if (result.has(externalRef)) { + continue; + } + const targetRef = routesById.routes.get(targetRefId); + if (!targetRef) { + throw new Error( + `Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`, + ); + } + + result.set(externalRef, targetRef); + } } - const bindings = bindingsConfig.get(); - for (const [externalRefId, targetRefId] of Object.entries(bindings)) { - if (typeof targetRefId !== 'string' || targetRefId === '') { - throw new Error( - `Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string`, - ); + // Finally fall back to attempting to map defaults, at lowest priority + for (const externalRef of routesById.externalRoutes.values()) { + if (!result.has(externalRef)) { + const defaultRefId = + toInternalExternalRouteRef(externalRef).getDefaultTarget(); + if (defaultRefId) { + const defaultRef = routesById.routes.get(defaultRefId); + if (defaultRef) { + result.set(externalRef, defaultRef); + } + } } - - const externalRef = routesById.externalRoutes.get(externalRefId); - if (!externalRef) { - throw new Error( - `Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`, - ); - } - // Route bindings defined in config have lower priority than those defined in code - if (result.has(externalRef)) { - continue; - } - const targetRef = routesById.routes.get(targetRefId); - if (!targetRef) { - throw new Error( - `Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`, - ); - } - - result.set(externalRef, targetRef); } return result; diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 8fd9d44638..f056e7a49e 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -31,17 +31,22 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res /** @internal */ export function resolveAppNodeSpecs(options: { - features: FrontendFeature[]; - builtinExtensions: Extension[]; - parameters: Array; + features?: FrontendFeature[]; + builtinExtensions?: Extension[]; + parameters?: Array; forbidden?: Set; }): AppNodeSpec[] { - const { builtinExtensions, parameters, forbidden = new Set() } = options; + const { + builtinExtensions = [], + parameters = [], + forbidden = new Set(), + features = [], + } = options; - const plugins = options.features.filter( + const plugins = features.filter( (f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin', ); - const overrides = options.features.filter( + const overrides = features.filter( (f): f is ExtensionOverrides => f.$$type === '@backstage/ExtensionOverrides', ); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 635e36aace..591e1cbf25 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -19,11 +19,9 @@ import { ConfigReader } from '@backstage/config'; import { AppTree, appTreeApiRef, - ComponentRef, componentsApiRef, coreExtensionData, createApiExtension, - createComponentExtension, createThemeExtension, createTranslationExtension, FrontendFeature, @@ -373,22 +371,10 @@ function createApiHolder( factory: () => routeResolutionApi, }); - const componentsExtensions = - tree.root.edges.attachments - .get('components') - ?.map(e => e.instance?.getData(createComponentExtension.componentDataRef)) - .filter(x => !!x) ?? []; - - const componentsMap = componentsExtensions.reduce( - (components, component) => - component ? components.set(component.ref, component?.impl) : components, - new Map, any>(), - ); - factoryRegistry.register('static', { api: componentsApiRef, deps: {}, - factory: () => new DefaultComponentsApi(componentsMap), + factory: () => DefaultComponentsApi.fromTree(tree), }); factoryRegistry.register('static', { diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 4116469c2a..28c8ea321c 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -573,6 +573,7 @@ export function createExternalRouteRef< ? (keyof TParams)[] : TParamKeys[]; optional?: TOptional; + defaultTarget?: string; }): ExternalRouteRef< keyof TParams extends never ? undefined diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx index 8e78bdc8c4..bdfdef3005 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -35,8 +35,8 @@ describe('AnalyticsContext', () => { it('returns default values', () => { const { result } = renderHook(() => useAnalyticsContext()); expect(result.current).toEqual({ - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }); }); }); @@ -49,8 +49,8 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension-id')).toHaveTextContent('App'); - expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('extension-id')).toHaveTextContent('app'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('app'); }); it('uses provided analytics context', () => { @@ -60,7 +60,7 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension-id')).toHaveTextContent('App'); + expect(result.getByTestId('extension-id')).toHaveTextContent('app'); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); }); diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx index 10ef475803..13047b41d7 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx @@ -37,8 +37,8 @@ export const useAnalyticsContext = (): AnalyticsContextValue => { // Provide a default value if no value exists. if (theContext === undefined) { return { - pluginId: 'root', - extensionId: 'App', + pluginId: 'app', + extensionId: 'app', }; } diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts index 21347e2fc7..3e3a9ad5eb 100644 --- a/packages/frontend-plugin-api/src/analytics/types.ts +++ b/packages/frontend-plugin-api/src/analytics/types.ts @@ -25,9 +25,6 @@ export type CommonAnalyticsContext = { */ pluginId: string; - /** - * The nearest known parent extension where the event was captured. - */ /** * The nearest known parent extension where the event was captured. */ diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx index beb16b1fbb..6dbcc81f11 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx @@ -53,8 +53,8 @@ describe('useAnalytics', () => { some: 'value', }, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, }); }); diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx index f3b5998de4..397906d1d1 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx @@ -23,8 +23,11 @@ import { Tracker } from './Tracker'; function useAnalyticsApi(): AnalyticsApi { try { return useApi(analyticsApiRef); - } catch { - return { captureEvent: () => {} }; + } catch (error) { + if (error.name === 'NotImplementedError') { + return { captureEvent: () => {} }; + } + throw error; } } diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 9a8eb5b750..768352b76e 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -65,7 +65,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extensionId: node.spec.id, - pluginId: node.spec.source?.id, + pluginId: node.spec.source?.id ?? 'app', }; return ( diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx index 2efa106750..a1231d8353 100644 --- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { lazy, ComponentType } from 'react'; +import { lazy, ComponentType } from 'react'; import { AnyExtensionInputMap, ResolvedExtensionInputs, @@ -23,7 +23,7 @@ import { } from '../wiring'; import { Expand } from '../types'; import { PortableSchema } from '../schema'; -import { ExtensionBoundary, ComponentRef } from '../components'; +import { ComponentRef } from '../components'; /** @public */ export function createComponentExtension< @@ -61,28 +61,26 @@ export function createComponentExtension< output: { component: createComponentExtension.componentDataRef, }, - factory({ config, inputs, node }) { - let ExtensionComponent: ComponentType; - + factory({ config, inputs }) { if ('sync' in options.loader) { - ExtensionComponent = options.loader.sync({ config, inputs }); - } else { - const lazyLoader = options.loader.lazy; - ExtensionComponent = lazy(() => - lazyLoader({ config, inputs }).then(Component => ({ - default: Component, - })), - ) as unknown as ComponentType; + return { + component: { + ref: options.ref, + impl: options.loader.sync({ config, inputs }) as ComponentType, + }, + }; } + const lazyLoader = options.loader.lazy; + const ExtensionComponent = lazy(() => + lazyLoader({ config, inputs }).then(Component => ({ + default: Component, + })), + ) as unknown as ComponentType; return { component: { ref: options.ref, - impl: props => ( - - - - ), + impl: ExtensionComponent, }, }; }, diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts index c9aabdef27..7a754ffa7b 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -44,6 +44,7 @@ export interface InternalExternalRouteRef< readonly version: 'v1'; getParams(): string[]; getDescription(): string; + getDefaultTarget(): string | undefined; setId(id: string): void; } @@ -80,10 +81,15 @@ class ExternalRouteRefImpl constructor( readonly optional: boolean, readonly params: string[] = [], + readonly defaultTarget: string | undefined, creationSite: string, ) { super(params, creationSite); } + + getDefaultTarget() { + return this.defaultTarget; + } } /** @@ -115,6 +121,14 @@ export function createExternalRouteRef< * if they aren't, `useExternalRouteRef` will return `undefined`. */ optional?: TOptional; + + /** + * The route (typically in another plugin) that this should map to by default. + * + * The string is expected to be on the standard `.` form, + * for example `techdocs.docRoot`. + */ + defaultTarget?: string; }): ExternalRouteRef< keyof TParams extends never ? undefined @@ -126,6 +140,7 @@ export function createExternalRouteRef< return new ExternalRouteRefImpl( Boolean(options?.optional), options?.params as string[] | undefined, + options?.defaultTarget, describeParentCallSite(), ) as ExternalRouteRef; } diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 634e03dbfc..07b4f7d012 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { ConsumedResponse } from '@backstage/errors'; import { RestEndpointMethodTypes } from '@octokit/rest'; // @public @@ -567,6 +568,8 @@ export class GithubIntegration implements ScmIntegration { // (undocumented) static factory: ScmIntegrationsFactory; // (undocumented) + parseRateLimitInfo(response: ConsumedResponse): RateLimitInfo; + // (undocumented) resolveEditUrl(url: string): string; // (undocumented) resolveUrl(options: { @@ -694,6 +697,12 @@ export type PersonalAccessTokenCredential = AzureCredentialBase & { personalAccessToken: string; }; +// @public +export interface RateLimitInfo { + // (undocumented) + isRateLimited: boolean; +} + // @public export function readAwsS3IntegrationConfig( config: Config, diff --git a/packages/integration/package.json b/packages/integration/package.json index 7e57293903..29a4273698 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -35,6 +35,7 @@ "dependencies": { "@azure/identity": "^4.0.0", "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", "@octokit/auth-app": "^4.0.0", "@octokit/rest": "^19.0.3", "cross-fetch": "^4.0.0", diff --git a/packages/integration/src/github/GithubIntegration.test.ts b/packages/integration/src/github/GithubIntegration.test.ts index d8e6020a64..70f1c4a16c 100644 --- a/packages/integration/src/github/GithubIntegration.test.ts +++ b/packages/integration/src/github/GithubIntegration.test.ts @@ -78,6 +78,33 @@ describe('GithubIntegration', () => { ), ).toBe('https://github.com/backstage/backstage/edit/master/README.md'); }); + + describe('isRateLimited', () => { + const integration = new GithubIntegration({ host: 'h.com' }); + + it.each` + status | ratelimitRemaining | expected + ${404} | ${100} | ${false} + ${429} | ${undefined} | ${true} + ${429} | ${100} | ${true} + ${403} | ${100} | ${false} + ${403} | ${0} | ${true} + `( + '(statusCode: $status, header: $ratelimitRemaining) === $expected', + ({ status, ratelimitRemaining, expected }) => { + const headers = new Headers({ + 'x-ratelimit-remaining': ratelimitRemaining, + }); + const result = integration.parseRateLimitInfo({ + status, + headers, + } as Response); + expect(result).toMatchObject({ + isRateLimited: expected, + }); + }, + ); + }); }); describe('replaceGithubUrlType', () => { diff --git a/packages/integration/src/github/GithubIntegration.ts b/packages/integration/src/github/GithubIntegration.ts index 50fc129ccf..880a2bed21 100644 --- a/packages/integration/src/github/GithubIntegration.ts +++ b/packages/integration/src/github/GithubIntegration.ts @@ -15,11 +15,16 @@ */ import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; -import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + RateLimitInfo, + ScmIntegration, + ScmIntegrationsFactory, +} from '../types'; import { GithubIntegrationConfig, readGithubIntegrationConfigs, } from './config'; +import { ConsumedResponse } from '@backstage/errors'; /** * A GitHub based integration. @@ -65,6 +70,15 @@ export class GithubIntegration implements ScmIntegration { resolveEditUrl(url: string): string { return replaceGithubUrlType(url, 'edit'); } + + parseRateLimitInfo(response: ConsumedResponse): RateLimitInfo { + return { + isRateLimited: + response.status === 429 || + (response.status === 403 && + response.headers.get('x-ratelimit-remaining') === '0'), + }; + } } /** diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 5738da3da0..700d7dc67c 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -37,5 +37,6 @@ export type { ScmIntegration, ScmIntegrationsFactory, ScmIntegrationsGroup, + RateLimitInfo, } from './types'; export type { ScmIntegrationRegistry } from './registry'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index cb4b78eb7c..0f0fca942e 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -108,3 +108,12 @@ export interface ScmIntegrationsGroup { export type ScmIntegrationsFactory = (options: { config: Config; }) => ScmIntegrationsGroup; + +/** + * Encapsulates information about the RateLimit state + * + * @public + */ +export interface RateLimitInfo { + isRateLimited: boolean; +} diff --git a/packages/repo-tools/templates/typescript-backstage/licenseInfo.mustache b/packages/repo-tools/templates/typescript-backstage/licenseInfo.mustache index afc90f6227..7a1b10e780 100644 --- a/packages/repo-tools/templates/typescript-backstage/licenseInfo.mustache +++ b/packages/repo-tools/templates/typescript-backstage/licenseInfo.mustache @@ -1,7 +1,5 @@ // -/** - * {{{appName}}}{{#version}}@{{{.}}}{{/version}} - * - * NOTE: This class is auto generated, do not edit the class manually. - */ \ No newline at end of file +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** \ No newline at end of file diff --git a/packages/repo-tools/templates/typescript-backstage/model.mustache b/packages/repo-tools/templates/typescript-backstage/model.mustache index 97d7858cb2..565eecb4e8 100644 --- a/packages/repo-tools/templates/typescript-backstage/model.mustache +++ b/packages/repo-tools/templates/typescript-backstage/model.mustache @@ -6,12 +6,6 @@ import { {{classname}} } from '{{filename}}.model'; {{/tsImports}} - -{{#description}} -/** - * {{{.}}} - */ -{{/description}} {{#isEnum}}{{>modelEnum}}{{/isEnum}}{{^isEnum}}{{#isAlias}}{{>modelAlias}}{{/isAlias}}{{^isAlias}}{{#taggedUnions}}{{>modelTaggedUnion}}{{/taggedUnions}}{{^taggedUnions}}{{#oneOf}}{{#-first}}{{>modelOneOf}}{{/-first}}{{/oneOf}}{{^oneOf}}{{>modelGeneric}}{{/oneOf}}{{/taggedUnions}}{{/isAlias}}{{/isEnum}} {{/model}} {{/models}} diff --git a/packages/repo-tools/templates/typescript-backstage/modelGenericAdditionalProperties.mustache b/packages/repo-tools/templates/typescript-backstage/modelGenericAdditionalProperties.mustache index b8d7d1ceb7..e1e94cea80 100644 --- a/packages/repo-tools/templates/typescript-backstage/modelGenericAdditionalProperties.mustache +++ b/packages/repo-tools/templates/typescript-backstage/modelGenericAdditionalProperties.mustache @@ -1,7 +1,5 @@ {{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericAdditionalProperties.mustache }} {{#additionalPropertiesType}} - - [key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}}; - + [key: string]: {{{additionalPropertiesType}}}; {{/additionalPropertiesType}} \ No newline at end of file diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index de681c2de4..27f432c22d 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -117,7 +117,7 @@ export default async function serve(opts: OptionValues) { stderrLogFunc: mkdocsLogFunc, mkdocsConfigFileName: mkdocsYmlPath, mkdocsParameterClean: opts.mkdocsParameterClean, - mkdocsParameterDirtyReload: opts.mkdocsParameterDirtyReload, + mkdocsParameterDirtyReload: opts.mkdocsParameterDirtyreload, mkdocsParameterStrict: opts.mkdocsParameterStrict, }); diff --git a/packages/test-utils/src/testUtils/TestApiProvider.tsx b/packages/test-utils/src/testUtils/TestApiProvider.tsx index c212b94bea..59fafbeadb 100644 --- a/packages/test-utils/src/testUtils/TestApiProvider.tsx +++ b/packages/test-utils/src/testUtils/TestApiProvider.tsx @@ -93,8 +93,8 @@ export class TestApiRegistry implements ApiHolder { * * @remarks * todo: remove this remark tag and ship in the api-reference. There's some odd formatting going on when this is made into a markdown doc, that there's no line break between - * the emmited

for To the following

so what happens is that when parsing in docusaurus, it thinks that the code block is mdx rather than a code - * snippet. Just ommiting this from the report for now until we can work out how to fix laterr. + * the emitted

for To the following

so what happens is that when parsing in docusaurus, it thinks that the code block is mdx rather than a code + * snippet. Just omitting this from the report for now until we can work out how to fix later. * A migration from `ApiRegistry` and `ApiProvider` might look like this, from: * * ```tsx diff --git a/plugins/adr/src/search/AdrSearchResultListItem.tsx b/plugins/adr/src/search/AdrSearchResultListItem.tsx index eebf74861d..f0c408df48 100644 --- a/plugins/adr/src/search/AdrSearchResultListItem.tsx +++ b/plugins/adr/src/search/AdrSearchResultListItem.tsx @@ -18,8 +18,6 @@ import React, { ReactNode } from 'react'; import { Box, Chip, - Divider, - ListItem, ListItemIcon, ListItemText, makeStyles, @@ -68,66 +66,63 @@ export function AdrSearchResultListItem(props: AdrSearchResultListItemProps) { if (!result) return null; return ( - <> - - {icon && {icon}} -
- - {highlight?.fields.title ? ( - - ) : ( - result.title - )} - - } - secondary={ - - {highlight?.fields.text ? ( - - ) : ( - result.text - )} - - } +
+ {icon && {icon}} +
+ + {highlight?.fields.title ? ( + + ) : ( + result.title + )} + + } + secondary={ + + {highlight?.fields.text ? ( + + ) : ( + result.text + )} + + } + /> + + - - - {result.status && ( - - )} - {result.date && ( - - )} - -
- - - + {result.status && ( + + )} + {result.date && } + +
+
); } diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index 1846211e72..5404248938 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -4,7 +4,9 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -13,8 +15,8 @@ import { IdentityApi } from '@backstage/core-plugin-api'; export const analyticsModuleGA: BackstagePlugin<{}, {}>; // @public -export class GoogleAnalytics implements AnalyticsApi { - captureEvent(event: AnalyticsEvent): void; +export class GoogleAnalytics implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; static fromConfig( config: Config, options?: { diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 3d367a5889..cabff881ad 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -32,6 +32,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "react-ga": "^3.3.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts index 65e9256aa8..48e43c2a45 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -508,4 +508,133 @@ describe('GoogleAnalytics', () => { expect(lastData.queueTime).toBeUndefined(); }); }); + + describe('api backward compatibility', () => { + it('continue working with legacy App category', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect to use the legacy default category + eventCategory: 'App', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + + it('use lowercase app as the new default category', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { + ...context, + extensionId: '', + extension: '', + }, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context: { + ...context, + extensionId: '', + extension: '', + }, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect to use the new default category + eventCategory: 'App', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + + it('prioritize new context extension id over old extension property', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { + ...context, + extensionId: 'app', + extension: '', + }, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context: { + ...context, + extensionId: 'page:index', + extension: '', + }, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect use the new context extension id + eventCategory: 'page:index', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + }); }); diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 17c5b26be1..d241cd9860 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -22,6 +22,11 @@ import { AnalyticsEventAttributes, IdentityApi, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyticsApi, + AnalyticsEvent as NewAnalyticsEvent, + AnalyticsContextValue as NewAnalyticsContextValue, +} from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { DeferredCapture } from '../../../util'; import { @@ -40,7 +45,7 @@ type CustomDimensionOrMetricConfig = { * Google Analytics API provider for the Backstage Analytics API. * @public */ -export class GoogleAnalytics implements AnalyticsApi { +export class GoogleAnalytics implements AnalyticsApi, NewAnalyticsApi { private readonly cdmConfig: CustomDimensionOrMetricConfig[]; private customUserIdTransform?: (userEntityRef: string) => Promise; private readonly capture: DeferredCapture; @@ -157,11 +162,18 @@ export class GoogleAnalytics implements AnalyticsApi { * pageview and the rest as custom events. All custom dimensions/metrics are * applied as they should be (set on pageview, merged object on events). */ - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; const customMetadata = this.getCustomDimensionMetrics(context, attributes); - if (action === 'navigate' && context.extension === 'App') { + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { this.capture.pageview(subject, customMetadata); return; } @@ -184,7 +196,7 @@ export class GoogleAnalytics implements AnalyticsApi { } this.capture.event({ - category: context.extension || 'App', + category, action, label: subject, value, @@ -197,7 +209,7 @@ export class GoogleAnalytics implements AnalyticsApi { * Event Attributes, e.g. { dimension1: "some value", metric8: 42 } */ private getCustomDimensionMetrics( - context: AnalyticsContextValue, + context: AnalyticsContextValue | NewAnalyticsContextValue, attributes: AnalyticsEventAttributes = {}, ) { const customDimensionsMetrics: { [x: string]: string | number | boolean } = diff --git a/plugins/analytics-module-ga4/api-report.md b/plugins/analytics-module-ga4/api-report.md index 77f3094210..b8282e834a 100644 --- a/plugins/analytics-module-ga4/api-report.md +++ b/plugins/analytics-module-ga4/api-report.md @@ -4,13 +4,15 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; // @public -export class GoogleAnalytics4 implements AnalyticsApi { - captureEvent(event: AnalyticsEvent): void; +export class GoogleAnalytics4 implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; static fromConfig( config: Config, options?: { diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json index 97b610c2bf..89113efe6d 100644 --- a/plugins/analytics-module-ga4/package.json +++ b/plugins/analytics-module-ga4/package.json @@ -32,6 +32,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "react-ga4": "^2.0.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts index 16db7075b3..025462903b 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts @@ -492,4 +492,60 @@ describe('GoogleAnalytics4', () => { }); }); }); + + describe('api backward compatibility', () => { + it('continue working with legacy App category', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + }); + }); + + it('use lowercase app as the new default category', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { ...context, extensionId: '', extension: '' }, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + }); + }); + + it('prioritize new context extension id over old extension property', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { ...context, extensionId: 'app', extension: '' }, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'app', + }); + }); + }); }); diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts index 9abf4719c6..43ff47b84e 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import ReactGA from 'react-ga4'; import { AnalyticsApi, @@ -21,6 +22,11 @@ import { AnalyticsEvent, IdentityApi, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyticsApi, + AnalyticsEvent as NewAnalyticsEvent, + AnalyticsContextValue as NewAnalyticsContextValue, +} from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { DeferredCapture } from '../../../util/DeferredCapture'; @@ -28,7 +34,7 @@ import { DeferredCapture } from '../../../util/DeferredCapture'; * Google Analytics API provider for the Backstage Analytics API. * @public */ -export class GoogleAnalytics4 implements AnalyticsApi { +export class GoogleAnalytics4 implements AnalyticsApi, NewAnalyticsApi { private readonly customUserIdTransform?: ( userEntityRef: string, ) => Promise; @@ -157,17 +163,24 @@ export class GoogleAnalytics4 implements AnalyticsApi { * applied as they should be (set on pageview, merged object on events). * @param event - AnalyticsEvent type captured */ - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; const customEventData = this.setEventParameters(context, attributes); if (this.contentGroupBy) { customEventData.content_group = context[this.contentGroupBy]!; } - if (action === 'navigate' && context.extension === 'App') { + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { this.capture.event( { - category: context.extension || 'App', + category, action: 'page_view', label: subject, value, @@ -183,7 +196,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { this.capture.event( { - category: context.extension || 'App', + category, action, label: subject, value, @@ -199,7 +212,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { * @param attributes additional analytics event attributes */ private setEventParameters( - context: AnalyticsContextValue, + context: AnalyticsContextValue | NewAnalyticsContextValue, attributes: AnalyticsEventAttributes = {}, ) { const customEventParameters: { diff --git a/plugins/analytics-module-newrelic-browser/api-report.md b/plugins/analytics-module-newrelic-browser/api-report.md index 7556bae317..09fbf8f0f3 100644 --- a/plugins/analytics-module-newrelic-browser/api-report.md +++ b/plugins/analytics-module-newrelic-browser/api-report.md @@ -4,14 +4,16 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; // @public -export class NewRelicBrowser implements AnalyticsApi { +export class NewRelicBrowser implements AnalyticsApi, AnalyticsApi_2 { // (undocumented) - captureEvent(event: AnalyticsEvent): void; + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; // (undocumented) static fromConfig( config: Config, diff --git a/plugins/analytics-module-newrelic-browser/package.json b/plugins/analytics-module-newrelic-browser/package.json index 0815b62b6c..44610dd026 100644 --- a/plugins/analytics-module-newrelic-browser/package.json +++ b/plugins/analytics-module-newrelic-browser/package.json @@ -26,6 +26,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@newrelic/browser-agent": "^1.236.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts b/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts index f2f35b5765..d65d02db64 100644 --- a/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts +++ b/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts @@ -19,6 +19,10 @@ import { IdentityApi, AnalyticsEvent, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyticsEvent, +} from '@backstage/frontend-plugin-api'; import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent'; import type { setAPI } from '@newrelic/browser-agent/loaders/api/api'; @@ -37,7 +41,7 @@ type NewRelicBrowserOptions = { * New Relic Browser API provider for the Backstage Analytics API. * @public */ -export class NewRelicBrowser implements AnalyticsApi { +export class NewRelicBrowser implements AnalyticsApi, NewAnalyicsApi { private readonly agent: NewRelicAPI; private constructor( @@ -121,9 +125,17 @@ export class NewRelicBrowser implements AnalyticsApi { ); } - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; - if (action === 'navigate' && context.extension === 'App') { + + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { const interaction = this.agent.interaction(); interaction.setName(subject); if (value) { diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index cf6b400443..eff52ea98d 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -193,7 +193,7 @@ export async function getOrganizationTeams( const query = ` query teams($org: String!, $cursor: String) { organization(login: $org) { - teams(first: 100, after: $cursor) { + teams(first: 50, after: $cursor) { pageInfo { hasNextPage, endCursor } nodes { slug diff --git a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts index cf5db78203..ac506051d2 100644 --- a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts @@ -18,7 +18,11 @@ import { Location } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; -import { DbLocationsRow } from '../../database/tables'; +import { + DbLocationsRow, + DbRefreshStateRow, + DbSearchRow, +} from '../../database/tables'; import { getEntityLocationRef } from '../../processing/util'; import { EntityProvider, @@ -26,6 +30,12 @@ import { } from '@backstage/plugin-catalog-node'; import { locationSpecToLocationEntity } from '../../util/conversion'; import { LocationInput, LocationStore } from '../../service/types'; +import { + ANNOTATION_ORIGIN_LOCATION, + CompoundEntityRef, + parseLocationRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; @@ -111,6 +121,46 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { }); } + async getLocationByEntity(entityRef: CompoundEntityRef): Promise { + const entityRefString = stringifyEntityRef(entityRef); + + const [entity] = await this.db('refresh_state') + .where({ entity_ref: entityRefString }) + .select('entity_id') + .limit(1); + if (!entity) { + throw new NotFoundError(`found no entity for ref ${entityRefString}`); + } + + const [locationKeyValue] = await this.db('search') + .where({ + entity_id: entity.entity_id, + key: `metadata.annotations.${ANNOTATION_ORIGIN_LOCATION}`, + }) + .select('value') + .limit(1); + if (!locationKeyValue) { + throw new NotFoundError( + `found no origin annotation for ref ${entityRefString}`, + ); + } + + const { type, target } = parseLocationRef(entityRefString); + // const kind, target = split[0], split[1]; + const [location] = await this.db('locations') + .where({ type, target }) + .select() + .limit(1); + + // select * from locations where type = 'split(prev)[0]' + if (!location) { + throw new NotFoundError( + `Found no location with type ${type} and target ${target}`, + ); + } + return location; + } + private get connection(): EntityProviderConnection { if (!this._connection) { throw new Error('location store is not initialized'); diff --git a/plugins/catalog-backend/src/schema/openapi.generated.ts b/plugins/catalog-backend/src/schema/openapi.generated.ts index c29dd78ec6..9c9dc46ad7 100644 --- a/plugins/catalog-backend/src/schema/openapi.generated.ts +++ b/plugins/catalog-backend/src/schema/openapi.generated.ts @@ -1417,6 +1417,62 @@ export const spec = { ], }, }, + '/locations/by-entity/{kind}/{namespace}/{name}': { + get: { + operationId: 'getLocationByEntity', + description: 'Get a location for entity.', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Location', + }, + }, + }, + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + parameters: [ + { + in: 'path', + name: 'kind', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'namespace', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'name', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + ], + }, + }, '/analyze-location': { post: { operationId: 'AnalyzeLocation', diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 51283d5fbc..0b1ed5dc21 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1056,6 +1056,41 @@ paths: allowReserved: true schema: type: string + /locations/by-entity/{kind}/{namespace}/{name}: + get: + operationId: getLocationByEntity + description: Get a location for entity. + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/Location' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + parameters: + - in: path + name: kind + required: true + allowReserved: true + schema: + type: string + - in: path + name: namespace + required: true + allowReserved: true + schema: + type: string + - in: path + name: name + required: true + allowReserved: true + schema: + type: string /analyze-location: post: operationId: AnalyzeLocation diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index e613f25cb2..c2ffee8003 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -24,6 +24,7 @@ describe('AuthorizedLocationService', () => { listLocations: jest.fn(), getLocation: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const fakePermissionApi = { authorize: jest.fn(), @@ -144,4 +145,36 @@ describe('AuthorizedLocationService', () => { ).rejects.toThrow(NotAllowedError); }); }); + + describe('getLocationByEntity', () => { + it('calls underlying service to get location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + await service.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'Bearer authtoken', + }, + ); + + expect(fakeLocationService.getLocationByEntity).toHaveBeenCalledWith({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + + it('throws error on DENY', async () => { + mockDeny(); + const service = createService(); + + await expect(() => + service.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { authorizationToken: 'Bearer authtoken' }, + ), + ).rejects.toThrow(NotFoundError); + }); + }); }); diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 68bb1553a8..0b9d50b20e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -15,7 +15,7 @@ */ import { Location } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { NotAllowedError, NotFoundError } from '@backstage/errors'; import { catalogLocationCreatePermission, @@ -111,4 +111,21 @@ export class AuthorizedLocationService implements LocationService { return this.locationService.deleteLocation(id); } + + async getLocationByEntity( + entityRef: CompoundEntityRef | string, + options?: { authorizationToken?: string | undefined } | undefined, + ): Promise { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationReadPermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + throw new NotFoundError(); + } + return this.locationService.getLocationByEntity(entityRef); + } } diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 0245fe1d4e..81982d5617 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -451,7 +451,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { countQuery.count('search.entity_id', { as: 'count' }); const [rows, [{ count }]] = await Promise.all([ - dbQuery, + limit > 0 ? dbQuery : [], // for performance reasons we invoke the countQuery // only on the first request. // The result is then embedded into the cursor diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 6d70923556..1c17f271fb 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -28,6 +28,7 @@ describe('DefaultLocationServiceTest', () => { createLocation: jest.fn(), listLocations: jest.fn(), getLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const locationService = new DefaultLocationService(store, orchestrator); @@ -347,4 +348,19 @@ describe('DefaultLocationServiceTest', () => { expect(store.getLocation).toHaveBeenCalledWith('123'); }); }); + + describe('getLocationByEntity', () => { + it('should call locationStore.getLocationByEntity', async () => { + await locationService.getLocationByEntity({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + expect(store.getLocationByEntity).toHaveBeenCalledWith({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + }); }); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 6603ebc08b..d0b603b90b 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -19,6 +19,8 @@ import { ANNOTATION_LOCATION, ANNOTATION_ORIGIN_LOCATION, stringifyEntityRef, + CompoundEntityRef, + parseEntityRef, } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; import { CatalogProcessingOrchestrator } from '../processing/types'; @@ -68,6 +70,12 @@ export class DefaultLocationService implements LocationService { return this.store.deleteLocation(id); } + getLocationByEntity( + entityRef: CompoundEntityRef | string, + ): Promise { + return this.store.getLocationByEntity(parseEntityRef(entityRef)); + } + private async processEntities( unprocessedEntities: DeferredEntity[], ): Promise { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 05ebf6e1a9..e08d3b1676 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -63,6 +63,7 @@ describe('createRouter readonly disabled', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; refreshService = { refresh: jest.fn() }; orchestrator = { process: jest.fn() }; @@ -621,6 +622,36 @@ describe('createRouter readonly disabled', () => { }); }); + describe('GET /locations/by-entity/:kind/:namespace/:name', () => { + it('happy path: gets location by entity ref', async () => { + const location: Location = { + id: 'foo', + type: 'url', + target: 'example.com', + }; + locationService.getLocationByEntity.mockResolvedValueOnce(location); + + const response = await request(app) + .get('/locations/by-entity/c/ns/n') + .set('authorization', 'Bearer someauthtoken'); + + expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); + expect(locationService.getLocationByEntity).toHaveBeenCalledWith( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'someauthtoken', + }, + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id: 'foo', + target: 'example.com', + type: 'url', + }); + }); + }); + describe('POST /validate-entity', () => { describe('valid entity', () => { it('returns 200', async () => { @@ -753,6 +784,7 @@ describe('createRouter readonly enabled', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const router = await createRouter({ entitiesCatalog, @@ -911,6 +943,36 @@ describe('createRouter readonly enabled', () => { expect(response.status).toEqual(403); }); }); + + describe('GET /locations/by-entity/:kind/:namespace/:name', () => { + it('happy path: gets location by entity ref', async () => { + const location: Location = { + id: 'foo', + type: 'url', + target: 'example.com', + }; + locationService.getLocationByEntity.mockResolvedValueOnce(location); + + const response = await request(app) + .get('/locations/by-entity/c/ns/n') + .set('authorization', 'Bearer someauthtoken'); + + expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); + expect(locationService.getLocationByEntity).toHaveBeenCalledWith( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'someauthtoken', + }, + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id: 'foo', + target: 'example.com', + type: 'url', + }); + }); + }); }); describe('NextRouter permissioning', () => { @@ -944,6 +1006,7 @@ describe('NextRouter permissioning', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; refreshService = { refresh: jest.fn() }; const router = await createRouter({ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 30c1fd8e3f..70b15c640d 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -290,6 +290,18 @@ export async function createRouter( ), }); res.status(204).end(); + }) + .get('/locations/by-entity/:kind/:namespace/:name', async (req, res) => { + const { kind, namespace, name } = req.params; + const output = await locationService.getLocationByEntity( + { kind, namespace, name }, + { + authorizationToken: getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ), + }, + ); + res.status(200).json(output); }); } diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index f6cfa2e783..878afe5662 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; /** @@ -48,6 +48,10 @@ export interface LocationService { id: string, options?: { authorizationToken?: string }, ): Promise; + getLocationByEntity( + entityRef: CompoundEntityRef | string, + options?: { authorizationToken?: string }, + ): Promise; } /** @@ -82,4 +86,5 @@ export interface LocationStore { listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; + getLocationByEntity(entityRef: CompoundEntityRef | string): Promise; } diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index cdd55e300f..1f6c77ccf3 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -40,6 +40,7 @@ describe('', () => { const catalog = { getEntities: jest.fn(), getEntityByRef: jest.fn(), + getEntitiesByRefs: jest.fn(), removeEntityByUid: jest.fn(), getLocationById: jest.fn(), getLocationByRef: jest.fn(), @@ -75,9 +76,13 @@ describe('', () => { }); test('renders without exploding', async () => { - catalog.getEntityByRef.mockImplementation(async _ => ({ - ...entity, - relations: [], + catalog.getEntitiesByRefs.mockImplementation(async _ => ({ + items: [ + { + ...entity, + relations: [], + }, + ], })); await renderInTestApp(wrapper, { @@ -89,13 +94,20 @@ describe('', () => { expect(await screen.findByText('b:d/c')).toBeInTheDocument(); expect(await screen.findAllByTestId('node')).toHaveLength(1); - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); + expect(catalog.getEntitiesByRefs).toHaveBeenCalledTimes(1); + expect(catalog.getEntitiesByRefs).toHaveBeenCalledWith( + expect.objectContaining({ entityRefs: ['b:d/c'] }), + ); }); test('renders with custom title', async () => { - catalog.getEntityByRef.mockImplementation(async _ => ({ - ...entity, - relations: [], + catalog.getEntitiesByRefs.mockImplementation(async _ => ({ + items: [ + { + ...entity, + relations: [], + }, + ], })); await renderInTestApp( @@ -116,9 +128,13 @@ describe('', () => { }); test('renders link to standalone viewer', async () => { - catalog.getEntityByRef.mockImplementation(async _ => ({ - ...entity, - relations: [], + catalog.getEntitiesByRefs.mockImplementation(async _ => ({ + items: [ + { + ...entity, + relations: [], + }, + ], })); await renderInTestApp(wrapper, { @@ -138,6 +154,15 @@ describe('', () => { }); test('renders link to standalone viewer with custom config', async () => { + catalog.getEntitiesByRefs.mockImplementation(async _ => ({ + items: [ + { + ...entity, + relations: [], + }, + ], + })); + await renderInTestApp( @@ -162,9 +187,13 @@ describe('', () => { }); test('captures analytics event on click', async () => { - catalog.getEntityByRef.mockImplementation(async _ => ({ - ...entity, - relations: [], + catalog.getEntitiesByRefs.mockImplementation(async _ => ({ + items: [ + { + ...entity, + relations: [], + }, + ], })); const analyticsSpy = new MockAnalyticsApi(); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index f2360b678d..e188dcc7ff 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -26,6 +26,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { CatalogGraphPage } from './CatalogGraphPage'; +import { GetEntitiesByRefsRequest } from '@backstage/catalog-client'; const navigate = jest.fn(); @@ -111,9 +112,14 @@ describe.skip('', () => { }, ], }; + const allEntities: Record = { + 'b:d/c': entityC, + 'b:d/e': entityE, + }; const catalog = { getEntities: jest.fn(), getEntityByRef: jest.fn(), + getEntitiesByRefs: jest.fn(), removeEntityByUid: jest.fn(), getLocationById: jest.fn(), getLocationByRef: jest.fn(), @@ -142,8 +148,10 @@ describe.skip('', () => { afterEach(() => jest.resetAllMocks()); test('should render without exploding', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); await renderInTestApp(wrapper, { @@ -156,12 +164,14 @@ describe.skip('', () => { await expect(screen.findByText('b:d/c')).resolves.toBeInTheDocument(); await expect(screen.findByText('b:d/e')).resolves.toBeInTheDocument(); await expect(screen.findAllByTestId('node')).resolves.toHaveLength(2); - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(2); + expect(catalog.getEntitiesByRefs).toHaveBeenCalledTimes(2); }); test('should toggle filters', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); await renderInTestApp(wrapper, { @@ -178,8 +188,10 @@ describe.skip('', () => { }); test('should select other entity', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); await renderInTestApp(wrapper, { @@ -196,8 +208,10 @@ describe.skip('', () => { }); test('should navigate to entity', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); await renderInTestApp(wrapper, { @@ -215,8 +229,10 @@ describe.skip('', () => { }); test('should capture analytics event when selecting other entity', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); const analyticsSpy = new MockAnalyticsApi(); @@ -242,8 +258,10 @@ describe.skip('', () => { }); test('should capture analytics event when navigating to entity', async () => { - catalog.getEntityByRef.mockImplementation(async (n: any) => - n === 'b:d/e' ? entityE : entityC, + catalog.getEntitiesByRefs.mockImplementation( + async ({ entityRefs }: GetEntitiesByRefsRequest) => ({ + items: entityRefs.map(ref => allEntities[ref]), + }), ); const analyticsSpy = new MockAnalyticsApi(); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 0d75fabc3b..786c37ca7e 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -27,6 +27,7 @@ describe('useEntityStore', () => { const catalogApi = { getEntities: jest.fn(), getEntityByRef: jest.fn(), + getEntitiesByRefs: jest.fn(), removeEntityByUid: jest.fn(), getLocationById: jest.fn(), getLocationByRef: jest.fn(), @@ -63,7 +64,7 @@ describe('useEntityStore', () => { }, }; - catalogApi.getEntityByRef.mockResolvedValue(entity); + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity] }); const { result } = renderHook(() => useEntityStore()); @@ -83,7 +84,7 @@ describe('useEntityStore', () => { test('handles request failures', async () => { const err = new Error('Hello World'); - catalogApi.getEntityByRef.mockRejectedValue(err); + catalogApi.getEntitiesByRefs.mockRejectedValue(err); const { result } = renderHook(() => useEntityStore()); @@ -100,7 +101,7 @@ describe('useEntityStore', () => { }); test('handles loading', async () => { - catalogApi.getEntityByRef.mockReturnValue(new Promise(() => {})); + catalogApi.getEntitiesByRefs.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => useEntityStore()); @@ -131,8 +132,16 @@ describe('useEntityStore', () => { name: 'name2', }, }; + const entity3: Entity = { + apiVersion: 'v1', + kind: 'kind', + metadata: { + namespace: 'namespace', + name: 'name3', + }, + }; - catalogApi.getEntityByRef.mockResolvedValue(entity1); + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity1] }); const { result } = renderHook(() => useEntityStore()); @@ -149,12 +158,15 @@ describe('useEntityStore', () => { }); }); - catalogApi.getEntityByRef.mockResolvedValue(entity2); + catalogApi.getEntitiesByRefs.mockResolvedValue({ + items: [entity2, entity3], + }); act(() => { result.current.requestEntities([ 'kind:namespace/name1', 'kind:namespace/name2', + 'kind:namespace/name3', ]); }); @@ -165,6 +177,7 @@ describe('useEntityStore', () => { expect(entities).toEqual({ 'kind:namespace/name1': entity1, 'kind:namespace/name2': entity2, + 'kind:namespace/name3': entity3, }); }); }); @@ -186,13 +199,26 @@ describe('useEntityStore', () => { name: 'name2', }, }; + const entity3: Entity = { + apiVersion: 'v1', + kind: 'kind', + metadata: { + namespace: 'namespace', + name: 'name3', + }, + }; - catalogApi.getEntityByRef.mockResolvedValue(entity1); + catalogApi.getEntitiesByRefs.mockResolvedValue({ + items: [entity1, entity2], + }); const { result } = renderHook(() => useEntityStore()); act(() => { - result.current.requestEntities(['kind:namespace/name1']); + result.current.requestEntities([ + 'kind:namespace/name1', + 'kind:namespace/name2', + ]); }); await waitFor(() => { @@ -201,13 +227,22 @@ describe('useEntityStore', () => { expect(error).toBeUndefined(); expect(entities).toEqual({ 'kind:namespace/name1': entity1, + 'kind:namespace/name2': entity2, }); }); - catalogApi.getEntityByRef.mockResolvedValue(entity2); + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1); + expect(catalogApi.getEntitiesByRefs).toHaveBeenLastCalledWith({ + entityRefs: ['kind:namespace/name1', 'kind:namespace/name2'], + }); + + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entity3] }); act(() => { - result.current.requestEntities(['kind:namespace/name2']); + result.current.requestEntities([ + 'kind:namespace/name2', + 'kind:namespace/name3', + ]); }); await waitFor(() => { @@ -216,9 +251,15 @@ describe('useEntityStore', () => { expect(error).toBeUndefined(); expect(entities).toEqual({ 'kind:namespace/name2': entity2, + 'kind:namespace/name3': entity3, }); }); + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(2); + expect(catalogApi.getEntitiesByRefs).toHaveBeenLastCalledWith({ + entityRefs: ['kind:namespace/name3'], + }); + act(() => { result.current.requestEntities(['kind:namespace/name1']); }); @@ -232,6 +273,6 @@ describe('useEntityStore', () => { }); }); - expect(catalogApi.getEntityByRef).toHaveBeenCalledTimes(2); + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(2); }); }); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts index 843b308281..5c33f8053d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts @@ -13,18 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import limiterFactory from 'p-limit'; import { Dispatch, useCallback, useRef, useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; // TODO: This is a good use case for a graphql API, once it is available in the // future. -const limiter = limiterFactory(10); - /** * Ensures that a set of requested entities is loaded. */ @@ -58,38 +55,24 @@ export function useEntityStore(): { }, [state, setEntities]); const [asyncState, fetch] = useAsyncFn(async () => { - const { requestedEntities, outstandingEntities, cachedEntities } = - state.current; - - await Promise.all( - Array.from(requestedEntities).map(entityRef => - limiter(async () => { - if (cachedEntities.has(entityRef)) { - return; - } - - if (outstandingEntities.has(entityRef)) { - await outstandingEntities.get(entityRef); - return; - } - - const promise = catalogClient.getEntityByRef(entityRef); - - outstandingEntities.set(entityRef, promise); - - try { - const entity = await promise; - - if (entity) { - cachedEntities.set(entityRef, entity); - updateEntities(); - } - } finally { - outstandingEntities.delete(entityRef); - } - }), - ), + const { requestedEntities, cachedEntities } = state.current; + const entityRefs: string[] = Array.from(requestedEntities).filter( + entityRef => !cachedEntities.has(entityRef), ); + if (entityRefs.length === 0) { + updateEntities(); + return; + } + + const { items } = await catalogClient.getEntitiesByRefs({ entityRefs }); + items.forEach(ent => { + if (ent) { + const entityRef = stringifyEntityRef(ent); + cachedEntities.set(entityRef, ent); + } + }); + + updateEntities(); }, [state, updateEntities]); const { loading, error } = asyncState; diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index feda3fd70d..d37cbfccc2 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -139,6 +139,8 @@ export interface CatalogSearchResultListItemProps { // (undocumented) icon?: ReactNode | ((result: IndexableDocument) => ReactNode); // (undocumented) + lineClamp?: number; + // (undocumented) rank?: number; // (undocumented) result?: IndexableDocument; diff --git a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx index 4c6b567d8a..f2ef05b128 100644 --- a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx +++ b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx @@ -20,6 +20,7 @@ import { Chip, ListItemIcon, ListItemText, + Typography, makeStyles, } from '@material-ui/core'; import { Link } from '@backstage/core-components'; @@ -56,6 +57,7 @@ export interface CatalogSearchResultListItemProps { result?: IndexableDocument; highlight?: ResultHighlight; rank?: number; + lineClamp?: number; } /** @public */ @@ -94,15 +96,27 @@ export function CatalogSearchResultListItem( } secondary={ - highlight?.fields.text ? ( - - ) : ( - result.text - ) + + {highlight?.fields.text ? ( + + ) : ( + result.text + )} + } /> diff --git a/plugins/explore/src/api/ExploreApi.ts b/plugins/explore/src/api/ExploreApi.ts index ef9eb646b7..e5d8c7efa6 100644 --- a/plugins/explore/src/api/ExploreApi.ts +++ b/plugins/explore/src/api/ExploreApi.ts @@ -29,7 +29,7 @@ export interface ExploreApi { /** * Returns a list of explore tools. * - * @param request - The The request query options + * @param request - The request query options */ getTools(request?: GetExploreToolsRequest): Promise; } diff --git a/plugins/home/README.md b/plugins/home/README.md index d41ae57162..066e637725 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -325,6 +325,38 @@ export default app.createRoot( ); ``` +You can filter the items that are shown in the component. +this can be done by using the config file. +Filtering is done by using 3 parameters: + +- `field` - define which field to filter. can be one of the following + - `id`: string + - `name`: string + - `pathname`: string + - `hits`: number + - `timestamp`: number + - `entityRef`: string +- `operator` - can be one of the following `'<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'` +- `value` - the value of the filter + +```yaml +home: + recentVisits: + filterBy: + - field: + operator: + value: + topVisits: + filterBy: + - field: + operator: + value: +``` + +`filterBy` configs that are not defined in the above format will be ignored. + +In order to validate the config you can use `backstage/cli config:check` + ## Contributing ### Homepage Components diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index dd6e22c57d..023c649ab4 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -169,6 +169,9 @@ export const homePlugin: BackstagePlugin< {} >; +// @public +export const isOperator: (s: string) => s is Operators; + // @public export type LayoutConfiguration = { component: ReactElement | string; @@ -181,6 +184,9 @@ export type LayoutConfiguration = { resizable?: boolean; }; +// @public +export type Operators = '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + // @public @deprecated (undocumented) export type RendererProps = RendererProps_2; @@ -271,7 +277,7 @@ export type VisitsApiQueryParams = { }>; filterBy?: Array<{ field: keyof Visit; - operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + operator: Operators; value: string | number; }>; }; diff --git a/plugins/home/config.d.ts b/plugins/home/config.d.ts new file mode 100644 index 0000000000..3834a14d89 --- /dev/null +++ b/plugins/home/config.d.ts @@ -0,0 +1,68 @@ +/* + * 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 interface Config { + home?: { + /** + * Top visited plugin + * @visibility frontend + */ + topVisits?: { + /** + * Filter By config + * @visibility frontend + */ + filterBy?: Array<{ + /** + * @visibility frontend + */ + field: string; + /** + * @visibility frontend + */ + operator: string; + /** + * @visibility frontend + */ + value: string | number; + }>; + }; + /** + * Recent visited plugin + * @visibility frontend + */ + recentVisits?: { + /** + * Filter By config + * @visibility frontend + */ + filterBy?: Array<{ + /** + * @visibility frontend + */ + field: string; + /** + * @visibility frontend + */ + operator: string; + /** + * @visibility frontend + */ + value: string | number; + }>; + }; + }; +} diff --git a/plugins/home/package.json b/plugins/home/package.json index c64ef1919e..e8fa2b666c 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -36,6 +36,7 @@ ] } }, + "configSchema": "config.d.ts", "sideEffects": false, "scripts": { "build": "backstage-cli package build", @@ -49,6 +50,7 @@ "dependencies": { "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", @@ -88,6 +90,7 @@ "@types/react-grid-layout": "^1.3.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ] } diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index ad750d81f2..610f4be483 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -16,6 +16,20 @@ import { createApiRef } from '@backstage/core-plugin-api'; +/** + * @public + * The operators that can be used in filter. + */ +export type Operators = '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + +/** + * @public + * Type guard for operators. + */ +export const isOperator = (s: string): s is Operators => { + return ['<', '<=', '==', '!=', '>', '>=', 'contains'].includes(s); +}; + /** * @public * Model for a visit entity. @@ -84,7 +98,7 @@ export type VisitsApiQueryParams = { */ filterBy?: Array<{ field: keyof Visit; - operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; + operator: Operators; value: string | number; }>; }; diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts index 6faeb62350..e361c624b9 100644 --- a/plugins/home/src/api/VisitsStorageApi.test.ts +++ b/plugins/home/src/api/VisitsStorageApi.test.ts @@ -250,7 +250,7 @@ describe('VisitsStorageApi.create', () => { ]); }); - it('filters by timestamp with >', async () => { + it('filters by timestamp with value >', async () => { const visits = await api.list({ filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], }); diff --git a/plugins/home/src/api/config.test.ts b/plugins/home/src/api/config.test.ts new file mode 100644 index 0000000000..c21d856038 --- /dev/null +++ b/plugins/home/src/api/config.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFilterConfig, createFilterByQueryParamFromConfig } from './config'; +import { MockConfigApi } from '@backstage/test-utils'; + +describe('config', () => { + describe('readFilterConfig', () => { + it('returns filter data', async () => { + const mockConfig = new MockConfigApi({ + field: 'pathname', + operator: '==', + value: '/home', + }); + const res = readFilterConfig(mockConfig); + expect(res).toEqual({ + field: 'pathname', + operator: '==', + value: '/home', + }); + }); + + it('returns undefined for invalid filter', async () => { + const mockInvalidConfig = new MockConfigApi({ + myField: 'pathname', + operator: '==', + value: '3', + }); + const res = readFilterConfig(mockInvalidConfig); + expect(res).toEqual(undefined); + }); + }); + + describe('createFilterByQueryParamFromConfig', () => { + it('returns filter data', async () => { + const mockConfig1 = new MockConfigApi({ + field: 'id', + operator: '==', + value: '3', + }); + const mockConfig2 = new MockConfigApi({ + field: 'pathname', + operator: '==', + value: 'path', + }); + const res = createFilterByQueryParamFromConfig([ + mockConfig1, + mockConfig2, + ]); + expect(res).toEqual([ + { field: 'id', operator: '==', value: '3' }, + { field: 'pathname', operator: '==', value: 'path' }, + ]); + }); + + it('returns only valid filters', async () => { + const mockValidConfig = new MockConfigApi({ + field: 'id', + operator: '==', + value: 3, + }); + const mockInvalidConfig = new MockConfigApi({ + myField: 'pathname', + operator: '==', + value: 'path', + }); + const res = createFilterByQueryParamFromConfig([ + mockValidConfig, + mockInvalidConfig, + ]); + expect(res).toEqual([{ field: 'id', operator: '==', value: 3 }]); + }); + }); +}); diff --git a/plugins/home/src/api/config.ts b/plugins/home/src/api/config.ts new file mode 100644 index 0000000000..65833c13d5 --- /dev/null +++ b/plugins/home/src/api/config.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Visit, + VisitsApiQueryParams, + Operators, + isOperator, +} from './VisitsApi'; +import { Config } from '@backstage/config'; + +/** + * Reads a single FilterBy config. + * + * @param config - The single config object + * + * @public + */ + +export function readFilterConfig(config: Config): + | { + field: keyof Visit; + operator: Operators; + value: string | number; + } + | undefined { + try { + const field = config.getString('field') as keyof Visit; + const operator = config.getString('operator'); + const value = getValue(config); + if (isOperator(operator) && value !== undefined) { + return { field, operator, value }; + } + return undefined; + } catch (error) { + // invalid filter config - ignore filter + return undefined; + } +} + +function getValue(config: Config) { + let value = undefined; + try { + value = config.getString('value'); + } catch (error) { + try { + value = config.getNumber('value'); + } catch { + // value is not string or number - ignore filter + } + } + return value; +} + +/** + * Reads a set of filter by configs. + * + * @param configs - All of the config objects + * + * @public + */ +export function createFilterByQueryParamFromConfig( + configs: Config[], +): VisitsApiQueryParams['filterBy'] | undefined { + try { + return configs + .map(readFilterConfig) + .filter(Boolean) as VisitsApiQueryParams['filterBy']; + } catch { + return undefined; + } +} diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx index 4442c319e6..2a7a6481ba 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -16,10 +16,15 @@ import React from 'react'; import { Content } from './Content'; -import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { + TestApiProvider, + renderInTestApp, + MockConfigApi, +} from '@backstage/test-utils'; import { visitsApiRef } from '../../api'; import { ContextProvider } from './Context'; import { waitFor } from '@testing-library/react'; +import { configApiRef } from '@backstage/core-plugin-api'; const visits = [ { @@ -29,14 +34,30 @@ const visits = [ hits: 35, timestamp: Date.now() - 86400_000, }, + { + id: 'tech-radar', + name: 'Tech Radar', + pathname: '/tech-radar', + hits: 40, + timestamp: Date.now() - 360_000, + }, ]; -const mockVisitsApi = { +let mockVisitsApi = { save: async () => visits[0], list: async () => visits, }; describe('', () => { + beforeEach(() => { + mockVisitsApi = { + save: async () => visits[0], + list: async () => visits, + }; + }); + + afterEach(() => jest.resetAllMocks()); + it('renders', async () => { const { getByText } = await renderInTestApp( @@ -117,9 +138,139 @@ describe('', () => { expect(container.querySelectorAll('li')[0]).toBeVisible(); expect(container.querySelectorAll('li')[1]).not.toBeVisible(); }); + + it('allows recent items to be filtered using config', async () => { + const configApiMock = new MockConfigApi({ + home: { + recentVisits: { + filterBy: [ + { + field: 'pathname', + operator: '==', + value: '/tech-radar', + }, + ], + }, + }, + }); + + const listSpy = jest.spyOn(mockVisitsApi, 'list'); + + await renderInTestApp( + + + + + , + ); + await waitFor(() => { + expect(listSpy).toHaveBeenCalledWith({ + limit: 8, + orderBy: [ + { + direction: 'desc', + field: 'timestamp', + }, + ], + filterBy: [{ field: 'pathname', operator: '==', value: '/tech-radar' }], + }); + }); + }); + + it('shows all recent items when there is no filtering in the config', async () => { + const listSpy = jest.spyOn(mockVisitsApi, 'list'); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(listSpy).toHaveBeenCalledWith({ + limit: 8, + orderBy: [ + { + direction: 'desc', + field: 'timestamp', + }, + ], + filterBy: [], + }); + }); + }); + + it('allows recent items to have no filter if the filter config is not valid', async () => { + const configApiMock = new MockConfigApi({ + home: { + recentVisits: { + filterBy: [ + { + operator: '==', + value: '/tech-radar', + }, + { + field: 'pathname', + operator: '==', + value: '/explore', + }, + ], + }, + }, + }); + + const listSpy = jest.spyOn(mockVisitsApi, 'list'); + + await renderInTestApp( + + + + + , + ); + await waitFor(() => { + expect(listSpy).toHaveBeenCalledWith({ + limit: 8, + orderBy: [ + { + direction: 'desc', + field: 'timestamp', + }, + ], + filterBy: [ + { + field: 'pathname', + operator: '==', + value: '/explore', + }, + ], + }); + }); + }); }); describe('', () => { + beforeEach(() => { + mockVisitsApi = { + save: async () => visits[0], + list: async () => visits, + }; + }); + + afterEach(() => jest.resetAllMocks()); + it('renders', async () => { const { getByText } = await renderInTestApp( @@ -133,4 +284,73 @@ describe('', () => { expect(getByText('Explore Backstage')).toBeInTheDocument(), ); }); + + it('allows top items to be filtered using config', async () => { + const configApiMock = new MockConfigApi({ + home: { + topVisits: { + filterBy: [ + { + field: 'pathname', + operator: '==', + value: '/explore', + }, + ], + }, + }, + }); + + const listSpy = jest.spyOn(mockVisitsApi, 'list'); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(listSpy).toHaveBeenCalledWith({ + limit: 8, + orderBy: [ + { + direction: 'desc', + field: 'hits', + }, + ], + filterBy: [{ field: 'pathname', operator: '==', value: '/explore' }], + }); + }); + }); + + it('shows all top items when there is no filtering in the config', async () => { + const listSpy = jest.spyOn(mockVisitsApi, 'list'); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(listSpy).toHaveBeenCalledWith({ + limit: 8, + orderBy: [ + { + direction: 'desc', + field: 'hits', + }, + ], + filterBy: [], + }); + }); + }); }); diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 4e847b717f..6c9d889d72 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -15,10 +15,11 @@ */ import React, { useEffect } from 'react'; +import { createFilterByQueryParamFromConfig } from '../../api/config'; import { VisitedByType } from './VisitedByType'; -import { Visit, visitsApiRef } from '../../api/VisitsApi'; +import { Visit, visitsApiRef } from '../../api'; import { ContextValueOnly, useContext } from './Context'; -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; /** @public */ @@ -60,22 +61,31 @@ export const Content = ({ setContext(state => ({ ...state, ...context })); }, [setContext, kind, visits, loading, numVisitsOpen, numVisitsTotal]); + const config = useApi(configApiRef); // Fetches data from visitsApi in case visits and loading are not provided const visitsApi = useApi(visitsApiRef); const { loading: reqLoading } = useAsync(async () => { if (!visits && !loading && kind === 'recent') { + const filterBy = createFilterByQueryParamFromConfig( + config.getOptionalConfigArray('home.recentVisits.filterBy') ?? [], + ); return await visitsApi .list({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'timestamp', direction: 'desc' }], + ...(filterBy && { filterBy }), }) .then(setVisits); } if (!visits && !loading && kind === 'top') { + const filterBy = createFilterByQueryParamFromConfig( + config.getOptionalConfigArray('home.topVisits.filterBy') ?? [], + ); return await visitsApi .list({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'hits', direction: 'desc' }], + ...(filterBy && { filterBy }), }) .then(setVisits); } diff --git a/plugins/kubernetes-react/api-report.md b/plugins/kubernetes-react/api-report.md index 7e9c2fbd61..8a0ec4de8f 100644 --- a/plugins/kubernetes-react/api-report.md +++ b/plugins/kubernetes-react/api-report.md @@ -685,7 +685,7 @@ export const PodExecTerminal: ( // @public export const PodExecTerminalDialog: ( props: PodExecTerminalProps, -) => React_2.JSX.Element; +) => false | React_2.JSX.Element | undefined; // @public export interface PodExecTerminalProps { diff --git a/plugins/kubernetes-react/src/components/KubernetesDialog/KubernetesDialog.tsx b/plugins/kubernetes-react/src/components/KubernetesDialog/KubernetesDialog.tsx new file mode 100644 index 0000000000..cd5802f464 --- /dev/null +++ b/plugins/kubernetes-react/src/components/KubernetesDialog/KubernetesDialog.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Theme, + createStyles, + makeStyles, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import React, { useState } from 'react'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + dialogPaper: { minHeight: 'calc(100% - 64px)' }, + dialogContent: { flexBasis: 0 }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }), +); + +export interface KubernetesDialogProps { + buttonAriaLabel: string; + buttonIcon: React.ReactNode; + buttonText: string; + children?: React.ReactNode; + disabled?: boolean; + title: string; +} + +/** + * Dialog component for use in the Kubernetes plugin + * + * @public + */ +export const KubernetesDialog = ({ + buttonAriaLabel, + buttonIcon, + buttonText, + children, + disabled, + title, +}: KubernetesDialogProps) => { + const classes = useStyles(); + + const [open, setOpen] = useState(false); + const openDialog = () => { + setOpen(true); + }; + const closeDialog = () => { + setOpen(false); + }; + + return ( + <> + + + {title} + + + + + + {children} + + + + + ); +}; diff --git a/plugins/kubernetes-react/src/components/KubernetesDialog/index.ts b/plugins/kubernetes-react/src/components/KubernetesDialog/index.ts new file mode 100644 index 0000000000..1b10c1346e --- /dev/null +++ b/plugins/kubernetes-react/src/components/KubernetesDialog/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './KubernetesDialog'; diff --git a/plugins/kubernetes-react/src/components/PodExecTerminal/PodExecTerminalDialog.tsx b/plugins/kubernetes-react/src/components/PodExecTerminal/PodExecTerminalDialog.tsx index 5ed8c8c0d1..0b321aa108 100644 --- a/plugins/kubernetes-react/src/components/PodExecTerminal/PodExecTerminalDialog.tsx +++ b/plugins/kubernetes-react/src/components/PodExecTerminal/PodExecTerminalDialog.tsx @@ -13,98 +13,39 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Button, - createStyles, - Dialog, - DialogContent, - DialogTitle, - IconButton, - makeStyles, - Theme, -} from '@material-ui/core'; -import CloseIcon from '@material-ui/icons/Close'; -import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; -import React, { useState } from 'react'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import React from 'react'; + +import { KubernetesDialog } from '../KubernetesDialog'; import { useIsPodExecTerminalSupported } from '../../hooks'; import { PodExecTerminal, PodExecTerminalProps } from './PodExecTerminal'; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - dialogPaper: { minHeight: 'calc(100% - 64px)' }, - dialogContent: { flexBasis: 0 }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }), -); - /** * Opens a terminal connected to the given pod's container in a dialog * * @public */ export const PodExecTerminalDialog = (props: PodExecTerminalProps) => { - const classes = useStyles(); const { clusterName, containerName, podName } = props; - const [open, setOpen] = useState(false); - const isPodExecTerminalSupported = useIsPodExecTerminalSupported(); - const openDialog = () => { - setOpen(true); - }; - - const closeDialog = () => { - setOpen(false); - }; - return ( - <> - {!isPodExecTerminalSupported.loading && - isPodExecTerminalSupported.value && ( - - - {podName} - {containerName} terminal shell on cluster{' '} - {clusterName} - - - - - - - - - )} - - - + + + ) ); }; diff --git a/plugins/kubernetes-react/src/components/Pods/PodLogs/PodLogsDialog.tsx b/plugins/kubernetes-react/src/components/Pods/PodLogs/PodLogsDialog.tsx index 48fda2de2a..3ffd4d8fa0 100644 --- a/plugins/kubernetes-react/src/components/Pods/PodLogs/PodLogsDialog.tsx +++ b/plugins/kubernetes-react/src/components/Pods/PodLogs/PodLogsDialog.tsx @@ -13,36 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState } from 'react'; +import React from 'react'; -import { - Button, - createStyles, - Dialog, - DialogContent, - DialogTitle, - IconButton, - makeStyles, - Theme, -} from '@material-ui/core'; import SubjectIcon from '@material-ui/icons/Subject'; -import CloseIcon from '@material-ui/icons/Close'; - +import { KubernetesDialog } from '../../KubernetesDialog'; import { PodLogs } from './PodLogs'; import { ContainerScope } from './types'; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }), -); - /** * Props for PodLogsDialog * @@ -58,43 +36,15 @@ export interface PodLogsDialogProps { * @public */ export const PodLogsDialog = ({ containerScope }: PodLogsDialogProps) => { - const classes = useStyles(); - const [open, setOpen] = useState(false); - - const openDialog = () => { - setOpen(true); - }; - - const closeDialog = () => { - setOpen(false); - }; return ( - <> - - - {containerScope.podName} - {containerScope.containerName} logs on - cluster {containerScope.clusterName} - - - - - - - - - - + } + buttonText="Logs" + disabled={false} + title={`${containerScope.podName} - ${containerScope.containerName} logs on cluster ${containerScope.clusterName}`} + > + + ); }; diff --git a/plugins/scaffolder-backend-module-github/api-report.md b/plugins/scaffolder-backend-module-github/api-report.md index d6a759dfa6..a588840585 100644 --- a/plugins/scaffolder-backend-module-github/api-report.md +++ b/plugins/scaffolder-backend-module-github/api-report.md @@ -375,6 +375,7 @@ export const createPublishGithubPullRequestAction: ( teamReviewers?: string[] | undefined; commitMessage?: string | undefined; update?: boolean | undefined; + forceFork?: boolean | undefined; }, JsonObject >; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts index c5b1038a01..3fe4f62e04 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts @@ -687,4 +687,57 @@ describe('createPublishGithubPullRequestAction', () => { }); }); }); + + describe('with force fork', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + repoUrl: 'github.com?owner=myorg&repo=myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + forceFork: true, + }; + + mockDir.setContent({ + [workspacePath]: { 'file.txt': 'Hello there!' }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + }); + + it('creates a pull request', async () => { + await instance.handler(ctx); + + expect(fakeClient.createPullRequest).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + head: 'new-app', + body: 'This PR is really good', + changes: [ + { + commit: 'Create my new app', + files: { + 'file.txt': { + content: Buffer.from('Hello there!').toString('base64'), + encoding: 'base64', + mode: '100644', + }, + }, + }, + ], + forceFork: true, + }); + }); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts index 2d8dc7ce8d..6a5c939905 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts @@ -135,6 +135,7 @@ export const createPublishGithubPullRequestAction = ( teamReviewers?: string[]; commitMessage?: string; update?: boolean; + forceFork?: boolean; }>({ id: 'publish:github:pull-request', examples, @@ -217,6 +218,11 @@ export const createPublishGithubPullRequestAction = ( title: 'Update', description: 'Update pull request if already exists', }, + forceFork: { + type: 'boolean', + title: 'Force Fork', + description: 'Create pull request from a fork', + }, }, }, output: { @@ -255,6 +261,7 @@ export const createPublishGithubPullRequestAction = ( teamReviewers, commitMessage, update, + forceFork, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); @@ -327,6 +334,7 @@ export const createPublishGithubPullRequestAction = ( head: branchName, draft, update, + forceFork, }; if (targetBranchName) { createOptions.base = targetBranchName; diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index cc16791390..074bc92bf5 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -145,6 +145,7 @@ export function createFetchPlainAction(options: { { url: string; targetPath?: string | undefined; + token?: string | undefined; }, JsonObject >; @@ -157,6 +158,7 @@ export function createFetchPlainFileAction(options: { { url: string; targetPath: string; + token?: string | undefined; }, JsonObject >; @@ -177,6 +179,9 @@ export function createFetchTemplateAction(options: { copyWithoutTemplating?: string[] | undefined; cookiecutterCompat?: boolean | undefined; replace?: boolean | undefined; + trimBlocks?: boolean | undefined; + lstripBlocks?: boolean | undefined; + token?: string | undefined; }, JsonObject >; @@ -268,6 +273,7 @@ export const createPublishGithubPullRequestAction: ( teamReviewers?: string[] | undefined; commitMessage?: string | undefined; update?: boolean | undefined; + forceFork?: boolean | undefined; }, JsonObject >; diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index 701863c7f6..14a58e4975 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -22,6 +22,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; import { JsonValue } from '@backstage/types'; +import { getMajorNodeVersion, isNoNodeSnapshotOptionProvided } from './helpers'; // language=JavaScript const mkScript = (nunjucksSource: string) => ` @@ -34,6 +35,7 @@ const { render, renderCompat } = (() => { const env = module.exports.configure({ autoescape: false, + ...JSON.parse(nunjucksConfigs), tags: { variableStart: '\${{', variableEnd: '}}', @@ -42,6 +44,7 @@ const { render, renderCompat } = (() => { const compatEnv = module.exports.configure({ autoescape: false, + ...JSON.parse(nunjucksConfigs), tags: { variableStart: '{{', variableEnd: '}}', @@ -102,13 +105,14 @@ export type TemplateFilter = _TemplateFilter; */ export type TemplateGlobal = _TemplateGlobal; -export interface SecureTemplaterOptions { +interface SecureTemplaterOptions { /* Enables jinja compatibility and the "jsonify" filter */ cookiecutterCompat?: boolean; /* Extra user-provided nunjucks filters */ templateFilters?: Record; /* Extra user-provided nunjucks globals */ templateGlobals?: Record; + nunjucksConfigs?: { trimBlocks?: boolean; lstripBlocks?: boolean }; } export type SecureTemplateRenderer = ( @@ -122,8 +126,17 @@ export class SecureTemplater { cookiecutterCompat, templateFilters = {}, templateGlobals = {}, + nunjucksConfigs = {}, } = options; + const nodeVersion = getMajorNodeVersion(); + if (nodeVersion >= 20 && !isNoNodeSnapshotOptionProvided()) { + throw new Error( + `When using Node.js version 20 or newer, the scaffolder backend plugin requires that it be started with the --no-node-snapshot option. + Please make sure that you have NODE_OPTIONS=--no-node-snapshot in your environment.`, + ); + } + const isolate = new Isolate({ memoryLimit: 128 }); const context = await isolate.createContext(); const contextGlobal = context.global; @@ -140,6 +153,8 @@ export class SecureTemplater { mkScript(nunjucksSource), ); + await contextGlobal.set('nunjucksConfigs', JSON.stringify(nunjucksConfigs)); + const availableFilters = Object.keys(templateFilters); await contextGlobal.set( diff --git a/plugins/scaffolder-backend/src/lib/templating/helpers.ts b/plugins/scaffolder-backend/src/lib/templating/helpers.ts new file mode 100644 index 0000000000..3dfa4589bf --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/templating/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isNoNodeSnapshotOptionProvided(): boolean { + return ( + process.env.NODE_OPTIONS?.includes('--no-node-snapshot') || + process.argv.includes('--no-node-snapshot') + ); +} + +/** + * Gets the major version of the currently running Node.js process. + * + * @remarks + * This function extracts the major version from `process.versions.node` (a string representing the Node.js version), + * which includes the major, minor, and patch versions. It splits this string by the `.` character to get an array + * of these versions, and then parses the first element of this array (the major version) to a number. + * + * @returns {number} The major version of the currently running Node.js process. + */ +export function getMajorNodeVersion(): number { + const version = process.versions.node; + return parseInt(version.split('.')[0], 10); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 249941096d..917624acf4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -77,6 +77,26 @@ describe('fetch:plain', () => { targetPath: 'lol', }, }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + outputPath: resolvePath(mockContext.workspacePath, 'lol'), + fetchUrl: + 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets', + }), + ); + }); + + it('should fetch plain with token', async () => { + await action.handler({ + ...mockContext, + input: { + url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets', + targetPath: 'lol', + token: 'mockToken', + }, + }); + expect(fetchContents).toHaveBeenCalledWith( expect.objectContaining({ outputPath: resolvePath(mockContext.workspacePath, 'lol'), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index 57f3d1121b..8bca630af9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -36,7 +36,11 @@ export function createFetchPlainAction(options: { }) { const { reader, integrations } = options; - return createTemplateAction<{ url: string; targetPath?: string }>({ + return createTemplateAction<{ + url: string; + targetPath?: string; + token?: string; + }>({ id: ACTION_ID, examples, description: @@ -58,6 +62,12 @@ export function createFetchPlainAction(options: { 'Target path within the working directory to download the contents to.', type: 'string', }, + token: { + title: 'Token', + description: + 'An optional token to use for authentication when reading the resources.', + type: 'string', + }, }, }, }, @@ -75,6 +85,7 @@ export function createFetchPlainAction(options: { baseUrl: ctx.templateInfo?.baseUrl, fetchUrl: ctx.input.url, outputPath, + token: ctx.input.token, }); }, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts index 084b08d640..7ea74a809b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts @@ -69,6 +69,21 @@ describe('fetch:plain:file', () => { ); }); + it('passed through the token to fetchFile', async () => { + await action.handler({ + ...mockContext, + input: { + url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets/Backstage%20Community%20Sessions.png', + token: 'mockToken', + targetPath: 'lol', + }, + }); + + expect(fetchFile).toHaveBeenCalledWith( + expect.objectContaining({ token: 'mockToken' }), + ); + }); + it('should fetch plain', async () => { await action.handler({ ...mockContext, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.ts index 0a791a31a4..da0255ff5a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.ts @@ -33,7 +33,11 @@ export function createFetchPlainFileAction(options: { }) { const { reader, integrations } = options; - return createTemplateAction<{ url: string; targetPath: string }>({ + return createTemplateAction<{ + url: string; + targetPath: string; + token?: string; + }>({ id: 'fetch:plain:file', description: 'Downloads single file and places it in the workspace.', examples, @@ -54,6 +58,12 @@ export function createFetchPlainFileAction(options: { 'Target path within the working directory to download the file as.', type: 'string', }, + token: { + title: 'Token', + description: + 'An optional token to use for authentication when reading the resources.', + type: 'string', + }, }, }, }, @@ -73,6 +83,7 @@ export function createFetchPlainFileAction(options: { baseUrl: ctx.templateInfo?.baseUrl, fetchUrl: ctx.input.url, outputPath, + token: ctx.input.token, }); }, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.ts index 4c4628e715..1e2212b4ee 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.ts @@ -20,7 +20,7 @@ import yaml from 'yaml'; export const examples: TemplateExample[] = [ { description: - 'Downloads a skelaton directory that lives alongside the template file and fill it out with values.', + 'Downloads a skeleton directory that lives alongside the template file and fill it out with values.', example: yaml.stringify({ steps: [ { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 119cd78eb7..25b65462e6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -371,6 +371,18 @@ describe('fetch:template', () => { fs.readlink(`${workspacePath}/target/brokenSymlink`), ).resolves.toEqual(`.${pathSep}not-a-real-file.txt`); }); + + it('passed through the token to the fetchContents call', async () => { + await action.handler( + mockContext({ + token: 'mockToken', + }), + ); + + expect(mockFetchContents).toHaveBeenCalledWith( + expect.objectContaining({ token: 'mockToken' }), + ); + }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 26b249b76d..63a595ef80 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -69,6 +69,9 @@ export function createFetchTemplateAction(options: { copyWithoutTemplating?: string[]; cookiecutterCompat?: boolean; replace?: boolean; + trimBlocks?: boolean; + lstripBlocks?: boolean; + token?: string; }>({ id: 'fetch:template', description: @@ -132,6 +135,12 @@ export function createFetchTemplateAction(options: { 'If set, replace files in targetPath instead of skipping existing ones.', type: 'boolean', }, + token: { + title: 'Token', + description: + 'An optional token to use for authentication when reading the resources.', + type: 'string', + }, }, }, }, @@ -195,6 +204,7 @@ export function createFetchTemplateAction(options: { baseUrl: ctx.templateInfo?.baseUrl, fetchUrl: ctx.input.url, outputPath: templateDir, + token: ctx.input.token, }); ctx.logger.info('Listing files and directories in template'); @@ -237,6 +247,10 @@ export function createFetchTemplateAction(options: { ...additionalTemplateFilters, }, templateGlobals: additionalTemplateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, }); for (const location of allEntriesInTemplate) { diff --git a/plugins/scaffolder-backend/src/service/helpers.ts b/plugins/scaffolder-backend/src/service/helpers.ts index 9e271a9d79..3412fec2d3 100644 --- a/plugins/scaffolder-backend/src/service/helpers.ts +++ b/plugins/scaffolder-backend/src/service/helpers.ts @@ -107,42 +107,3 @@ export async function findTemplate(options: { return template as TemplateEntityV1beta3; } - -/** - * Checks if the '--no-node-snapshot' option is included in the NODE_OPTIONS environment variable - * or not included in the command line arguments. - * - * @remarks - * This function checks whether the '--no-node-snapshot' option is part of the NODE_OPTIONS environment - * variable or is missing from the command line arguments. If either condition is met, the function returns `true`. - * This check is especially important when using the "isolated-vm" package with Node.js version 20.x or later. - * - * According to the "isolated-vm" documentation on GitHub (https://github.com/laverdet/isolated-vm), - * if you are using a version of Node.js 20.x or later and you don't pass the '--no-node-snapshot' option, - * it can cause the process to crash. This function helps prevent such crashes by ensuring that the option - * is correctly provided. - * - * @returns {boolean} Returns `true` if the '--no-node-snapshot' option is included in the NODE_OPTIONS - * environment variable or not included in the command line arguments. Otherwise, it returns `false`. - */ -export function isNoNodeSnapshotOptionProvided(): boolean { - return ( - process.env.NODE_OPTIONS?.includes('--no-node-snapshot') || - process.argv.includes('--no-node-snapshot') - ); -} - -/** - * Gets the major version of the currently running Node.js process. - * - * @remarks - * This function extracts the major version from `process.versions.node` (a string representing the Node.js version), - * which includes the major, minor, and patch versions. It splits this string by the `.` character to get an array - * of these versions, and then parses the first element of this array (the major version) to a number. - * - * @returns {number} The major version of the currently running Node.js process. - */ -export function getMajorNodeVersion(): number { - const version = process.versions.node; - return parseInt(version.split('.')[0], 10); -} diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index dec3dcf69a..c0322af283 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -61,13 +61,7 @@ import { } from '../scaffolder'; import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { - findTemplate, - getEntityBaseUrl, - getMajorNodeVersion, - getWorkingDirectory, - isNoNodeSnapshotOptionProvided, -} from './helpers'; +import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers'; import { IdentityApi, IdentityApiGetIdentityRequest, @@ -266,14 +260,6 @@ export async function createRouter( const logger = parentLogger.child({ plugin: 'scaffolder' }); - const nodeVersion = getMajorNodeVersion(); - if (nodeVersion >= 20 && !isNoNodeSnapshotOptionProvided()) { - throw new Error( - 'When using node v20+ Scaffolder requires that node be started with the --no-node-snapshot option. Please restart ' + - 'Backstage providing the node --no-node-snapshot option.', - ); - } - const identity: IdentityApi = options.identity || buildDefaultIdentityClient(options); const workingDirectory = await getWorkingDirectory(config, logger); diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 6b1e6a54b6..fec2205462 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -195,6 +195,7 @@ export function fetchContents(options: { baseUrl?: string; fetchUrl?: string; outputPath: string; + token?: string; }): Promise; // @public @@ -204,6 +205,7 @@ export function fetchFile(options: { baseUrl?: string; fetchUrl?: string; outputPath: string; + token?: string; }): Promise; // @public (undocumented) diff --git a/plugins/scaffolder-node/src/actions/fetch.test.ts b/plugins/scaffolder-node/src/actions/fetch.test.ts index 55b949fca7..42f75d02b4 100644 --- a/plugins/scaffolder-node/src/actions/fetch.test.ts +++ b/plugins/scaffolder-node/src/actions/fetch.test.ts @@ -124,6 +124,20 @@ describe('fetchContents helper', () => { expect(fs.ensureDir).toHaveBeenCalledWith('foo'); expect(dirFunction).toHaveBeenCalledWith({ targetDir: 'foo' }); }); + + it('should pass through the token provided through to the URL reader', async () => { + await fetchContents({ + ...options, + outputPath: 'mydir/foo', + fetchUrl: 'https://github.com/backstage/foo', + token: 'mockToken', + }); + + expect(readTree).toHaveBeenCalledWith( + 'https://github.com/backstage/foo', + expect.objectContaining({ token: 'mockToken' }), + ); + }); }); describe('fetch file', () => { @@ -211,5 +225,19 @@ describe('fetchContents helper', () => { expect(fs.ensureDir).toHaveBeenCalledWith('mydir'); expect(fs.outputFile).toHaveBeenCalledWith('mydir/foo', 'test'); }); + + it('should pass through the token provided through to the URL reader', async () => { + await fetchFile({ + ...options, + outputPath: 'mydir/foo', + fetchUrl: 'https://github.com/backstage/foo', + token: 'mockToken', + }); + + expect(readUrl).toHaveBeenCalledWith( + 'https://github.com/backstage/foo', + expect.objectContaining({ token: 'mockToken' }), + ); + }); }); }); diff --git a/plugins/scaffolder-node/src/actions/fetch.ts b/plugins/scaffolder-node/src/actions/fetch.ts index 66bbc0a842..2ddc2b1ae6 100644 --- a/plugins/scaffolder-node/src/actions/fetch.ts +++ b/plugins/scaffolder-node/src/actions/fetch.ts @@ -32,8 +32,16 @@ export async function fetchContents(options: { baseUrl?: string; fetchUrl?: string; outputPath: string; + token?: string; }) { - const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options; + const { + reader, + integrations, + baseUrl, + fetchUrl = '.', + outputPath, + token, + } = options; const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl); @@ -45,7 +53,7 @@ export async function fetchContents(options: { } else { const readUrl = getReadUrl(fetchUrl, baseUrl, integrations); - const res = await reader.readTree(readUrl); + const res = await reader.readTree(readUrl, { token }); await fs.ensureDir(outputPath); await res.dir({ targetDir: outputPath }); } @@ -63,8 +71,16 @@ export async function fetchFile(options: { baseUrl?: string; fetchUrl?: string; outputPath: string; + token?: string; }) { - const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options; + const { + reader, + integrations, + baseUrl, + fetchUrl = '.', + outputPath, + token, + } = options; const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl); @@ -76,7 +92,7 @@ export async function fetchFile(options: { } else { const readUrl = getReadUrl(fetchUrl, baseUrl, integrations); - const res = await reader.readUrl(readUrl); + const res = await reader.readUrl(readUrl, { token }); await fs.ensureDir(path.dirname(outputPath)); const buffer = await res.buffer(); await fs.outputFile(outputPath, buffer.toString()); diff --git a/plugins/scaffolder-react/api-report-alpha.md b/plugins/scaffolder-react/api-report-alpha.md index 6976951670..a521fecc70 100644 --- a/plugins/scaffolder-react/api-report-alpha.md +++ b/plugins/scaffolder-react/api-report-alpha.md @@ -129,6 +129,7 @@ export type ScaffolderPageContextMenuProps = { onEditorClicked?: () => void; onActionsClicked?: () => void; onTasksClicked?: () => void; + onCreateClicked?: () => void; }; // @alpha diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index bb038cd2ee..0f6da12d6c 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -66,6 +66,7 @@ "@types/json-schema": "^7.0.9", "@types/react": "^16.13.1 || ^17.0.0", "classnames": "^2.2.6", + "flatted": "3.2.9", "humanize-duration": "^3.25.1", "immer": "^9.0.1", "json-schema": "^0.4.0", diff --git a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx index 8326b361e1..2239c86e06 100644 --- a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx +++ b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx @@ -20,6 +20,7 @@ import ListItemText from '@material-ui/core/ListItemText'; import MenuItem from '@material-ui/core/MenuItem'; import MenuList from '@material-ui/core/MenuList'; import Popover from '@material-ui/core/Popover'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { makeStyles } from '@material-ui/core/styles'; import Description from '@material-ui/icons/Description'; import Edit from '@material-ui/icons/Edit'; @@ -40,6 +41,7 @@ export type ScaffolderPageContextMenuProps = { onEditorClicked?: () => void; onActionsClicked?: () => void; onTasksClicked?: () => void; + onCreateClicked?: () => void; }; /** @@ -48,7 +50,8 @@ export type ScaffolderPageContextMenuProps = { export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, ) { - const { onEditorClicked, onActionsClicked, onTasksClicked } = props; + const { onEditorClicked, onActionsClicked, onTasksClicked, onCreateClicked } = + props; const classes = useStyles(); const [anchorEl, setAnchorEl] = useState(); @@ -89,6 +92,14 @@ export function ScaffolderPageContextMenu( transformOrigin={{ vertical: 'top', horizontal: 'right' }} > + {onCreateClicked && ( + + + + + + + )} {onEditorClicked && ( diff --git a/plugins/scaffolder-react/src/next/components/TemplateOutputs/LinkOutputs.tsx b/plugins/scaffolder-react/src/next/components/TemplateOutputs/LinkOutputs.tsx index d172aa9cb3..7877653197 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateOutputs/LinkOutputs.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateOutputs/LinkOutputs.tsx @@ -17,7 +17,7 @@ import { IconComponent, useApp, useRouteRef } from '@backstage/core-plugin-api'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { Button, makeStyles } from '@material-ui/core'; import React from 'react'; -import WebIcon from '@material-ui/icons/Web'; +import LinkIcon from '@material-ui/icons/Link'; import { parseEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { ScaffolderTaskOutput } from '../../../api'; @@ -37,7 +37,7 @@ export const LinkOutputs = (props: { output: ScaffolderTaskOutput }) => { const entityRoute = useRouteRef(entityRouteRef); const iconResolver = (key?: string): IconComponent => - app.getSystemIcon(key!) ?? WebIcon; + app.getSystemIcon(key!) ?? LinkIcon; return ( <> diff --git a/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx b/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx index 8f61aa6a30..74a5eabb8f 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx @@ -15,7 +15,7 @@ */ import { IconComponent, useApp } from '@backstage/core-plugin-api'; import { Button } from '@material-ui/core'; -import WebIcon from '@material-ui/icons/Web'; +import DescriptionIcon from '@material-ui/icons/Description'; import React from 'react'; import { ScaffolderTaskOutput } from '../../../api'; @@ -33,7 +33,7 @@ export const TextOutputs = (props: { const app = useApp(); const iconResolver = (key?: string): IconComponent => - app.getSystemIcon(key!) ?? WebIcon; + app.getSystemIcon(key!) ?? DescriptionIcon; return ( <> diff --git a/plugins/scaffolder-react/src/next/lib/schema.test.ts b/plugins/scaffolder-react/src/next/lib/schema.test.ts index 4dc6cda603..5bfd65133c 100644 --- a/plugins/scaffolder-react/src/next/lib/schema.test.ts +++ b/plugins/scaffolder-react/src/next/lib/schema.test.ts @@ -412,6 +412,113 @@ describe('extractSchemaFromStep', () => { }); }); + it('doesnt override existing uiSchema with things from dependencies', () => { + const inputSchema: JsonObject = { + type: 'object', + title: + "Field 2 depend on field 1, field 1 is a radio button but it's visible as a field", + required: ['exampleField1'], + properties: { + exampleField0: { + title: 'Radio button that is not a dependency', + type: 'string', + enum: ['foo', 'bar'], + 'ui:widget': 'radio', + }, + exampleField1: { + title: 'Radio button input that is a dependency', + type: 'string', + enum: ['visible', 'hidden'], + 'ui:widget': 'radio', + }, + }, + dependencies: { + exampleField1: { + oneOf: [ + { + properties: { + exampleField1: { + enum: ['visible'], + }, + exampleField2: { + title: 'FIELD 2', + type: 'string', + description: 'Explanation', + }, + }, + }, + { + properties: { + exampleField1: { + enum: ['hidden'], + }, + }, + }, + ], + }, + }, + }; + + const expectedSchema = { + type: 'object', + title: + "Field 2 depend on field 1, field 1 is a radio button but it's visible as a field", + required: ['exampleField1'], + properties: { + exampleField0: { + title: 'Radio button that is not a dependency', + type: 'string', + enum: ['foo', 'bar'], + }, + exampleField1: { + title: 'Radio button input that is a dependency', + type: 'string', + enum: ['visible', 'hidden'], + }, + }, + dependencies: { + exampleField1: { + oneOf: [ + { + properties: { + exampleField1: { + enum: ['visible'], + }, + exampleField2: { + title: 'FIELD 2', + type: 'string', + description: 'Explanation', + }, + }, + }, + { + properties: { + exampleField1: { + enum: ['hidden'], + }, + }, + }, + ], + }, + }, + }; + + const expectedUiSchema = { + exampleField0: { + 'ui:widget': 'radio', + }, + exampleField1: { + 'ui:widget': 'radio', + }, + exampleField2: {}, + }; + + expect(extractSchemaFromStep(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); + it('transforms conditional schema', () => { const inputSchema: JsonObject = { type: 'object', diff --git a/plugins/scaffolder-react/src/next/lib/schema.ts b/plugins/scaffolder-react/src/next/lib/schema.ts index c606d715e7..2ca2a6fa7b 100644 --- a/plugins/scaffolder-react/src/next/lib/schema.ts +++ b/plugins/scaffolder-react/src/next/lib/schema.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { JsonObject } from '@backstage/types'; +import { stringify, parse } from 'flatted'; import { FieldValidation, UiSchema } from '@rjsf/utils'; function isObject(value: unknown): value is JsonObject { @@ -57,9 +58,13 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { if (!isObject(schemaNode)) { continue; } - const innerUiSchema = {}; - uiSchema[propName] = innerUiSchema; - extractUiSchema(schemaNode, innerUiSchema); + + if (!isObject(uiSchema[propName])) { + const innerUiSchema = {}; + uiSchema[propName] = innerUiSchema; + } + + extractUiSchema(schemaNode, uiSchema[propName] as JsonObject); } } @@ -123,7 +128,7 @@ export const extractSchemaFromStep = ( inputStep: JsonObject, ): { uiSchema: UiSchema; schema: JsonObject } => { const uiSchema: UiSchema = {}; - const returnSchema: JsonObject = JSON.parse(JSON.stringify(inputStep)); + const returnSchema: JsonObject = parse(stringify(inputStep)); extractUiSchema(returnSchema, uiSchema); return { uiSchema, schema: returnSchema }; }; diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 5e7f9810c5..9957d22456 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -41,7 +41,7 @@ import classNames from 'classnames'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandLessIcon from '@material-ui/icons/ExpandLess'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CodeSnippet, Content, @@ -52,6 +52,13 @@ import { Progress, } from '@backstage/core-components'; import Chip from '@material-ui/core/Chip'; +import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; +import { useNavigate } from 'react-router-dom'; +import { + editRouteRef, + rootRouteRef, + scaffolderListTaskRouteRef, +} from '../../routes'; const useStyles = makeStyles(theme => ({ code: { @@ -109,6 +116,17 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => { export const ActionsPage = () => { const api = useApi(scaffolderApiRef); + const navigate = useNavigate(); + const editorLink = useRouteRef(editRouteRef); + const tasksLink = useRouteRef(scaffolderListTaskRouteRef); + const createLink = useRouteRef(rootRouteRef); + + const scaffolderPageContextMenuProps = { + onEditorClicked: () => navigate(editorLink()), + onActionsClicked: undefined, + onTasksClicked: () => navigate(tasksLink()), + onCreateClicked: () => navigate(createLink()), + }; const classes = useStyles(); const { loading, value, error } = useAsync(async () => { return api.listActions(); @@ -326,7 +344,9 @@ export const ActionsPage = () => { pageTitleOverride="Create a New Component" title="Installed actions" subtitle="This is the collection of all installed actions" - /> + > + + {items} ); diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx index aac0ed5c79..8cd8ccefda 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx @@ -38,7 +38,9 @@ import { TaskStatusColumn, TemplateTitleColumn, } from './columns'; -import { rootRouteRef } from '../../routes'; +import { actionsRouteRef, editRouteRef, rootRouteRef } from '../../routes'; +import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; +import { useNavigate } from 'react-router-dom'; export interface MyTaskPageProps { initiallySelectedFilter?: 'owned' | 'all'; @@ -134,13 +136,26 @@ const ListTaskPageContent = (props: MyTaskPageProps) => { }; export const ListTasksPage = (props: MyTaskPageProps) => { + const navigate = useNavigate(); + const editorLink = useRouteRef(editRouteRef); + const actionsLink = useRouteRef(actionsRouteRef); + const createLink = useRouteRef(rootRouteRef); + + const scaffolderPageContextMenuProps = { + onEditorClicked: () => navigate(editorLink()), + onActionsClicked: () => navigate(actionsLink()), + onTasksClicked: undefined, + onCreateClicked: () => navigate(createLink()), + }; return (
+ > + +
diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx index aab693e590..d8145c4646 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -27,6 +27,14 @@ import { type LayoutOptions, } from '@backstage/plugin-scaffolder-react'; import { TemplateEditorIntro } from './TemplateEditorIntro'; +import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; +import { useNavigate } from 'react-router-dom'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { + actionsRouteRef, + rootRouteRef, + scaffolderListTaskRouteRef, +} from '../../routes'; type Selection = | { @@ -48,6 +56,17 @@ interface TemplateEditorPageProps { export function TemplateEditorPage(props: TemplateEditorPageProps) { const [selection, setSelection] = useState(); + const navigate = useNavigate(); + const actionsLink = useRouteRef(actionsRouteRef); + const tasksLink = useRouteRef(scaffolderListTaskRouteRef); + const createLink = useRouteRef(rootRouteRef); + + const scaffolderPageContextMenuProps = { + onEditorClicked: undefined, + onActionsClicked: () => navigate(actionsLink()), + onTasksClicked: () => navigate(tasksLink()), + onCreateClicked: () => navigate(createLink()), + }; let content: JSX.Element | null = null; if (selection?.type === 'local') { @@ -100,7 +119,9 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
+ > + +
{content}
); diff --git a/plugins/techdocs/src/reader/transformers/styles/rules/typeset.ts b/plugins/techdocs/src/reader/transformers/styles/rules/typeset.ts index 4c1eef1ea4..cc7ba6bace 100644 --- a/plugins/techdocs/src/reader/transformers/styles/rules/typeset.ts +++ b/plugins/techdocs/src/reader/transformers/styles/rules/typeset.ts @@ -115,4 +115,8 @@ ${headings.reduce((style, heading) => { .md-typeset pre > code::-webkit-scrollbar-thumb:hover { background-color: hsla(0, 0%, 0%, 0.87); } + +.md-typeset code { + word-break: keep-all; +} `; diff --git a/storybook/yarn.lock b/storybook/yarn.lock index 94acb9696e..54f50cbbb5 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -2842,90 +2842,90 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-arm64@npm:1.3.105" +"@swc/core-darwin-arm64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-arm64@npm:1.3.107" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-x64@npm:1.3.105" +"@swc/core-darwin-x64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-x64@npm:1.3.107" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.105" +"@swc/core-linux-arm-gnueabihf@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.107" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-gnu@npm:1.3.105" +"@swc/core-linux-arm64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-musl@npm:1.3.105" +"@swc/core-linux-arm64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-gnu@npm:1.3.105" +"@swc/core-linux-x64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-musl@npm:1.3.105" +"@swc/core-linux-x64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-musl@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-arm64-msvc@npm:1.3.105" +"@swc/core-win32-arm64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.107" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-ia32-msvc@npm:1.3.105" +"@swc/core-win32-ia32-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.107" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-x64-msvc@npm:1.3.105" +"@swc/core-win32-x64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.107" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/core@npm:^1.3.46": - version: 1.3.105 - resolution: "@swc/core@npm:1.3.105" + version: 1.3.107 + resolution: "@swc/core@npm:1.3.107" dependencies: - "@swc/core-darwin-arm64": 1.3.105 - "@swc/core-darwin-x64": 1.3.105 - "@swc/core-linux-arm-gnueabihf": 1.3.105 - "@swc/core-linux-arm64-gnu": 1.3.105 - "@swc/core-linux-arm64-musl": 1.3.105 - "@swc/core-linux-x64-gnu": 1.3.105 - "@swc/core-linux-x64-musl": 1.3.105 - "@swc/core-win32-arm64-msvc": 1.3.105 - "@swc/core-win32-ia32-msvc": 1.3.105 - "@swc/core-win32-x64-msvc": 1.3.105 + "@swc/core-darwin-arm64": 1.3.107 + "@swc/core-darwin-x64": 1.3.107 + "@swc/core-linux-arm-gnueabihf": 1.3.107 + "@swc/core-linux-arm64-gnu": 1.3.107 + "@swc/core-linux-arm64-musl": 1.3.107 + "@swc/core-linux-x64-gnu": 1.3.107 + "@swc/core-linux-x64-musl": 1.3.107 + "@swc/core-win32-arm64-msvc": 1.3.107 + "@swc/core-win32-ia32-msvc": 1.3.107 + "@swc/core-win32-x64-msvc": 1.3.107 "@swc/counter": ^0.1.1 "@swc/types": ^0.1.5 peerDependencies: @@ -2954,7 +2954,7 @@ __metadata: peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 5baa880bc92748ef4845d9c65eba5d6dd01adaa673854e20a5116f5e267c12180db50e563cf3c34a415772b9742d021176a9d9a91065c190ef6f54fefe85728c + checksum: 0dccff50461fb8c0f4af053b70e555c91386cb07aa7657a7328d58e397d15640723587549416d8fa7dcc073ad11b39318146bd50ec4a82345ce2ce39c7ba4c00 languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index 1c83813e10..91a5836b28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1547,15 +1547,15 @@ __metadata: linkType: hard "@azure/identity@npm:^4.0.0": - version: 4.0.0 - resolution: "@azure/identity@npm:4.0.0" + version: 4.0.1 + resolution: "@azure/identity@npm:4.0.1" dependencies: "@azure/abort-controller": ^1.0.0 "@azure/core-auth": ^1.5.0 "@azure/core-client": ^1.4.0 "@azure/core-rest-pipeline": ^1.1.0 "@azure/core-tracing": ^1.0.0 - "@azure/core-util": ^1.0.0 + "@azure/core-util": ^1.3.0 "@azure/logger": ^1.0.0 "@azure/msal-browser": ^3.5.0 "@azure/msal-node": ^2.5.1 @@ -1564,7 +1564,7 @@ __metadata: open: ^8.0.0 stoppable: ^1.1.0 tslib: ^2.2.0 - checksum: 534a62afe4715d18e221e021f8088873b1efad34344bd4c6c4685572c89517f15a646e246e9233e8db7942b0a5c73f1961ea63e3baf39c28edde1ba51da4423d + checksum: 2c975ca70b274dc185022c2b19f1774079fd5cfb08c78ec3500e4baf973911b2958031d5fa36369d3c9acdeb25db457cc497991e7393b383103c058e097703e5 languageName: node linkType: hard @@ -3586,7 +3586,7 @@ __metadata: "@types/fs-extra": ^9.0.1 "@types/http-proxy": ^1.17.4 "@types/inquirer": ^8.1.3 - "@types/jest": ^29.0.0 + "@types/jest": ^29.5.11 "@types/minimatch": ^5.0.0 "@types/node": ^18.17.8 "@types/npm-packlist": ^3.0.0 @@ -3635,7 +3635,7 @@ __metadata: handlebars: ^4.7.3 html-webpack-plugin: ^5.3.1 inquirer: ^8.2.0 - jest: ^29.0.2 + jest: ^29.7.0 jest-css-modules: ^2.1.0 jest-environment-jsdom: ^29.0.2 jest-runtime: ^29.0.2 @@ -3649,6 +3649,7 @@ __metadata: nodemon: ^3.0.1 npm-packlist: ^5.0.0 ora: ^5.3.0 + p-queue: ^6.6.2 postcss: ^8.1.0 process: ^0.11.10 react-dev-utils: ^12.0.0-next.60 @@ -3895,6 +3896,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -4147,6 +4149,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" + "@backstage/errors": "workspace:^" "@octokit/auth-app": ^4.0.0 "@octokit/rest": ^19.0.3 "@types/luxon": ^3.0.0 @@ -4310,6 +4313,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -4332,6 +4336,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -4353,6 +4358,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@newrelic/browser-agent": ^1.236.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -6991,6 +6997,7 @@ __metadata: "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" @@ -8420,6 +8427,7 @@ __metadata: "@types/luxon": ^3.0.0 "@types/react": ^16.13.1 || ^17.0.0 classnames: ^2.2.6 + flatted: 3.2.9 humanize-duration: ^3.25.1 immer: ^9.0.1 json-schema: ^0.4.0 @@ -10178,13 +10186,13 @@ __metadata: linkType: hard "@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.23.0": - version: 6.23.0 - resolution: "@codemirror/view@npm:6.23.0" + version: 6.23.1 + resolution: "@codemirror/view@npm:6.23.1" dependencies: "@codemirror/state": ^6.4.0 style-mod: ^4.1.0 w3c-keyname: ^2.2.4 - checksum: 6e5f2314a3da2c724dc6a525654d949d3f2fcf7009f4d85f980d52ddc885c8969717e903ca1d9132afbe7c524af5d19bff8285fd394106282a965ae83aa47db4 + checksum: 5ea3ba5761c574e1f6e1f1058cb452189c890982a77991606d0ae40da3c6fff77f7c7fc3c43fa78d62677ccdfa65dbc56175706b793e34ad4ec7a63b21e8c18e languageName: node linkType: hard @@ -10469,9 +10477,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/aix-ppc64@npm:0.19.11" +"@esbuild/aix-ppc64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/aix-ppc64@npm:0.19.12" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard @@ -10490,9 +10498,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/android-arm64@npm:0.19.11" +"@esbuild/android-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-arm64@npm:0.19.12" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -10511,9 +10519,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/android-arm@npm:0.19.11" +"@esbuild/android-arm@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-arm@npm:0.19.12" conditions: os=android & cpu=arm languageName: node linkType: hard @@ -10532,9 +10540,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/android-x64@npm:0.19.11" +"@esbuild/android-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/android-x64@npm:0.19.12" conditions: os=android & cpu=x64 languageName: node linkType: hard @@ -10553,9 +10561,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/darwin-arm64@npm:0.19.11" +"@esbuild/darwin-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/darwin-arm64@npm:0.19.12" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -10574,9 +10582,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/darwin-x64@npm:0.19.11" +"@esbuild/darwin-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/darwin-x64@npm:0.19.12" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -10595,9 +10603,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/freebsd-arm64@npm:0.19.11" +"@esbuild/freebsd-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/freebsd-arm64@npm:0.19.12" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -10616,9 +10624,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/freebsd-x64@npm:0.19.11" +"@esbuild/freebsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/freebsd-x64@npm:0.19.12" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -10637,9 +10645,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-arm64@npm:0.19.11" +"@esbuild/linux-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-arm64@npm:0.19.12" conditions: os=linux & cpu=arm64 languageName: node linkType: hard @@ -10658,9 +10666,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-arm@npm:0.19.11" +"@esbuild/linux-arm@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-arm@npm:0.19.12" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -10679,9 +10687,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-ia32@npm:0.19.11" +"@esbuild/linux-ia32@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-ia32@npm:0.19.12" conditions: os=linux & cpu=ia32 languageName: node linkType: hard @@ -10700,9 +10708,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-loong64@npm:0.19.11" +"@esbuild/linux-loong64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-loong64@npm:0.19.12" conditions: os=linux & cpu=loong64 languageName: node linkType: hard @@ -10721,9 +10729,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-mips64el@npm:0.19.11" +"@esbuild/linux-mips64el@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-mips64el@npm:0.19.12" conditions: os=linux & cpu=mips64el languageName: node linkType: hard @@ -10742,9 +10750,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-ppc64@npm:0.19.11" +"@esbuild/linux-ppc64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-ppc64@npm:0.19.12" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard @@ -10763,9 +10771,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-riscv64@npm:0.19.11" +"@esbuild/linux-riscv64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-riscv64@npm:0.19.12" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard @@ -10784,9 +10792,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-s390x@npm:0.19.11" +"@esbuild/linux-s390x@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-s390x@npm:0.19.12" conditions: os=linux & cpu=s390x languageName: node linkType: hard @@ -10805,9 +10813,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/linux-x64@npm:0.19.11" +"@esbuild/linux-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/linux-x64@npm:0.19.12" conditions: os=linux & cpu=x64 languageName: node linkType: hard @@ -10826,9 +10834,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/netbsd-x64@npm:0.19.11" +"@esbuild/netbsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/netbsd-x64@npm:0.19.12" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard @@ -10847,9 +10855,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/openbsd-x64@npm:0.19.11" +"@esbuild/openbsd-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/openbsd-x64@npm:0.19.12" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard @@ -10868,9 +10876,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/sunos-x64@npm:0.19.11" +"@esbuild/sunos-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/sunos-x64@npm:0.19.12" conditions: os=sunos & cpu=x64 languageName: node linkType: hard @@ -10889,9 +10897,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/win32-arm64@npm:0.19.11" +"@esbuild/win32-arm64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-arm64@npm:0.19.12" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -10910,9 +10918,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/win32-ia32@npm:0.19.11" +"@esbuild/win32-ia32@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-ia32@npm:0.19.12" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -10931,9 +10939,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.19.11": - version: 0.19.11 - resolution: "@esbuild/win32-x64@npm:0.19.11" +"@esbuild/win32-x64@npm:0.19.12": + version: 0.19.12 + resolution: "@esbuild/win32-x64@npm:0.19.12" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -12175,11 +12183,11 @@ __metadata: linkType: hard "@keyv/redis@npm:^2.5.3": - version: 2.8.3 - resolution: "@keyv/redis@npm:2.8.3" + version: 2.8.4 + resolution: "@keyv/redis@npm:2.8.4" dependencies: ioredis: ^5.3.2 - checksum: 4b8fa4baeab75aace0a8c5d02e30dfb87fa2e27236d8ce3577ff862c4efa2ae2f6d4c14b4933e08fd24fd2719a01d17ac596e20792a1d3c0c2af51a46ba48936 + checksum: 088fb439dc900d6c848c187a0a3218f8c80e3d5df0ec94995d2245b701d59c9d99d4ccc2b48b12682e20d1c0653ababc2f7a51a04737c4df539f4dac82eca87b languageName: node linkType: hard @@ -12836,9 +12844,9 @@ __metadata: languageName: node linkType: hard -"@mswjs/interceptors@npm:^0.25.14": - version: 0.25.14 - resolution: "@mswjs/interceptors@npm:0.25.14" +"@mswjs/interceptors@npm:^0.25.15": + version: 0.25.15 + resolution: "@mswjs/interceptors@npm:0.25.15" dependencies: "@open-draft/deferred-promise": ^2.2.0 "@open-draft/logger": ^0.3.0 @@ -12846,7 +12854,7 @@ __metadata: is-node-process: ^1.2.0 outvariant: ^1.2.1 strict-event-emitter: ^0.5.1 - checksum: caf9513cf6848ff0c3f1402abf881d3c1333f68fae54076cfd3fd919b7edb709769342bd3afd2a60ef4ae698ef47776b6203e0ea6a5d8e788e97eda7ed9dbbd4 + checksum: dbe43f2df398bbe48ee5ea4ecf055144c9c5689218e5955b01378ea084044dab992d1b434d3533e75df044030a976402e53ec65d743c2619e024defd75ffc076 languageName: node linkType: hard @@ -17016,90 +17024,90 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-arm64@npm:1.3.105" +"@swc/core-darwin-arm64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-arm64@npm:1.3.107" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-darwin-x64@npm:1.3.105" +"@swc/core-darwin-x64@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-darwin-x64@npm:1.3.107" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.105" +"@swc/core-linux-arm-gnueabihf@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.107" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-gnu@npm:1.3.105" +"@swc/core-linux-arm64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-arm64-musl@npm:1.3.105" +"@swc/core-linux-arm64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.107" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-gnu@npm:1.3.105" +"@swc/core-linux-x64-gnu@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-linux-x64-musl@npm:1.3.105" +"@swc/core-linux-x64-musl@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-linux-x64-musl@npm:1.3.107" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-arm64-msvc@npm:1.3.105" +"@swc/core-win32-arm64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.107" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-ia32-msvc@npm:1.3.105" +"@swc/core-win32-ia32-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.107" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.3.105": - version: 1.3.105 - resolution: "@swc/core-win32-x64-msvc@npm:1.3.105" +"@swc/core-win32-x64-msvc@npm:1.3.107": + version: 1.3.107 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.107" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/core@npm:^1.3.46": - version: 1.3.105 - resolution: "@swc/core@npm:1.3.105" + version: 1.3.107 + resolution: "@swc/core@npm:1.3.107" dependencies: - "@swc/core-darwin-arm64": 1.3.105 - "@swc/core-darwin-x64": 1.3.105 - "@swc/core-linux-arm-gnueabihf": 1.3.105 - "@swc/core-linux-arm64-gnu": 1.3.105 - "@swc/core-linux-arm64-musl": 1.3.105 - "@swc/core-linux-x64-gnu": 1.3.105 - "@swc/core-linux-x64-musl": 1.3.105 - "@swc/core-win32-arm64-msvc": 1.3.105 - "@swc/core-win32-ia32-msvc": 1.3.105 - "@swc/core-win32-x64-msvc": 1.3.105 + "@swc/core-darwin-arm64": 1.3.107 + "@swc/core-darwin-x64": 1.3.107 + "@swc/core-linux-arm-gnueabihf": 1.3.107 + "@swc/core-linux-arm64-gnu": 1.3.107 + "@swc/core-linux-arm64-musl": 1.3.107 + "@swc/core-linux-x64-gnu": 1.3.107 + "@swc/core-linux-x64-musl": 1.3.107 + "@swc/core-win32-arm64-msvc": 1.3.107 + "@swc/core-win32-ia32-msvc": 1.3.107 + "@swc/core-win32-x64-msvc": 1.3.107 "@swc/counter": ^0.1.1 "@swc/types": ^0.1.5 peerDependencies: @@ -17128,7 +17136,7 @@ __metadata: peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 5baa880bc92748ef4845d9c65eba5d6dd01adaa673854e20a5116f5e267c12180db50e563cf3c34a415772b9742d021176a9d9a91065c190ef6f54fefe85728c + checksum: 0dccff50461fb8c0f4af053b70e555c91386cb07aa7657a7328d58e397d15640723587549416d8fa7dcc073ad11b39318146bd50ec4a82345ce2ce39c7ba4c00 languageName: node linkType: hard @@ -18212,7 +18220,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:*, @types/jest@npm:^29.0.0": +"@types/jest@npm:*, @types/jest@npm:^29.0.0, @types/jest@npm:^29.5.11": version: 29.5.11 resolution: "@types/jest@npm:29.5.11" dependencies: @@ -19353,14 +19361,14 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^6.12.0": - version: 6.19.0 - resolution: "@typescript-eslint/eslint-plugin@npm:6.19.0" + version: 6.19.1 + resolution: "@typescript-eslint/eslint-plugin@npm:6.19.1" dependencies: "@eslint-community/regexpp": ^4.5.1 - "@typescript-eslint/scope-manager": 6.19.0 - "@typescript-eslint/type-utils": 6.19.0 - "@typescript-eslint/utils": 6.19.0 - "@typescript-eslint/visitor-keys": 6.19.0 + "@typescript-eslint/scope-manager": 6.19.1 + "@typescript-eslint/type-utils": 6.19.1 + "@typescript-eslint/utils": 6.19.1 + "@typescript-eslint/visitor-keys": 6.19.1 debug: ^4.3.4 graphemer: ^1.4.0 ignore: ^5.2.4 @@ -19373,25 +19381,25 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 9880567d52d4e6559e2343caeed68f856d593b42816b8f705cd98d5a5b46cc620e3bebaaf08bbc982061bba18e5be94d6c539c0c816e8772ddabba0ad4e9363e + checksum: ad04000cd6c15d864ff92655baa3aec99bb0ccf4714fedd145fedde60a27590a5feafe480beb2f0f3864b416098bde1e9431bada7480eb7ca4efad891e1d2f6f languageName: node linkType: hard "@typescript-eslint/parser@npm:^6.7.2": - version: 6.19.0 - resolution: "@typescript-eslint/parser@npm:6.19.0" + version: 6.19.1 + resolution: "@typescript-eslint/parser@npm:6.19.1" dependencies: - "@typescript-eslint/scope-manager": 6.19.0 - "@typescript-eslint/types": 6.19.0 - "@typescript-eslint/typescript-estree": 6.19.0 - "@typescript-eslint/visitor-keys": 6.19.0 + "@typescript-eslint/scope-manager": 6.19.1 + "@typescript-eslint/types": 6.19.1 + "@typescript-eslint/typescript-estree": 6.19.1 + "@typescript-eslint/visitor-keys": 6.19.1 debug: ^4.3.4 peerDependencies: eslint: ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 0ac91ff83fdf693de4494b45be79f25803ea6ca3ee717e4f96785b7ffc1da0180adb0426b61bc6eff5666c8ef9ea58c50efbd4351ef9018c0050116cbd74a62b + checksum: cd29619da08a2d9b7123ba4d8240989c747f8e0d5672179d8b147e413ee1334d1fa48570b0c37cf0ae4e26a275fd2d268cbe702c6fed639d3331abbb3292570a languageName: node linkType: hard @@ -19405,22 +19413,22 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/scope-manager@npm:6.19.0" +"@typescript-eslint/scope-manager@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/scope-manager@npm:6.19.1" dependencies: - "@typescript-eslint/types": 6.19.0 - "@typescript-eslint/visitor-keys": 6.19.0 - checksum: 47d9d1b70cd64f9d1bb717090850e0ff1a34e453c28b43fd0cecaea4db05cacebd60f5da55b35c4b3cc01491f02e9de358f82a0822b27c00e80e3d1a27de32d1 + "@typescript-eslint/types": 6.19.1 + "@typescript-eslint/visitor-keys": 6.19.1 + checksum: 848cdebc16a3803e8a6d6035a7067605309a652bb2425f475f755b5ace4d80d2c17c8c8901f0f4759556da8d0a5b71024d472b85c3f3c70d0e6dcfe2a972ef35 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/type-utils@npm:6.19.0" +"@typescript-eslint/type-utils@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/type-utils@npm:6.19.1" dependencies: - "@typescript-eslint/typescript-estree": 6.19.0 - "@typescript-eslint/utils": 6.19.0 + "@typescript-eslint/typescript-estree": 6.19.1 + "@typescript-eslint/utils": 6.19.1 debug: ^4.3.4 ts-api-utils: ^1.0.1 peerDependencies: @@ -19428,7 +19436,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: a88f022617be636f43429a7c7c5cd2e0e29955e96d4a9fed7d03467dc4a432b1240a71009d62213604ddb3522be9694e6b78882ee805687cda107021d1ddb203 + checksum: eab1a30f8d85f7c6e2545de5963fbec2f3bb91913d59623069b4b0db372a671ab048c7018376fc853c3af06ea39417f3e7b27dd665027dd812347a5e64cecd77 languageName: node linkType: hard @@ -19439,10 +19447,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/types@npm:6.19.0" - checksum: 1371b5ba41c1d2879b3c2823ab01a30cf034e476ef53ff2a7f9e9a4a0056dfbbfecd3143031b05430aa6c749233cacbd01b72cea38a9ece1c6cf95a5cd43da6a +"@typescript-eslint/types@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/types@npm:6.19.1" + checksum: 598ce222b59c20432d06f60703d0c2dd16d9b2151569c192852136c57b8188e3ef6ef9fddaa2c136c9a756fcc7d873c0e29ec41cfd340564842287ef7b4571cd languageName: node linkType: hard @@ -19464,12 +19472,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/typescript-estree@npm:6.19.0" +"@typescript-eslint/typescript-estree@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/typescript-estree@npm:6.19.1" dependencies: - "@typescript-eslint/types": 6.19.0 - "@typescript-eslint/visitor-keys": 6.19.0 + "@typescript-eslint/types": 6.19.1 + "@typescript-eslint/visitor-keys": 6.19.1 debug: ^4.3.4 globby: ^11.1.0 is-glob: ^4.0.3 @@ -19479,24 +19487,24 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 919f9588840cdab7e0ef6471f4c35d602523b142b2cffeabe9171d6ce65eb7f41614d0cb17e008e0d8e796374821ab053ced35b84642c3b1d491987362f2fdb5 + checksum: fb71a14aeee0468780219c5b8d39075f85d360b04ccd0ee88f4f0a615d2c232a6d3016e36d8c6eda2d9dfda86b4f4cc2c3d7582940fb29d33c7cf305e124d4e2 languageName: node linkType: hard -"@typescript-eslint/utils@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/utils@npm:6.19.0" +"@typescript-eslint/utils@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/utils@npm:6.19.1" dependencies: "@eslint-community/eslint-utils": ^4.4.0 "@types/json-schema": ^7.0.12 "@types/semver": ^7.5.0 - "@typescript-eslint/scope-manager": 6.19.0 - "@typescript-eslint/types": 6.19.0 - "@typescript-eslint/typescript-estree": 6.19.0 + "@typescript-eslint/scope-manager": 6.19.1 + "@typescript-eslint/types": 6.19.1 + "@typescript-eslint/typescript-estree": 6.19.1 semver: ^7.5.4 peerDependencies: eslint: ^7.0.0 || ^8.0.0 - checksum: 05a26251a526232b08850b6c3327637213ef989453e353f3a8255433b74893a70d5c38369c528b762e853b7586d7830d728b372494e65f37770ecb05a88112d4 + checksum: fe72e75c3ea17a85772b83f148555ea94ff5d55d13586f3fc038833197a74f8071e14c2bbf1781c40eec20005f052f4be2513a725eea82a15da3cb9af3046c70 languageName: node linkType: hard @@ -19528,13 +19536,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/visitor-keys@npm:6.19.0" +"@typescript-eslint/visitor-keys@npm:6.19.1": + version: 6.19.1 + resolution: "@typescript-eslint/visitor-keys@npm:6.19.1" dependencies: - "@typescript-eslint/types": 6.19.0 + "@typescript-eslint/types": 6.19.1 eslint-visitor-keys: ^3.4.1 - checksum: 35b11143e1b55ecf01e0f513085df2cc83d0781f4a8354dc10f6ec3356f66b91a1ed8abadb6fb66af1c1746f9c874eabc8b5636882466e229cda5d6a39aada08 + checksum: bdf057a42e776970a89cdd568e493e3ea7ec085544d8f318d33084da63c3395ad2c0fb9cef9f61ceeca41f5dab54ab064b7078fe596889005e412ec74d2d1ae4 languageName: node linkType: hard @@ -25766,32 +25774,32 @@ __metadata: linkType: hard "esbuild@npm:^0.19.0, esbuild@npm:~0.19.10": - version: 0.19.11 - resolution: "esbuild@npm:0.19.11" + version: 0.19.12 + resolution: "esbuild@npm:0.19.12" dependencies: - "@esbuild/aix-ppc64": 0.19.11 - "@esbuild/android-arm": 0.19.11 - "@esbuild/android-arm64": 0.19.11 - "@esbuild/android-x64": 0.19.11 - "@esbuild/darwin-arm64": 0.19.11 - "@esbuild/darwin-x64": 0.19.11 - "@esbuild/freebsd-arm64": 0.19.11 - "@esbuild/freebsd-x64": 0.19.11 - "@esbuild/linux-arm": 0.19.11 - "@esbuild/linux-arm64": 0.19.11 - "@esbuild/linux-ia32": 0.19.11 - "@esbuild/linux-loong64": 0.19.11 - "@esbuild/linux-mips64el": 0.19.11 - "@esbuild/linux-ppc64": 0.19.11 - "@esbuild/linux-riscv64": 0.19.11 - "@esbuild/linux-s390x": 0.19.11 - "@esbuild/linux-x64": 0.19.11 - "@esbuild/netbsd-x64": 0.19.11 - "@esbuild/openbsd-x64": 0.19.11 - "@esbuild/sunos-x64": 0.19.11 - "@esbuild/win32-arm64": 0.19.11 - "@esbuild/win32-ia32": 0.19.11 - "@esbuild/win32-x64": 0.19.11 + "@esbuild/aix-ppc64": 0.19.12 + "@esbuild/android-arm": 0.19.12 + "@esbuild/android-arm64": 0.19.12 + "@esbuild/android-x64": 0.19.12 + "@esbuild/darwin-arm64": 0.19.12 + "@esbuild/darwin-x64": 0.19.12 + "@esbuild/freebsd-arm64": 0.19.12 + "@esbuild/freebsd-x64": 0.19.12 + "@esbuild/linux-arm": 0.19.12 + "@esbuild/linux-arm64": 0.19.12 + "@esbuild/linux-ia32": 0.19.12 + "@esbuild/linux-loong64": 0.19.12 + "@esbuild/linux-mips64el": 0.19.12 + "@esbuild/linux-ppc64": 0.19.12 + "@esbuild/linux-riscv64": 0.19.12 + "@esbuild/linux-s390x": 0.19.12 + "@esbuild/linux-x64": 0.19.12 + "@esbuild/netbsd-x64": 0.19.12 + "@esbuild/openbsd-x64": 0.19.12 + "@esbuild/sunos-x64": 0.19.12 + "@esbuild/win32-arm64": 0.19.12 + "@esbuild/win32-ia32": 0.19.12 + "@esbuild/win32-x64": 0.19.12 dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -25841,7 +25849,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: ae949a796d1d06b55275ae7491ce137857468f69a93d8cc9c0943d2a701ac54e14dbb250a2ba56f2ad98283669578f1ec3bd85a4681910a5ff29a2470c3bd62c + checksum: 2936e29107b43e65a775b78b7bc66ddd7d76febd73840ac7e825fb22b65029422ff51038a08d19b05154f543584bd3afe7d1ef1c63900429475b17fbe61cb61f languageName: node linkType: hard @@ -27530,10 +27538,10 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.1.0": - version: 3.1.1 - resolution: "flatted@npm:3.1.1" - checksum: 508935e3366d95444131f0aaa801a4301f24ea5bcb900d12764e7335b46b910730cc1b5bcfcfb8eccb7c8db261ba0671c6a7ca30d10870ff7a7756dc7e731a7a +"flatted@npm:3.2.9, flatted@npm:^3.1.0": + version: 3.2.9 + resolution: "flatted@npm:3.2.9" + checksum: f14167fbe26a9d20f6fca8d998e8f1f41df72c8e81f9f2c9d61ed2bea058248f5e1cbd05e7f88c0e5087a6a0b822a1e5e2b446e879f3cfbe0b07ba2d7f80b026 languageName: node linkType: hard @@ -31346,7 +31354,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.0.2": +"jest@npm:^29.7.0": version: 29.7.0 resolution: "jest@npm:29.7.0" dependencies: @@ -34632,13 +34640,13 @@ __metadata: linkType: hard "msw@npm:^2.0.0, msw@npm:^2.0.8": - version: 2.1.4 - resolution: "msw@npm:2.1.4" + version: 2.1.5 + resolution: "msw@npm:2.1.5" dependencies: "@bundled-es-modules/cookie": ^2.0.0 "@bundled-es-modules/statuses": ^1.0.1 "@mswjs/cookies": ^1.1.0 - "@mswjs/interceptors": ^0.25.14 + "@mswjs/interceptors": ^0.25.15 "@open-draft/until": ^2.1.0 "@types/cookie": ^0.6.0 "@types/statuses": ^2.0.4 @@ -34660,7 +34668,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: da8aaf9682ac48a635966beef9add9493297de797b266066bcd8ae0c2708488b81558251412e41489511a63deda1774b42e28197e9b73ddf14d9ecf8bb916e7a + checksum: 19a54a25baa584d1bafa219e1c2245073688eeed6ea5330f27c868a2f279390ee4fbd8a13d64cf0a056149d7e2faa300f901959bab8854e010f29368524d7c4f languageName: node linkType: hard