Merge remote-tracking branch 'origin/master' into package-workspaces
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Internal refactor to move shared utilities into their consuming modules, reducing cross-module dependencies.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-node': patch
|
||||
---
|
||||
|
||||
Added `toString()` method to `Lockfile` for serializing lockfiles back to string format.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `migrate package-exports` command has been removed. Use `repo fix` instead.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Migrated internal versioning utilities to use `@backstage/cli-node` instead of a local implementation.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
<Box bg="neutral-2">...</Box>
|
||||
|
||||
// After
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">...</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
**Affected components:** Box, Flex, Grid, Card, Accordion, Popover, Tooltip, Dialog, Menu
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Added `documentation` as the default entity content group for the TechDocs entity content tab.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Fixed entity page tab groups not respecting the ordering from the `groups` configuration.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
---
|
||||
|
||||
Added back `formFieldsApiRef` and `ScaffolderFormFieldsApi` as alpha exports.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Internal refactor of CLI command modules.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-node': patch
|
||||
---
|
||||
|
||||
Added `hasBackstageYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Added back the `formFieldsApiRef` and `ScaffolderFormFieldsApi` alpha exports that were unintentionally removed.
|
||||
@@ -527,6 +527,7 @@ unassign
|
||||
unbreak
|
||||
Unconference
|
||||
undici
|
||||
ungrouped
|
||||
unicode
|
||||
unmanaged
|
||||
unmount
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'microsite/**'
|
||||
- '!microsite/data/**'
|
||||
- 'beps/**'
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Fixes entity page tab groups not respecting the ordering from the groups configuration.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
+44
-6
@@ -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 */}
|
||||
<TestApiProvider apis={apis}>
|
||||
<AlertDisplay />
|
||||
<Story />
|
||||
{Array.from({
|
||||
length:
|
||||
selectedBackground === 'app'
|
||||
? 0
|
||||
: parseInt(selectedBackground.split('-')[1], 10),
|
||||
}).reduce<React.ReactNode>(
|
||||
children => (
|
||||
<Box bg="neutral" p="4">
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
<Story />,
|
||||
)}
|
||||
</TestApiProvider>
|
||||
</UnifiedThemeProvider>
|
||||
);
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
export const surfacesSnippet = `<Flex direction="column" gap="4">
|
||||
<Box bg="neutral-1">
|
||||
<Box bg="neutral">
|
||||
<Button variant="secondary">Hello World</Button>
|
||||
</Box>
|
||||
<Box bg="neutral-1">
|
||||
<Box bg="neutral">
|
||||
<Button variant="secondary">Hello World</Button>
|
||||
</Box>
|
||||
</Flex>`;
|
||||
|
||||
export const adaptiveSnippet = `<Box bg="neutral-1">
|
||||
export const adaptiveSnippet = `<Box bg="neutral">
|
||||
<Card> {/* automatically set background to neutral-2 */}
|
||||
<Button variant="secondary">Button with background set to neutral-3</Button>
|
||||
</Card>
|
||||
</Box>`;
|
||||
|
||||
export const customCardSnippet = `<Box bg="autoIncrement">
|
||||
export const customCardSnippet = `<Box bg="neutral">
|
||||
<Text>Hello World</Text>
|
||||
</Box>`;
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||

|
||||
|
||||
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!
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+15
-27
@@ -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<string, ActionsRegistryActionOptions<any, any>> =
|
||||
@@ -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;
|
||||
|
||||
+28
-5
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<GetEntitiesByRefsResponse> {
|
||||
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<QueryEntitiesResponse> {
|
||||
const params: Partial<
|
||||
Parameters<typeof this.apiClient.getEntitiesByQuery>[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<GetEntitiesByQuery['query']> = {};
|
||||
|
||||
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<QueryEntitiesResponse> {
|
||||
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<GetEntityFacetsResponse> {
|
||||
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<GetEntityFacetsResponse> {
|
||||
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}
|
||||
*/
|
||||
|
||||
@@ -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<string>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* @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<TypedResponse<EntitiesQueryResponse>> {
|
||||
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<TypedResponse<EntityFacetsResponse>> {
|
||||
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 -
|
||||
|
||||
+4
@@ -24,4 +24,8 @@
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: Array<string>;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
+38
@@ -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<QueryEntitiesByPredicateRequestOrderByInner>;
|
||||
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
+27
@@ -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<string>;
|
||||
}
|
||||
+34
@@ -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';
|
||||
+30
@@ -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<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<GetEntitiesByRefsResponse> {
|
||||
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<QueryEntitiesResponse> {
|
||||
// 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<GetEntityFacetsResponse> {
|
||||
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<string, number>();
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '../../..'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ export class GitUtils {
|
||||
static readFileAtRef(path: string, ref: string): Promise<string>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function hasBackstageYarnPlugin(workspaceDir?: string): Promise<boolean>;
|
||||
|
||||
// @public
|
||||
export function isMonoRepo(): Promise<boolean>;
|
||||
|
||||
@@ -111,6 +114,7 @@ export class Lockfile {
|
||||
keys(): IterableIterator<string>;
|
||||
static load(path: string): Promise<Lockfile>;
|
||||
static parse(content: string): Lockfile;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -220,6 +224,17 @@ export function runWorkerQueueThreads<TItem, TResult, TContext>(
|
||||
results: TResult[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export class SuccessCache {
|
||||
// (undocumented)
|
||||
static create(options: { name: string; basePath?: string }): SuccessCache;
|
||||
// (undocumented)
|
||||
read(): Promise<Set<string>>;
|
||||
static trimPaths(input: string): string;
|
||||
// (undocumented)
|
||||
write(newEntries: Iterable<string>): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type WorkerQueueThreadsOptions<TItem, TResult, TContext> = {
|
||||
items: Iterable<TItem>;
|
||||
|
||||
+15
-3
@@ -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<Set<string>> {
|
||||
@@ -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}`),
|
||||
);
|
||||
Vendored
+17
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, LockfileQueryEntry[]>;
|
||||
private readonly data: LockfileData;
|
||||
private readonly legacy: boolean;
|
||||
|
||||
private constructor(
|
||||
packages: Map<string, LockfileQueryEntry[]>,
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
+15
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> - 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<boolean> {
|
||||
const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml');
|
||||
export async function hasBackstageYarnPlugin(
|
||||
workspaceDir?: string,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, LockfileQueryEntry[]>();
|
||||
|
||||
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<string, LockfileQueryEntry[]>;
|
||||
private readonly data: LockfileData;
|
||||
private readonly legacy: boolean;
|
||||
|
||||
private constructor(
|
||||
packages: Map<string, LockfileQueryEntry[]>,
|
||||
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<string> {
|
||||
return this.packages.keys();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.legacy
|
||||
? legacyStringifyLockfile(this.data)
|
||||
: NEW_HEADER + stringifySyml(this.data);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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({
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
@@ -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 <path>',
|
||||
'Config files to load instead of app-config.yaml',
|
||||
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
|
||||
Array<string>(),
|
||||
] 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { readEntryPoints } from '../entryPoints';
|
||||
import {
|
||||
createTypeDistProject,
|
||||
getEntryPointDefaultFeatureType,
|
||||
} from '../../../../lib/typeDistProject';
|
||||
} from '../typeDistProject';
|
||||
import {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
defaultRemoteSharedDependencies,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AppConfig[]>((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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
};
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 <path>',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user