diff --git a/.changeset/add-query-catalog-entities-action.md b/.changeset/add-query-catalog-entities-action.md new file mode 100644 index 0000000000..78fb30d584 --- /dev/null +++ b/.changeset/add-query-catalog-entities-action.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added `query-catalog-entities` action to the catalog backend actions registry. Supports predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/by-refs-predicate-backend.md b/.changeset/by-refs-predicate-backend.md new file mode 100644 index 0000000000..a2a0346122 --- /dev/null +++ b/.changeset/by-refs-predicate-backend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added support for predicate-based filtering on the `/entities/by-refs` endpoint via the `query` field in the request body. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/by-refs-predicate-client.md b/.changeset/by-refs-predicate-client.md new file mode 100644 index 0000000000..5ed136fbe1 --- /dev/null +++ b/.changeset/by-refs-predicate-client.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Added support for the `query` field in `getEntitiesByRefs` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md new file mode 100644 index 0000000000..7f12c5372c --- /dev/null +++ b/.changeset/chatty-wasps-sink.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized. + +Affected components: Container diff --git a/.changeset/clean-up-own-root-dir.md b/.changeset/clean-up-own-root-dir.md new file mode 100644 index 0000000000..c668073688 --- /dev/null +++ b/.changeset/clean-up-own-root-dir.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-common': patch +--- + +The `findOwnRootDir` utility now searches for the monorepo root by traversing up the directory tree looking for a `package.json` with `workspaces`, instead of assuming a fixed `../..` relative path. If no workspaces root is found during this traversal, `findOwnRootDir` now throws to enforce stricter validation of the repository layout. diff --git a/.changeset/cli-internalize-lib-modules.md b/.changeset/cli-internalize-lib-modules.md new file mode 100644 index 0000000000..f9ef219a3a --- /dev/null +++ b/.changeset/cli-internalize-lib-modules.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to move shared utilities into their consuming modules, reducing cross-module dependencies. diff --git a/.changeset/cli-node-lockfile-tostring.md b/.changeset/cli-node-lockfile-tostring.md new file mode 100644 index 0000000000..bf4fc31225 --- /dev/null +++ b/.changeset/cli-node-lockfile-tostring.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added `toString()` method to `Lockfile` for serializing lockfiles back to string format. diff --git a/.changeset/cli-remove-migrate-package-exports.md b/.changeset/cli-remove-migrate-package-exports.md new file mode 100644 index 0000000000..03ad1974d8 --- /dev/null +++ b/.changeset/cli-remove-migrate-package-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': minor +--- + +**BREAKING**: The `migrate package-exports` command has been removed. Use `repo fix` instead. diff --git a/.changeset/cli-split-loadCliConfig.md b/.changeset/cli-split-loadCliConfig.md new file mode 100644 index 0000000000..d9c3571fe0 --- /dev/null +++ b/.changeset/cli-split-loadCliConfig.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to split `loadCliConfig` into separate implementations for the build and config CLI modules, removing a cross-module dependency. diff --git a/.changeset/cli-versioning-consolidation.md b/.changeset/cli-versioning-consolidation.md new file mode 100644 index 0000000000..114b931c9b --- /dev/null +++ b/.changeset/cli-versioning-consolidation.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Migrated internal versioning utilities to use `@backstage/cli-node` instead of a local implementation. diff --git a/.changeset/cold-dodos-think.md b/.changeset/cold-dodos-think.md new file mode 100644 index 0000000000..021e5f679e --- /dev/null +++ b/.changeset/cold-dodos-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-devtools': patch +--- + +Fixed URL encoding of task IDs for the trigger feature (tasks that contained a "/" in their ID were not triggered) diff --git a/.changeset/dark-snakes-nail.md b/.changeset/dark-snakes-nail.md new file mode 100644 index 0000000000..d4eb6f0dd2 --- /dev/null +++ b/.changeset/dark-snakes-nail.md @@ -0,0 +1,21 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Simplified the neutral background prop API for container components. The explicit `neutral-1`, `neutral-2`, `neutral-3`, and `neutral-auto` values have been removed from `ProviderBg`. They are replaced by a single `'neutral'` value that always auto-increments from the parent context, making it impossible to skip or pin to an explicit neutral level. + +**Migration:** + +Replace any explicit `bg="neutral-1"`, `bg="neutral-2"`, `bg="neutral-3"`, or `bg="neutral-auto"` props with `bg="neutral"`. To achieve a specific neutral level in stories or tests, use nested containers — each additional `bg="neutral"` wrapper increments by one level. + +```tsx +// Before +... + +// After + + ... + +``` + +**Affected components:** Box, Flex, Grid, Card, Accordion, Popover, Tooltip, Dialog, Menu diff --git a/.changeset/early-baboons-roll.md b/.changeset/early-baboons-roll.md new file mode 100644 index 0000000000..bdd55ec507 --- /dev/null +++ b/.changeset/early-baboons-roll.md @@ -0,0 +1,16 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Removed `--bui-bg-popover` CSS token. Popover, Tooltip, Menu, and Dialog now use `--bui-bg-app` for their outer shell and `Box bg="neutral-1"` for content areas, providing better theme consistency and eliminating a redundant token. + +**Migration:** + +Replace any usage of `--bui-bg-popover` with `--bui-bg-neutral-1` (for content surfaces) or `--bui-bg-app` (for outer shells): + +```diff +- background: var(--bui-bg-popover); ++ background: var(--bui-bg-neutral-1); +``` + +**Affected components:** Popover, Tooltip, Menu, Dialog diff --git a/.changeset/entity-content-group-api-docs.md b/.changeset/entity-content-group-api-docs.md new file mode 100644 index 0000000000..8eb3e12435 --- /dev/null +++ b/.changeset/entity-content-group-api-docs.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Added default entity content groups for the API docs entity content tabs. The API definition tab defaults to the `documentation` group and the APIs tab defaults to the `development` group. diff --git a/.changeset/entity-content-group-techdocs.md b/.changeset/entity-content-group-techdocs.md new file mode 100644 index 0000000000..a99992301c --- /dev/null +++ b/.changeset/entity-content-group-techdocs.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Added `documentation` as the default entity content group for the TechDocs entity content tab. diff --git a/.changeset/entity-page-group-aliases-and-ordering-catalog.md b/.changeset/entity-page-group-aliases-and-ordering-catalog.md new file mode 100644 index 0000000000..c2a17ae773 --- /dev/null +++ b/.changeset/entity-page-group-aliases-and-ordering-catalog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Added support for group alias IDs and configurable content ordering on the entity page. Groups can now declare `aliases` so that content targeting an aliased group is included in the group. A new `contentOrder` option (default `title`) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides. diff --git a/.changeset/entity-page-group-aliases-and-ordering.md b/.changeset/entity-page-group-aliases-and-ordering.md new file mode 100644 index 0000000000..35b18063d9 --- /dev/null +++ b/.changeset/entity-page-group-aliases-and-ordering.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Added `aliases` and `contentOrder` fields to `EntityContentGroupDefinition`, allowing groups to declare alias IDs and control the sort order of their content items. diff --git a/.changeset/facets-predicate-backend.md b/.changeset/facets-predicate-backend.md new file mode 100644 index 0000000000..9ecdef11b6 --- /dev/null +++ b/.changeset/facets-predicate-backend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added support for predicate-based filtering on the `/entity-facets` endpoint via a new `POST` method. Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/facets-predicate-client.md b/.changeset/facets-predicate-client.md new file mode 100644 index 0000000000..5493d7c82a --- /dev/null +++ b/.changeset/facets-predicate-client.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Added support for the `query` field in `getEntityFacets` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators. diff --git a/.changeset/fix-entity-tab-group-ordering.md b/.changeset/fix-entity-tab-group-ordering.md new file mode 100644 index 0000000000..6fab3f77af --- /dev/null +++ b/.changeset/fix-entity-tab-group-ordering.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Fixed entity page tab groups not respecting the ordering from the `groups` configuration. diff --git a/.changeset/fix-form-fields-api-export-react.md b/.changeset/fix-form-fields-api-export-react.md new file mode 100644 index 0000000000..787131280a --- /dev/null +++ b/.changeset/fix-form-fields-api-export-react.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports. diff --git a/.changeset/fix-mcp-error-forwarding.md b/.changeset/fix-mcp-error-forwarding.md new file mode 100644 index 0000000000..fa12e224a9 --- /dev/null +++ b/.changeset/fix-mcp-error-forwarding.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +'@backstage/plugin-mcp-actions-backend': patch +--- + +Fixed error forwarding in the actions registry so that known errors like `InputError` and `NotFoundError` thrown by actions preserve their original status codes and messages instead of being wrapped in `ForwardedError` and coerced to 500. diff --git a/.changeset/fix-mui-styles-shared-dep.md b/.changeset/fix-mui-styles-shared-dep.md new file mode 100644 index 0000000000..0154c15559 --- /dev/null +++ b/.changeset/fix-mui-styles-shared-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/module-federation-common': patch +--- + +Fixed the `@mui/material/styles` shared dependency key by removing a trailing slash that caused module resolution failures with MUI package exports. diff --git a/.changeset/github-org-verified-emails.md b/.changeset/github-org-verified-emails.md new file mode 100644 index 0000000000..b825395556 --- /dev/null +++ b/.changeset/github-org-verified-emails.md @@ -0,0 +1,34 @@ +--- +'@backstage/plugin-catalog-backend-module-github': minor +--- + +The default user transformer now prefers organization verified domain emails over the user's public GitHub email when populating the user entity profile. It also strips plus-addressed routing tags that GitHub adds to these emails. + +If you want to retain the old behavior, you can do so with a custom user transformer using the `githubOrgEntityProviderTransformsExtensionPoint`: + +```ts +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { githubOrgEntityProviderTransformsExtensionPoint } from '@backstage/plugin-catalog-backend-module-github-org'; +import { defaultUserTransformer } from '@backstage/plugin-catalog-backend-module-github'; + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'github-org-custom-transforms', + register(env) { + env.registerInit({ + deps: { + transforms: githubOrgEntityProviderTransformsExtensionPoint, + }, + async init({ transforms }) { + transforms.setUserTransformer(async (item, ctx) => { + const entity = await defaultUserTransformer(item, ctx); + if (entity && item.email) { + entity.spec.profile!.email = item.email; + } + return entity; + }); + }, + }); + }, +}); +``` diff --git a/.changeset/move-clean-pack-to-build.md b/.changeset/move-clean-pack-to-build.md new file mode 100644 index 0000000000..9f1969ec41 --- /dev/null +++ b/.changeset/move-clean-pack-to-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor of CLI command modules. diff --git a/.changeset/move-utils-to-cli-node.md b/.changeset/move-utils-to-cli-node.md new file mode 100644 index 0000000000..f708dde666 --- /dev/null +++ b/.changeset/move-utils-to-cli-node.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added `hasBackstageYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`. diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md new file mode 100644 index 0000000000..5f046e31b2 --- /dev/null +++ b/.changeset/rich-ducks-ring.md @@ -0,0 +1,10 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +Added predicate-based entity filtering via POST /entities/by-query endpoint. + +Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. + +The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. diff --git a/.changeset/scaffolder-export-form-fields-api.md b/.changeset/scaffolder-export-form-fields-api.md deleted file mode 100644 index 02ead2d2b2..0000000000 --- a/.changeset/scaffolder-export-form-fields-api.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Added back the `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports that were unintentionally removed. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index f6f37b17cf..81ef949651 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -527,6 +527,7 @@ unassign unbreak Unconference undici +ungrouped unicode unmanaged unmount diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml index d9851778c6..0fca26a12c 100644 --- a/.github/workflows/ci-noop.yml +++ b/.github/workflows/ci-noop.yml @@ -6,6 +6,7 @@ on: pull_request: paths: - 'microsite/**' + - '!microsite/data/**' - 'beps/**' permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac6120d28..8f2b4af0f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,11 @@ name: CI on: # NOTE: If you change these you must update ci-noop.yml as well pull_request: - paths-ignore: - - 'microsite/**' - - 'beps/**' + paths: + - '**' + - '!microsite/**' + - 'microsite/data/**' + - '!beps/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index af42f25390..6ace7d72a3 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -7,7 +7,10 @@ on: - '**.md' jobs: - check-all-files: + check-docs: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: @@ -18,18 +21,34 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Vale does not support file excludes, so we use the script to generate a list of files instead - # The action also does not allow args or a local config file to be passed in, so the files array - # also contains an "--config=.github/vale/config.ini" option - - name: generate vale args - id: generate - run: echo "args=$(node scripts/check-docs-quality.js --ci-args)" >> $GITHUB_OUTPUT + - name: Install vale + run: | + VALE_VERSION="3.7.1" + VALE_CHECKSUM="ba4924bf2c5884499f09b02a6eb3318b29df40a3e81701c0804b9b1aefcd9483" + VALE_DIST_DIR="/tmp/vale-dist" + VALE_ARCHIVE="vale_${VALE_VERSION}_Linux_64-bit.tar.gz" - - name: documentation quality check - uses: errata-ai/vale-action@d89dee975228ae261d22c15adcd03578634d429c # v2.1.1 - with: - # This also contains --config=.github/vale/config.ini ... :/ - files: '${{ steps.generate.outputs.args }}' - version: latest + mkdir -p "$VALE_DIST_DIR" + + # Download pinned Vale binary + curl -fsSL -H "Authorization: token $GH_TOKEN" "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/${VALE_ARCHIVE}" -o "$VALE_DIST_DIR/$VALE_ARCHIVE" + + # Verify the integrity of the downloaded archive + echo "$VALE_CHECKSUM $VALE_DIST_DIR/$VALE_ARCHIVE" | sha256sum --check + + mkdir -p "$HOME/.local/bin" + tar -xzf "$VALE_DIST_DIR/$VALE_ARCHIVE" -C "$HOME/.local/bin" vale + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + vale --version env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + + - name: Get changed files + run: | + gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ + --paginate -q '.[] | select(.status != "removed") | .filename' > /tmp/pr-files.txt + env: + GH_TOKEN: ${{ github.token }} + + - name: Documentation quality check + run: node scripts/check-docs-quality.js --ci /tmp/pr-files.txt diff --git a/.patches/pr-32996.txt b/.patches/pr-32996.txt new file mode 100644 index 0000000000..2dd0fde602 --- /dev/null +++ b/.patches/pr-32996.txt @@ -0,0 +1 @@ +Fixes the `@mui/material/styles` shared dependency key by removing a trailing slash that caused module resolution failures with MUI package exports. \ No newline at end of file diff --git a/.patches/pr-33004.txt b/.patches/pr-33004.txt new file mode 100644 index 0000000000..5594a76e6b --- /dev/null +++ b/.patches/pr-33004.txt @@ -0,0 +1 @@ +Fixes entity page tab groups not respecting the ordering from the groups configuration. \ No newline at end of file diff --git a/.patches/pr-33034.txt b/.patches/pr-33034.txt new file mode 100644 index 0000000000..4648da00b2 --- /dev/null +++ b/.patches/pr-33034.txt @@ -0,0 +1 @@ +Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports to `@backstage/plugin-scaffolder-react`. This change was incorrectly applied to `@backstage/plugin-scaffolder` in the 1.48.2 release, which is now gone. Note that the API signature of this API has still changed since the 1.47 release. \ No newline at end of file diff --git a/.storybook/modes.ts b/.storybook/modes.ts index 7087fa35eb..ae4ff76a7c 100644 --- a/.storybook/modes.ts +++ b/.storybook/modes.ts @@ -15,4 +15,19 @@ export const allModes = { themeMode: 'dark', themeName: 'spotify', }, + 'light spotify neutral-1': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-1', + }, + 'light spotify neutral-2': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-2', + }, + 'light spotify neutral-3': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-3', + }, } as const; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6a0177cafe..9734cee295 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,7 +3,7 @@ import addonDocs from '@storybook/addon-docs'; import addonThemes from '@storybook/addon-themes'; import addonLinks from '@storybook/addon-links'; import { definePreview } from '@storybook/react-vite'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { TestApiProvider } from '@backstage/test-utils'; import { AlertDisplay } from '@backstage/core-components'; import { apis } from './support/apis'; @@ -19,6 +19,7 @@ import './storybook.css'; // Custom themes import './themes/spotify.css'; +import { Box } from '../packages/ui/src/components/Box'; export default definePreview({ tags: ['manifest'], @@ -30,8 +31,8 @@ export default definePreview({ toolbar: { icon: 'circlehollow', items: [ - { value: 'light', icon: 'circlehollow', title: 'Light' }, - { value: 'dark', icon: 'circle', title: 'Dark' }, + { value: 'light', icon: 'sun', title: 'Light' }, + { value: 'dark', icon: 'moon', title: 'Dark' }, ], dynamicTitle: true, }, @@ -49,11 +50,26 @@ export default definePreview({ dynamicTitle: true, }, }, + background: { + name: 'Background', + description: 'Global background for components', + defaultValue: 'app', + toolbar: { + icon: 'contrast', + items: [ + { value: 'app', title: 'App Background' }, + { value: 'neutral-1', title: 'Neutral 1 Background' }, + { value: 'neutral-2', title: 'Neutral 2 Background' }, + { value: 'neutral-3', title: 'Neutral 3 Background' }, + ], + }, + }, }, initialGlobals: { themeMode: 'light', themeName: 'backstage', + background: 'app', }, parameters: { @@ -70,7 +86,13 @@ export default definePreview({ options: { storySort: { - order: ['Backstage UI', 'Plugins', 'Layout', 'Navigation'], + order: [ + 'Backstage UI', + 'Guidelines', + 'Plugins', + 'Layout', + 'Navigation', + ], }, }, @@ -114,12 +136,14 @@ export default definePreview({ }, decorators: [ - Story => { + (Story, context) => { const [globals] = useGlobals(); const selectedTheme = globals.themeMode === 'light' ? themes.light : themes.dark; const selectedThemeMode = globals.themeMode || 'light'; const selectedThemeName = globals.themeName || 'backstage'; + const selectedBackground = globals.background || 'app'; + const isFullscreen = context.parameters.layout === 'fullscreen'; useEffect(() => { document.body.removeAttribute('data-theme-mode'); @@ -133,6 +157,8 @@ export default definePreview({ }, [selectedTheme, selectedThemeName]); document.body.style.backgroundColor = 'var(--bui-bg-app)'; + document.body.style.padding = + isFullscreen && selectedBackground !== 'app' ? '1rem' : ''; const docsStoryElements = document.getElementsByClassName('docs-story'); Array.from(docsStoryElements).forEach(element => { (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; @@ -143,7 +169,19 @@ export default definePreview({ {/* @ts-ignore */} - + {Array.from({ + length: + selectedBackground === 'app' + ? 0 + : parseInt(selectedBackground.split('-')[1], 10), + }).reduce( + children => ( + + {children} + + ), + , + )} ); diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index b72683f53e..89b71c48d3 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -213,6 +213,10 @@ .bui-Tag { border-radius: var(--bui-radius-full); } + + .bui-Container { + padding-inline: 0; + } } [data-theme-mode='light'][data-theme-name='spotify'] { diff --git a/docs-ui/src/app/snippets.ts b/docs-ui/src/app/snippets.ts index e16463a085..4a93c31831 100644 --- a/docs-ui/src/app/snippets.ts +++ b/docs-ui/src/app/snippets.ts @@ -1,19 +1,19 @@ export const surfacesSnippet = ` - + - + `; -export const adaptiveSnippet = ` +export const adaptiveSnippet = ` {/* automatically set background to neutral-2 */} `; -export const customCardSnippet = ` +export const customCardSnippet = ` Hello World `; diff --git a/docs/conf/user-interface/index.md b/docs/conf/user-interface/index.md index cb53ebe81c..107fde3d57 100644 --- a/docs/conf/user-interface/index.md +++ b/docs/conf/user-interface/index.md @@ -156,7 +156,6 @@ These colors form a layered neutral scale for your application backgrounds. `--b | Token Name | Description | | ----------------------------- | ------------------------------------------------------------ | | `--bui-bg-app` | The base background color of your Backstage instance. | -| `--bui-bg-popover` | The background color used for popovers, tooltips, and menus. | | `--bui-bg-neutral-1` | First elevated layer. Use for cards, dialogs, and panels. | | `--bui-bg-neutral-1-hover` | Hover state for elements on neutral-1. | | `--bui-bg-neutral-1-pressed` | Pressed state for elements on neutral-1. | diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index ca8b8f638d..97f579c5a3 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -210,6 +210,191 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters, as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration. +### `POST /entities/by-query` + +This supports the same features as the `GET` variant, but in a `POST` body to +not have to abide by URL length limits. Additionally, it supports advanced, more +expressive querying format - see below. The response format is identical. + +#### Querying by filter predicate + +You can pass in a filter predicate to select a subset of entities in the +catalog. They are comprised of an optional logical expression tree (using +`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers +(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`). + +This is an example of what such a filter predicate expression might look like: + +```js +{ + "query": { + "$all": [ + { + "kind": "Component", + "spec.type": { "$in": ["service", "website"] } + }, + { + "$not": { + "metadata.annotations.backstage.io/orphan": "true" + } + } + ] + } +} +``` + +A filter set is an object whose keys are dot separated paths into an object, and +the values are either primitives (string, number, or boolean) or custom matchers +as per below. An example of a simple such filter set is: + +```js +// All of the following must be true for a given entity (there's an +// implicit AND between them) +{ + // The kind field is matched against a literal, case insensitively + "kind": "Component", + // The type field inside the spec is matched using a custom matcher, see below + "spec.type": { "$in": ["service", "website"] } +} +``` + +The root of the query is always an object, whether there is a logic expression +tree or not. Nodes with a single key that starts with a `$` sign have special +meaning. + +- `$not`: Logical negation. + + Its value must be a single expression. Example: + + ```js + // Matches entities that do NOT have kind Component + { + "$not": { + "kind": "Component", + } + } + ``` + + Note that `$not` cannot be used in a right hand side value matcher. + + ```js + // ❌ WRONG + { "kind": { "$not": "Component" } } + // ✅ CORRECT + { "$not": { "kind": "Component" } } + ``` + +- `$all`: Require that all given expressions match each entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that BOTH have kind Component and type website + { + "$all": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array always matches every entity. + +- `$any`: Require that at least one of a set of expressions match a given entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that EITHER have kind Component or type website + { + "$any": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array never matches anything. + +- `$exists`: Assert on the existence of fields. + + Its value is either `true`, meaning that the field must exist on the entity + (no matter what its value), or `false`, meaning that it must not exist. + Example: + + ```js + // Matches entities that DO NOT have that annotation, ignoring what the + // value might be + { + "metadata.annotations.backstage.io/orphan": { + "$exists": false + }, + } + ``` + +- `$in`: Assert that a field has any of a set of primitive values. + + Its value must be an array of string, number, and/or boolean values. Example: + + ```js + // Matches entities whose type is EITHER service or website + { + "spec.type": { + "$in": ["service", "website"] + } + } + ``` + + The matching is case insensitive. An empty array never matches anything. + +- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text. + + Its value is a string. Example: + + ```js + // Matches entities whose project slug annotation starts with "backstage/" + { + "metadata.annotations.github.com/project-slug": { + "$hasPrefix": "backstage/" + } + } + ``` + + The matching is case insensitive, and captures both exact matches and strings + that start with the given prefix. + +- `$contains`: Assert that an array contains an element that matches the given expression. + + There is only limited support for this matcher. One use case is for relations: + + ```js + { + // Specifically type and (optionally) targetRef supported, and only + // with equality or "$in" for the targetRef + "relations": { + "$contains": { + "type": "ownedBy", + "targetRef": { + "$in": ["user:default/foo", "group:default/bar"] + } + } + } + } + ``` + + The other use case is for arrays where you match with a primitive value, such + as tags. Example: + + ```js + { + // Works for array fields whose items are primitive values + // (typically strings, but numbers and booleans are also supported) + "metadata.tags": { + "$contains": "java" + } + } + ``` + ### `GET /entities` Lists entities. diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index d62bb34505..5ddcb7d5a4 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -728,6 +728,49 @@ Notes: - Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the [IconBundleBlueprint documentation](https://backstage.io/api/stable/variables/_backstage_plugin-app-react.IconBundleBlueprint.html)). - Group icons are only rendered if `showNavItemIcons` is set to `true`. +### Content ordering within groups + +By default, content items within each group are sorted alphabetically by title. You can change this with the `contentOrder` option, which supports two modes: + +- **`title`** (default) — sort alphabetically by the content extension's title (case-insensitive). +- **`natural`** — preserve the natural extension discovery/registration order. + +A page-level `contentOrder` sets the default for all groups, and individual groups can override it: + +```yaml +app: + extensions: + - page:catalog/entity: + config: + # Default content order for all groups + contentOrder: title + + groups: + - documentation: + title: Docs + # Override: keep natural order for this group + contentOrder: natural +``` + +Note that content ordering only applies to content items within groups. Ungrouped tabs (those not matching any group definition) always retain their natural order. + +### Group aliases + +Groups can declare `aliases` — a list of other group IDs that should be treated as equivalent. Any entity content extension targeting an aliased group ID will be included in the aliasing group. This is useful when renaming or merging groups without having to reconfigure individual extensions: + +```yaml +app: + extensions: + - page:catalog/entity: + config: + groups: + - develop: + title: Develop + # Content targeting 'development' will appear in this group + aliases: + - development +``` + ### Overriding or disabling a tab's group (per extension) Each entity content extension (tabs on the entity page) can declare a default `group` in code. You can override or disable this per installation in `app-config.yaml` using the extension's config: diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index ac8baadad5..7fdb9056ab 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -1121,7 +1121,7 @@ Describes the following entity kind: | `apiVersion` | `backstage.io/v1alpha1` | | `kind` | `Resource` | -A resource describes the infrastructure a system needs to operate, like BigTable +A Resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them. diff --git a/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx new file mode 100644 index 0000000000..67941fa436 --- /dev/null +++ b/microsite/blog/2026-02-25-get-a-jump-on-contribfest.mdx @@ -0,0 +1,48 @@ +--- +title: 'Get a jump on ContribFest with the new web app' +author: Elaine de Mattos Silva Bezerra, DB Systel GmbH, Heikki Hellgren, OP Financial Group & André Wanlin, Spotify +--- + +![Get a jump on ContribFest with the new web app](assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png) + +Become a Contrib Champ and join us at ContribFest, where commits become legendary! + +We are once again hosting ContribFest at KubeCon + CloudNativeCon. This time around, it's taking place in Amsterdam on March 26, 2026, at 13:45 CET — make sure to [add it to your schedule](https://kccnceu2026.sched.com/event/2EF7v/contribfest-supercharge-your-open-source-impact-backstage-contribfest-live-andre-wanlin-emma-indal-spotify-heikki-hellgren-op-financial-group-elaine-bezerra-db-systel-gmbh?iframe=no). Learn more about what to expect below and get started now by exploring the new [ContribFest web app](https://contribfest.backstage.io/). + +{/* truncate */} + +## Introducing the ContribFest web app + +We're excited to announce the new ContribFest web app: [https://contribfest.backstage.io/](https://contribfest.backstage.io/). The app simplifies local setup and helps you quickly find good issues to work on from the curated list pre-selected by your ContribFest co-hosts. + +You'll see that the app is broken down into five sections: + +- [Welcome](https://contribfest.backstage.io/): This is where you'll find links to all the things, including the session's slide deck, assignment sheet, the Backstage and Community Plugins repositories, and their respective contribution guides. +- [Getting Started](https://contribfest.backstage.io/getting-started/): Whether you are new to Backstage or an old hat, use this handy checklist to help you get your local environment set up for contributing, including all the commands. (Make sure you check all the boxes, you never know what might happen! 😉) +- [Curated Issues](https://contribfest.backstage.io/issues/): This is what you come to the session for: finding an issue that speaks to you and contributing towards it. This section has a list of issues that we've curated — and filters, so you can slice and dice the list to find the perfect issue to work on. +- [Contrib Champs](https://contribfest.backstage.io/contrib-champs/): We've hosted three other ContribFests in the past — this is where you'll find merged PRs from those sessions, a place to celebrate contributions. Make sure to tag your PRs with “ContribFest”, and maybe your name will show up here one day, too! 🏆 +- [Hall of Hosts](https://contribfest.backstage.io/hall-of-hosts/): ContribFest would not take place without the various community members who have stepped up to help co-host the sessions. This is where you'll see an honor roll of past co-hosts. 🙏 + +## About those Contrib Champs + +The goals of the Backstage ContribFest sessions are many — foster community, work with experts, etc. — but it's pretty obvious that contributions are the most important. It's in the name after all. Here are a few past contributions that we wanted to share to give you an idea of what that looks like: + +- [#27694](https://github.com/backstage/backstage/pull/27694) by [hyb175](https://github.com/hyb175) — Add Pagination to Tech Docs Table: for those with lots of entities with TechDocs, this is a massive performance improvement. +- [#29470](https://github.com/backstage/backstage/pull/29470) by [ioboi](https://github.com/ioboi) — Openshift Auth provider: this allows those using OpenShift to use it to sign into their Backstage instance. +- [#31770](https://github.com/backstage/backstage/pull/31770) by [theZMC](https://github.com/theZMC) — Render HTML in GitHub-flavored Markdown: with this change in place, HTML will now render correctly in the MarkdownContent component when you are using the GitHub-flavored Markdown mode. + +Check out the [Contrib Champs page](https://contribfest.backstage.io/contrib-champs/) to see the full list! + +## Using Dev Containers + +Along with the new ContribFest web app, we are also looking to use Dev Containers this time around to help streamline the session for those who'd like to use that option to get started. On the [Getting Started page](https://contribfest.backstage.io/getting-started/), pick the Dev Containers radio button and then follow the checklist. To give you a quick preview, you'll need to have the following installed: + +- Git, you'll need this to be able to pull down the code +- Docker Desktop (or Docker Engine on Linux) +- VS Code with the Dev Containers extension or IntelliJ IDEA Ultimate + +Check out our [Dev Containers tutorial](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/devcontainer.md) for a deeper dive into the subject. + +## Amsterdam, here we come! + +On behalf of the Backstage ContribFest co-host team, thank you for following along. We're looking forward to meeting you in Amsterdam and working together on your contributions. Please be sure to introduce yourself! diff --git a/microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png b/microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png new file mode 100644 index 0000000000..21e726aeb7 Binary files /dev/null and b/microsite/blog/assets/2026-02-25/backstage-contribfest-kubecon-web-app-header.png differ diff --git a/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml new file mode 100644 index 0000000000..6b8227c629 --- /dev/null +++ b/microsite/data/plugins/balajisiva-backstage-plugin-template-builder.yaml @@ -0,0 +1,11 @@ +--- +title: Template Builder +author: Balaji Sivasubramanian +authorUrl: https://github.com/balajisiva +category: Tooling +description: Visual editor for creating and managing Backstage scaffolder templates with live YAML preview, flow visualization, and GitHub integration +documentation: https://github.com/balajisiva/backstage-template-builder#readme +iconUrl: https://raw.githubusercontent.com/balajisiva/backstage-template-builder/main/plugins/backstage-template-builder/icon.svg +npmPackageName: '@balajisiva/backstage-plugin-template-builder' +addedDate: '2026-02-16' +status: active diff --git a/microsite/data/plugins/n8n.yaml b/microsite/data/plugins/n8n.yaml new file mode 100644 index 0000000000..7898c1efe4 --- /dev/null +++ b/microsite/data/plugins/n8n.yaml @@ -0,0 +1,11 @@ +--- +title: n8n +author: André Ahlert Junior +authorUrl: https://github.com/andreahlert +category: Other +description: n8n workflow automation — view workflows and execution history on Backstage entity pages. +documentation: https://github.com/backstage/community-plugins/tree/main/workspaces/n8n/plugins/n8n +iconUrl: /img/logo-gradient-on-dark.svg +npmPackageName: '@backstage-community/plugin-n8n' +addedDate: '2026-02-15' +status: active diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index 2000e785b3..0e49365c08 100644 --- a/packages/app/app-config.yaml +++ b/packages/app/app-config.yaml @@ -48,6 +48,8 @@ app: - page:catalog/entity: config: showNavItemIcons: true + # default content order for all groups, can be 'title' or 'natural' + # contentOrder: title groups: # placing a tab at the beginning - overview: @@ -58,6 +60,9 @@ app: - documentation: title: Docs icon: docs + # example aliasing a group + # aliases: + # - docs - deployment: title: Deployments # example adding a new group @@ -97,22 +102,19 @@ app: # - entity-card:azure-devops/readme # Entity page contents - - entity-content:catalog/overview: - config: - group: overview + - entity-content:catalog/overview - entity-content:api-docs/definition - entity-content:api-docs/apis: config: - # example associating with a default group + # example overriding the default group group: documentation icon: kind:api - entity-content:techdocs: config: - group: documentation icon: techdocs - entity-content:kubernetes/kubernetes: config: - # example disassociating with a default group + # example disassociating from the default group group: false # - entity-content:azure-devops/pipelines # - entity-content:azure-devops/pull-requests diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 7348e0a185..ffe74f81d9 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -28,12 +28,7 @@ import { ActionsRegistryActionOptions, ActionsRegistryService, } from '@backstage/backend-plugin-api/alpha'; -import { - ForwardedError, - InputError, - NotAllowedError, - NotFoundError, -} from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { private actions: Map> = @@ -131,31 +126,24 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - try { - const result = await action.action({ - input: input.data, - credentials, - logger: this.logger, - }); + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${req.params.actionId}"`, - output.error, - ); - } - - res.json({ output: output.data }); - } catch (error) { - throw new ForwardedError( - `Failed execution of action "${req.params.actionId}"`, - error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, ); } + + res.json({ output: output.data }); }, ); return router; diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 79c103c284..1e7c27960e 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -22,7 +22,7 @@ import { import { httpRouterServiceFactory } from '../../../entrypoints/httpRouter'; import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; -import { InputError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; describe('actionsRegistryServiceFactory', () => { @@ -510,7 +510,7 @@ describe('actionsRegistryServiceFactory', () => { expect(body).toMatchObject({ output: { ok: true } }); }); - it('should return the error from the action if it throws', async () => { + it('should forward the original error when the action throws a known error', async () => { const { server } = await startTestBackend({ features: [pluginSubject, ...defaultServices], }); @@ -528,9 +528,32 @@ describe('actionsRegistryServiceFactory', () => { expect(status).toBe(400); expect(body).toMatchObject({ error: { - message: expect.stringContaining( - 'Failed execution of action "my-plugin:test"', - ), + name: 'InputError', + message: 'test', + }, + }); + }); + + it('should forward a NotFoundError from the action with 404 status', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockRejectedValue(new NotFoundError('entity not found')); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(404); + expect(body).toMatchObject({ + error: { + name: 'NotFoundError', + message: 'entity not found', }, }); }); diff --git a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts index 94e5987afd..384a5e8e95 100644 --- a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts +++ b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts @@ -17,7 +17,7 @@ import { BackstageCredentials, LoggerService, } from '@backstage/backend-plugin-api'; -import { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { JsonObject, JsonValue } from '@backstage/types'; import { z, AnyZodObject } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -126,31 +126,24 @@ export class MockActionsRegistry throw new InputError(`Invalid input to action "${opts.id}"`, input.error); } - try { - const result = await action.action({ - input: input.data, - credentials: opts.credentials ?? mockCredentials.none(), - logger: this.logger, - }); + const result = await action.action({ + input: input.data, + credentials: opts.credentials ?? mockCredentials.none(), + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${opts.id}"`, - output.error, - ); - } - - return { output: output.data }; - } catch (error) { - throw new ForwardedError( - `Failed execution of action "${opts.id}"`, - error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${opts.id}"`, + output.error, ); } + + return { output: output.data }; } register< diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 58fc4bc8a1..c155084ccd 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -7,8 +7,8 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; -import { FilterPredicate } from '@backstage/filter-predicates'; -import { SerializedError } from '@backstage/errors'; +import type { FilterPredicate } from '@backstage/filter-predicates'; +import type { SerializedError } from '@backstage/errors'; // @public export type AddLocationRequest = { @@ -236,6 +236,7 @@ export interface GetEntitiesByRefsRequest { entityRefs: string[]; fields?: EntityFieldsQuery | undefined; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public @@ -280,6 +281,7 @@ export interface GetEntityAncestorsResponse { export interface GetEntityFacetsRequest { facets: string[]; filter?: EntityFilterQuery; + query?: FilterPredicate; } // @public @@ -320,6 +322,7 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; filter?: EntityFilterQuery; + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index d62323852e..70265bd6cb 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -302,6 +302,103 @@ describe('CatalogClient', () => { expect(response).toEqual({ items: [entity, undefined] }); }); + + it('sends only query predicate in the body when query is provided without filter', async () => { + expect.assertions(3); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe(''); + await expect(req.json()).resolves.toEqual({ + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + }); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); + + it('merges filter and query into $all predicate when both are provided', async () => { + expect.assertions(4); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe(''); + const body = await req.json(); + expect(body.entityRefs).toEqual(['k:n/a']); + expect(body.query).toEqual({ + $all: [{ kind: 'Component' }, { kind: 'API' }], + }); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + query: { kind: 'Component' }, + filter: { kind: ['API'] }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); + + it('sends filter as query parameter when only filter is provided (backward compat)', async () => { + expect.assertions(4); + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }; + server.use( + rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { + expect(req.url.search).toBe('?filter=kind%3DAPI'); + const body = await req.json(); + expect(body).toEqual({ entityRefs: ['k:n/a'] }); + expect(body.query).toBeUndefined(); + return res(ctx.json({ items: [entity] })); + }), + ); + + const response = await client.getEntitiesByRefs( + { + entityRefs: ['k:n/a'], + filter: { kind: ['API'] }, + }, + { token }, + ); + + expect(response).toEqual({ items: [entity] }); + }); }); describe('queryEntities', () => { @@ -540,6 +637,350 @@ describe('CatalogClient', () => { }); }); + describe('queryEntities with predicate-based queries (POST endpoint)', () => { + const defaultResponse = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-1', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-a', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-2', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-b', + }, + }, + ], + totalItems: 2, + pageInfo: {}, + }; + + it('should use POST endpoint when query is provided', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ + query: { kind: 'component' }, + limit: 20, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + query: { kind: 'component' }, + limit: 20, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(defaultResponse.items); + expect(response.totalItems).toBe(2); + }); + + it('should support $all operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $any operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $not operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $exists operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $in operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support complex nested predicates', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send orderFields with correct format', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send multiple orderFields with correct format', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, + ], + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send limit and offset parameters in the body', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body.limit).toBe(50); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + limit: 50, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should paginate using POST when cursor contains a query', async () => { + // Simulate a cursor that contains a query predicate (as the server would encode it) + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + query: { kind: 'component' }, + totalItems: 100, + }), + ).toString('base64'); + + const page2Response = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'service-3', namespace: 'default' }, + }, + ], + totalItems: 100, + pageInfo: {}, + }; + + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ cursor: cursorPayload }); + return res(ctx.json(page2Response)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + cursor: cursorPayload, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(page2Response.items); + expect(response.totalItems).toBe(100); + }); + + it('should use GET endpoint for cursor without query', async () => { + // A cursor that does NOT contain a query field should go to GET + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + totalItems: 50, + }), + ).toString('base64'); + + const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) => + res( + ctx.json({ + items: [], + totalItems: 50, + pageInfo: {}, + }), + ), + ); + + const mockedPostEndpoint = jest.fn(); + + server.use( + rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint), + rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint), + ); + + await client.queryEntities({ cursor: cursorPayload }); + + expect(mockedGetEndpoint).toHaveBeenCalledTimes(1); + expect(mockedPostEndpoint).not.toHaveBeenCalled(); + }); + + it('should handle errors from POST endpoint', async () => { + const mockedEndpoint = jest + .fn() + .mockImplementation((_req, res, ctx) => res(ctx.status(400))); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await expect(() => + client.queryEntities({ query: { kind: 'component' } }), + ).rejects.toThrow(/Request failed with 400/); + }); + }); + describe('streamEntities', () => { const defaultResponse: QueryEntitiesResponse = { items: [ diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index b45bef8bbd..a671a155d3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -20,7 +20,8 @@ import { parseEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError } from '@backstage/errors'; +import { InputError, ResponseError } from '@backstage/errors'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { AddLocationRequest, AddLocationResponse, @@ -46,10 +47,17 @@ import { StreamEntitiesRequest, ValidateEntityResponse, } from './types/api'; -import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils'; +import { + convertFilterToPredicate, + isQueryEntitiesInitialRequest, + splitRefsIntoChunks, + cursorContainsQuery, +} from './utils'; import { DefaultApiClient, + GetEntitiesByQuery, GetLocationsByQueryRequest, + QueryEntitiesByPredicateRequest, TypedResponse, } from './schema/openapi'; import type { @@ -229,11 +237,36 @@ export class CatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, options?: CatalogRequestOptions, ): Promise { + const { filter, query } = request; + + // Only convert and merge if both filter and query are provided, or if + // query alone is provided. When only filter is given, preserve the old + // query-parameter behavior for backward compatibility. + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = { $all: [filterPredicate, converted] }; + } + } + const getOneChunk = async (refs: string[]) => { const response = await this.apiClient.getEntitiesByRefs( { - body: { entityRefs: refs, fields: request.fields }, - query: { filter: this.getFilterValue(request.filter) }, + body: { + entityRefs: refs, + fields: request.fields, + ...(filterPredicate && { + query: filterPredicate as unknown as { [key: string]: any }, + }), + }, + query: filterPredicate + ? {} + : { filter: this.getFilterValue(request.filter) }, }, options, ); @@ -266,11 +299,26 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest = {}, options?: CatalogRequestOptions, ): Promise { - const params: Partial< - Parameters[0]['query'] - > = {}; + const isInitialRequest = isQueryEntitiesInitialRequest(request); - if (isQueryEntitiesInitialRequest(request)) { + // Route to POST endpoint if query predicate is provided (initial request) + if (isInitialRequest && request.query) { + return this.queryEntitiesByPredicate(request, options); + } + + // Route to POST endpoint if cursor contains a query predicate (pagination) + // TODO(freben): It's costly and non-opaque to have to introspect the cursor + // like this. It should be refactored in the future to not need this. + // Suggestion: make the GET and POST endpoints understand the same cursor + // format, and pick which one to call ONLY based on whether the cursor size + // risks hitting url length limits + if (!isInitialRequest && cursorContainsQuery(request.cursor)) { + return this.queryEntitiesByPredicate(request, options); + } + + const params: Partial = {}; + + if (isInitialRequest) { const { fields = [], filter, @@ -320,6 +368,84 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Query entities using predicate-based filters (POST endpoint). + * @internal + */ + private async queryEntitiesByPredicate( + request: QueryEntitiesRequest, + options?: CatalogRequestOptions, + ): Promise { + const body: QueryEntitiesByPredicateRequest = {}; + + if (isQueryEntitiesInitialRequest(request)) { + const { + filter, + query, + limit, + offset, + orderFields, + fullTextFilter, + fields, + } = request; + + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if ( + typeof query !== 'object' || + query === null || + Array.isArray(query) + ) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; + } + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = filterPredicate + ? { $all: [filterPredicate, converted] } + : converted; + } + if (filterPredicate !== undefined) { + body.query = filterPredicate as unknown as { [key: string]: any }; + } + + if (limit !== undefined) { + body.limit = limit; + } + if (offset !== undefined) { + body.offset = offset; + } + if (orderFields !== undefined) { + body.orderBy = [orderFields].flat(); + } + if (fullTextFilter) { + body.fullTextFilter = fullTextFilter; + } + if (fields?.length) { + body.fields = fields; + } + } else { + body.cursor = request.cursor; + if (request.limit !== undefined) { + body.limit = request.limit; + } + if (request.fields?.length) { + body.fields = request.fields; + } + } + + const res = await this.requestRequired( + await this.apiClient.queryEntitiesByPredicate({ body }, options), + ); + + return { + items: res.items, + totalItems: res.totalItems, + pageInfo: res.pageInfo, + }; + } + /** * {@inheritdoc CatalogApi.getEntityByRef} */ @@ -378,7 +504,13 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise { - const { filter = [], facets } = request; + const { filter = [], query, facets } = request; + + // Route to POST endpoint if query predicate is provided + if (query) { + return this.getEntityFacetsByPredicate(request, options); + } + return await this.requestOptional( await this.apiClient.getEntityFacets( { @@ -389,6 +521,45 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Get entity facets using predicate-based filters (POST endpoint). + * @internal + */ + private async getEntityFacetsByPredicate( + request: GetEntityFacetsRequest, + options?: CatalogRequestOptions, + ): Promise { + const { filter, query, facets } = request; + + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; + } + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = filterPredicate + ? { $all: [filterPredicate, converted] } + : converted; + } + + return await this.requestOptional( + await this.apiClient.queryEntityFacetsByPredicate( + { + body: { + facets, + ...(filterPredicate && { + query: filterPredicate as unknown as { [key: string]: any }, + }), + }, + }, + options, + ), + ); + } + /** * {@inheritdoc CatalogApi.addLocation} */ diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 4530cd795c..899e310e94 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -29,6 +29,8 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; +import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model'; @@ -139,6 +141,18 @@ export type GetEntityFacets = { filter?: Array; }; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; +}; +/** + * @public + */ +export type QueryEntityFacetsByPredicate = { + body: QueryEntityFacetsByPredicateRequest; +}; /** * @public */ @@ -449,6 +463,56 @@ export class DefaultApiClient { }); } + /** + * Query entities using predicate-based filters. + * @param queryEntitiesByPredicateRequest - + */ + public async queryEntitiesByPredicate( + // @ts-ignore + request: QueryEntitiesByPredicate, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/entities/by-query`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + + /** + * Get entity facets using predicate-based filters. + * @param queryEntityFacetsByPredicateRequest - + */ + public async queryEntityFacetsByPredicate( + // @ts-ignore + request: QueryEntityFacetsByPredicate, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/entity-facets`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + /** * Refresh the entity related to entityRef. * @param refreshEntityRequest - diff --git a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts new file mode 100644 index 0000000000..17a9c9962f --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequest { + cursor?: string; + limit?: number; + offset?: number; + orderBy?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts new file mode 100644 index 0000000000..e7e971e1ea --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntityFacetsByPredicateRequest { + facets: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index abdb58378c..ebecb8dbb7 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -45,6 +45,10 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; +export * from '../models/QueryEntityFacetsByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index d867ab1d28..98b5d5c034 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -370,6 +370,25 @@ describe('InMemoryCatalogClient', () => { { kind: 'CustomKind', metadata: { name: 'e1' } }, ]); }); + + it('supports query predicate filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntitiesByRefs({ + entityRefs: ['secondcustomkind:default/e2', 'customkind:default/e1'], + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([undefined, entity1]); + }); + + it('supports both filter and query predicate together', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntitiesByRefs({ + entityRefs: ['customkind:default/e1', 'customkind:other/e3'], + filter: { kind: 'CustomKind' }, + query: { 'metadata.namespace': 'other' }, + }); + expect(result.items).toEqual([undefined, entity3]); + }); }); describe('queryEntities', () => { @@ -683,6 +702,83 @@ describe('InMemoryCatalogClient', () => { ]); }); + it('filters by predicate query', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([entity1, entity3]); + expect(result.totalItems).toBe(2); + }); + + it('filters by predicate query with $all', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }], + }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('filters by predicate query with $any', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + expect(result.items).toEqual([entity1, entity3, entity4]); + }); + + it('filters by predicate query with $not', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [ + { kind: 'CustomKind' }, + { $not: { 'spec.lifecycle': 'production' } }, + ], + }, + }); + expect(result.items).toEqual([]); + }); + + it('filters by predicate query with $in', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.type': { $in: ['service', 'library'] } }, + }); + expect(result.items).toEqual([entity1, entity2, entity3]); + }); + + it('filters by predicate query with $exists', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.lifecycle': { $exists: false } }, + }); + expect(result.items).toEqual([entity4]); + }); + + it('preserves query predicate through cursor pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const page1 = await client.queryEntities({ + query: { kind: 'CustomKind' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 1, + }); + expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']); + expect(page1.totalItems).toBe(2); + expect(page1.pageInfo.nextCursor).toBeDefined(); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 1, + }); + expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + }); + it('throws InputError for invalid cursor', async () => { const client = new InMemoryCatalogClient({ entities }); await expect( @@ -898,6 +994,29 @@ describe('InMemoryCatalogClient', () => { }); expect(result.facets['spec.nonexistent']).toEqual([]); }); + + it('supports query predicate filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.type'], + query: { kind: 'CustomKind' }, + }); + expect(result.facets['spec.type']).toEqual([ + { value: 'service', count: 2 }, + ]); + }); + + it('supports both filter and query predicate together', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.type'], + filter: { kind: 'CustomKind' }, + query: { 'metadata.namespace': 'default' }, + }); + expect(result.facets['spec.type']).toEqual([ + { value: 'service', count: 1 }, + ]); + }); }); describe('not implemented methods', () => { diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index e64706ff8d..dd7f269188 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,6 +51,10 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; +import { + FilterPredicate, + filterPredicateToFilterFunction, +} from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -357,10 +361,15 @@ export class InMemoryCatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, ): Promise { const filter = createFilter(request.filter); + const queryFilter = request.query + ? filterPredicateToFilterFunction(request.query) + : undefined; const refMap = this.#createEntityRefMap(); const items = request.entityRefs .map(ref => refMap.get(ref)) - .map(e => (e && filter(e) ? e : undefined)); + .map(e => + e && filter(e) && (!queryFilter || queryFilter(e)) ? e : undefined, + ); return { items: request.fields ? items.map(e => (e ? applyFieldsFilter(e, request.fields) : undefined)) @@ -373,6 +382,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; + let query: FilterPredicate | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -386,12 +396,14 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); + query = c.query as FilterPredicate | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; + query = request?.query; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; @@ -401,6 +413,11 @@ export class InMemoryCatalogClient implements CatalogApi { // Apply filter let items = this.#entities.filter(createFilter(filter)); + // Apply predicate-based query filter + if (query) { + items = items.filter(filterPredicateToFilterFunction(query)); + } + // Apply full-text filter, defaulting to the sort field or metadata.uid if (fullTextFilter) { const orderFieldsList = orderFields ? [orderFields].flat() : []; @@ -432,6 +449,7 @@ export class InMemoryCatalogClient implements CatalogApi { const cursorBase = { filter: serializeFilter(filter), + query, orderFields, fullTextFilter, totalItems, @@ -493,7 +511,12 @@ export class InMemoryCatalogClient implements CatalogApi { request: GetEntityFacetsRequest, ): Promise { const filter = createFilter(request.filter); - const filteredEntities = this.#entities.filter(filter); + let filteredEntities = this.#entities.filter(filter); + if (request.query) { + filteredEntities = filteredEntities.filter( + filterPredicateToFilterFunction(request.query), + ); + } const facets = Object.fromEntries( request.facets.map(facet => { const facetValues = new Map(); diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index c766b094b8..fac4ed5928 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; -import { SerializedError } from '@backstage/errors'; +import type { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import type { SerializedError } from '@backstage/errors'; import type { AnalyzeLocationRequest, AnalyzeLocationResponse, } from '@backstage/plugin-catalog-common'; -import { FilterPredicate } from '@backstage/filter-predicates'; +import type { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol can be used in place of a value when passed to filters in e.g. @@ -212,6 +212,16 @@ export interface GetEntitiesByRefsRequest { * If given, return only entities that match the given filter. */ filter?: EntityFilterQuery; + /** + * If given, return only entities that match the given predicate query. + * + * @remarks + * + * Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`, + * `$contains`, and `$hasPrefix`. When both `filter` and `query` are + * provided, they are combined with `$all`. + */ + query?: FilterPredicate; } /** @@ -297,6 +307,16 @@ export interface GetEntityFacetsRequest { * of that key, no matter what its value is. */ filter?: EntityFilterQuery; + /** + * If given, return only entities that match the given predicate query. + * + * @remarks + * + * Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`, + * `$contains`, and `$hasPrefix`. When both `filter` and `query` are + * provided, they are combined with `$all`. + */ + query?: FilterPredicate; /** * Dot separated paths for the facets to extract from each entity. * @@ -418,16 +438,43 @@ export type QueryEntitiesRequest = * The method takes this type in an initial pagination request, * when requesting the first batch of entities. * - * The properties filter, sortField, query and sortFieldOrder, are going + * The properties filter, query, sortField and sortFieldOrder, are going * to be immutable for the entire lifecycle of the following requests. * + * @remarks + * + * Either `filter` or `query` can be provided, or even both: + * - `filter`: Uses the traditional key-value filter syntax (GET endpoint) + * - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint) + * * @public */ export type QueryEntitiesInitialRequest = { fields?: string[]; limit?: number; offset?: number; + /** + * Traditional key-value based filter. + */ filter?: EntityFilterQuery; + /** + * Predicate-based filter with operators for logical expressions (`$all`, + * `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and + * (partially) `$contains`). + * + * @example + * ```typescript + * { + * query: { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': { $in: ['service', 'website'] } } + * ] + * } + * } + * ``` + */ + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; @@ -567,6 +614,7 @@ export interface CatalogApi { * const response = await catalogClient.queryEntities({ * filter: [{ kind: 'group' }], * limit: 20, + * fields: ['metadata', 'kind'], * fullTextFilter: { * term: 'A', * }, @@ -583,11 +631,15 @@ export interface CatalogApi { * * ``` * const secondBatchResponse = await catalogClient - * .queryEntities({ cursor: response.nextCursor }); + * .queryEntities({ + * cursor: response.nextCursor, + * limit: 20, + * fields: ['metadata', 'kind'], + * }); * ``` * - * secondBatchResponse will contain the next batch of (maximum) 20 entities, - * together with a prevCursor property, useful to fetch the previous batch. + * `secondBatchResponse` will contain the next batch of (maximum) 20 entities, + * together with a `prevCursor` property, useful to fetch the previous batch. * * @public * diff --git a/packages/catalog-client/src/utils.test.ts b/packages/catalog-client/src/utils.test.ts index 2f00859985..ce60cc3083 100644 --- a/packages/catalog-client/src/utils.test.ts +++ b/packages/catalog-client/src/utils.test.ts @@ -14,7 +14,87 @@ * limitations under the License. */ -import { splitRefsIntoChunks } from './utils'; +import { CATALOG_FILTER_EXISTS } from './types/api'; +import { convertFilterToPredicate, splitRefsIntoChunks } from './utils'; + +describe('convertFilterToPredicate', () => { + it('converts a single string value', () => { + expect(convertFilterToPredicate({ kind: 'component' })).toEqual({ + kind: 'component', + }); + }); + + it('converts multiple keys into $all', () => { + expect( + convertFilterToPredicate({ + kind: 'component', + 'spec.type': 'service', + }), + ).toEqual({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }); + }); + + it('converts an array of string values into $in', () => { + expect( + convertFilterToPredicate({ 'spec.type': ['service', 'website'] }), + ).toEqual({ + 'spec.type': { $in: ['service', 'website'] }, + }); + }); + + it('converts CATALOG_FILTER_EXISTS into $exists', () => { + expect( + convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts an array of records into $any (OR)', () => { + expect( + convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]), + ).toEqual({ + $any: [{ kind: 'component' }, { kind: 'api' }], + }); + }); + + it('converts array of records with multiple keys each', () => { + expect( + convertFilterToPredicate([ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'api' }, + ]), + ).toEqual({ + $any: [ + { $all: [{ kind: 'component' }, { 'spec.type': 'service' }] }, + { kind: 'api' }, + ], + }); + }); + + it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => { + expect( + convertFilterToPredicate({ + 'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'], + }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts a single-element array filter without wrapping in $any', () => { + expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({ + kind: 'component', + }); + }); + + it('ignores entries with no valid values', () => { + expect( + convertFilterToPredicate({ kind: 'component', other: [] as string[] }), + ).toEqual({ kind: 'component' }); + }); +}); describe('splitRefsIntoChunks', () => { it('splits by count limit', () => { diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index a16cf53db1..231aa2409f 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -14,7 +14,13 @@ * limitations under the License. */ +import type { + FilterPredicate, + FilterPredicateExpression, +} from '@backstage/filter-predicates'; import { + CATALOG_FILTER_EXISTS, + EntityFilterQuery, QueryEntitiesCursorRequest, QueryEntitiesInitialRequest, QueryEntitiesRequest, @@ -26,6 +32,58 @@ export function isQueryEntitiesInitialRequest( return !(request as QueryEntitiesCursorRequest).cursor; } +/** + * Check if a cursor contains a predicate query by attempting to decode it. + * @internal + */ +export function cursorContainsQuery(cursor: string): boolean { + try { + const decoded = JSON.parse(atob(cursor)); + return 'query' in decoded; + } catch { + return false; + } +} + +/** + * Converts an {@link EntityFilterQuery} into a predicate query object. + * @internal + */ +export function convertFilterToPredicate(filter: EntityFilterQuery): + | FilterPredicateExpression + | { + $all: FilterPredicate[]; + } + | { + $any: FilterPredicate[]; + } { + const records = [filter].flat(); + + const clauses = records.map(record => { + const parts: FilterPredicateExpression[] = []; + + for (const [key, value] of Object.entries(record)) { + const values = [value].flat(); + const strings = values.filter((v): v is string => typeof v === 'string'); + const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS); + + if (hasExists) { + // Ignore whether there ALSO were some strings - that would boil down to + // just existence anyway since there's effectively an OR between them + parts.push({ [key]: { $exists: true } } as FilterPredicateExpression); + } else if (strings.length === 1) { + parts.push({ [key]: strings[0] } as FilterPredicateExpression); + } else if (strings.length > 1) { + parts.push({ [key]: { $in: strings } } as FilterPredicateExpression); + } + } + + return parts.length === 1 ? parts[0] : { $all: parts }; + }); + + return clauses.length === 1 ? clauses[0] : { $any: clauses }; +} + /** * Takes a set of entity refs, and splits them into chunks (groups) such that * the total string length in each chunk does not exceed the default Express.js diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts index f3d7b9b873..df0e7c2044 100644 --- a/packages/cli-common/src/paths.test.ts +++ b/packages/cli-common/src/paths.test.ts @@ -16,18 +16,18 @@ /* eslint-disable no-restricted-syntax */ import { resolve as resolvePath } from 'node:path'; -import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths'; +import { findPaths, findRootPath, findOwnRootDir, findOwnPaths } from './paths'; describe('paths', () => { afterEach(() => { jest.restoreAllMocks(); }); - it('findOwnDir and findOwnRootDir should find owns paths', () => { - const dir = findOwnDir(__dirname); - const root = findOwnRootDir(dir); + it('findOwnPaths and findOwnRootDir should find own paths', () => { + const own = findOwnPaths(__dirname); + const root = findOwnRootDir(own.dir); - expect(dir).toBe(resolvePath(__dirname, '..')); + expect(own.dir).toBe(resolvePath(__dirname, '..')); expect(root).toBe(resolvePath(__dirname, '../../..')); }); diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index 1cd84d4f5b..c6bedc77e4 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -119,7 +119,23 @@ export function findOwnRootDir(ownDir: string) { ); } - return resolvePath(ownDir, '../..'); + const rootDir = findRootPath(ownDir, pkgJsonPath => { + try { + const content = fs.readFileSync(pkgJsonPath, 'utf8'); + const data = JSON.parse(content); + return Boolean(data.workspaces); + } catch (error) { + throw new Error( + `Failed to read package.json at '${pkgJsonPath}', ${error}`, + ); + } + }); + + if (!rootDir) { + throw new Error(`No monorepo root found when searching from '${ownDir}'`); + } + + return rootDir; } // Hierarchical directory cache shared across all OwnPathsImpl instances. @@ -199,11 +215,6 @@ class OwnPathsImpl implements OwnPaths { }; } -// Finds the root of a given package -export function findOwnDir(searchDir: string) { - return OwnPathsImpl.findDir(searchDir); -} - // Used by the test utility in testUtils.ts to override targetPaths export let targetPathsOverride: TargetPaths | undefined; diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 7d198f0a3f..16cfba3c0d 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -35,14 +35,17 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", + "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "^3.0.0", "fs-extra": "^11.2.0", "semver": "^7.5.3", + "yaml": "^2.0.0", "zod": "^3.25.76" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/test-utils": "workspace:^" + "@backstage/test-utils": "workspace:^", + "@types/yarnpkg__lockfile": "^1.1.4" } } diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index b3d8fe5641..9d4887ca59 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -99,6 +99,9 @@ export class GitUtils { static readFileAtRef(path: string, ref: string): Promise; } +// @public +export function hasBackstageYarnPlugin(workspaceDir?: string): Promise; + // @public export function isMonoRepo(): Promise; @@ -111,6 +114,7 @@ export class Lockfile { keys(): IterableIterator; static load(path: string): Promise; static parse(content: string): Lockfile; + toString(): string; } // @public @@ -220,6 +224,17 @@ export function runWorkerQueueThreads( results: TResult[]; }>; +// @public +export class SuccessCache { + // (undocumented) + static create(options: { name: string; basePath?: string }): SuccessCache; + // (undocumented) + read(): Promise>; + static trimPaths(input: string): string; + // (undocumented) + write(newEntries: Iterable): Promise; +} + // @public export type WorkerQueueThreadsOptions = { items: Iterable; diff --git a/packages/cli/src/lib/cache/SuccessCache.ts b/packages/cli-node/src/cache/SuccessCache.ts similarity index 86% rename from packages/cli/src/lib/cache/SuccessCache.ts rename to packages/cli-node/src/cache/SuccessCache.ts index 8131a469a4..78913ed0b6 100644 --- a/packages/cli/src/lib/cache/SuccessCache.ts +++ b/packages/cli-node/src/cache/SuccessCache.ts @@ -22,6 +22,12 @@ const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli'; const CACHE_MAX_AGE_MS = 7 * 24 * 3600_000; +/** + * A file-system-based cache that tracks successful operations by storing + * timestamped marker files. + * + * @public + */ export class SuccessCache { readonly #path: string; @@ -34,8 +40,15 @@ export class SuccessCache { return input.replaceAll(targetPaths.rootDir, ''); } - constructor(name: string, basePath?: string) { - this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name); + static create(options: { name: string; basePath?: string }): SuccessCache { + return new SuccessCache(options); + } + + private constructor(options: { name: string; basePath?: string }) { + this.#path = resolvePath( + options.basePath ?? DEFAULT_CACHE_BASE_PATH, + options.name, + ); } async read(): Promise> { @@ -89,7 +102,6 @@ export class SuccessCache { const empty = Buffer.alloc(0); for (const key of newEntries) { - // Remove any existing items with the key we're about to add const trimmedItems = existingItems.filter(item => item.endsWith(`_${key}`), ); diff --git a/packages/cli-node/src/cache/index.ts b/packages/cli-node/src/cache/index.ts new file mode 100644 index 0000000000..53c9f0e4e4 --- /dev/null +++ b/packages/cli-node/src/cache/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 { SuccessCache } from './SuccessCache'; diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 5540666a05..35074507c4 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -20,7 +20,9 @@ * @packageDocumentation */ +export * from './cache'; +export * from './concurrency'; export * from './git'; export * from './monorepo'; -export * from './concurrency'; export * from './roles'; +export * from './yarn'; diff --git a/packages/cli-node/src/monorepo/Lockfile.test.ts b/packages/cli-node/src/monorepo/Lockfile.test.ts index 03b50fea90..c2d35f7352 100644 --- a/packages/cli-node/src/monorepo/Lockfile.test.ts +++ b/packages/cli-node/src/monorepo/Lockfile.test.ts @@ -15,6 +15,7 @@ */ import { Lockfile } from './Lockfile'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -29,7 +30,74 @@ __metadata: cacheKey: 8 `; -describe('New Lockfile', () => { +const mockLegacy = `${LEGACY_HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x: + version "2.0.1" + +b@^2: + version "2.0.0" +`; + +const mockModern = `${MODERN_HEADER} +a@^1: + version: 1.0.1 + dependencies: + b: ^2 + integrity: sha512-xyz + resolved: "https://my-registry/a-1.0.01.tgz#abc123" + +"b@2.0.x, b@^2.0.1": + version: 2.0.1 + +b@^2: + version: 2.0.0 +`; + +describe('Lockfile', () => { + const mockDir = createMockDirectory(); + + it('should load and serialize a legacy lockfile', async () => { + mockDir.setContent({ + 'yarn.lock': mockLegacy, + }); + + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); + expect(lockfile.get('a')).toEqual([ + { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, + ]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' }, + { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, + ]); + expect(lockfile.toString()).toBe(mockLegacy); + }); + + it('should load and serialize a modern lockfile', async () => { + mockDir.setContent({ + 'yarn.lock': mockModern, + }); + + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); + expect(lockfile.get('a')).toEqual([ + { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, + ]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, + { range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, + { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, + ]); + expect(lockfile.toString()).toBe(mockModern); + }); +}); + +describe('Lockfile advanced', () => { describe('diff', () => { const lockfileLegacyA = Lockfile.parse(`${LEGACY_HEADER} a@^1: diff --git a/packages/cli-node/src/monorepo/Lockfile.ts b/packages/cli-node/src/monorepo/Lockfile.ts index ff624191af..65e22dcf13 100644 --- a/packages/cli-node/src/monorepo/Lockfile.ts +++ b/packages/cli-node/src/monorepo/Lockfile.ts @@ -14,12 +14,22 @@ * limitations under the License. */ -import { parseSyml } from '@yarnpkg/parsers'; +import { parseSyml, stringifySyml } from '@yarnpkg/parsers'; +import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile'; import crypto from 'node:crypto'; import fs from 'fs-extra'; const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746 +const NEW_HEADER = `${[ + `# This file is generated by running "yarn install" inside your project.\n`, + `# Manual changes might be lost - proceed with caution!\n`, +].join(``)}\n`; + +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136 +const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i; + /** @internal */ type LockfileData = { [entry: string]: { @@ -97,6 +107,8 @@ export class Lockfile { * @public */ static parse(content: string): Lockfile { + const legacy = LEGACY_REGEX.test(content); + let data: LockfileData; try { data = parseSyml(content); @@ -130,18 +142,21 @@ export class Lockfile { } } - return new Lockfile(packages, data); + return new Lockfile(packages, data, legacy); } private readonly packages: Map; private readonly data: LockfileData; + private readonly legacy: boolean; private constructor( packages: Map, data: LockfileData, + legacy: boolean = false, ) { this.packages = packages; this.data = data; + this.legacy = legacy; } /** Returns the name of all packages available in the lockfile */ @@ -154,6 +169,15 @@ export class Lockfile { return this.packages.keys(); } + /** + * Serialize the lockfile back to a string. + */ + toString(): string { + return this.legacy + ? legacyStringifyLockfile(this.data) + : NEW_HEADER + stringifySyml(this.data); + } + /** * Creates a simplified dependency graph from the lockfile data, where each * key is a package, and the value is a set of all packages that it depends on diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli-node/src/yarn/index.ts similarity index 79% rename from packages/cli/src/lib/versioning/index.ts rename to packages/cli-node/src/yarn/index.ts index e0b9280ee6..871aa14ca8 100644 --- a/packages/cli/src/lib/versioning/index.ts +++ b/packages/cli-node/src/yarn/index.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -export { Lockfile } from './Lockfile'; -export { fetchPackageInfo, mapDependencies } from './packages'; -export type { YarnInfoInspectData } from './packages'; +export { hasBackstageYarnPlugin } from './yarnPlugin'; diff --git a/packages/cli/src/lib/yarnPlugin.test.ts b/packages/cli-node/src/yarn/yarnPlugin.test.ts similarity index 79% rename from packages/cli/src/lib/yarnPlugin.test.ts rename to packages/cli-node/src/yarn/yarnPlugin.test.ts index a07c2a2ac6..ecbff45ba7 100644 --- a/packages/cli/src/lib/yarnPlugin.test.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.test.ts @@ -16,12 +16,12 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; -import { getHasYarnPlugin } from './yarnPlugin'; +import { hasBackstageYarnPlugin } from './yarnPlugin'; const mockDir = createMockDirectory(); overrideTargetPaths(mockDir.path); -describe('getHasYarnPlugin', () => { +describe('hasBackstageYarnPlugin', () => { beforeEach(() => { mockDir.clear(); }); @@ -29,7 +29,7 @@ describe('getHasYarnPlugin', () => { it('should return false when .yarnrc.yml does not exist', async () => { mockDir.setContent({}); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -38,7 +38,7 @@ describe('getHasYarnPlugin', () => { '.yarnrc.yml': '', }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -47,7 +47,7 @@ describe('getHasYarnPlugin', () => { '.yarnrc.yml': 'plugins: []', }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -60,7 +60,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(false); }); @@ -74,7 +74,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(true); }); @@ -86,7 +86,7 @@ plugins: `, }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(); expect(result).toBe(true); }); @@ -95,7 +95,7 @@ plugins: '.yarnrc.yml': 'invalid: yaml: content: [', }); - await expect(getHasYarnPlugin()).rejects.toThrow(); + await expect(hasBackstageYarnPlugin()).rejects.toThrow(); }); it('should throw error when .yarnrc.yml has unexpected structure', async () => { @@ -105,21 +105,22 @@ plugins: "not an array" `, }); - await expect(getHasYarnPlugin()).rejects.toThrow( + await expect(hasBackstageYarnPlugin()).rejects.toThrow( 'Unexpected content in .yarnrc.yml', ); }); - it('should handle plugins with different structure', async () => { + it('should resolve from a custom workspace directory', async () => { mockDir.setContent({ - '.yarnrc.yml': ` + 'custom-dir': { + '.yarnrc.yml': ` plugins: - path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs - - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs `, + }, }); - const result = await getHasYarnPlugin(); + const result = await hasBackstageYarnPlugin(mockDir.resolve('custom-dir')); expect(result).toBe(true); }); }); diff --git a/packages/cli/src/lib/yarnPlugin.ts b/packages/cli-node/src/yarn/yarnPlugin.ts similarity index 77% rename from packages/cli/src/lib/yarnPlugin.ts rename to packages/cli-node/src/yarn/yarnPlugin.ts index 0390345651..65383edd0c 100644 --- a/packages/cli/src/lib/yarnPlugin.ts +++ b/packages/cli-node/src/yarn/yarnPlugin.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; +import { resolve as resolvePath } from 'node:path'; import yaml from 'yaml'; import z from 'zod'; import { targetPaths } from '@backstage/cli-common'; @@ -30,15 +31,21 @@ const yarnRcSchema = z.object({ }); /** - * Detects whether the Backstage Yarn plugin is installed in the target repository. + * Detects whether the Backstage Yarn plugin is installed in the given workspace directory. * - * @returns Promise - true if the plugin is installed, false otherwise + * @param workspaceDir - The workspace root directory to check. Defaults to the target root. + * @returns Promise resolving to true if the plugin is installed, false otherwise + * @public */ -export async function getHasYarnPlugin(): Promise { - const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml'); +export async function hasBackstageYarnPlugin( + workspaceDir?: string, +): Promise { + const yarnRcPath = resolvePath( + workspaceDir ?? targetPaths.rootDir, + '.yarnrc.yml', + ); const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => { if (e.code === 'ENOENT') { - // gracefully continue in case the file doesn't exist return ''; } throw e; diff --git a/packages/cli/package.json b/packages/cli/package.json index 27a1b92471..9a4e690e92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,8 +77,6 @@ "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.16.0", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "^3.0.0", "bfj": "^9.0.2", "buffer": "^6.0.3", "chalk": "^4.0.0", @@ -182,7 +180,6 @@ "@types/tar": "^6.1.1", "@types/terser-webpack-plugin": "^5.0.4", "@types/webpack-sources": "^3.2.3", - "@types/yarnpkg__lockfile": "^1.1.4", "del": "^8.0.0", "esbuild-loader": "^4.0.0", "eslint-webpack-plugin": "^4.2.0", diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts deleted file mode 100644 index d6996b9143..0000000000 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ /dev/null @@ -1,102 +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 { Lockfile } from './Lockfile'; -import { createMockDirectory } from '@backstage/backend-test-utils'; - -const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - -`; - -const MODERN_HEADER = `# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 6 - cacheKey: 8 -`; - -const mockA = `${LEGACY_HEADER} -a@^1: - version "1.0.1" - resolved "https://my-registry/a-1.0.01.tgz#abc123" - integrity sha512-xyz - dependencies: - b "^2" - -b@2.0.x: - version "2.0.1" - -b@^2: - version "2.0.0" -`; - -describe('Lockfile', () => { - const mockDir = createMockDirectory(); - - it('should load and serialize mockA', async () => { - mockDir.setContent({ - 'yarn.lock': mockA, - }); - - const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); - expect(lockfile.get('a')).toEqual([ - { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, - ]); - expect(lockfile.get('b')).toEqual([ - { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' }, - { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, - ]); - expect(lockfile.toString()).toBe(mockA); - }); -}); - -const mockANew = `${MODERN_HEADER} -a@^1: - version: 1.0.1 - dependencies: - b: ^2 - integrity: sha512-xyz - resolved: "https://my-registry/a-1.0.01.tgz#abc123" - -"b@2.0.x, b@^2.0.1": - version: 2.0.1 - -b@^2: - version: 2.0.0 -`; - -describe('New Lockfile', () => { - const mockDir = createMockDirectory(); - - it('should load and serialize mockANew', async () => { - mockDir.setContent({ - 'yarn.lock': mockANew, - }); - - const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); - expect(lockfile.get('a')).toEqual([ - { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, - ]); - expect(lockfile.get('b')).toEqual([ - { range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, - { range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' }, - { range: '^2', version: '2.0.0', dataKey: 'b@^2' }, - ]); - expect(lockfile.toString()).toBe(mockANew); - }); -}); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts deleted file mode 100644 index 7a9a2c801a..0000000000 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ /dev/null @@ -1,138 +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 fs from 'fs-extra'; -import { parseSyml, stringifySyml } from '@yarnpkg/parsers'; -import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile'; - -const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; - -type LockfileData = { - [entry: string]: { - version: string; - resolved?: string; - integrity?: string /* old */; - checksum?: string /* new */; - dependencies?: { [name: string]: string }; - peerDependencies?: { [name: string]: string }; - }; -}; - -type LockfileQueryEntry = { - range: string; - version: string; - dataKey: string; -}; - -// the new yarn header is handled out of band of the parsing -// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746 -const NEW_HEADER = `${[ - `# This file is generated by running "yarn install" inside your project.\n`, - `# Manual changes might be lost - proceed with caution!\n`, -].join(``)}\n`; - -// taken from yarn parser package -// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136 -const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i; - -// these are special top level yarn keys. -// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9 -const SPECIAL_OBJECT_KEYS = [ - `__metadata`, - `version`, - `resolution`, - `dependencies`, - `peerDependencies`, - `dependenciesMeta`, - `peerDependenciesMeta`, - `binaries`, -]; - -export class Lockfile { - static async load(path: string) { - const lockfileContents = await fs.readFile(path, 'utf8'); - return Lockfile.parse(lockfileContents); - } - - static parse(content: string) { - const legacy = LEGACY_REGEX.test(content); - - let data: LockfileData; - try { - data = parseSyml(content); - } catch (err) { - throw new Error(`Failed yarn.lock parse, ${err}`); - } - - const packages = new Map(); - - for (const [key, value] of Object.entries(data)) { - if (SPECIAL_OBJECT_KEYS.includes(key)) continue; - - const [, name, ranges] = ENTRY_PATTERN.exec(key) ?? []; - if (!name) { - throw new Error(`Failed to parse yarn.lock entry '${key}'`); - } - - let queries = packages.get(name); - if (!queries) { - queries = []; - packages.set(name, queries); - } - for (let range of ranges.split(/\s*,\s*/)) { - if (range.startsWith(`${name}@`)) { - range = range.slice(`${name}@`.length); - } - if (range.startsWith('npm:')) { - range = range.slice('npm:'.length); - } - queries.push({ range, version: value.version, dataKey: key }); - } - } - - return new Lockfile(packages, data, legacy); - } - - private readonly packages: Map; - private readonly data: LockfileData; - private readonly legacy: boolean; - - private constructor( - packages: Map, - data: LockfileData, - legacy: boolean = false, - ) { - this.packages = packages; - this.data = data; - this.legacy = legacy; - } - - /** Get the entries for a single package in the lockfile */ - get(name: string): LockfileQueryEntry[] | undefined { - return this.packages.get(name); - } - - /** Returns the name of all packages available in the lockfile */ - keys(): IterableIterator { - return this.packages.keys(); - } - - toString() { - return this.legacy - ? legacyStringifyLockfile(this.data) - : NEW_HEADER + stringifySyml(this.data); - } -} diff --git a/packages/cli/src/modules/maintenance/commands/package/clean.ts b/packages/cli/src/modules/build/commands/package/clean.ts similarity index 100% rename from packages/cli/src/modules/maintenance/commands/package/clean.ts rename to packages/cli/src/modules/build/commands/package/clean.ts diff --git a/packages/cli/src/modules/maintenance/commands/package/pack.ts b/packages/cli/src/modules/build/commands/package/pack.ts similarity index 89% rename from packages/cli/src/modules/maintenance/commands/package/pack.ts rename to packages/cli/src/modules/build/commands/package/pack.ts index e5e176d17e..0265c07093 100644 --- a/packages/cli/src/modules/maintenance/commands/package/pack.ts +++ b/packages/cli/src/modules/build/commands/package/pack.ts @@ -17,12 +17,12 @@ import { productionPack, revertProductionPack, -} from '../../../../modules/build/lib/packager/productionPack'; +} from '../../lib/packager/productionPack'; import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { publishPreflightCheck } from '../../lib/publishing'; -import { createTypeDistProject } from '../../../../lib/typeDistProject'; +import { createTypeDistProject } from '../../lib/typeDistProject'; export const pre = async () => { publishPreflightCheck({ diff --git a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts index c81fd2fd66..689cd20f7a 100644 --- a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts +++ b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts @@ -19,11 +19,11 @@ import { resolve as resolvePath } from 'node:path'; import { getModuleFederationRemoteOptions, serveBundle, -} from '../../../../build/lib/bundler'; +} from '../../../lib/bundler'; import { targetPaths } from '@backstage/cli-common'; import { BackstagePackageJson } from '@backstage/cli-node'; -import { hasReactDomClient } from '../../../../build/lib/bundler/hasReactDomClient'; +import { hasReactDomClient } from '../../../lib/bundler/hasReactDomClient'; interface StartAppOptions { verifyVersions?: boolean; diff --git a/packages/cli/src/modules/build/commands/repo/build.ts b/packages/cli/src/modules/build/commands/repo/build.ts index dd65e926b2..59d0a839e4 100644 --- a/packages/cli/src/modules/build/commands/repo/build.ts +++ b/packages/cli/src/modules/build/commands/repo/build.ts @@ -28,7 +28,7 @@ import { } from '@backstage/cli-node'; import { buildFrontend } from '../../lib/buildFrontend'; import { buildBackend } from '../../lib/buildBackend'; -import { createScriptOptionsParser } from '../../../../lib/optionsParser'; +import { createScriptOptionsParser } from '../../lib/optionsParser'; export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/maintenance/commands/repo/clean.ts b/packages/cli/src/modules/build/commands/repo/clean.ts similarity index 100% rename from packages/cli/src/modules/maintenance/commands/repo/clean.ts rename to packages/cli/src/modules/build/commands/repo/clean.ts diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 9b37efaa8b..30a9b3a19e 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -16,8 +16,14 @@ import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; -import { configOption } from '../config'; +import { lazy } from '../../wiring/lazy'; + +const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), + Array(), +] as const; export function registerPackageCommands(command: Command) { command @@ -197,6 +203,58 @@ export const buildPlugin = createCliPlugin({ }, }); + reg.addCommand({ + path: ['package', 'clean'], + description: 'Delete cache directories', + execute: async ({ args }) => { + const command = new Command(); + const defaultCommand = command.action( + lazy(() => import('./commands/package/clean'), 'default'), + ); + + await defaultCommand.parseAsync(args, { from: 'user' }); + }, + }); + + reg.addCommand({ + path: ['package', 'prepack'], + description: 'Prepares a package for packaging before publishing', + execute: async ({ args }) => { + const command = new Command(); + const defaultCommand = command.action( + lazy(() => import('./commands/package/pack'), 'pre'), + ); + + await defaultCommand.parseAsync(args, { from: 'user' }); + }, + }); + + reg.addCommand({ + path: ['package', 'postpack'], + description: 'Restores the changes made by the prepack command', + execute: async ({ args }) => { + const command = new Command(); + const defaultCommand = command.action( + lazy(() => import('./commands/package/pack'), 'post'), + ); + + await defaultCommand.parseAsync(args, { from: 'user' }); + }, + }); + + reg.addCommand({ + path: ['repo', 'clean'], + description: 'Delete cache and output directories', + execute: async ({ args }) => { + const command = new Command(); + const defaultCommand = command.action( + lazy(() => import('./commands/repo/clean'), 'command'), + ); + + await defaultCommand.parseAsync(args, { from: 'user' }); + }, + }); + reg.addCommand({ path: ['build-workspace'], description: diff --git a/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts b/packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts similarity index 100% rename from packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts rename to packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts diff --git a/packages/cli/src/modules/build/lib/buildFrontend.ts b/packages/cli/src/modules/build/lib/buildFrontend.ts index 6a5785a5b9..7ca337cfdb 100644 --- a/packages/cli/src/modules/build/lib/buildFrontend.ts +++ b/packages/cli/src/modules/build/lib/buildFrontend.ts @@ -18,7 +18,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { buildBundle, getModuleFederationRemoteOptions } from './bundler'; import { BackstagePackageJson } from '@backstage/cli-node'; -import { loadCliConfig } from '../../config/lib/config'; +import { loadCliConfig } from './config'; interface BuildAppOptions { targetDir: string; diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli/src/modules/build/lib/bundler/config.ts index 1fe81cbec8..c3676ecf70 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli/src/modules/build/lib/bundler/config.ts @@ -32,7 +32,7 @@ import pickBy from 'lodash/pickBy'; import { runOutput, targetPaths } from '@backstage/cli-common'; import { transforms } from './transforms'; -import { version } from '../../../../lib/version'; +import { version } from '../../../../wiring/version'; import yn from 'yn'; import { hasReactDomClient } from './hasReactDomClient'; import { createWorkspaceLinkingPlugins } from './linkWorkspaces'; diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts index 307cb5cebc..69e1415b5d 100644 --- a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts +++ b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts @@ -20,7 +20,7 @@ import { readEntryPoints } from '../entryPoints'; import { createTypeDistProject, getEntryPointDefaultFeatureType, -} from '../../../../lib/typeDistProject'; +} from '../typeDistProject'; import { BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL, defaultRemoteSharedDependencies, diff --git a/packages/cli/src/modules/build/lib/bundler/server.ts b/packages/cli/src/modules/build/lib/bundler/server.ts index 57d6bed052..e474157c7c 100644 --- a/packages/cli/src/modules/build/lib/bundler/server.ts +++ b/packages/cli/src/modules/build/lib/bundler/server.ts @@ -24,7 +24,7 @@ import { RspackDevServer } from '@rspack/dev-server'; import { targetPaths } from '@backstage/cli-common'; -import { loadCliConfig } from '../../../config/lib/config'; +import { loadCliConfig } from '../config'; import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; diff --git a/packages/cli/src/modules/build/lib/config.ts b/packages/cli/src/modules/build/lib/config.ts new file mode 100644 index 0000000000..56bfc68ced --- /dev/null +++ b/packages/cli/src/modules/build/lib/config.ts @@ -0,0 +1,128 @@ +/* + * 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 { ConfigSources, loadConfigSchema } from '@backstage/config-loader'; +import { AppConfig, ConfigReader } from '@backstage/config'; +import { targetPaths } from '@backstage/cli-common'; + +import { getPackages } from '@manypkg/get-packages'; +import { PackageGraph } from '@backstage/cli-node'; +import { resolve as resolvePath } from 'node:path'; + +type Options = { + args: string[]; + targetDir?: string; + fromPackage?: string; + withFilteredKeys?: boolean; + watch?: (newFrontendAppConfigs: AppConfig[]) => void; +}; + +export async function loadCliConfig(options: Options) { + const targetDir = options.targetDir ?? targetPaths.dir; + + const { packages } = await getPackages(targetDir); + + let localPackageNames; + if (options.fromPackage) { + if (packages.length) { + const graph = PackageGraph.fromPackages(packages); + localPackageNames = Array.from( + graph.collectPackageNames([options.fromPackage], node => { + // Workaround for Backstage main repo only, since the CLI has some artificial devDependencies + if (node.name === '@backstage/cli') { + return undefined; + } + return node.localDependencies.keys(); + }), + ); + } else { + localPackageNames = [options.fromPackage]; + } + } else { + localPackageNames = packages.map(p => p.packageJson.name); + } + + const schema = await loadConfigSchema({ + dependencies: localPackageNames, + packagePaths: [targetPaths.resolveRoot('package.json')], + }); + + const source = ConfigSources.default({ + allowMissingDefaultConfig: true, + watch: Boolean(options.watch), + rootDir: targetPaths.rootDir, + argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]), + }); + + const appConfigs = await new Promise((resolve, reject) => { + async function loadConfigReaderLoop() { + let loaded = false; + + try { + const abortController = new AbortController(); + for await (const { configs } of source.readConfigData({ + signal: abortController.signal, + })) { + if (loaded) { + const newFrontendAppConfigs = schema.process(configs, { + visibility: ['frontend'], + withFilteredKeys: options.withFilteredKeys, + ignoreSchemaErrors: true, + }); + options.watch?.(newFrontendAppConfigs); + } else { + resolve(configs); + loaded = true; + + if (!options.watch) { + abortController.abort(); + } + } + } + } catch (error) { + if (loaded) { + console.error(`Failed to reload configuration, ${error}`); + } else { + reject(error); + } + } + } + loadConfigReaderLoop(); + }); + + const configurationLoadedMessage = appConfigs.length + ? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}` + : `No configuration files found, running without config`; + + process.stderr.write(`${configurationLoadedMessage}\n`); + + const frontendAppConfigs = schema.process(appConfigs, { + visibility: ['frontend'], + withFilteredKeys: options.withFilteredKeys, + ignoreSchemaErrors: true, + }); + const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs); + + const fullConfig = ConfigReader.fromConfigs(appConfigs); + + return { + schema, + appConfigs, + frontendConfig, + frontendAppConfigs, + fullConfig, + }; +} diff --git a/packages/cli/src/lib/optionsParser.ts b/packages/cli/src/modules/build/lib/optionsParser.ts similarity index 95% rename from packages/cli/src/lib/optionsParser.ts rename to packages/cli/src/modules/build/lib/optionsParser.ts index 45c7eac0aa..15561a2e42 100644 --- a/packages/cli/src/lib/optionsParser.ts +++ b/packages/cli/src/modules/build/lib/optionsParser.ts @@ -62,8 +62,8 @@ export function createScriptOptionsParser( // Triggers the writing of options to the result object cmd.parseOptions(argsStr.split(' ')); - (cmd as any)._storeOptionsAsProperties = currentOpts; - (cmd as any)._optionValues = currentStore; + (cmd as any)._optionValues = currentOpts; + (cmd as any)._storeOptionsAsProperties = currentStore; return result; }; diff --git a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts b/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts index 50fb0f4e31..9f63525186 100644 --- a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts @@ -44,7 +44,7 @@ import { PackageGraphNode, runConcurrentTasks, } from '@backstage/cli-node'; -import { createTypeDistProject } from '../../../../lib/typeDistProject'; +import { createTypeDistProject } from '../typeDistProject'; // These packages aren't safe to pack in parallel since the CLI depends on them const UNSAFE_PACKAGES = [ diff --git a/packages/cli/src/modules/build/lib/packager/productionPack.ts b/packages/cli/src/modules/build/lib/packager/productionPack.ts index ce9f15388c..55eb6edc48 100644 --- a/packages/cli/src/modules/build/lib/packager/productionPack.ts +++ b/packages/cli/src/modules/build/lib/packager/productionPack.ts @@ -19,7 +19,7 @@ import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'node:path'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../entryPoints'; -import { getEntryPointDefaultFeatureType } from '../../../../lib/typeDistProject'; +import { getEntryPointDefaultFeatureType } from '../typeDistProject'; import { Project } from 'ts-morph'; const PKG_PATH = 'package.json'; diff --git a/packages/cli/src/modules/maintenance/lib/publishing.ts b/packages/cli/src/modules/build/lib/publishing.ts similarity index 100% rename from packages/cli/src/modules/maintenance/lib/publishing.ts rename to packages/cli/src/modules/build/lib/publishing.ts diff --git a/packages/cli/src/lib/typeDistProject.test.ts b/packages/cli/src/modules/build/lib/typeDistProject.test.ts similarity index 100% rename from packages/cli/src/lib/typeDistProject.test.ts rename to packages/cli/src/modules/build/lib/typeDistProject.test.ts diff --git a/packages/cli/src/lib/typeDistProject.ts b/packages/cli/src/modules/build/lib/typeDistProject.ts similarity index 100% rename from packages/cli/src/lib/typeDistProject.ts rename to packages/cli/src/modules/build/lib/typeDistProject.ts diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli/src/modules/config/index.ts index 3eabf5e9fc..234e62fde0 100644 --- a/packages/cli/src/modules/config/index.ts +++ b/packages/cli/src/modules/config/index.ts @@ -16,7 +16,7 @@ import { createCliPlugin } from '../../wiring/factory'; import yargs from 'yargs'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export const configOption = [ '--config ', diff --git a/packages/cli/src/modules/config/lib/config.ts b/packages/cli/src/modules/config/lib/config.ts index 1d920eb31e..763af2174f 100644 --- a/packages/cli/src/modules/config/lib/config.ts +++ b/packages/cli/src/modules/config/lib/config.ts @@ -27,11 +27,9 @@ type Options = { targetDir?: string; fromPackage?: string; mockEnv?: boolean; - withFilteredKeys?: boolean; withDeprecatedKeys?: boolean; fullVisibility?: boolean; strict?: boolean; - watch?: (newFrontendAppConfigs: AppConfig[]) => void; }; export async function loadCliConfig(options: Options) { @@ -73,48 +71,29 @@ export async function loadCliConfig(options: Options) { substitutionFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, - watch: Boolean(options.watch), rootDir: targetPaths.rootDir, argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]), }); const appConfigs = await new Promise((resolve, reject) => { - async function loadConfigReaderLoop() { + async function readConfig() { let loaded = false; - try { const abortController = new AbortController(); for await (const { configs } of source.readConfigData({ signal: abortController.signal, })) { - if (loaded) { - const newFrontendAppConfigs = schema.process(configs, { - visibility: options.fullVisibility - ? ['frontend', 'backend', 'secret'] - : ['frontend'], - withFilteredKeys: options.withFilteredKeys, - withDeprecatedKeys: options.withDeprecatedKeys, - ignoreSchemaErrors: !options.strict, - }); - options.watch?.(newFrontendAppConfigs); - } else { - resolve(configs); - loaded = true; - - if (!options.watch) { - abortController.abort(); - } - } + resolve(configs); + loaded = true; + abortController.abort(); } } catch (error) { - if (loaded) { - console.error(`Failed to reload configuration, ${error}`); - } else { + if (!loaded) { reject(error); } } } - loadConfigReaderLoop(); + readConfig(); }); const configurationLoadedMessage = appConfigs.length @@ -130,7 +109,6 @@ export async function loadCliConfig(options: Options) { visibility: options.fullVisibility ? ['frontend', 'backend', 'secret'] : ['frontend'], - withFilteredKeys: options.withFilteredKeys, withDeprecatedKeys: options.withDeprecatedKeys, ignoreSchemaErrors: !options.strict, }); diff --git a/packages/cli/src/modules/create-github-app/index.ts b/packages/cli/src/modules/create-github-app/index.ts index da1e04aaac..5a3e30e870 100644 --- a/packages/cli/src/modules/create-github-app/index.ts +++ b/packages/cli/src/modules/create-github-app/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'new', diff --git a/packages/cli/src/modules/info/commands/info.ts b/packages/cli/src/modules/info/commands/info.ts index c50a9da373..c6732e2af1 100644 --- a/packages/cli/src/modules/info/commands/info.ts +++ b/packages/cli/src/modules/info/commands/info.ts @@ -17,8 +17,11 @@ import { version as cliVersion } from '../../../../package.json'; import os from 'node:os'; import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common'; -import { Lockfile } from '../../../lib/versioning'; -import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; +import { + BackstagePackageJson, + Lockfile, + PackageGraph, +} from '@backstage/cli-node'; import { minimatch } from 'minimatch'; import fs from 'fs-extra'; diff --git a/packages/cli/src/modules/info/index.ts b/packages/cli/src/modules/info/index.ts index c14926df85..0919c2ce07 100644 --- a/packages/cli/src/modules/info/index.ts +++ b/packages/cli/src/modules/info/index.ts @@ -15,7 +15,7 @@ */ import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'info', diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index dafcff45c6..a3ae69302c 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -24,11 +24,11 @@ import { BackstagePackageJson, Lockfile, runWorkerQueueThreads, + SuccessCache, } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import { createScriptOptionsParser } from '../../../../lib/optionsParser'; -import { SuccessCache } from '../../../../lib/cache/SuccessCache'; +import { createScriptOptionsParser } from '../../lib/optionsParser'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; @@ -41,7 +41,10 @@ function depCount(pkg: BackstagePackageJson) { export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); - const cache = new SuccessCache('lint', opts.successCacheDir); + const cache = SuccessCache.create({ + name: 'lint', + basePath: opts.successCacheDir, + }); const cacheContext = opts.successCache ? { entries: await cache.read(), diff --git a/packages/cli/src/modules/lint/index.ts b/packages/cli/src/modules/lint/index.ts index 6cba232da9..40c374aa3b 100644 --- a/packages/cli/src/modules/lint/index.ts +++ b/packages/cli/src/modules/lint/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export function registerPackageLintCommand(command: Command) { command.arguments('[directories...]'); diff --git a/packages/cli/src/modules/lint/lib/optionsParser.ts b/packages/cli/src/modules/lint/lib/optionsParser.ts new file mode 100644 index 0000000000..15561a2e42 --- /dev/null +++ b/packages/cli/src/modules/lint/lib/optionsParser.ts @@ -0,0 +1,70 @@ +/* + * 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 { Command } from 'commander'; + +export function createScriptOptionsParser( + anyCmd: Command, + commandPath: string[], +) { + // Regardless of what command instance is passed in we want to find + // the root command and resolve the path from there + let rootCmd = anyCmd; + while (rootCmd.parent) { + rootCmd = rootCmd.parent; + } + + // Now find the command that was requested + let targetCmd = rootCmd as Command | undefined; + for (const name of commandPath) { + targetCmd = targetCmd?.commands.find(c => c.name() === name) as + | Command + | undefined; + } + + if (!targetCmd) { + throw new Error( + `Could not find package command '${commandPath.join(' ')}'`, + ); + } + const cmd = targetCmd; + + const expectedScript = `backstage-cli ${commandPath.join(' ')}`; + + return (scriptStr?: string) => { + if (!scriptStr || !scriptStr.startsWith(expectedScript)) { + return undefined; + } + + const argsStr = scriptStr.slice(expectedScript.length).trim(); + + // Can't clone or copy or even use commands as prototype, so we mutate + // the necessary members instead, and then reset them once we're done + const currentOpts = (cmd as any)._optionValues; + const currentStore = (cmd as any)._storeOptionsAsProperties; + + const result: Record = {}; + (cmd as any)._storeOptionsAsProperties = false; + (cmd as any)._optionValues = result; + + // Triggers the writing of options to the result object + cmd.parseOptions(argsStr.split(' ')); + + (cmd as any)._optionValues = currentOpts; + (cmd as any)._storeOptionsAsProperties = currentStore; + + return result; + }; +} diff --git a/packages/cli/src/modules/maintenance/commands/repo/fix.ts b/packages/cli/src/modules/maintenance/commands/repo/fix.ts index 073cb69ea1..de09d8b490 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/fix.ts +++ b/packages/cli/src/modules/maintenance/commands/repo/fix.ts @@ -31,8 +31,6 @@ import { } from 'node:path'; import { targetPaths } from '@backstage/cli-common'; -import { publishPreflightCheck } from '../../lib/publishing'; - const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json']; /** @@ -507,8 +505,6 @@ export async function command(opts: OptionValues): Promise { fixPluginId, fixPluginPackages, fixPeerModules, - // Run the publish preflight check too, to make sure we don't uncover errors during publishing - publishPreflightCheck, ); } diff --git a/packages/cli/src/modules/maintenance/index.ts b/packages/cli/src/modules/maintenance/index.ts index 41192afdc4..e2584527f0 100644 --- a/packages/cli/src/modules/maintenance/index.ts +++ b/packages/cli/src/modules/maintenance/index.ts @@ -15,50 +15,11 @@ */ import { Command } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'maintenance', init: async reg => { - reg.addCommand({ - path: ['package', 'clean'], - description: 'Delete cache directories', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/clean'), 'default'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, - }); - - reg.addCommand({ - path: ['package', 'prepack'], - description: 'Prepares a package for packaging before publishing', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/pack'), 'pre'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, - }); - - reg.addCommand({ - path: ['package', 'postpack'], - description: 'Restores the changes made by the prepack command', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/pack'), 'post'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, - }); - reg.addCommand({ path: ['repo', 'fix'], description: 'Automatically fix packages in the project', @@ -79,19 +40,6 @@ export default createCliPlugin({ }, }); - reg.addCommand({ - path: ['repo', 'clean'], - description: 'Delete cache and output directories', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/repo/clean'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, - }); - reg.addCommand({ path: ['repo', 'list-deprecations'], description: 'List deprecations', diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli/src/modules/migrate/commands/packageExports.ts index 4eb741ce4d..ea42638e58 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli/src/modules/migrate/commands/packageExports.ts @@ -14,21 +14,8 @@ * limitations under the License. */ -import { - fixPackageExports, - readFixablePackages, - writeFixedPackages, -} from '../../maintenance/commands/repo/fix'; - export async function command() { - console.log( - 'The `migrate package-exports` command is deprecated, use `repo fix` instead.', + throw new Error( + 'The `migrate package-exports` command has been removed, use `repo fix` instead.', ); - const packages = await readFixablePackages(); - - for (const pkg of packages) { - fixPackageExports(pkg); - } - - await writeFixedPackages(packages); } diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index 598f05acce..c80af07c16 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -19,7 +19,7 @@ import * as runObj from '@backstage/cli-common'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils'; -import { YarnInfoInspectData } from '../../../../lib/versioning/packages'; +import { YarnInfoInspectData } from '../../lib/versioning/packages'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; @@ -69,8 +69,8 @@ jest.mock('@backstage/cli-common', () => { }); const mockFetchPackageInfo = jest.fn(); -jest.mock('../../../../lib/versioning/packages', () => { - const actual = jest.requireActual('../../../../lib/versioning/packages'); +jest.mock('../../lib/versioning/packages', () => { + const actual = jest.requireActual('../../lib/versioning/packages'); return { ...actual, fetchPackageInfo: (name: string) => mockFetchPackageInfo(name), diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 4283028216..8fe7013ac8 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -30,14 +30,16 @@ import { OptionValues } from 'commander'; import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; -import { getHasYarnPlugin } from '../../../../lib/yarnPlugin'; +import { + hasBackstageYarnPlugin, + Lockfile, + runConcurrentTasks, +} from '@backstage/cli-node'; import { fetchPackageInfo, - Lockfile, mapDependencies, YarnInfoInspectData, -} from '../../../../lib/versioning'; -import { runConcurrentTasks } from '@backstage/cli-node'; +} from '../../lib/versioning/packages'; import { getManifestByReleaseLine, getManifestByVersion, @@ -74,7 +76,7 @@ function extendsDefaultPattern(pattern: string): boolean { export default async (opts: OptionValues) => { const lockfilePath = targetPaths.resolveRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); - const hasYarnPlugin = await getHasYarnPlugin(); + const yarnPluginEnabled = await hasBackstageYarnPlugin(); let pattern = opts.pattern; @@ -128,7 +130,7 @@ export default async (opts: OptionValues) => { }); } - if (hasYarnPlugin) { + if (yarnPluginEnabled) { console.log(); console.log( `Updating yarn plugin to v${releaseManifest.releaseVersion}...`, @@ -212,7 +214,7 @@ export default async (opts: OptionValues) => { const oldLockfileRange = await asLockfileVersion(oldRange); const useBackstageRange = - hasYarnPlugin && + yarnPluginEnabled && // Only use backstage:^ versions if the package is present in // the manifest for the release we're bumping to. releaseManifest.packages.find( @@ -252,7 +254,7 @@ export default async (opts: OptionValues) => { if (extendsDefaultPattern(pattern)) { await bumpBackstageJsonVersion( releaseManifest.releaseVersion, - hasYarnPlugin, + yarnPluginEnabled, ); } else { console.log( @@ -317,7 +319,7 @@ export default async (opts: OptionValues) => { console.log(); } - if (hasYarnPlugin) { + if (yarnPluginEnabled) { console.log(); console.log( chalk.blue( diff --git a/packages/cli/src/modules/migrate/index.ts b/packages/cli/src/modules/migrate/index.ts index ac56c06b7a..d94d04ed8d 100644 --- a/packages/cli/src/modules/migrate/index.ts +++ b/packages/cli/src/modules/migrate/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'migrate', diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/modules/migrate/lib/versioning/packages.test.ts similarity index 100% rename from packages/cli/src/lib/versioning/packages.test.ts rename to packages/cli/src/modules/migrate/lib/versioning/packages.test.ts diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/modules/migrate/lib/versioning/packages.ts similarity index 100% rename from packages/cli/src/lib/versioning/packages.ts rename to packages/cli/src/modules/migrate/lib/versioning/packages.ts diff --git a/packages/cli/src/lib/versioning/yarn.ts b/packages/cli/src/modules/migrate/lib/versioning/yarn.ts similarity index 100% rename from packages/cli/src/lib/versioning/yarn.ts rename to packages/cli/src/modules/migrate/lib/versioning/yarn.ts diff --git a/packages/cli/src/modules/new/index.ts b/packages/cli/src/modules/new/index.ts index 120e77c7c0..ac21b825fe 100644 --- a/packages/cli/src/modules/new/index.ts +++ b/packages/cli/src/modules/new/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; import { NotImplementedError } from '@backstage/errors'; export default createCliPlugin({ diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 511e656c91..dd6f43b9a7 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -24,11 +24,11 @@ import startCase from 'lodash/startCase'; import upperCase from 'lodash/upperCase'; import upperFirst from 'lodash/upperFirst'; import lowerFirst from 'lodash/lowerFirst'; -import { Lockfile } from '../../../../lib/versioning'; +import { Lockfile } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import { createPackageVersionProvider } from '../../../../lib/version'; -import { getHasYarnPlugin } from '../../../../lib/yarnPlugin'; +import { createPackageVersionProvider } from '../version'; +import { hasBackstageYarnPlugin } from '@backstage/cli-node'; const builtInHelpers = { camelCase, @@ -55,9 +55,9 @@ export class PortableTemplater { /* ignored */ } - const hasYarnPlugin = await getHasYarnPlugin(); + const yarnPluginEnabled = await hasBackstageYarnPlugin(); const versionProvider = createPackageVersionProvider(lockfile, { - preferBackstageProtocol: hasYarnPlugin, + preferBackstageProtocol: yarnPluginEnabled, }); const templater = new PortableTemplater( diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/modules/new/lib/version.test.ts similarity index 99% rename from packages/cli/src/lib/version.test.ts rename to packages/cli/src/modules/new/lib/version.test.ts index d582c397c3..86b92e35c3 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/modules/new/lib/version.test.ts @@ -15,7 +15,7 @@ */ import { packageVersions, createPackageVersionProvider } from './version'; -import { Lockfile } from './versioning'; +import { Lockfile } from '@backstage/cli-node'; import corePluginApiPkg from '@backstage/core-plugin-api/package.json'; import { createMockDirectory } from '@backstage/backend-test-utils'; diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/modules/new/lib/version.ts similarity index 62% rename from packages/cli/src/lib/version.ts rename to packages/cli/src/modules/new/lib/version.ts index aa8556534f..0849e8a9e8 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/modules/new/lib/version.ts @@ -14,13 +14,8 @@ * limitations under the License. */ -import fs from 'fs-extra'; import semver from 'semver'; -import { findOwnPaths } from '@backstage/cli-common'; -import { Lockfile } from './versioning'; - -/* eslint-disable-next-line no-restricted-syntax */ -const ownPaths = findOwnPaths(__dirname); +import { Lockfile } from '@backstage/cli-node'; /* eslint-disable @backstage/no-relative-monorepo-imports */ /* @@ -35,28 +30,28 @@ This does not create an actual dependency on these packages and does not bring i Rollup will extract the value of the version field in each package at build time without leaving any imports in place. */ -import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json'; -import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json'; -import { version as catalogClient } from '../../../../packages/catalog-client/package.json'; -import { version as cli } from '../../../../packages/cli/package.json'; -import { version as config } from '../../../../packages/config/package.json'; -import { version as coreAppApi } from '../../../../packages/core-app-api/package.json'; -import { version as coreComponents } from '../../../../packages/core-components/package.json'; -import { version as corePluginApi } from '../../../../packages/core-plugin-api/package.json'; -import { version as devUtils } from '../../../../packages/dev-utils/package.json'; -import { version as errors } from '../../../../packages/errors/package.json'; -import { version as frontendDefaults } from '../../../../packages/frontend-defaults/package.json'; -import { version as frontendPluginApi } from '../../../../packages/frontend-plugin-api/package.json'; -import { version as frontendTestUtils } from '../../../../packages/frontend-test-utils/package.json'; -import { version as testUtils } from '../../../../packages/test-utils/package.json'; -import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json'; -import { version as scaffolderNodeTestUtils } from '../../../../plugins/scaffolder-node-test-utils/package.json'; -import { version as authBackend } from '../../../../plugins/auth-backend/package.json'; -import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json'; -import { version as catalogNode } from '../../../../plugins/catalog-node/package.json'; -import { version as theme } from '../../../../packages/theme/package.json'; -import { version as types } from '../../../../packages/types/package.json'; -import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json'; +import { version as backendPluginApi } from '../../../../../../packages/backend-plugin-api/package.json'; +import { version as backendTestUtils } from '../../../../../../packages/backend-test-utils/package.json'; +import { version as catalogClient } from '../../../../../../packages/catalog-client/package.json'; +import { version as cli } from '../../../../../../packages/cli/package.json'; +import { version as config } from '../../../../../../packages/config/package.json'; +import { version as coreAppApi } from '../../../../../../packages/core-app-api/package.json'; +import { version as coreComponents } from '../../../../../../packages/core-components/package.json'; +import { version as corePluginApi } from '../../../../../../packages/core-plugin-api/package.json'; +import { version as devUtils } from '../../../../../../packages/dev-utils/package.json'; +import { version as errors } from '../../../../../../packages/errors/package.json'; +import { version as frontendDefaults } from '../../../../../../packages/frontend-defaults/package.json'; +import { version as frontendPluginApi } from '../../../../../../packages/frontend-plugin-api/package.json'; +import { version as frontendTestUtils } from '../../../../../../packages/frontend-test-utils/package.json'; +import { version as testUtils } from '../../../../../../packages/test-utils/package.json'; +import { version as scaffolderNode } from '../../../../../../plugins/scaffolder-node/package.json'; +import { version as scaffolderNodeTestUtils } from '../../../../../../plugins/scaffolder-node-test-utils/package.json'; +import { version as authBackend } from '../../../../../../plugins/auth-backend/package.json'; +import { version as authBackendModuleGuestProvider } from '../../../../../../plugins/auth-backend-module-guest-provider/package.json'; +import { version as catalogNode } from '../../../../../../plugins/catalog-node/package.json'; +import { version as theme } from '../../../../../../packages/theme/package.json'; +import { version as types } from '../../../../../../packages/types/package.json'; +import { version as backendDefaults } from '../../../../../../packages/backend-defaults/package.json'; export const packageVersions: Record = { '@backstage/backend-defaults': backendDefaults, @@ -84,14 +79,6 @@ export const packageVersions: Record = { '@backstage/plugin-catalog-node': catalogNode, }; -export function findVersion() { - const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8'); - return JSON.parse(pkgContent).version; -} - -export const version = findVersion(); -export const isDev = fs.pathExistsSync(ownPaths.resolve('src')); - export function createPackageVersionProvider( lockfile?: Lockfile, options?: { diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index 1f57279935..53f86fd635 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -22,7 +22,7 @@ import yargs from 'yargs'; import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli'; import { relative as relativePath } from 'node:path'; import { Command, OptionValues } from 'commander'; -import { Lockfile, PackageGraph } from '@backstage/cli-node'; +import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node'; import { runCheck, @@ -31,7 +31,6 @@ import { findOwnPaths, isChildPath, } from '@backstage/cli-common'; -import { SuccessCache } from '../../../../lib/cache/SuccessCache'; type JestProject = { displayName: string; @@ -333,7 +332,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise { ); } - const cache = new SuccessCache('test', opts.successCacheDir); + const cache = SuccessCache.create({ + name: 'test', + basePath: opts.successCacheDir, + }); const graph = await getPackageGraph(); // Shared state for the bridge diff --git a/packages/cli/src/modules/test/index.ts b/packages/cli/src/modules/test/index.ts index ecc546aee3..f003d9c054 100644 --- a/packages/cli/src/modules/test/index.ts +++ b/packages/cli/src/modules/test/index.ts @@ -15,7 +15,7 @@ */ import { createCliPlugin } from '../../wiring/factory'; import { Command } from 'commander'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'test', diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 702b0f4f49..1a700b3505 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -15,7 +15,7 @@ */ import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../lib/lazy'; +import { lazy } from '../../wiring/lazy'; import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath'; export default createCliPlugin({ diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index eab7961b40..a605199179 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -18,9 +18,9 @@ import { CommandGraph } from './CommandGraph'; import { CliFeature, OpaqueCliPlugin } from './types'; import { CommandRegistry } from './CommandRegistry'; import { Command } from 'commander'; -import { version } from '../lib/version'; +import { version } from './version'; import chalk from 'chalk'; -import { exitWithError } from '../lib/errors'; +import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; import { isPromise } from 'node:util/types'; diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/wiring/errors.ts similarity index 100% rename from packages/cli/src/lib/errors.ts rename to packages/cli/src/wiring/errors.ts diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/wiring/lazy.ts similarity index 96% rename from packages/cli/src/lib/lazy.ts rename to packages/cli/src/wiring/lazy.ts index d1255ea1bc..64a4d43ef4 100644 --- a/packages/cli/src/lib/lazy.ts +++ b/packages/cli/src/wiring/lazy.ts @@ -15,7 +15,7 @@ */ import { assertError } from '@backstage/errors'; -import { exitWithError } from '../lib/errors'; +import { exitWithError } from './errors'; type ActionFunc = (...args: any[]) => Promise; type ActionExports = { diff --git a/packages/cli/src/wiring/version.ts b/packages/cli/src/wiring/version.ts new file mode 100644 index 0000000000..d7d0b3a2fe --- /dev/null +++ b/packages/cli/src/wiring/version.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { findOwnPaths } from '@backstage/cli-common'; + +/* eslint-disable-next-line no-restricted-syntax */ +const ownPaths = findOwnPaths(__dirname); + +export function findVersion() { + const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8'); + return JSON.parse(pkgContent).version; +} + +export const version = findVersion(); +export const isDev = fs.pathExistsSync(ownPaths.resolve('src')); diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx index cb066e8b21..081b3c730a 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -165,7 +165,7 @@ describe('dynamicFrontendFeaturesLoader', () => { shareConfig: { singleton: true, requiredVersion: '*', eager: true }, }, { - name: '@mui/material/styles/', + name: '@mui/material/styles', version: '5.16.14', lib: async () => ({ default: {} }), shareConfig: { singleton: true, requiredVersion: '*', eager: true }, diff --git a/packages/module-federation-common/src/defaults.ts b/packages/module-federation-common/src/defaults.ts index 27e0e292cf..644d4ee480 100644 --- a/packages/module-federation-common/src/defaults.ts +++ b/packages/module-federation-common/src/defaults.ts @@ -55,7 +55,7 @@ const defaultSharedDependencies = { // MUI v5 // not setting import: false for MUI packages as this // will break once Backstage moves to BUI - '@mui/material/styles/': { + '@mui/material/styles': { host: {}, remote: {}, }, diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 5922155e93..1a015747a8 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -76,7 +76,7 @@ export const AccordionDefinition: { readonly propDefs: { readonly bg: { readonly dataAttribute: true; - readonly default: 'neutral-auto'; + readonly default: 'neutral'; }; readonly children: {}; readonly className: {}; @@ -897,6 +897,7 @@ export const DialogDefinition: { readonly classNames: { readonly overlay: 'bui-DialogOverlay'; readonly dialog: 'bui-Dialog'; + readonly content: 'bui-DialogContent'; readonly header: 'bui-DialogHeader'; readonly headerTitle: 'bui-DialogHeaderTitle'; readonly body: 'bui-DialogBody'; @@ -1011,14 +1012,7 @@ export const FlexDefinition: { 'direction', ]; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; @@ -1095,14 +1089,7 @@ export const GridDefinition: { 'py', ]; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; @@ -1113,14 +1100,7 @@ export const GridItemDefinition: { }; readonly utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan']; readonly dataAttributes: { - readonly bg: readonly [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ]; + readonly bg: readonly ['neutral', 'danger', 'warning', 'success']; }; }; @@ -1331,6 +1311,7 @@ export const MenuDefinition: { readonly classNames: { readonly root: 'bui-Menu'; readonly popover: 'bui-MenuPopover'; + readonly inner: 'bui-MenuInner'; readonly content: 'bui-MenuContent'; readonly section: 'bui-MenuSection'; readonly sectionHeader: 'bui-MenuSectionHeader'; @@ -1602,7 +1583,7 @@ export interface PopoverProps extends Omit { } // @public -export type ProviderBg = ContainerBg | 'neutral-auto'; +export type ProviderBg = 'neutral' | 'danger' | 'warning' | 'success'; // @public (undocumented) export interface QueryOptions { @@ -2218,6 +2199,7 @@ export const Tooltip: ForwardRefExoticComponent< export const TooltipDefinition: { readonly classNames: { readonly tooltip: 'bui-Tooltip'; + readonly content: 'bui-TooltipContent'; readonly arrow: 'bui-TooltipArrow'; }; }; diff --git a/packages/ui/src/components/Accordion/Accordion.stories.tsx b/packages/ui/src/components/Accordion/Accordion.stories.tsx index b88b547e08..5def4f57d3 100644 --- a/packages/ui/src/components/Accordion/Accordion.stories.tsx +++ b/packages/ui/src/components/Accordion/Accordion.stories.tsx @@ -195,7 +195,7 @@ export const AutoBg = meta.story({ - + Neutral 1 container @@ -210,35 +210,41 @@ export const AutoBg = meta.story({ - - Neutral 2 container - - - - - - - - - - - - + + + Neutral 2 container + + + + + + + + + + + + + - - Neutral 3 container - - - - - - - - - - - - + + + + Neutral 3 container + + + + + + + + + + + + + + ), diff --git a/packages/ui/src/components/Accordion/definition.ts b/packages/ui/src/components/Accordion/definition.ts index 69ef9be3a7..2e76f0cd77 100644 --- a/packages/ui/src/components/Accordion/definition.ts +++ b/packages/ui/src/components/Accordion/definition.ts @@ -34,7 +34,7 @@ export const AccordionDefinition = defineComponent()({ }, bg: 'provider', propDefs: { - bg: { dataAttribute: true, default: 'neutral-auto' }, + bg: { dataAttribute: true, default: 'neutral' }, children: {}, className: {}, }, diff --git a/packages/ui/src/components/Alert/Alert.stories.tsx b/packages/ui/src/components/Alert/Alert.stories.tsx index 28239becb9..589d55b7dd 100644 --- a/packages/ui/src/components/Alert/Alert.stories.tsx +++ b/packages/ui/src/components/Alert/Alert.stories.tsx @@ -297,7 +297,7 @@ export const OnDifferentBackgrounds = meta.story({ On Neutral 1 - + @@ -305,18 +305,24 @@ export const OnDifferentBackgrounds = meta.story({ On Neutral 2 - - - - + + + + + + On Neutral 3 - - - - + + + + + + + + ), @@ -337,7 +343,7 @@ export const Responsive = meta.story({ export const WithUtilityProps = meta.story({ render: () => ( - + ( Default - - Neutral 1 + + Neutral (level 1) - - Neutral 2 + + + Neutral (level 2) + - - Neutral 3 - - - Responsive Neutral + + + + Neutral (level 3) + + Danger @@ -381,11 +384,11 @@ export const BackgroundColors = meta.story({ export const NestedNeutralColors = meta.story({ args: { px: '6', py: '4', children: null }, render: args => ( - + - + - + diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 5e5df2073b..5781daca6a 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ import preview from '../../../../../.storybook/preview'; +import { allModes } from '../../../../../.storybook/modes'; import { Button } from './Button'; import { Flex } from '../Flex'; import { Box } from '../Box'; @@ -55,100 +56,37 @@ export const Variants = meta.story({ control: false, }, }, + chromatic: { + modes: { + 'light spotify neutral-1': allModes['light spotify neutral-1'], + 'light spotify neutral-2': allModes['light spotify neutral-2'], + 'light spotify neutral-3': allModes['light spotify neutral-3'], + }, + }, }, render: () => ( - - Default - - - - - - - - + + + + - - Neutral 1 - - - - - - - - - - - Neutral 2 - - - - - - - - - - - Neutral 3 - - - - - - - - + + + + ), @@ -208,20 +146,6 @@ export const Destructive = meta.story({ - - On Neutral 1 - - - - - - Sizes @@ -433,26 +357,32 @@ export const AutoBg = meta.story({ neutral level by 1. No prop is needed on the button -- it's fully automatic. - + Neutral 1 container - - Neutral 2 container - - - - + + + Neutral 2 container + + + + + - - Neutral 3 container - - - - + + + + Neutral 3 container + + + + + + ), diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 3957b7beff..87671c4cf6 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -135,23 +135,29 @@ export const Backgrounds = meta.story({ No parent Defaults to neutral-1 - + On neutral-1 Auto-increments to neutral-2 - - - On neutral-2 - Auto-increments to neutral-3 - + + + + On neutral-2 + Auto-increments to neutral-3 + + - - - On neutral-3 - Steps up to neutral-4 - + + + + + On neutral-3 + Steps up to neutral-4 + + + ), @@ -197,23 +203,29 @@ export const BgOnProviders = meta.story({ No provider Card defaults to neutral-1 - + On neutral-1 Card auto-increments to neutral-2 - - - On neutral-2 - Card auto-increments to neutral-3 - + + + + On neutral-2 + Card auto-increments to neutral-3 + + - - - On neutral-3 - Card visually at neutral-4 - + + + + + On neutral-3 + Card visually at neutral-4 + + + ), @@ -226,11 +238,7 @@ export const CustomCardWithBox = meta.story({ A custom card built with Box. Use Box with an explicit bg prop to create a card-like container that participates in the bg system as a provider. - + diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index e3e4781eac..5eb09f2edc 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -44,7 +44,7 @@ export const Card = forwardRef((props, ref) => { return ( ( +
+ +
+ ), + ], args: { defaultOpen: true, }, diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx index 1c33588306..0c0f4bf53f 100644 --- a/packages/ui/src/components/Dialog/Dialog.tsx +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -33,6 +33,8 @@ import { Button } from '../Button'; import { useStyles } from '../../hooks/useStyles'; import { DialogDefinition } from './definition'; import { Flex } from '../Flex'; +import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; import styles from './Dialog.module.css'; /** @public */ @@ -71,7 +73,14 @@ export const Dialog = forwardRef, DialogProps>( ...style, }} > - {children} + + + {children} + + ); diff --git a/packages/ui/src/components/Dialog/definition.ts b/packages/ui/src/components/Dialog/definition.ts index d2fcb7bb4d..f9aadc9147 100644 --- a/packages/ui/src/components/Dialog/definition.ts +++ b/packages/ui/src/components/Dialog/definition.ts @@ -24,6 +24,7 @@ export const DialogDefinition = { classNames: { overlay: 'bui-DialogOverlay', dialog: 'bui-Dialog', + content: 'bui-DialogContent', header: 'bui-DialogHeader', headerTitle: 'bui-DialogHeaderTitle', body: 'bui-DialogBody', diff --git a/packages/ui/src/components/Flex/Flex.stories.tsx b/packages/ui/src/components/Flex/Flex.stories.tsx index ff2aa442ea..a0a1dd60bf 100644 --- a/packages/ui/src/components/Flex/Flex.stories.tsx +++ b/packages/ui/src/components/Flex/Flex.stories.tsx @@ -253,18 +253,21 @@ export const Backgrounds = meta.story({ render: args => ( Default - - Neutral 1 - - - Neutral 2 - - - Neutral 3 - - - Responsive Bg + + Neutral (level 1) + + + Neutral (level 2) + + + + + + Neutral (level 3) + + + Danger @@ -278,20 +281,20 @@ export const Backgrounds = meta.story({ ), }); -export const BgNeutralAuto = meta.story({ +export const BgNeutral = meta.story({ args: { px: '6', py: '4', gap: '4' }, render: args => (
- Using bg="neutral-auto" on Flex auto-increments from the parent context. - The first Flex defaults to neutral-1 (no parent), then each nested Flex + Using bg="neutral" on Flex auto-increments from the parent context. The + first Flex defaults to neutral-1 (no parent), then each nested Flex increments by one, capping at neutral-3.
- -
Neutral 1 (auto, no parent)
- + +
Neutral 1 (no parent)
+
Neutral 2 (auto-incremented)
- +
Neutral 3 (auto-incremented, capped)
diff --git a/packages/ui/src/components/Flex/definition.ts b/packages/ui/src/components/Flex/definition.ts index d0452d6b2c..2701ce5e10 100644 --- a/packages/ui/src/components/Flex/definition.ts +++ b/packages/ui/src/components/Flex/definition.ts @@ -45,13 +45,6 @@ export const FlexDefinition = { 'direction', ], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Grid/Grid.stories.tsx b/packages/ui/src/components/Grid/Grid.stories.tsx index b3b6957359..f60517bc31 100644 --- a/packages/ui/src/components/Grid/Grid.stories.tsx +++ b/packages/ui/src/components/Grid/Grid.stories.tsx @@ -113,18 +113,21 @@ export const Backgrounds = meta.story({ render: args => ( - - Neutral 1 - - - Neutral 2 - - - Neutral 3 - - - Responsive Bg + + Neutral (level 1) + + + Neutral (level 2) + + + + + + Neutral (level 3) + + + Danger @@ -137,28 +140,26 @@ export const Backgrounds = meta.story({ - - Neutral 1 - - - - - Neutral 2 - - - - - Neutral 3 - - - - - Responsive Bg + + Neutral (level 1) + + + + Neutral (level 2) + + + + + + + + Neutral (level 3) + + + + Danger @@ -179,7 +180,7 @@ export const Backgrounds = meta.story({ ), }); -export const BgNeutralAuto = meta.story({ +export const BgNeutral = meta.story({ args: { px: '6', py: '4', columns: '2', gap: '4' }, render: args => ( @@ -188,12 +189,12 @@ export const BgNeutralAuto = meta.story({ default. Only an explicit bg prop establishes a new bg level. Nested grids without a bg prop inherit the parent context unchanged. - + Neutral 1 (Grid.Root) - - Nested: neutral-2 (explicit) - Nested: neutral-2 (explicit) + + Nested: neutral-2 (auto-incremented) + Nested: neutral-2 (auto-incremented) diff --git a/packages/ui/src/components/Grid/definition.ts b/packages/ui/src/components/Grid/definition.ts index c3acb59274..2f417ab34b 100644 --- a/packages/ui/src/components/Grid/definition.ts +++ b/packages/ui/src/components/Grid/definition.ts @@ -43,14 +43,7 @@ export const GridDefinition = { 'py', ], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; @@ -64,13 +57,6 @@ export const GridItemDefinition = { }, utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan'], dataAttributes: { - bg: [ - 'neutral-1', - 'neutral-2', - 'neutral-3', - 'danger', - 'warning', - 'success', - ] as const, + bg: ['neutral', 'danger', 'warning', 'success'] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index 6dca183395..0717a9468d 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -18,12 +18,13 @@ @layer components { .bui-MenuPopover { + --menu-border-radius: var(--bui-radius-2); display: flex; flex-direction: column; box-shadow: var(--bui-shadow); border: 1px solid var(--bui-border-1); - border-radius: var(--bui-radius-2); - background: var(--bui-bg-popover); + border-radius: var(--menu-border-radius); + background: var(--bui-bg-app); color: var(--bui-fg-primary); outline: none; transition: transform 200ms, opacity 200ms; @@ -55,6 +56,10 @@ } } + .bui-MenuInner { + border-radius: var(--menu-border-radius); + } + .bui-MenuContent { max-height: inherit; box-sizing: border-box; diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index ffe184d4ae..982055ade1 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -198,6 +198,24 @@ export const PreviewLinks = meta.story({ }); export const Opened = meta.story({ + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( +
+ +
+ ), + ], args: { ...Preview.input.args, }, diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index e02292ba44..a9835b2f68 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -58,6 +58,8 @@ import { } from '../InternalLinkProvider'; import styles from './Menu.module.css'; import clsx from 'clsx'; +import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); @@ -119,18 +121,25 @@ export const Menu = (props: MenuProps) => { )} placement={placement} > - {virtualized ? ( - + - {menuContent} - - ) : ( - menuContent - )} + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + + ); @@ -169,18 +178,25 @@ export const MenuListBox = (props: MenuListBoxProps) => { )} placement={placement} > - {virtualized ? ( - + - {listBoxContent} - - ) : ( - listBoxContent - )} + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent + )} + + ); }; @@ -219,43 +235,50 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { )} placement={placement} > - - + - + + + + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent )} - placeholder={props.placeholder || 'Search...'} - /> - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - + + + ); @@ -298,43 +321,50 @@ export const MenuAutocompleteListbox = ( )} placement={placement} > - - + - + + + + + + + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent )} - placeholder={props.placeholder || 'Search...'} - /> - - - - - {virtualized ? ( - - {listBoxContent} - - ) : ( - listBoxContent - )} - + + + ); }; diff --git a/packages/ui/src/components/Menu/definition.ts b/packages/ui/src/components/Menu/definition.ts index e4ba0d83ab..860ad1d729 100644 --- a/packages/ui/src/components/Menu/definition.ts +++ b/packages/ui/src/components/Menu/definition.ts @@ -24,6 +24,7 @@ export const MenuDefinition = { classNames: { root: 'bui-Menu', popover: 'bui-MenuPopover', + inner: 'bui-MenuInner', content: 'bui-MenuContent', section: 'bui-MenuSection', sectionHeader: 'bui-MenuSectionHeader', diff --git a/packages/ui/src/components/Popover/Popover.module.css b/packages/ui/src/components/Popover/Popover.module.css index 69d2acffcf..cdd323817c 100644 --- a/packages/ui/src/components/Popover/Popover.module.css +++ b/packages/ui/src/components/Popover/Popover.module.css @@ -18,9 +18,10 @@ @layer components { .bui-Popover { + --popover-border-radius: var(--bui-radius-3); box-shadow: var(--bui-shadow); - border-radius: var(--bui-radius-3); - background: var(--bui-bg-popover); + border-radius: var(--popover-border-radius); + background: var(--bui-bg-app); border: 1px solid var(--bui-border-1); forced-color-adjust: none; outline: none; @@ -76,6 +77,7 @@ padding: var(--bui-space-4); flex: 1 1 auto; min-height: 0; + border-radius: var(--popover-border-radius); } .bui-PopoverArrow { @@ -89,11 +91,14 @@ we split the stroke and fill across separate elements in order to guarantee that the stroke is always overlaying a consistent color. */ - path:nth-child(1) { - fill: var(--bui-bg-popover); + use:nth-of-type(1) { + fill: var(--bui-bg-app); + } + use:nth-of-type(2) { + fill: var(--bui-bg-neutral-1); } - path:nth-child(2) { + path { fill: var(--bui-border-1); } diff --git a/packages/ui/src/components/Popover/Popover.stories.tsx b/packages/ui/src/components/Popover/Popover.stories.tsx index ea92bf9d5c..0a0e68b572 100644 --- a/packages/ui/src/components/Popover/Popover.stories.tsx +++ b/packages/ui/src/components/Popover/Popover.stories.tsx @@ -19,6 +19,7 @@ import { Button } from '../Button/Button'; import { DialogTrigger } from '../Dialog/Dialog'; import { Text } from '../Text/Text'; import { Flex } from '../Flex/Flex'; +import { Box } from '../Box'; const meta = preview.meta({ title: 'Backstage UI/Popover', @@ -74,6 +75,24 @@ export const Default = meta.story({ }); export const IsOpen = Default.extend({ + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( +
+ +
+ ), + ], args: { isOpen: true, }, @@ -189,6 +208,9 @@ export const WithRichContent = Default.extend({ This is a popover with rich content. It can contain multiple elements and formatted text. + + You can also use the automatic bg system inside it. + } + /> + + + + {metrics.map(metric => ( + + ))} + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Storybook meta +// --------------------------------------------------------------------------- + +const meta = { + title: 'Guidelines/Cards with Table', + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story: () => JSX.Element) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/packages/ui/src/hooks/useBg.tsx b/packages/ui/src/hooks/useBg.tsx index 6e298fedf9..2284e5f337 100644 --- a/packages/ui/src/hooks/useBg.tsx +++ b/packages/ui/src/hooks/useBg.tsx @@ -69,6 +69,24 @@ export const BgProvider = ({ bg, children }: BgProviderProps) => { ); }; +/** + * Resets the bg context to undefined, cutting any inherited neutral chain. + * Use this inside overlay components (Popover, Tooltip, Dialog, Menu) so + * their content always starts from neutral-1 regardless of where the trigger + * is placed in the tree. + * + * @internal + */ +export const BgReset = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + /** * Hook for consumer components (e.g. Button) to read the parent bg context. * @@ -90,10 +108,14 @@ export function useBgConsumer(): BgContextValue { * * - `bg` is `undefined` -- transparent, no context change, returns `{ bg: undefined }`. * This is the default for Box, Flex, and Grid (they do **not** auto-increment). - * - `bg` is a `ContainerBg` value -- uses that value directly (e.g. `'neutral-1'`). - * - `bg` is `'neutral-auto'` -- increments the neutral level from the parent context, - * capping at `neutral-3`. Only components that explicitly pass `'neutral-auto'` - * (e.g. Card) will auto-increment; it is never implicit. + * - `bg` is `'neutral'` -- when the parent bg is neutral, increments the neutral + * level from the parent context, capping at `neutral-3`. When the parent bg is + * an intent (`'danger'` | `'warning'` | `'success'`), the intent passes through + * unchanged (i.e. `bg: 'neutral'` does not override the parent intent). The + * increment is always relative to the parent; it is not possible to pin a + * container to an explicit neutral level. + * - `bg` is `'danger'` | `'warning'` | `'success'` -- sets the bg to that intent + * explicitly, regardless of the parent value. * * **Capping:** * @@ -116,7 +138,7 @@ export function useBgProvider(bg?: Responsive): BgContextValue { const resolved = resolveResponsiveValue(bg, breakpoint); - if (resolved === 'neutral-auto') { + if (resolved === 'neutral') { return { bg: incrementNeutralBg(context.bg) }; } diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index d3dfd996aa..af87bfb20b 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -182,7 +182,7 @@ export interface ComponentDefinition { } /** - * Background type for the neutral bg system. + * Resolved background level stored in context and applied as `data-bg` on DOM elements. * * Supports neutral levels ('neutral-1' through 'neutral-3') and * intent backgrounds ('danger', 'warning', 'success'). @@ -190,6 +190,9 @@ export interface ComponentDefinition { * The 'neutral-4' level is not exposed as a prop value -- it is reserved * for leaf component CSS (e.g. Button on a 'neutral-3' surface). * + * This is the resolved/internal representation used by the bg context system. + * For the prop type accepted by container components, use `ProviderBg` instead. + * * @public */ export type ContainerBg = @@ -201,11 +204,13 @@ export type ContainerBg = | 'success'; /** - * Background values accepted by provider components. + * Background values accepted by provider components (Box, Flex, Grid, Card, etc.). * - * Includes all `ContainerBg` values plus `'neutral-auto'` which - * automatically increments the neutral level from the parent context. + * - `'neutral'` — automatically increments the neutral level from the parent context, + * capping at the maximum level. This is always incremental; explicit levels cannot + * be set directly. + * - `'danger'` | `'warning'` | `'success'` — intent backgrounds used as-is. * * @public */ -export type ProviderBg = ContainerBg | 'neutral-auto'; +export type ProviderBg = 'neutral' | 'danger' | 'warning' | 'success'; diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 0c44d5d51c..4afe97dee9 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -174,6 +174,7 @@ const apiDocsDefinitionEntityContent = EntityContentBlueprint.make({ params: { path: '/definition', title: 'Definition', + group: 'documentation', filter: { kind: 'api' }, loader: async () => import('./components/ApiDefinitionCard').then(m => ( @@ -191,6 +192,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ params: { path: '/apis', title: 'APIs', + group: 'development', filter: { kind: 'component' }, loader: async () => import('./components/ApisCards').then(m => ( diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts new file mode 100644 index 0000000000..f90d67bcc9 --- /dev/null +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserEntity } from '@backstage/catalog-model'; +import { graphql } from '@octokit/graphql'; +import { + defaultUserTransformer, + TransformerContext, +} from './defaultTransformers'; +import { GithubUser } from './github'; + +const ctx: TransformerContext = { + client: graphql, + query: '', + org: 'test-org', +}; + +function makeUser(overrides: Partial = {}): GithubUser { + return { + login: 'testuser', + avatarUrl: '', + ...overrides, + }; +} + +describe('defaultUserTransformer', () => { + it('populates all fields correctly', async () => { + const result = (await defaultUserTransformer( + makeUser({ + name: 'Test User', + email: 'test@example.com', + bio: 'A test bio', + avatarUrl: 'https://example.com/avatar.png', + id: 'user-id-123', + }), + ctx, + )) as UserEntity; + + expect(result.metadata.name).toBe('testuser'); + expect(result.metadata.description).toBe('A test bio'); + expect(result.metadata.annotations).toEqual({ + 'github.com/user-login': 'testuser', + 'github.com/user-id': 'user-id-123', + }); + expect(result.spec.profile).toEqual({ + displayName: 'Test User', + email: 'test@example.com', + picture: 'https://example.com/avatar.png', + }); + expect(result.spec.memberOf).toEqual([]); + }); + + it('prefers verified domain email over regular email', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('strips plus-addressed tag from verified domain email', async () => { + const result = (await defaultUserTransformer( + makeUser({ + organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('amckay@spotify.com'); + }); + + it('uses verified domain email when regular email is absent', async () => { + const result = (await defaultUserTransformer( + makeUser({ + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('falls back to regular email when verified array is empty', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: [], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('falls back to regular email when verified array is undefined', async () => { + const result = (await defaultUserTransformer( + makeUser({ + email: 'public@gmail.com', + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('sets no email when both are absent', async () => { + const result = (await defaultUserTransformer( + makeUser(), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBeUndefined(); + }); +}); diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts index 4de9af89c8..d71cda270d 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts @@ -81,7 +81,12 @@ export const defaultUserTransformer = async ( if (item.bio) entity.metadata.description = item.bio; if (item.name) entity.spec.profile!.displayName = item.name; - if (item.email) entity.spec.profile!.email = item.email; + // GitHub returns verified domain emails as plus-addressed routing aliases + // (e.g. user+abc123@example.com). Strip the tag to get the real address. + const email = item.organizationVerifiedDomainEmails?.length + ? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '') + : item.email; + if (email) entity.spec.profile!.email = email; if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl; return entity; }; diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..3be032a161 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.test.ts @@ -0,0 +1,344 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +const testEntities = [ + { + kind: 'Component', + apiVersion: 'backstage.io/v1alpha1', + metadata: { + name: 'service-a', + namespace: 'default', + annotations: { 'backstage.io/techdocs-ref': 'dir:.' }, + }, + spec: { + type: 'service', + dependsOn: ['component:default/shared-lib', 'api:default/user-api'], + }, + }, + { + kind: 'Component', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'website-b', namespace: 'default' }, + spec: { type: 'website' }, + }, + { + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'user-api', namespace: 'default' }, + spec: { type: 'openapi' }, + }, + { + kind: 'Group', + apiVersion: 'backstage.io/v1alpha1', + metadata: { name: 'team-alpha', namespace: 'default' }, + }, +]; + +function createCatalogQueryAction(options?: { + entities?: typeof testEntities; +}) { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock({ + entities: options?.entities ?? testEntities, + }); + createQueryCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + return { invoke: mockActionsRegistry.invoke.bind(mockActionsRegistry) }; +} + +describe('createQueryCatalogEntitiesAction', () => { + it('should return all entities when no filter is provided', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: {}, + }); + + expect(result.output).toEqual({ + items: testEntities, + totalItems: 4, + hasMoreEntities: false, + nextPageCursor: undefined, + }); + }); + + it('should return empty results when no entities match', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { kind: 'NonExistent' } }, + }); + + expect(result.output).toEqual({ + items: [], + totalItems: 0, + hasMoreEntities: false, + nextPageCursor: undefined, + }); + }); + + it('should filter by kind', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { kind: 'Component' } }, + }); + + expect(result.output).toMatchObject({ + totalItems: 2, + items: expect.arrayContaining([ + expect.objectContaining({ + metadata: { + name: 'service-a', + namespace: 'default', + annotations: { 'backstage.io/techdocs-ref': 'dir:.' }, + }, + }), + expect.objectContaining({ + metadata: { name: 'website-b', namespace: 'default' }, + }), + ]), + }); + }); + + it('should filter with multiple conditions', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component', 'spec.type': 'service' }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $not operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { query: { $not: { kind: 'Group' } } }, + }); + + expect(result.output).toMatchObject({ totalItems: 3 }); + const items = (result.output as any).items; + expect(items.every((e: any) => e.kind !== 'Group')).toBe(true); + }); + + it('should support $all operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + $all: [{ kind: 'Component' }, { 'spec.type': 'service' }], + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $any operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + $any: [{ kind: 'API' }, { kind: 'Group' }], + }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 2 }); + const items = (result.output as any).items; + expect( + items.every((e: any) => e.kind === 'API' || e.kind === 'Group'), + ).toBe(true); + }); + + it('should support $exists: true', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'metadata.annotations.backstage.io/techdocs-ref': { $exists: true }, + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support $exists: false', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'metadata.annotations.backstage.io/techdocs-ref': { $exists: false }, + }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 3 }); + const items = (result.output as any).items; + expect(items.every((e: any) => e.metadata.name !== 'service-a')).toBe(true); + }); + + it('should support $in operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { 'spec.type': { $in: ['service', 'openapi'] } }, + }, + }); + + expect(result.output).toMatchObject({ totalItems: 2 }); + const names = (result.output as any).items.map((e: any) => e.metadata.name); + expect(names).toEqual(expect.arrayContaining(['service-a', 'user-api'])); + }); + + it('should support $contains operator', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { + 'spec.dependsOn': { $contains: 'component:default/shared-lib' }, + }, + }, + }); + + expect(result.output).toMatchObject({ + totalItems: 1, + items: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'service-a' }), + }), + ], + }); + }); + + it('should support pagination with limit', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { limit: 2 }, + }); + + const output = result.output as any; + expect(output.items).toHaveLength(2); + expect(output.totalItems).toBe(4); + expect(output.hasMoreEntities).toBe(true); + expect(output.nextPageCursor).toBeDefined(); + }); + + it('should support cursor-based pagination', async () => { + const { invoke } = createCatalogQueryAction(); + + const firstPage = await invoke({ + id: 'test:query-catalog-entities', + input: { limit: 2 }, + }); + const firstOutput = firstPage.output as any; + + const secondPage = await invoke({ + id: 'test:query-catalog-entities', + input: { cursor: firstOutput.nextPageCursor, limit: 2 }, + }); + const secondOutput = secondPage.output as any; + + expect(secondOutput.items).toHaveLength(2); + expect(secondOutput.hasMoreEntities).toBe(false); + }); + + it('should support orderFields', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component' }, + orderFields: { field: 'metadata.name', order: 'desc' }, + }, + }); + + const names = (result.output as any).items.map((e: any) => e.metadata.name); + expect(names).toEqual(['website-b', 'service-a']); + }); + + it('should support array orderFields for multi-field sorting', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + orderFields: [ + { field: 'kind', order: 'asc' }, + { field: 'metadata.name', order: 'asc' }, + ], + }, + }); + + const kinds = (result.output as any).items.map((e: any) => e.kind); + expect(kinds).toEqual(['API', 'Component', 'Component', 'Group']); + }); + + it('should support fields projection', async () => { + const { invoke } = createCatalogQueryAction(); + const result = await invoke({ + id: 'test:query-catalog-entities', + input: { + query: { kind: 'Component', 'spec.type': 'service' }, + fields: ['kind', 'metadata.name'], + }, + }); + + const items = (result.output as any).items; + expect(items).toHaveLength(1); + expect(items[0]).toEqual({ + kind: 'Component', + metadata: { name: 'service-a' }, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts new file mode 100644 index 0000000000..6bf4171163 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { CatalogService } from '@backstage/plugin-catalog-node'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; + +export const createQueryCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'query-catalog-entities', + title: 'Query Catalog Entities', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: ` +Query entities from the Backstage Software Catalog using predicate filters. + +## Catalog Model + +The catalog contains entities of different kinds. Every entity has "kind", "apiVersion", "metadata", and optionally "spec" and "relations". Fields use dot notation for querying. + +Common metadata fields on all entities: name, namespace (default: "default"), title, description, labels, annotations, tags (string array), links. + +Entity references use the format "kind:namespace/name", e.g. "component:default/my-service" or "user:default/jane.doe". + +### Entity Kinds + +**Component** - A piece of software such as a service, website, or library. + spec fields: type (e.g. "service", "website", "library"), lifecycle (e.g. "production", "experimental", "deprecated"), owner (entity ref), system, subcomponentOf, providesApis, consumesApis, dependsOn, dependencyOf. + +**API** - An interface that components expose, such as REST APIs or event streams. + spec fields: type (e.g. "openapi", "asyncapi", "graphql", "grpc"), lifecycle, owner (entity ref), definition (the API spec content), system. + +**System** - A collection of components, APIs, and resources that together expose some functionality. + spec fields: owner (entity ref), domain, type. + +**Domain** - A grouping of systems that share terminology, domain models, and business purpose. + spec fields: owner (entity ref), subdomainOf, type. + +**Resource** - Infrastructure required to operate a component, such as databases or storage buckets. + spec fields: type, owner (entity ref), system, dependsOn, dependencyOf. + +**Group** - An organizational entity such as a team or business unit. + spec fields: type (e.g. "team", "business-unit"), children (entity refs), parent (entity ref), members (entity refs), profile (displayName, email, picture). + +**User** - A person, such as an employee or contractor. + spec fields: memberOf (entity refs), profile (displayName, email, picture). + +**Location** - A marker that references other catalog descriptor files to be ingested. + spec fields: type, target, targets, presence. + +### Relations + +Entities have bidirectional relations stored in the "relations" array. Common relation types: ownedBy/ownerOf, dependsOn/dependencyOf, providesApi/apiProvidedBy, consumesApi/apiConsumedBy, parentOf/childOf, memberOf/hasMember, partOf/hasPart. + +Relations can be queried via "relations." e.g. "relations.ownedby: user:default/jane-doe". The value there must always be a valid entity reference. + +When querying for entity relationships, prefer using relations over spec fields. For example, use "relations.ownedby" instead of "spec.owner" to find entities owned by a particular group or user. + +## Query Syntax + +The query uses predicate expressions with dot-notation field paths. + +Simple matching: + { query: { kind: "Component" } } + { query: { kind: "Component", "spec.type": "service" } } + +Value operators: + { query: { kind: { "$in": ["API", "Component"] } } } + { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } } + { query: { "metadata.tags": { "$contains": "java" } } } + { query: { "metadata.name": { "$hasPrefix": "team-" } } } + +Logical operators: + { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } } + { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } } + { query: { "$not": { kind: "Group" } } } + +Querying relations - find all entities owned by a specific group: + { query: { "relations.ownedby": "group:default/team-alpha" } } + +Combined example - find production services or websites with TechDocs: + { query: { "$all": [ + { kind: "Component", "spec.lifecycle": "production" }, + { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] }, + { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } + ] } } + +## Other Options + +Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] } +Sort results: { orderFields: { field: "metadata.name", order: "asc" } } +Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } } +Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor. + `, + schema: { + input: z => + z.object({ + query: createZodV3FilterPredicateSchema(z) + .optional() + .describe( + 'Entity predicate query. Supports field matching, $all, $any, $not, $exists, $in, $contains, and $hasPrefix operators.', + ), + fields: z + .array(z.string()) + .optional() + .describe( + 'Specific fields to include in the response. If not provided, all fields are returned. Each entry is a dot separated path into an entity, e.g. `spec.type`.', + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of entities to return at a time.'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of entities to skip before returning results.'), + orderFields: z + .union([ + z.object({ + field: z + .string() + .describe( + 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.', + ), + order: z.enum(['asc', 'desc']).describe('Sort order'), + }), + z.array( + z.object({ + field: z + .string() + .describe( + 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.', + ), + order: z.enum(['asc', 'desc']).describe('Sort order'), + }), + ), + ]) + .optional() + .describe( + 'Ordering criteria for the results. Can be a single order directive or an array for multi-field sorting.', + ), + fullTextFilter: z + .object({ + term: z.string().describe('Full text search term'), + fields: z + .array(z.string()) + .optional() + .describe( + 'Fields to search within. Each entry is a dot separated path into an entity, e.g. `spec.type`.', + ), + }) + .optional() + .describe('Full text search criteria'), + cursor: z + .string() + .optional() + .describe( + 'Cursor for pagination. This can be used only after the first request with a response containing a cursor. If a cursor is given it takes precedence over `offset`.', + ), + }), + output: z => + z.object({ + items: z + .array(z.object({}).passthrough()) + .describe('List of entities'), + totalItems: z.number().describe('Total number of entities'), + hasMoreEntities: z + .boolean() + .describe('Whether more entities are available'), + nextPageCursor: z + .string() + .optional() + .describe('Next page cursor used to fetch next page of entities'), + }), + }, + action: async ({ input, credentials }) => { + const response = await catalog.queryEntities( + { + ...input, + query: input.query, + }, + { credentials }, + ); + + return { + output: { + items: response.items, + totalItems: response.totalItems, + hasMoreEntities: !!response.pageInfo.nextCursor, + nextPageCursor: response.pageInfo.nextCursor, + }, + }; + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts index 31a89a31f3..a6926a2fd9 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -16,7 +16,6 @@ import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; -import { ForwardedError } from '@backstage/errors'; describe('createRegisterCatalogEntitiesAction', () => { it('should successfully register a catalog location with a valid URL', async () => { @@ -78,14 +77,13 @@ describe('createRegisterCatalogEntitiesAction', () => { ).rejects.toThrow('not-a-valid-url is an invalid URL'); }); - it('should throw a ForwardedError if catalog.addLocation throws an error', async () => { + it('should throw the original error if catalog.addLocation fails', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); - const errorMessage = 'Failed to add location'; mockCatalog.addLocation = jest .fn() - .mockRejectedValue(new Error(errorMessage)); + .mockRejectedValue(new Error('Failed to add location')); createRegisterCatalogEntitiesAction({ catalog: mockCatalog, @@ -100,6 +98,9 @@ describe('createRegisterCatalogEntitiesAction', () => { 'https://github.com/example/repo/blob/main/catalog-info.yaml', }, }), - ).rejects.toThrow(ForwardedError); + ).rejects.toMatchObject({ + name: 'Error', + message: 'Failed to add location', + }); }); }); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts index 492a427630..bd886e37ba 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -192,10 +192,13 @@ describe('createUnregisterCatalogEntitiesAction', () => { ); }); - it('should throw NotFoundError if no location matches the URL', async () => { + it('should throw NotFoundError with the original message if no location matches the URL', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); + const locationUrl = + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; + mockCatalog.getLocations = jest.fn().mockResolvedValue({ items: [ { id: 'location-id-1', target: 'https://other-url.com/catalog.yaml' }, @@ -207,51 +210,24 @@ describe('createUnregisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - await expect( - mockActionsRegistry.invoke({ - id: 'test:unregister-entity', - input: { - type: { - locationUrl: - 'https://github.com/backstage/demo/blob/master/catalog-info.yaml', - }, - }, - }), - ).rejects.toThrow(/NotFoundError/); - }); - - it('should throw NotFoundError with descriptive message when location not found', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); - - const locationUrl = - 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; - - mockCatalog.getLocations = jest.fn().mockResolvedValue({ - items: [], - }); - - createUnregisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, - }); - await expect( mockActionsRegistry.invoke({ id: 'test:unregister-entity', input: { type: { locationUrl } }, }), - ).rejects.toThrow(`Location with URL ${locationUrl} not found`); + ).rejects.toMatchObject({ + name: 'NotFoundError', + message: `Location with URL ${locationUrl} not found`, + }); }); - it('should throw an error if catalog.getLocations throws an error', async () => { + it('should throw the original error if catalog.getLocations fails', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); - const errorMessage = 'Failed to get locations'; mockCatalog.getLocations = jest .fn() - .mockRejectedValue(new Error(errorMessage)); + .mockRejectedValue(new Error('Failed to get locations')); createUnregisterCatalogEntitiesAction({ catalog: mockCatalog, @@ -268,7 +244,10 @@ describe('createUnregisterCatalogEntitiesAction', () => { }, }, }), - ).rejects.toThrow(errorMessage); + ).rejects.toMatchObject({ + name: 'Error', + message: 'Failed to get locations', + }); }); }); }); diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index 5940bc32f1..04587c1a4e 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -19,6 +19,7 @@ import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts' import { createValidateEntityAction } from './createValidateEntityAction.ts'; import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts'; import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts'; +import { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction.ts'; export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; @@ -28,4 +29,5 @@ export const createCatalogActions = (options: { createValidateEntityAction(options); createRegisterCatalogEntitiesAction(options); createUnregisterCatalogEntitiesAction(options); + createQueryCatalogEntitiesAction(options); }; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1fbf337986..3d6aff55ba 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -17,6 +17,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A pagination rule for entities. @@ -85,6 +86,10 @@ export interface EntitiesBatchRequest { * they did not exist. */ filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + */ + query?: FilterPredicate; /** * Strips out only the parts of the entity bodies to include in the response. */ @@ -119,6 +124,10 @@ export interface EntityFacetsRequest { * A filter to apply on the full list of entities before computing the facets. */ filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + */ + query?: FilterPredicate; /** * The facets to compute. * @@ -212,6 +221,10 @@ export interface QueryEntitiesInitialRequest { limit?: number; offset?: number; filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + */ + query?: FilterPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -273,6 +286,10 @@ export type Cursor = { * A filter to be applied to the full list of entities. */ filter?: EntityFilter; + /** + * A predicate-based query to be applied to the full list of entities. + */ + query?: FilterPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 9f78cc6210..382dfedade 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1036,6 +1036,8 @@ paths: type: array items: type: string + query: + $ref: '#/components/schemas/JsonObject' examples: Fetch Backstage entities: value: @@ -1095,6 +1097,68 @@ paths: type: string explode: false style: form + post: + operationId: QueryEntitiesByPredicate + tags: + - Entity + description: Query entities using predicate-based filters. + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/EntitiesQueryResponse' + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + cursor: + type: string + limit: + type: number + offset: + type: number + orderBy: + type: array + items: + type: object + required: + - field + - order + properties: + field: + type: string + order: + type: string + enum: + - asc + - desc + fullTextFilter: + type: object + properties: + term: + type: string + fields: + type: array + items: + type: string + fields: + type: array + items: + type: string + query: + $ref: '#/components/schemas/JsonObject' /entity-facets: get: operationId: GetEntityFacets @@ -1132,6 +1196,40 @@ paths: value: - spec.type - $ref: '#/components/parameters/filter' + post: + operationId: QueryEntityFacetsByPredicate + tags: + - Entity + description: Get entity facets using predicate-based filters. + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/EntityFacetsResponse' + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - facets + properties: + facets: + type: array + items: + type: string + query: + $ref: '#/components/schemas/JsonObject' /locations: post: operationId: CreateLocation diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index 8254df6bab..3362f903d0 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -26,6 +26,8 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; +import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -120,6 +122,20 @@ export type GetEntityFacets = { }; response: EntityFacetsResponse | Error | Error; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; + response: EntitiesQueryResponse | Error | Error; +}; +/** + * @public + */ +export type QueryEntityFacetsByPredicate = { + body: QueryEntityFacetsByPredicateRequest; + response: EntityFacetsResponse | Error | Error; +}; /** * @public */ @@ -220,6 +236,10 @@ export type EndpointMap = { '#get|/entity-facets': GetEntityFacets; + '#post|/entities/by-query': QueryEntitiesByPredicate; + + '#post|/entity-facets': QueryEntityFacetsByPredicate; + '#post|/refresh': RefreshEntity; '#post|/validate-entity': ValidateEntity; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts index 0e7255f9c6..607c992ace 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByRefsRequest.model.ts @@ -24,4 +24,8 @@ export interface GetEntitiesByRefsRequest { entityRefs: Array; fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts new file mode 100644 index 0000000000..17a9c9962f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequest { + cursor?: string; + limit?: number; + offset?: number; + orderBy?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts new file mode 100644 index 0000000000..e7e971e1ea --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts new file mode 100644 index 0000000000..14fd3a8bc0 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntityFacetsByPredicateRequest { + facets: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index abdb58378c..ebecb8dbb7 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -45,6 +45,10 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; +export * from '../models/QueryEntityFacetsByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index b4078df721..74668310c5 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1124,6 +1124,9 @@ export const spec = { type: 'string', }, }, + query: { + $ref: '#/components/schemas/JsonObject', + }, }, }, examples: { @@ -1228,6 +1231,95 @@ export const spec = { }, ], }, + post: { + operationId: 'QueryEntitiesByPredicate', + tags: ['Entity'], + description: 'Query entities using predicate-based filters.', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/EntitiesQueryResponse', + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + cursor: { + type: 'string', + }, + limit: { + type: 'number', + }, + offset: { + type: 'number', + }, + orderBy: { + type: 'array', + items: { + type: 'object', + required: ['field', 'order'], + properties: { + field: { + type: 'string', + }, + order: { + type: 'string', + enum: ['asc', 'desc'], + }, + }, + }, + }, + fullTextFilter: { + type: 'object', + properties: { + term: { + type: 'string', + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, + query: { + $ref: '#/components/schemas/JsonObject', + }, + }, + }, + }, + }, + }, + }, }, '/entity-facets': { get: { @@ -1284,6 +1376,57 @@ export const spec = { }, ], }, + post: { + operationId: 'QueryEntityFacetsByPredicate', + tags: ['Entity'], + description: 'Get entity facets using predicate-based filters.', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/EntityFacetsResponse', + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['facets'], + properties: { + facets: { + type: 'array', + items: { + type: 'string', + }, + }, + query: { + $ref: '#/components/schemas/JsonObject', + }, + }, + }, + }, + }, + }, + }, }, '/locations': { post: { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..85fbd16eeb 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { Cursor, QueryEntitiesResponse } from '../catalog/types'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedEntitiesCatalog', () => { @@ -306,6 +307,144 @@ describe('AuthorizedEntitiesCatalog', () => { }, }); }); + + it('passes through query alongside permission filter on CONDITIONAL with initial request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['xxx', null], + query: userQuery, + filter: { key: 'kind', values: ['b'] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 1, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: userQuery, + filter: { key: 'kind', values: ['b'] }, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['xxx', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('passes through cursor query alongside permission filter on CONDITIONAL with cursor request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['yyy', null], + query: userQuery, + filter: { key: 'kind', values: ['b'] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + prevCursor: { + isPrevious: true, + orderFieldValues: ['aaa', null], + query: userQuery, + filter: { key: 'kind', values: ['b'] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 3, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const cursor: Cursor = { + query: userQuery, + orderFields: [{ field: 'name', order: 'asc' }], + isPrevious: false, + orderFieldValues: ['xxx', null], + }; + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + cursor, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + cursor: { + ...cursor, + filter: { key: 'kind', values: ['b'] }, + }, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['yyy', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + + expect(response.pageInfo.prevCursor).toEqual({ + isPrevious: true, + orderFieldValues: ['aaa', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..910188b036 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -35,6 +35,7 @@ import { QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { basicEntityFilter } from './request'; import { isQueryEntitiesCursorRequest } from './util'; import { EntityFilter } from '@backstage/plugin-catalog-node'; @@ -147,9 +148,11 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { let permissionedRequest: QueryEntitiesRequest; let requestFilter: EntityFilter | undefined; + let requestQuery: FilterPredicate | undefined; if (isQueryEntitiesCursorRequest(request)) { requestFilter = request.cursor.filter; + requestQuery = request.cursor.query; permissionedRequest = { ...request, @@ -161,13 +164,15 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { }, }; } else { + requestFilter = request.filter; + requestQuery = request.query; + permissionedRequest = { ...request, filter: request.filter ? { allOf: [permissionFilter, request.filter] } : permissionFilter, }; - requestFilter = request.filter; } const response = await this.entitiesCatalog.queryEntities( @@ -177,11 +182,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && { ...response.pageInfo.prevCursor, filter: requestFilter, + query: requestQuery, }; const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && { ...response.pageInfo.nextCursor, filter: requestFilter, + query: requestQuery, }; return { @@ -193,6 +200,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { }; } + // The ALLOW case return this.entitiesCatalog.queryEntities(request); } diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 7012c2e505..f9e41b679c 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2054,6 +2054,38 @@ describe('DefaultEntitiesCatalog', () => { ]); }, ); + + it.each(databases.eachSupportedId())( + 'should apply both filter and query when both are given, %p', + async databaseId => { + await createDatabase(databaseId); + + // Add entities with different kinds and names + await addEntityToSearch(entityFrom('A', { kind: 'component' })); + await addEntityToSearch(entityFrom('B', { kind: 'component' })); + await addEntityToSearch(entityFrom('C', { kind: 'api' })); + await addEntityToSearch(entityFrom('D', { kind: 'api' })); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + // Use filter to restrict to kind=component, and query to restrict to name=A + const response = await catalog.queryEntities({ + filter: { key: 'kind', values: ['component'] }, + query: { 'metadata.name': 'a' }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), + }); + + const resultEntities = entitiesResponseToObjects(response.items); + expect(resultEntities).toEqual([ + entityFrom('A', { kind: 'component' }), + ]); + }, + ); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 92c0bb0ecf..38cd145421 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { chunk as lodashChunk, isEqual } from 'lodash'; -import { z } from 'zod'; import { Cursor, EntitiesBatchRequest, @@ -49,7 +48,6 @@ import { isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, } from './util'; -import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; @@ -226,9 +224,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }) .whereIn('final_entities.entity_ref', chunk); - if (request?.filter) { + if (request?.filter || request?.query) { query = applyEntityFilterToQuery({ filter: request.filter, + query: request.query, targetQuery: query, onEntityIdField: 'final_entities.entity_id', knex: this.database, @@ -303,10 +302,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }); } - // Add regular filters, if given - if (cursor.filter) { + // Add regular filters and/or predicate query, if given + if (cursor.filter || cursor.query) { applyEntityFilterToQuery({ filter: cursor.filter, + query: cursor.query, targetQuery: inner, onEntityIdField: 'final_entities.entity_id', knex: this.database, @@ -687,9 +687,10 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }) .groupBy(['search.key', 'search.original_value']); - if (request.filter) { + if (request.filter || request.query) { applyEntityFilterToQuery({ filter: request.filter, + query: request.query, targetQuery: query, onEntityIdField: 'search.entity_id', knex: this.database, @@ -713,40 +714,24 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } } -const entityFilterParser: z.ZodSchema = z.lazy(() => - z - .object({ - key: z.string(), - values: z.array(z.string()).optional(), - }) - .or(z.object({ not: entityFilterParser })) - .or(z.object({ anyOf: z.array(entityFilterParser) })) - .or(z.object({ allOf: z.array(entityFilterParser) })), -); - -export const cursorParser: z.ZodSchema = z.object({ - orderFields: z.array( - z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), - ), - orderFieldValues: z.array(z.string().or(z.null())), - filter: entityFilterParser.optional(), - isPrevious: z.boolean(), - query: z.string().optional(), - firstSortFieldValues: z.array(z.string().or(z.null())).optional(), - totalItems: z.number().optional(), -}); - function parseCursorFromRequest( request?: QueryEntitiesRequest, ): Partial & { skipTotalItems: boolean } { if (isQueryEntitiesInitialRequest(request)) { const { filter, + query, orderFields: sortFields = [], fullTextFilter, skipTotalItems = false, } = request; - return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems }; + return { + filter, + query, + orderFields: sortFields, + fullTextFilter, + skipTotalItems, + }; } if (isQueryEntitiesCursorRequest(request)) { return { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index e13d33d58d..1984f2f04f 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -479,6 +479,87 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /entities/by-query', () => { + it('queries entities with a predicate filter', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 1, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items, + totalItems: 1, + pageInfo: {}, + }); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 10, + credentials: mockCredentials.user(), + }), + ); + }); + + it('queries entities with an offset', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 5, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 2, offset: 3 }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 2, + offset: 3, + credentials: mockCredentials.user(), + }), + ); + }); + + it('paginates with a cursor in the body', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + const cursor = mockCursor({ totalItems: 100, isPrevious: false }); + + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: { nextCursor: mockCursor() }, + totalItems: 100, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ cursor: encodeCursor(cursor) }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + cursor, + credentials: mockCredentials.user(), + }), + ); + }); + }); + describe('GET /entities/by-uid/:uid', () => { it('can fetch entity by uid', async () => { const entity: Entity = { @@ -644,6 +725,85 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /entities/by-refs with query predicate', () => { + it('can fetch entities by refs with a predicate query', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'component', + metadata: { + name: 'a', + }, + }; + const entityRef = stringifyEntityRef(entity); + entitiesCatalog.entitiesBatch.mockResolvedValue({ + items: { type: 'object', entities: [entity] }, + }); + const response = await request(app) + .post('/entities/by-refs') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: [entityRef], + query: { kind: 'Component' }, + }), + ); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ + entityRefs: [entityRef], + fields: undefined, + credentials: mockCredentials.user(), + filter: undefined, + query: { kind: 'Component' }, + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ items: [entity] }); + }); + + it('forwards both filter query param and body query predicate independently', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'component', + metadata: { + name: 'a', + }, + }; + const entityRef = stringifyEntityRef(entity); + entitiesCatalog.entitiesBatch.mockResolvedValue({ + items: { type: 'object', entities: [entity] }, + }); + const response = await request(app) + .post('/entities/by-refs?filter=kind=Component') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: [entityRef], + query: { 'metadata.namespace': 'default' }, + }), + ); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ + entityRefs: [entityRef], + fields: undefined, + credentials: mockCredentials.user(), + filter: { key: 'kind', values: ['Component'] }, + query: { 'metadata.namespace': 'default' }, + }); + expect(response.status).toEqual(200); + }); + + it('rejects invalid query predicate', async () => { + const response = await request(app) + .post('/entities/by-refs') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + entityRefs: ['component:default/a'], + query: { $invalid: 'bad' }, + }), + ); + expect(response.status).toEqual(400); + }); + }); + describe('GET /locations', () => { it('happy path: lists locations', async () => { const locations: Location[] = [ @@ -1109,6 +1269,89 @@ describe('createRouter readonly disabled', () => { ); }); }); + + describe('GET /entity-facets', () => { + it('returns facets', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + + const response = await request(app).get('/entity-facets?facet=kind'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + }); + + it('returns facets with filter parameter', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + + const response = await request(app).get( + '/entity-facets?facet=spec.type&filter=kind=Component', + ); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + expect(entitiesCatalog.facets).toHaveBeenCalledWith( + expect.objectContaining({ + facets: ['spec.type'], + filter: { key: 'kind', values: ['Component'] }, + }), + ); + }); + }); + + describe('POST /entity-facets', () => { + it('returns facets with predicate query', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + + const response = await request(app) + .post('/entity-facets') + .send({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { 'spec.type': [{ value: 'service', count: 3 }] }, + }); + expect(entitiesCatalog.facets).toHaveBeenCalledWith( + expect.objectContaining({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }), + ); + }); + + it('returns facets without query predicate', async () => { + entitiesCatalog.facets.mockResolvedValue({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + + const response = await request(app) + .post('/entity-facets') + .send({ facets: ['kind'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + facets: { kind: [{ value: 'Component', count: 5 }] }, + }); + }); + + it('returns 400 for missing facets', async () => { + const response = await request(app) + .post('/entity-facets') + .send({ query: { kind: 'Component' } }); + + expect(response.status).toBe(400); + }); + }); }); describe('createRouter readonly and raw json enabled', () => { diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 11e459ae82..1a7f1bf6fc 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -47,6 +47,7 @@ import { parseQueryEntitiesParams, } from './request'; import { parseEntityFacetParams } from './request/parseEntityFacetParams'; +import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery'; import { parseEntityOrderParams } from './request/parseEntityOrderParams'; import { parseEntityPaginationParams } from './request/parseEntityPaginationParams'; import { @@ -65,6 +66,7 @@ import { encodeLocationQueryCursor, parseLocationQuery, } from './request/parseLocationQuery'; +import { parseEntityQuery } from './request/parseEntityQuery'; /** * Options used by {@link createRouter}. @@ -258,6 +260,59 @@ export async function createRouter( throw err; } }) + .post('/entities/by-query', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'entity-fetch', + request: req, + meta: { + queryType: 'by-query', + }, + }); + + try { + const credentials = await httpAuth.credentials(req); + const { fields: rawFields, ...parsed } = parseEntityQuery( + req.body ?? {}, + ); + const fields = rawFields?.length + ? parseEntityTransformParams({ fields: rawFields }) + : undefined; + + const { items, pageInfo, totalItems } = + await entitiesCatalog.queryEntities({ + credentials, + fields, + ...parsed, + }); + + const meta = { + totalItems, + pageInfo: { + ...(pageInfo.nextCursor && { + nextCursor: encodeCursor(pageInfo.nextCursor), + }), + ...(pageInfo.prevCursor && { + prevCursor: encodeCursor(pageInfo.prevCursor), + }), + }, + }; + + await auditorEvent?.success({ meta }); + + await writeEntitiesResponse({ + res, + items, + alwaysUseObjectMode: enableRelationsCompatibility, + responseWrapper: entities => ({ + items: entities, + ...meta, + }), + }); + } catch (err) { + await auditorEvent?.fail({ error: err }); + throw err; + } + }) .get('/entities/by-query', async (req, res) => { const auditorEvent = await auditor.createEvent({ eventId: 'entity-fetch', @@ -470,6 +525,7 @@ export async function createRouter( const { items } = await entitiesCatalog.entitiesBatch({ entityRefs: request.entityRefs, filter: parseEntityFilterParams(req.query), + query: request.query, fields: parseEntityTransformParams(req.query, request.fields), credentials: await httpAuth.credentials(req), }); @@ -510,6 +566,31 @@ export async function createRouter( await auditorEvent?.success(); + res.status(200).json(response); + } catch (err) { + await auditorEvent?.fail({ + error: err, + }); + throw err; + } + }) + .post('/entity-facets', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'entity-facets', + request: req, + }); + + try { + const { facets, query } = parseEntityFacetsQuery(req.body ?? {}); + + const response = await entitiesCatalog.facets({ + query, + facets, + credentials: await httpAuth.credentials(req), + }); + + await auditorEvent?.success(); + res.status(200).json(response); } catch (err) { await auditorEvent?.fail({ diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts index b2a7fb6708..49356cd0ff 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts @@ -18,8 +18,10 @@ import { EntitiesSearchFilter, EntityFilter, } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; +import { applyPredicateEntityFilterToQuery } from './applyPredicateEntityFilterToQuery'; function isEntitiesSearchFilter( filter: EntitiesSearchFilter | EntityFilter, @@ -118,13 +120,29 @@ function applyInStrategy( // The actual exported function export function applyEntityFilterToQuery(options: { - filter: EntityFilter; + filter?: EntityFilter; + query?: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; strategy?: 'in' | 'join'; }): Knex.QueryBuilder { - const { filter, targetQuery, onEntityIdField, knex } = options; + const { filter, query, targetQuery, onEntityIdField, knex } = options; - return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false); + let result = targetQuery; + + if (filter) { + result = applyInStrategy(filter, result, onEntityIdField, knex, false); + } + + if (query) { + result = applyPredicateEntityFilterToQuery({ + filter: query, + targetQuery: result, + onEntityIdField, + knex, + }); + } + + return result; } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts new file mode 100644 index 0000000000..cea9d961f7 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -0,0 +1,430 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { applyEntityFilterToQuery } from './applyEntityFilterToQuery'; +import { + DbFinalEntitiesRow, + DbRefreshStateRow, + DbSearchRow, +} from '../../database/tables'; +import { Knex } from 'knex'; +import { applyDatabaseMigrations } from '../../database/migrations'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { v4 as uuid } from 'uuid'; +import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create(); + +describe.each(databases.eachSupportedId())( + 'applyEntityFilterToQuery with predicate queries, %p', + databaseId => { + let knex: Knex; + + beforeAll(async () => { + knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-a', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + relations: [ + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { type: 'consumesApi', targetRef: 'api:default/api-d' }, + ], + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-b', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }], + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'website-c', namespace: 'default' }, + spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }], + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'api-d', namespace: 'default' }, + spec: { type: 'openapi', lifecycle: 'production' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bare-e', + namespace: 'default', + tags: ['java', 'backend'], + }, + spec: {}, + }); + }); + + afterAll(async () => { + knex.destroy(); + }); + + async function addEntity(entity: Entity) { + const id = uuid(); + const entityRef = stringifyEntityRef(entity); + const entityJson = JSON.stringify(entity); + + await knex('refresh_state').insert({ + entity_id: id, + entity_ref: entityRef, + unprocessed_entity: entityJson, + errors: '[]', + next_update_at: '2031-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + + await knex('final_entities').insert({ + entity_id: id, + entity_ref: entityRef, + final_entity: entityJson, + hash: 'h', + stitch_ticket: '', + }); + + const search = await buildEntitySearch(id, entity); + await knex('search').insert(search); + } + + async function query(predicate: FilterPredicate): Promise { + const q = + knex('final_entities').whereNotNull('final_entity'); + applyEntityFilterToQuery({ + query: predicate, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + return await q.then(rows => + rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), + ); + } + + describe('field expressions', () => { + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('throws on unsupported field operator', async () => { + await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow( + /\$contains operator, but got/, + ); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + }); + + describe('$all', () => { + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + }); + + describe('$any', () => { + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + }); + + describe('$not', () => { + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + }); + + describe('$in', () => { + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + }); + + describe('$exists', () => { + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + }); + + describe('$hasPrefix', () => { + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + }); + + describe('$contains', () => { + it('filters with primitive $contains on array fields', async () => { + await expect( + query({ 'metadata.tags': { $contains: 'java' } }), + ).resolves.toEqual(['bare-e']); + }); + + it('matches relations by type only (existence)', async () => { + await expect( + query({ relations: { $contains: { type: 'consumesApi' } } }), + ).resolves.toEqual(['service-a']); + }); + + it('matches relations by type and exact targetRef', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'ownedBy', + targetRef: 'group:default/team-a', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef case-insensitively', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'OwnedBy', + targetRef: 'Group:Default/Team-A', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('handles mixed-case keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { + TyPe: 'ownedBy', + TargetRef: 'group:default/team-a', + }, + }, + } as any), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef with $in', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'ownedBy', + targetRef: { + $in: ['group:default/team-a', 'group:default/team-b'], + }, + }, + }, + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('throws on unsupported keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', badKey: 'value' }, + } as any, + }), + ).rejects.toThrow(/Unsupported key "badKey" in \$contains/); + }); + + it('throws on duplicate keys in $contains object', async () => { + await expect( + query({ + relations: { + // JSON.parse allows duplicate keys, last one wins, but we + // simulate via an object that has both casings + $contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'), + }, + }), + ).rejects.toThrow(/Duplicate key "Type" in \$contains/); + }); + + it('throws on $contains object without type', async () => { + await expect( + query({ + relations: { + $contains: { targetRef: 'group:default/team-a' }, + } as any, + }), + ).rejects.toThrow(/requires a "type" string property/); + }); + + it('throws on empty $in array in targetRef', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: { $in: [] } }, + }, + }), + ).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/); + }); + + it('throws on unsupported targetRef value', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: ['bad'] }, + } as any, + }), + ).rejects.toThrow(/Unsupported value in \$contains for "relations"/); + }); + + it('throws on object $contains for non-relations fields', async () => { + await expect( + query({ + 'metadata.tags': { + $contains: { type: 'something' }, + } as any, + }), + ).rejects.toThrow( + /Object form of \$contains is not supported for field/, + ); + }); + }); + + describe('nested operators', () => { + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + }); + + describe('combined filter and query', () => { + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull( + 'final_entity', + ); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows + .map(row => JSON.parse(row.final_entity!).metadata.name) + .toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); + }); + }); + }, +); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts new file mode 100644 index 0000000000..d915b8c4b5 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -0,0 +1,329 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FilterPredicate, + FilterPredicatePrimitive, + FilterPredicateValue, +} from '@backstage/filter-predicates'; +import { InputError } from '@backstage/errors'; +import { Knex } from 'knex'; +import { DbSearchRow } from '../../database/tables'; + +function isPrimitive(value: unknown): value is FilterPredicatePrimitive { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function applyPredicateEntityFilterToQuery(options: { + filter: FilterPredicate; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { filter, targetQuery, onEntityIdField, knex } = options; + + // We do not support top-level primitives; all matching happens through objects + if (!isObject(filter)) { + const actual = JSON.stringify(filter); + throw new InputError( + `Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`, + ); + } + + if ('$not' in filter) { + return targetQuery.andWhereNot(inner => + applyPredicateEntityFilterToQuery({ + filter: filter.$not, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } + + if ('$all' in filter) { + if (filter.$all.length === 0) { + return targetQuery.andWhereRaw('1 = 1'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$all) { + outer.andWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } + }); + } + + if ('$any' in filter) { + if (filter.$any.length === 0) { + return targetQuery.andWhereRaw('1 = 0'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$any) { + outer.orWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } + }); + } + + // Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } + if (Object.keys(filter).length === 0) { + return targetQuery.andWhereRaw('1 = 1'); + } + return targetQuery.andWhere(inner => { + for (const [keyAnyCase, value] of Object.entries(filter)) { + applyFieldCondition({ + key: keyAnyCase.toLocaleLowerCase('en-US'), + value, + targetQuery: inner, + onEntityIdField, + knex, + }); + } + }); +} + +/** + * Applies a single { key: value } filter to the target query. + */ +function applyFieldCondition(options: { + key: string; + value: FilterPredicateValue; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { key, value, targetQuery, onEntityIdField, knex } = options; + + if (isPrimitive(value)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key, + value: String(value).toLocaleLowerCase('en-US'), + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if (isObject(value)) { + if ('$exists' in value) { + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key }); + return targetQuery.andWhere( + onEntityIdField, + value.$exists ? 'in' : 'not in', + existsQuery, + ); + } + + if ('$in' in value) { + const values = value.$in.map(v => String(v).toLocaleLowerCase('en-US')); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .whereIn('value', values); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if ('$hasPrefix' in value) { + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if ('$contains' in value) { + const target = value.$contains; + + // If the target is a primitive, match on the special array syntax. + // + // FROM: `{ "a": { "$contains": "b" } }` + // + // TO: `{ "a": "b" }` + // + // The search table does not actually show us that "a" was an array to + // begin with, so this can mistakenly also match on an object that had a + // "b" key with a primitive value. We'll consider that an acceptable + // tradeoff though. + if (isPrimitive(target)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key, + value: String(target).toLocaleLowerCase('en-US'), + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + // Object form of $contains - currently only supports relation-style + // objects with "type" and optional "targetRef" keys. + // + // FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }` + // + // TO: search for key = "relations.ownedby" AND value = "group:default/team-a" + if (isObject(target)) { + if (key === 'relations') { + return applyContainsRelation({ + target, + targetQuery, + onEntityIdField, + knex, + }); + } + + throw new InputError( + `Object form of $contains is not supported for field "${key}"`, + ); + } + + const actual = JSON.stringify(target); + throw new InputError( + `Unsupported $contains target for field "${key}": ${actual}`, + ); + } + } + + const actual = JSON.stringify(value); + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`, + ); +} + +/** + * Handles expressions on the form + * + * ``` + * { + * "relations": { + * "$contains": { + * "type": "ownedBy", + * "targetRef": "group:default/team-a" + * } + * } + * } + * ``` + * + * which map onto the search table's special `relation.: ` + * syntax. + * + * Only the keys "type" and "targetRef" are supported. The "type" key is + * required. If "targetRef" is omitted, it becomes an existence check for any + * relation of that type. The "targetRef" value can be a string or an `$in` + * array. + */ +function applyContainsRelation(options: { + target: Record; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { target: rawTarget, targetQuery, onEntityIdField, knex } = options; + + function parseStringOrIn(value: unknown): string[] { + if (typeof value === 'string') { + return [value.toLocaleLowerCase('en-US')]; + } + if ( + isObject(value) && + Object.keys(value).length === 1 && + '$in' in value && + Array.isArray(value.$in) && + value.$in.every((v): v is string => typeof v === 'string') + ) { + if (value.$in.length === 0) { + throw new InputError( + `Empty "$in" array for $contains on "relations" is not allowed`, + ); + } + return value.$in.map(v => v.toLocaleLowerCase('en-US')); + } + const actual = JSON.stringify(value); + throw new InputError( + `Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`, + ); + } + + let type: string | undefined; + let targetRef: string[] | undefined; + + for (const [rawKey, value] of Object.entries(rawTarget)) { + const key = rawKey.toLocaleLowerCase('en-US'); + + if (key === 'type') { + if (type !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + if (typeof value !== 'string') { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + type = value; + } else if (key === 'targetref') { + if (targetRef !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + targetRef = parseStringOrIn(value); + } else { + throw new InputError( + `Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`, + ); + } + } + + if (!type) { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: `relations.${type.toLocaleLowerCase('en-US')}` }); + + if (targetRef) { + matchQuery.whereIn('value', targetRef); + } + + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); +} diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts index d5469315be..02ad1d5566 100644 --- a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -15,20 +15,36 @@ */ import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; import { Request } from 'express'; -import { z } from 'zod'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); const schema = z.object({ entityRefs: z.array(z.string()), fields: z.array(z.string()).optional(), + query: filterPredicateSchema.optional(), }); -export function entitiesBatchRequest(req: Request): z.infer { - try { - return schema.parse(req.body); - } catch (error) { +export interface ParsedEntitiesBatchRequest { + entityRefs: string[]; + fields?: string[]; + query?: FilterPredicate; +} + +export function entitiesBatchRequest(req: Request): ParsedEntitiesBatchRequest { + const result = schema.safeParse(req.body); + if (!result.success) { throw new InputError( - `Malformed request body (did you remember to specify an application/json content type?), ${error.message}`, + `Malformed request body (did you remember to specify an application/json content type?), ${fromZodError( + result.error, + )}`, ); } + return result.data; } diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts new file mode 100644 index 0000000000..473466f950 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityFacetsQuery } from './parseEntityFacetsQuery'; + +describe('parseEntityFacetsQuery', () => { + it('parses facets with no query', () => { + expect(parseEntityFacetsQuery({ facets: ['kind'] })).toEqual({ + facets: ['kind'], + query: undefined, + }); + }); + + it('parses facets with a simple query', () => { + expect( + parseEntityFacetsQuery({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }), + ).toEqual({ + facets: ['spec.type'], + query: { kind: 'Component' }, + }); + }); + + it('parses facets with complex predicate query', () => { + const query = { + $all: [{ kind: 'Component' }, { 'spec.lifecycle': 'production' }], + }; + expect(parseEntityFacetsQuery({ facets: ['spec.type'], query })).toEqual({ + facets: ['spec.type'], + query, + }); + }); + + it('throws on missing facets', () => { + expect(() => parseEntityFacetsQuery({} as any)).toThrow( + 'Missing or empty facets parameter', + ); + }); + + it('throws on empty facets array', () => { + expect(() => parseEntityFacetsQuery({ facets: [] })).toThrow( + 'Missing or empty facets parameter', + ); + }); + + it('throws on invalid query (null)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: null as any }), + ).toThrow(); + }); + + it('throws on invalid query (array)', () => { + expect(() => + parseEntityFacetsQuery({ facets: ['kind'], query: [] as any }), + ).toThrow(); + }); + + it('throws on invalid query (invalid operator)', () => { + expect(() => + parseEntityFacetsQuery({ + facets: ['kind'], + query: { $invalid: 'bad' } as any, + }), + ).toThrow(); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts new file mode 100644 index 0000000000..04e5e2ab82 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFacetsQuery.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; +import { QueryEntityFacetsByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +export interface ParsedEntityFacetsQuery { + facets: string[]; + query?: FilterPredicate; +} + +export function parseEntityFacetsQuery( + request: Readonly, +): ParsedEntityFacetsQuery { + // Parse facets + if (!request.facets || request.facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + const facets = request.facets.filter(f => f.length > 0); + if (facets.length === 0) { + throw new InputError('Missing or empty facets parameter'); + } + + // Parse query predicate + let query: FilterPredicate | undefined; + if (request.query !== undefined) { + const result = filterPredicateSchema.safeParse(request.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + query = result.data; + } + + return { facets, query }; +} diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts new file mode 100644 index 0000000000..7380259188 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityQuery } from './parseEntityQuery'; +import { encodeCursor } from '../util'; +import { Cursor } from '../../catalog/types'; + +describe('parseEntityQuery', () => { + describe('initial request', () => { + it('returns empty result for empty request', () => { + const result = parseEntityQuery({}); + expect(result).toEqual({ + query: undefined, + orderFields: undefined, + fullTextFilter: undefined, + fields: undefined, + limit: undefined, + offset: undefined, + }); + }); + + it('parses a simple query predicate', () => { + const query = { kind: 'component' }; + const result = parseEntityQuery({ query }); + expect(result).toEqual( + expect.objectContaining({ query: { kind: 'component' } }), + ); + }); + + it('parses a complex query with $all, $any, $not', () => { + const query = { + $all: [ + { kind: 'component' }, + { $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $exists operator', () => { + const query = { 'metadata.labels.team': { $exists: true } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $in operator', () => { + const query = { kind: { $in: ['component', 'api'] } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('passes through limit', () => { + const result = parseEntityQuery({ limit: 50 }); + expect(result).toEqual(expect.objectContaining({ limit: 50 })); + }); + + it('passes through offset', () => { + const result = parseEntityQuery({ offset: 100 }); + expect(result).toEqual(expect.objectContaining({ offset: 100 })); + }); + + it('passes through limit and offset together', () => { + const result = parseEntityQuery({ limit: 50, offset: 100 }); + expect(result).toEqual( + expect.objectContaining({ limit: 50, offset: 100 }), + ); + }); + + it('passes through fields', () => { + const result = parseEntityQuery({ + fields: ['metadata.name', 'kind'], + }); + expect(result).toEqual( + expect.objectContaining({ fields: ['metadata.name', 'kind'] }), + ); + }); + + it('parses orderBy into orderFields', () => { + const result = parseEntityQuery({ + orderBy: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], + }); + expect(result).toEqual( + expect.objectContaining({ + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], + }), + ); + }); + + it('parses fullTextFilter', () => { + const result = parseEntityQuery({ + fullTextFilter: { term: 'search term', fields: ['metadata.name'] }, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { + term: 'search term', + fields: ['metadata.name'], + }, + }), + ); + }); + + it('defaults fullTextFilter term to empty string when missing', () => { + const result = parseEntityQuery({ + fullTextFilter: {} as any, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { term: '', fields: undefined }, + }), + ); + }); + + it('throws on invalid query predicate', () => { + expect(() => + parseEntityQuery({ query: { $invalid: true } as any }), + ).toThrow(/Invalid query/); + }); + + it('throws when query root is not an object', () => { + expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow( + /Query must be an object/, + ); + }); + + it('throws on invalid orderBy order value', () => { + expect(() => + parseEntityQuery({ + // @ts-expect-error - invalid order value + orderBy: [{ field: 'metadata.name', order: 'sideways' }], + }), + ).toThrow(/Invalid order field order/); + }); + }); + + describe('cursor request', () => { + function makeCursor(partial: Partial): string { + const full: Cursor = { + orderFields: [], + orderFieldValues: [], + isPrevious: false, + ...partial, + }; + return encodeCursor(full); + } + + it('decodes a valid cursor', () => { + const cursor = makeCursor({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }); + const result = parseEntityQuery({ cursor }); + expect(result).toEqual( + expect.objectContaining({ + cursor: expect.objectContaining({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }), + }), + ); + }); + + it('passes through limit and fields with cursor', () => { + const cursor = makeCursor({}); + const result = parseEntityQuery({ + cursor, + limit: 25, + fields: ['metadata.name'], + }); + expect(result).toEqual( + expect.objectContaining({ + limit: 25, + fields: ['metadata.name'], + }), + ); + }); + + it('throws on empty cursor string', () => { + expect(() => parseEntityQuery({ cursor: '' })).toThrow( + /Cursor cannot be empty/, + ); + }); + + it('throws on invalid base64 cursor', () => { + expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow( + /Malformed cursor/, + ); + }); + + it('throws on invalid JSON in cursor', () => { + const cursor = Buffer.from('not json', 'utf8').toString('base64'); + expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/); + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts new file mode 100644 index 0000000000..27992c273a --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; +import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model'; +import { EntityOrder } from '../../catalog/types'; +import { Cursor } from '../../catalog/types'; +import { decodeCursor } from '../util'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +function isSupportedFilterPredicateRoot( + value: FilterPredicate | undefined, +): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return true; +} + +function parseOrderFields( + orderField: Array<{ field: string; order: string }> | undefined, +): EntityOrder[] | undefined { + if (!orderField?.length) { + return undefined; + } + return orderField.map(({ field, order }) => { + if (order !== 'asc' && order !== 'desc') { + throw new InputError('Invalid order field order, must be asc or desc'); + } + return { field, order }; + }); +} + +export type ParsedEntityQuery = + | { + cursor: Cursor; + fields?: string[]; + limit?: number; + } + | { + query?: FilterPredicate; + orderFields?: EntityOrder[]; + fullTextFilter?: { term: string; fields?: string[] }; + fields?: string[]; + limit?: number; + offset?: number; + }; + +export function parseEntityQuery( + request: Readonly, +): ParsedEntityQuery { + if (request.cursor !== undefined) { + if (!request.cursor) { + throw new InputError('Cursor cannot be empty'); + } + + const cursor = decodeCursor(request.cursor); + return { + cursor, + fields: request.fields, + limit: request.limit, + }; + } + + let query: FilterPredicate | undefined; + if (request.query !== undefined) { + const result = filterPredicateSchema.safeParse(request.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + if (!isSupportedFilterPredicateRoot(result.data)) { + throw new InputError('Query must be an object'); + } + query = result.data; + } + + const orderFields = parseOrderFields(request.orderBy); + + return { + query, + orderFields, + fullTextFilter: request.fullTextFilter + ? { + term: request.fullTextFilter.term ?? '', + fields: request.fullTextFilter.fields, + } + : undefined, + fields: request.fields, + limit: request.limit, + offset: request.offset, + }; +} diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index 7e0e8b42f9..708eec2de3 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -15,6 +15,7 @@ */ import { InputError, NotAllowedError } from '@backstage/errors'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; import { Request } from 'express'; import lodash from 'lodash'; import { z } from 'zod'; @@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema = z.lazy(() => .or(z.object({ allOf: z.array(entityFilterParser) })), ); +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + export const cursorParser: z.ZodSchema = z.object({ orderFields: z.array( z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), @@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.string().optional(), + query: filterPredicateSchema.optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index acd85b5540..9c76a221dc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -345,6 +345,8 @@ export type EntityContentGroupDefinitions = Record< { title: string; icon?: string | ReactElement; + aliases?: string[]; + contentOrder?: 'title' | 'natural'; } >; diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 83b9aa983b..04c13db484 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -47,6 +47,10 @@ export type EntityContentGroupDefinitions = Record< { title: string; icon?: string | ReactElement; + /** Other group IDs that should be treated as aliases for this group. */ + aliases?: string[]; + /** How to sort the content items within this group. Overrides the page-level default. */ + contentOrder?: 'title' | 'natural'; } >; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c2d856561f..3aae7d8920 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1097,9 +1097,12 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; + contentOrder?: 'title' | 'natural' | undefined; + aliases?: string[] | undefined; } >[] | undefined; + contentOrder: 'title' | 'natural'; showNavItemIcons: boolean; path: string | undefined; title: string | undefined; @@ -1111,10 +1114,13 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; + contentOrder?: 'title' | 'natural' | undefined; + aliases?: string[] | undefined; } >[] | undefined; showNavItemIcons?: boolean | undefined; + contentOrder?: 'title' | 'natural' | undefined; title?: string | undefined; path?: string | undefined; }; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index ee6e2fc012..895f36150f 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,6 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'title' | 'natural'; showNavItemIcons?: boolean; } @@ -110,6 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, + defaultContentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -164,6 +166,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx index 3bca08288f..a85d795cd8 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.test.tsx @@ -16,8 +16,10 @@ import { screen } from '@testing-library/react'; import { useSelectedSubRoute } from './EntityTabs'; +import { EntityTabsList } from './EntityTabsList'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; function TestSubRouteHook(props: { subRoutes: Array<{ @@ -37,6 +39,83 @@ function TestSubRouteHook(props: { ); } +describe('EntityTabsList', () => { + it('should render groups in the order defined by groupDefinitions', () => { + const tabs = [ + { id: '/cicd', label: 'CI/CD', path: 'cicd', group: 'cicd' }, + { + id: '/overview', + label: 'Overview', + path: 'overview', + group: 'overview', + }, + { + id: '/techdocs', + label: 'TechDocs', + path: 'techdocs', + group: 'techdocs', + }, + ]; + + const groupDefinitions = { + overview: { title: 'Overview' }, + techdocs: { title: 'TechDocs' }, + cicd: { title: 'CI/CD' }, + }; + + renderInTestApp( + , + ); + + const tabElements = screen.getAllByRole('tab'); + expect(tabElements).toHaveLength(3); + expect(tabElements[0]).toHaveTextContent('Overview'); + expect(tabElements[1]).toHaveTextContent('TechDocs'); + expect(tabElements[2]).toHaveTextContent('CI/CD'); + }); + + it('should place ungrouped tabs after defined groups', () => { + const tabs = [ + { id: '/standalone', label: 'Standalone', path: 'standalone' }, + { + id: '/overview', + label: 'Overview', + path: 'overview', + group: 'overview', + }, + { + id: '/techdocs', + label: 'TechDocs', + path: 'techdocs', + group: 'techdocs', + }, + ]; + + const groupDefinitions = { + overview: { title: 'Overview' }, + techdocs: { title: 'TechDocs' }, + }; + + renderInTestApp( + , + ); + + const tabElements = screen.getAllByRole('tab'); + expect(tabElements).toHaveLength(3); + expect(tabElements[0]).toHaveTextContent('Overview'); + expect(tabElements[1]).toHaveTextContent('TechDocs'); + expect(tabElements[2]).toHaveTextContent('Standalone'); + }); +}); + describe('EntityTabs', () => { const subRoutes = [ { diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index ed737266a5..102df76042 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,11 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, showIcons } = props; + const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -107,6 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} + defaultContentOrder={defaultContentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index d77cc5e444..9abbfb69d9 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,32 +78,104 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; +function resolveGroupId( + tabGroup: string | undefined, + groupDefinitions: EntityContentGroupDefinitions, + aliasToGroup: Record, +): string | undefined { + if (!tabGroup) { + return undefined; + } + if (groupDefinitions[tabGroup]) { + return tabGroup; + } + return aliasToGroup[tabGroup]; +} + export function EntityTabsList(props: EntityTabsListProps) { const styles = useStyles(); const { t } = useTranslationRef(catalogTranslationRef); - const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props; + const { + tabs: items, + selectedIndex = 0, + showIcons, + groupDefinitions, + defaultContentOrder = 'title', + } = props; - const groups = useMemo( + const aliasToGroup = useMemo( () => - items.reduce((result, tab) => { - const group = tab.group ? groupDefinitions[tab.group] : undefined; - const groupOrId = group && tab.group ? tab.group : tab.id; - result[groupOrId] = result[groupOrId] ?? { - group, - items: [], - }; - result[groupOrId].items.push(tab); - return result; - }, {} as Record), - [items, groupDefinitions], + Object.entries(groupDefinitions).reduce((map, [groupId, def]) => { + for (const alias of def.aliases ?? []) { + map[alias] = groupId; + } + return map; + }, {} as Record), + [groupDefinitions], ); + const groups = useMemo(() => { + const byKey = items.reduce((result, tab) => { + const resolvedGroupId = resolveGroupId( + tab.group, + groupDefinitions, + aliasToGroup, + ); + const group = resolvedGroupId + ? groupDefinitions[resolvedGroupId] + : undefined; + const groupOrId = group && resolvedGroupId ? resolvedGroupId : tab.id; + result[groupOrId] = result[groupOrId] ?? { + group, + items: [], + }; + result[groupOrId].items.push(tab); + return result; + }, {} as Record); + + const groupOrder = Object.keys(groupDefinitions); + const sorted = Object.entries(byKey).sort(([a], [b]) => { + const ai = groupOrder.indexOf(a); + const bi = groupOrder.indexOf(b); + if (ai !== -1 && bi !== -1) { + return ai - bi; + } + if (ai !== -1) { + return -1; + } + if (bi !== -1) { + return 1; + } + return 0; + }); + + for (const [id, tabGroup] of sorted) { + const groupDef = groupDefinitions[id]; + if (groupDef) { + const order = groupDef.contentOrder ?? defaultContentOrder; + if (order === 'title') { + tabGroup.items.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), + ); + } + } + } + + return sorted; + }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); + const selectedItem = items[selectedIndex]; + const selectedGroup = resolveGroupId( + selectedItem?.group, + groupDefinitions, + aliasToGroup, + ); return ( - {Object.entries(groups).map(([id, tabGroup]) => ( + {groups.map(([id, tabGroup]) => ( { expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview'); }); + it('Should resolve group aliases', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + groups: [ + { + docs: { title: 'Docs', aliases: ['documentation'] }, + }, + ], + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole('tab', { name: /Docs/ })); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /TechDocs/ }), + ).toHaveAttribute('href', '/techdocs'), + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute( + 'href', + '/apidocs', + ), + ); + }); + + it('Should sort content by title by default', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('ApiDocs'); + expect(buttons[1]).toHaveTextContent('TechDocs'); + }); + + it('Should preserve natural order when configured', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + contentOrder: 'natural', + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('TechDocs'); + expect(buttons[1]).toHaveTextContent('ApiDocs'); + }); + + it('Should support per-group content order override', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + contentOrder: 'title', + groups: [ + { + documentation: { + title: 'Documentation', + contentOrder: 'natural', + }, + }, + ], + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); + + await userEvent.click( + await screen.findByRole('tab', { name: /Documentation/ }), + ); + + const buttons = await screen.findAllByRole('button', { + name: /Docs/, + }); + expect(buttons[0]).toHaveTextContent('TechDocs'); + expect(buttons[1]).toHaveTextContent('ApiDocs'); + }); + it('Should render groups on the correct order', async () => { const tester = createExtensionTester( Object.assign({ namespace: 'catalog' }, catalogEntityPage), diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index acb9ec8d51..4d37a18c98 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -109,10 +109,14 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ z.object({ title: z.string(), icon: z.string().optional(), + aliases: z.array(z.string()).optional(), + contentOrder: z.enum(['title', 'natural']).optional(), }), ), ) .optional(), + contentOrder: z => + z.enum(['title', 'natural']).optional().default('title'), showNavItemIcons: z => z.boolean().optional().default(false), }, }, @@ -174,6 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} + defaultContentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( diff --git a/plugins/devtools/src/api/DevToolsClient.test.ts b/plugins/devtools/src/api/DevToolsClient.test.ts new file mode 100644 index 0000000000..d1972cd528 --- /dev/null +++ b/plugins/devtools/src/api/DevToolsClient.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DevToolsClient } from './DevToolsClient'; + +describe('DevToolsClient', () => { + const mockBaseUrl = 'http://backstage/api/catalog'; + const discoveryApi = { + getBaseUrl: async (pluginId: string) => `${mockBaseUrl}/${pluginId}`, + }; + const mockFetch = jest.fn(); + const fetchApi = { fetch: mockFetch }; + + let client: DevToolsClient; + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'triggered' }), + }); + client = new DevToolsClient({ discoveryApi, fetchApi }); + }); + + afterEach(() => jest.resetAllMocks()); + + it('should URL-encode the taskId when triggering a scheduled task', async () => { + await client.triggerScheduledTask( + 'my-plugin', + 'task/with/slashes:and-special-chars', + ); + + expect(mockFetch).toHaveBeenCalledWith( + `${mockBaseUrl}/my-plugin/.backstage/scheduler/v1/tasks/task%2Fwith%2Fslashes%3Aand-special-chars/trigger`, + { method: 'POST' }, + ); + }); +}); diff --git a/plugins/devtools/src/api/DevToolsClient.ts b/plugins/devtools/src/api/DevToolsClient.ts index cd4f53fa81..0577757838 100644 --- a/plugins/devtools/src/api/DevToolsClient.ts +++ b/plugins/devtools/src/api/DevToolsClient.ts @@ -70,7 +70,7 @@ export class DevToolsClient implements DevToolsApi { ): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl(plugin)}/`; const url = new URL( - `.backstage/scheduler/v1/tasks/${taskId}/trigger`, + `.backstage/scheduler/v1/tasks/${encodeURIComponent(taskId)}/trigger`, baseUrl, ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 038700c4a5..c75572bf20 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -26,6 +26,7 @@ import { CallToolResultSchema, ListToolsResultSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { InputError, NotFoundError } from '@backstage/errors'; describe('McpService', () => { it('should list the available actions as tools in the mcp backend', async () => { @@ -343,4 +344,116 @@ describe('McpService', () => { }), ); }); + + it('should forward the original InputError when an action throws one', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'failing-action', + title: 'Failing', + description: 'An action that throws InputError', + schema: { + input: z => z.object({ value: z.string() }), + output: z => z.object({}), + }, + action: async () => { + throw new InputError('the value was invalid'); + }, + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ + name: 'test client', + version: '1.0', + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'failing-action', arguments: { value: 'test' } }, + }, + CallToolResultSchema, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'InputError: the value was invalid', + }, + ], + isError: true, + }); + }); + + it('should forward the original NotFoundError when an action throws one', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'not-found-action', + title: 'Not Found', + description: 'An action that throws NotFoundError', + schema: { + input: z => z.object({ id: z.string() }), + output: z => z.object({}), + }, + action: async () => { + throw new NotFoundError('entity does not exist'); + }, + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ + name: 'test client', + version: '1.0', + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'not-found-action', arguments: { id: 'abc' } }, + }, + CallToolResultSchema, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'NotFoundError: entity does not exist', + }, + ], + isError: true, + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/services/handleErrors.test.ts b/plugins/mcp-actions-backend/src/services/handleErrors.test.ts new file mode 100644 index 0000000000..17fe6de0a6 --- /dev/null +++ b/plugins/mcp-actions-backend/src/services/handleErrors.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + InputError, + NotFoundError, + NotAllowedError, + ForwardedError, + ResponseError, +} from '@backstage/errors'; +import { handleErrors } from './handleErrors'; + +describe('handleErrors', () => { + it('should return the error description for a known error', async () => { + const result = await handleErrors(async () => { + throw new InputError('bad input'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: bad input' }], + isError: true, + }); + }); + + it('should return the error description for a NotFoundError', async () => { + const result = await handleErrors(async () => { + throw new NotFoundError('entity not found'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'NotFoundError: entity not found' }], + isError: true, + }); + }); + + it('should return the error description for a NotAllowedError', async () => { + const result = await handleErrors(async () => { + throw new NotAllowedError('forbidden'); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'NotAllowedError: forbidden' }], + isError: true, + }); + }); + + it('should rethrow an unknown error', async () => { + await expect( + handleErrors(async () => { + throw new Error('unknown problem'); + }), + ).rejects.toThrow('unknown problem'); + }); + + it('should handle a ForwardedError that inherits the cause name', async () => { + const result = await handleErrors(async () => { + throw new ForwardedError( + 'wrapper message', + new InputError('original error'), + ); + }); + + // ForwardedError inherits name from cause and CustomErrorBase + // concatenates the cause message into the full message + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'InputError: wrapper message; caused by InputError: original error', + }, + ], + isError: true, + }); + }); + + it('should extract the cause from a ResponseError', async () => { + const response = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + error: { name: 'InputError', message: 'bad value' }, + response: { statusCode: 400 }, + }), + }; + + const responseError = await ResponseError.fromResponse(response as any); + + const result = await handleErrors(async () => { + throw responseError; + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: bad value' }], + isError: true, + }); + }); + + it('should recursively extract through nested ResponseErrors', async () => { + const innerResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => + JSON.stringify({ + error: { + name: 'ResponseError', + message: 'Request failed with 400 Bad Request', + cause: { name: 'InputError', message: 'deeply nested error' }, + }, + response: { statusCode: 400 }, + }), + }; + + const responseError = await ResponseError.fromResponse( + innerResponse as any, + ); + + const result = await handleErrors(async () => { + throw responseError; + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'InputError: deeply nested error' }], + isError: true, + }); + }); +}); diff --git a/plugins/mcp-actions-backend/src/services/handleErrors.ts b/plugins/mcp-actions-backend/src/services/handleErrors.ts index 5266c80e75..97070bb723 100644 --- a/plugins/mcp-actions-backend/src/services/handleErrors.ts +++ b/plugins/mcp-actions-backend/src/services/handleErrors.ts @@ -35,14 +35,14 @@ const knownErrors = new Set([ 'ServiceUnavailableError', ]); -// Extracts the cause error, if the provided error is `ResponseError` or -// `ForwardedError` with a cause. +// Recursively extracts the innermost cause from ResponseError or +// ForwardedError wrappers to surface the original error. function extractCause(err: ErrorLike): ErrorLike { if ( (err.name === 'ResponseError' || err instanceof ForwardedError) && isError(err.cause) ) { - return err.cause; + return extractCause(err.cause); } return err; } diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 1b6fb99838..c546f265be 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { AnyApiRef } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiRef } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react'; @@ -198,6 +199,9 @@ export type FormFieldExtensionData< schema?: FieldSchema, z.output>; }; +// @alpha (undocumented) +export const formFieldsApiRef: ApiRef; + // @alpha (undocumented) export type FormValidation = { [name: string]: FieldValidation | FormValidation; @@ -272,6 +276,12 @@ export type ScaffolderFormDecoratorContext< ) => void; }; +// @alpha (undocumented) +export interface ScaffolderFormFieldsApi { + // (undocumented) + loadFormFields(): Promise; +} + // @alpha (undocumented) export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, diff --git a/plugins/scaffolder-react/src/next/api/index.ts b/plugins/scaffolder-react/src/next/api/index.ts index 9988f4dd82..1ecddeaf41 100644 --- a/plugins/scaffolder-react/src/next/api/index.ts +++ b/plugins/scaffolder-react/src/next/api/index.ts @@ -14,4 +14,12 @@ * limitations under the License. */ -export { type FormField } from './types'; +import { createApiRef } from '@backstage/frontend-plugin-api'; +import { ScaffolderFormFieldsApi } from './types'; + +export { type FormField, type ScaffolderFormFieldsApi } from './types'; + +/** @alpha */ +export const formFieldsApiRef = createApiRef({ + id: 'plugin.scaffolder.form-fields-loader', +}); diff --git a/plugins/scaffolder-react/src/next/api/types.ts b/plugins/scaffolder-react/src/next/api/types.ts index 38929ee189..e3ab855708 100644 --- a/plugins/scaffolder-react/src/next/api/types.ts +++ b/plugins/scaffolder-react/src/next/api/types.ts @@ -18,3 +18,8 @@ export interface FormField { readonly $$type: '@backstage/scaffolder/FormField'; } + +/** @alpha */ +export interface ScaffolderFormFieldsApi { + loadFormFields(): Promise; +} diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index e553e706da..9baa74407f 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -17,6 +17,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FilterPredicate } from '@backstage/filter-predicates'; import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; +import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { IconComponent } from '@backstage/frontend-plugin-api'; @@ -31,6 +32,7 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormFieldsApi } from '@backstage/plugin-scaffolder-react/alpha'; import { SubRouteRef } from '@backstage/core-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; @@ -480,8 +482,7 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ // @alpha (undocumented) export const formDecoratorsApiRef: ApiRef; -// @alpha (undocumented) -export const formFieldsApiRef: ApiRef; +export { formFieldsApiRef }; // @alpha @deprecated export type FormProps = Pick< @@ -502,11 +503,7 @@ export interface ScaffolderFormDecoratorsApi { getFormDecorators(): Promise; } -// @alpha (undocumented) -export interface ScaffolderFormFieldsApi { - // (undocumented) - loadFormFields(): Promise; -} +export { ScaffolderFormFieldsApi }; // @public (undocumented) export type ScaffolderTemplateEditorClassKey = diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 8c17f8cc1c..91306bf5fd 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -25,11 +25,13 @@ import { } from '@backstage/frontend-plugin-api'; import { rootRouteRef } from '../routes'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; +import { + FormFieldBlueprint, + formFieldsApiRef, +} from '@backstage/plugin-scaffolder-react/alpha'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderClient } from '../api'; -import { formFieldsApiRef } from './formFieldsApi'; export const scaffolderPage = PageBlueprint.makeWithOverrides({ inputs: { diff --git a/plugins/scaffolder/src/alpha/formFieldsApi.ts b/plugins/scaffolder/src/alpha/formFieldsApi.ts index bcc2190f7e..b065af9a82 100644 --- a/plugins/scaffolder/src/alpha/formFieldsApi.ts +++ b/plugins/scaffolder/src/alpha/formFieldsApi.ts @@ -16,25 +16,14 @@ import { ApiBlueprint, - createApiRef, createExtensionInput, } from '@backstage/frontend-plugin-api'; import { FormFieldBlueprint, - type FormField, + formFieldsApiRef, } from '@backstage/plugin-scaffolder-react/alpha'; import { OpaqueFormField } from '@internal/scaffolder'; -/** @alpha */ -export interface ScaffolderFormFieldsApi { - loadFormFields(): Promise; -} - -/** @alpha */ -const formFieldsApiRef = createApiRef({ - id: 'plugin.scaffolder.form-fields-loader', -}); - export const formFieldsApi = ApiBlueprint.makeWithOverrides({ name: 'form-fields', inputs: { @@ -69,4 +58,7 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({ }, }); -export { formFieldsApiRef }; +export { + formFieldsApiRef, + type ScaffolderFormFieldsApi, +} from '@backstage/plugin-scaffolder-react/alpha'; diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index 409bf03269..6acc6b5e6d 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -223,6 +223,7 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ { path: 'docs', title: 'TechDocs', + group: 'documentation', routeRef: rootCatalogDocsRouteRef, loader: () => { // Merge addons from the API with old-style direct attachments diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index fbedcfe077..06383539c3 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -36,6 +36,7 @@ const IGNORED_WHEN_LISTING = [ const IGNORED_WHEN_EXPLICIT = [ /^ADOPTERS\.md$/, /^OWNERS\.md$/, + /^.*[/\\]CHANGELOG\.md$/, // generated from changesets anyway - THOSE should have been checked earlier /^.*[/\\]knip-report\.md$/, ]; @@ -71,7 +72,7 @@ async function exitIfMissingVale() { try { // eslint-disable-next-line @backstage/no-undeclared-imports await require('command-exists')('vale'); - } catch (e) { + } catch { console.log( `Language linter (vale) was not found. Please install vale linter (https://vale.sh/docs/vale-cli/installation/).\n`, ); @@ -101,7 +102,92 @@ async function runVale(files) { return true; } +async function ciCheck(prFilesPath) { + const content = await fs.readFile(prFilesPath, 'utf8'); + const prFiles = content.split('\n').filter(f => f.trim()); + + const mdFiles = prFiles + .filter(f => f.endsWith('.md')) + .filter(f => !IGNORED_WHEN_LISTING.some(p => p.test(f))); + + if (mdFiles.length === 0) { + console.log('No documentation files to check.'); + return; + } + + console.log(`Checking ${mdFiles.length} changed documentation file(s)...`); + + const result = spawnSync( + 'vale', + [ + '--config', + resolvePath(rootDir, '.vale.ini'), + '--output=JSON', + ...mdFiles, + ], + { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }, + ); + + if (result.error) { + console.error('Failed to run vale:', result.error.message); + process.exit(1); + } + + let issueCount = 0; + + if (result.stdout && result.stdout.trim()) { + try { + const data = JSON.parse(result.stdout); + for (const [file, alerts] of Object.entries(data)) { + for (const alert of alerts) { + const severityLevels = { + error: 'error', + warning: 'warning', + suggestion: 'notice', + }; + const level = severityLevels[alert.Severity] ?? 'notice'; + const col = alert.Span ? alert.Span[0] : 1; + const endCol = alert.Span ? `,endColumn=${alert.Span[1] + 1}` : ''; + console.log( + `::${level} file=${file},line=${alert.Line},col=${col}${endCol},title=${alert.Check}::${alert.Message}`, + ); + issueCount++; + } + } + } catch { + console.error('Failed to parse vale output:'); + console.error(result.stdout); + process.exit(1); + } + } + + if (result.stderr && result.stderr.trim()) { + console.error(result.stderr); + } + + if (issueCount > 0) { + console.log( + `\nFound ${issueCount} documentation quality issue(s). Please review the annotations above.`, + ); + } + + if (result.status !== 0) { + process.exit(1); + } +} + async function main() { + if (process.argv.includes('--ci')) { + const idx = process.argv.indexOf('--ci'); + const prFilesPath = process.argv[idx + 1]; + if (!prFilesPath) { + console.error('Usage: check-docs-quality.js --ci '); + process.exit(1); + } + await ciCheck(prFilesPath); + return; + } + if (process.argv.includes('--ci-args')) { const files = await listFiles(); diff --git a/yarn.lock b/yarn.lock index 14a3f7453e..9a951c6f98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,9 +3272,12 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" + "@types/yarnpkg__lockfile": "npm:^1.1.4" + "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" fs-extra: "npm:^11.2.0" semver: "npm:^7.5.3" + yaml: "npm:^2.0.0" zod: "npm:^3.25.76" languageName: unknown linkType: soft @@ -3343,11 +3346,8 @@ __metadata: "@types/terser-webpack-plugin": "npm:^5.0.4" "@types/webpack-env": "npm:^1.15.2" "@types/webpack-sources": "npm:^3.2.3" - "@types/yarnpkg__lockfile": "npm:^1.1.4" "@typescript-eslint/eslint-plugin": "npm:^8.17.0" "@typescript-eslint/parser": "npm:^8.16.0" - "@yarnpkg/lockfile": "npm:^1.1.0" - "@yarnpkg/parsers": "npm:^3.0.0" bfj: "npm:^9.0.2" buffer: "npm:^6.0.3" chalk: "npm:^4.0.0" @@ -10906,6 +10906,15 @@ __metadata: languageName: node linkType: hard +"@kwsites/file-exists@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/file-exists@npm:1.1.1" + dependencies: + debug: "npm:^4.1.1" + checksum: 10/4ff945de7293285133aeae759caddc71e73c4a44a12fac710fdd4f574cce2671a3f89d8165fdb03d383cfc97f3f96f677d8de3c95133da3d0e12a123a23109fe + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.3 resolution: "@leichtgewicht/ip-codec@npm:2.0.3" @@ -21491,14 +21500,14 @@ __metadata: languageName: node linkType: hard -"@types/dockerode@npm:^3.3.47": - version: 3.3.47 - resolution: "@types/dockerode@npm:3.3.47" +"@types/dockerode@npm:^4.0.1": + version: 4.0.1 + resolution: "@types/dockerode@npm:4.0.1" dependencies: "@types/docker-modem": "npm:*" "@types/node": "npm:*" "@types/ssh2": "npm:*" - checksum: 10/b840ae7872398a3b02e5789006a69d0cf5bb7ec6c0eb714c7ca04ca093add8de4cd06204ecd8f01388e347e62927cf4c599e8b7dba53e81c1350910da766d517 + checksum: 10/d16b3a69a20fac269b2317a978442a6752dea158729a264511a3812dcb4c756e0ee079b39b61068cee182f268661e27e32aa7a28815262f0e088ffeb9f2f48c5 languageName: node linkType: hard @@ -26091,9 +26100,9 @@ __metadata: linkType: hard "basic-ftp@npm:^5.0.2": - version: 5.0.3 - resolution: "basic-ftp@npm:5.0.3" - checksum: 10/8f69811a7f4088c5ae39025f1a662522aad517b4065365fbbe80676dc9ccf7a188463dfcb2d9573af2af859d7de70015f612c20a04a311b520efbf7c21dc1ff2 + version: 5.2.0 + resolution: "basic-ftp@npm:5.2.0" + checksum: 10/f5a15d789aa98859af4da9e976154b2aeae19052e1762dc68d259d2bce631dafa40c667aa06d7346cd630aa6f9cc9a26f515b468e0bd24243fbae2149c7d01ad languageName: node linkType: hard @@ -29595,12 +29604,12 @@ __metadata: languageName: node linkType: hard -"docker-compose@npm:^1.3.0": - version: 1.3.0 - resolution: "docker-compose@npm:1.3.0" +"docker-compose@npm:^1.3.1": + version: 1.3.1 + resolution: "docker-compose@npm:1.3.1" dependencies: yaml: "npm:^2.2.2" - checksum: 10/7c1b395fc104031eadef08cac0c028af11fd9e3084265dca9e17bc76ad27c2bfe96d8b74df258bb64b198f9e0f1792a7f2c63123cddc0d60bb776f211d54cfb2 + checksum: 10/39e0e4b96c0dea8c88df9770a4d102a9e2437ad0af5f5f28a9f88fbf164eb4fb81fde0d8740500bd451abafab74072e5d5cfa35cc85254722bbe2b7dbc7c033a languageName: node linkType: hard @@ -39697,6 +39706,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^3.0.1": + version: 3.0.1 + resolution: "mkdirp@npm:3.0.1" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 10/16fd79c28645759505914561e249b9a1f5fe3362279ad95487a4501e4467abeb714fd35b95307326b8fd03f3c7719065ef11a6f97b7285d7888306d1bd2232ba + languageName: node + linkType: hard + "mock-socket@npm:^9.3.0": version: 9.3.1 resolution: "mock-socket@npm:9.3.1" @@ -40575,13 +40593,13 @@ __metadata: linkType: hard "nodemon@npm:^3.0.1": - version: 3.1.11 - resolution: "nodemon@npm:3.1.11" + version: 3.1.14 + resolution: "nodemon@npm:3.1.14" dependencies: chokidar: "npm:^3.5.2" debug: "npm:^4" ignore-by-default: "npm:^1.0.1" - minimatch: "npm:^3.1.2" + minimatch: "npm:^10.2.1" pstree.remy: "npm:^1.1.8" semver: "npm:^7.5.3" simple-update-notifier: "npm:^2.0.0" @@ -40590,7 +40608,7 @@ __metadata: undefsafe: "npm:^2.0.5" bin: nodemon: bin/nodemon.js - checksum: 10/0f43d2c70abe0764e26e438dbe0c78bc429746a558dabf5dd94b13f6f3a79b4bd7d5793347acafddaee5eab594a806c2ad43efc999d342e28d185661718da8dc + checksum: 10/187aa93fc461bb001bac5de63df49a38a7b1cb3c60a5fd6a3b0f2b15da68fe5adfd0f91244be2fd1f57263cbd351e3113cb04c4edf4af23699dd95b2782fbf9b languageName: node linkType: hard @@ -43467,12 +43485,13 @@ __metadata: languageName: node linkType: hard -"properties-reader@npm:^2.3.0": - version: 2.3.0 - resolution: "properties-reader@npm:2.3.0" +"properties-reader@npm:^3.0.1": + version: 3.0.1 + resolution: "properties-reader@npm:3.0.1" dependencies: - mkdirp: "npm:^1.0.4" - checksum: 10/0b41eb4136dc278ae0d97968ccce8de2d48d321655b319192e31f2424f1c6e052182204671e65aa8967216360cb3e7cbd9129830062e058fe9d6a1d74964c29a + "@kwsites/file-exists": "npm:^1.1.1" + mkdirp: "npm:^3.0.1" + checksum: 10/5a96c33dad9925c399bf3c3e343f3eb801fa1b4802330d1bfbf1e7d7639dafa935deb6c60be38b292544b57793b03ba1eaf5191cc3a7d018cda7bd6ead19a15c languageName: node linkType: hard @@ -48491,25 +48510,25 @@ __metadata: linkType: hard "testcontainers@npm:^11.9.0": - version: 11.11.0 - resolution: "testcontainers@npm:11.11.0" + version: 11.12.0 + resolution: "testcontainers@npm:11.12.0" dependencies: "@balena/dockerignore": "npm:^1.0.2" - "@types/dockerode": "npm:^3.3.47" + "@types/dockerode": "npm:^4.0.1" archiver: "npm:^7.0.1" async-lock: "npm:^1.4.1" byline: "npm:^5.0.0" debug: "npm:^4.4.3" - docker-compose: "npm:^1.3.0" + docker-compose: "npm:^1.3.1" dockerode: "npm:^4.0.9" get-port: "npm:^7.1.0" proper-lockfile: "npm:^4.1.2" - properties-reader: "npm:^2.3.0" + properties-reader: "npm:^3.0.1" ssh-remote-port-forward: "npm:^1.0.4" tar-fs: "npm:^3.1.1" tmp: "npm:^0.2.5" - undici: "npm:^7.16.0" - checksum: 10/6a05baf5cd9ff0eab7ecb6c31f10e1204d9b4a88698f2ecea9f4008a6ca9130302a83f0b79e303bfda577de3dd26c74689bb4636720b28d7dd3753ce50422865 + undici: "npm:^7.22.0" + checksum: 10/d15ebc0be1c09192c4c26060bfec2f7ad2cf338624bed4a1ca2d548419853e54ea7deb8cd21cca21d55976552f09a00093ab7be822017e045f4c29e09b7e9ba2 languageName: node linkType: hard @@ -49676,7 +49695,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:^7.16.0, undici@npm:^7.2.3": +"undici@npm:^7.2.3, undici@npm:^7.22.0": version: 7.22.0 resolution: "undici@npm:7.22.0" checksum: 10/a7a1813ba4b74c0d46cc8dd160386202c05699ffc487c5d882cf40e6d2435c8d6faff3b8f8675d09bd1ef0386e370675c26b59b9a8c8b3f17b9f82a42236a927