Merge branch 'master' into range-slider-component

This commit is contained in:
root
2026-03-05 14:09:58 +05:30
1360 changed files with 28424 additions and 12143 deletions
+11
View File
@@ -1,5 +1,16 @@
# @backstage/app-defaults
## 1.7.6-next.0
### Patch Changes
- Updated dependencies
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-permission-react@0.4.41-next.0
## 1.7.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/app-defaults",
"version": "1.7.5",
"version": "1.7.6-next.0",
"description": "Provides the default wiring of a Backstage App",
"backstage": {
"role": "web-library"
+8
View File
@@ -1,5 +1,13 @@
# app-example-plugin
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/core-components@0.18.8-next.0
## 0.0.32
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-example-plugin",
"version": "0.0.32",
"version": "0.0.33-next.0",
"description": "Backstage internal example plugin",
"backstage": {
"role": "frontend-plugin",
+88
View File
@@ -1,5 +1,93 @@
# example-app-legacy
## 0.2.119-next.1
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.1
- @backstage/cli@0.36.0-next.1
- @backstage/plugin-devtools@0.1.37-next.1
- @backstage/plugin-scaffolder@1.35.5-next.1
- @backstage/plugin-api-docs@0.13.5-next.1
- @backstage/plugin-techdocs@1.17.1-next.1
- @backstage/plugin-catalog@1.34.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.1
- @backstage/plugin-scaffolder-react@1.19.8-next.1
- @backstage/plugin-mui-to-bui@0.2.5-next.1
- @backstage/plugin-catalog-graph@0.5.8-next.1
- @backstage/plugin-catalog-import@0.13.11-next.1
- @backstage/plugin-home@0.9.3-next.1
- @backstage/plugin-org@0.6.50-next.1
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/integration-react@1.2.16-next.1
- @backstage/theme@0.7.2
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.1
- @backstage/plugin-kubernetes-cluster@0.0.35-next.1
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-search@1.6.2-next.1
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.1
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.1
## 0.2.119-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-mui-to-bui@0.2.5-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.2.118
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app-legacy",
"version": "0.2.118",
"version": "0.2.119-next.1",
"backstage": {
"role": "frontend"
},
@@ -166,7 +166,7 @@ const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6} xs={12}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
@@ -330,7 +330,7 @@ const userPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
@@ -349,7 +349,7 @@ const groupPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
@@ -374,7 +374,7 @@ const systemPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -418,7 +418,7 @@ const domainPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -437,7 +437,7 @@ const resourcePage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
+100
View File
@@ -1,5 +1,105 @@
# example-app
## 0.0.33-next.1
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.1
- @backstage/cli@0.36.0-next.1
- @backstage/plugin-devtools@0.1.37-next.1
- @backstage/plugin-scaffolder@1.35.5-next.1
- @backstage/plugin-api-docs@0.13.5-next.1
- @backstage/plugin-techdocs@1.17.1-next.1
- @backstage/plugin-catalog@1.34.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.1
- @backstage/plugin-scaffolder-react@1.19.8-next.1
- @backstage/plugin-app@0.4.1-next.1
- @backstage/plugin-app-visualizer@0.2.1-next.1
- @backstage/plugin-catalog-graph@0.5.8-next.1
- @backstage/plugin-catalog-import@0.13.11-next.1
- @backstage/plugin-home@0.9.3-next.1
- @backstage/plugin-org@0.6.50-next.1
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-compat-api@0.5.9-next.1
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/frontend-defaults@0.4.1-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/integration-react@1.2.16-next.1
- @backstage/theme@0.7.2
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-auth@0.1.6-next.0
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.1
- @backstage/plugin-kubernetes-cluster@0.0.35-next.1
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-search@1.6.2-next.1
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.1
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.1
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-app@0.4.1-next.0
- @backstage/plugin-app-visualizer@0.2.1-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-compat-api@0.5.9-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/frontend-defaults@0.4.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-auth@0.1.6-next.0
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.0.32
### Patch Changes
+8 -6
View File
@@ -48,6 +48,8 @@ app:
- page:catalog/entity:
config:
showNavItemIcons: true
# default content order for all groups, can be 'title' or 'natural'
# defaultContentOrder: 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app",
"version": "0.0.32",
"version": "0.0.33-next.1",
"backstage": {
"role": "frontend"
},
+9
View File
@@ -1,5 +1,14 @@
# @backstage/backend-app-api
## 1.5.1-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
## 1.5.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-app-api",
"version": "1.5.0",
"version": "1.5.1-next.0",
"description": "Core API used by Backstage backend apps",
"backstage": {
"role": "node-library"
@@ -899,7 +899,7 @@ describe('BackendInitializer', () => {
});
it('should reject duplicate plugins', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -922,13 +922,24 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(false);
});
it('should reject duplicate modules', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(testPlugin);
init.add(
createBackendModule({
@@ -954,8 +965,202 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' is already registered",
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
const modResult = plugin?.modules.find(
m =>
m.failure?.error.message ===
"Module 'mod' for plugin 'test' is already registered",
);
expect(modResult).toBeDefined();
expect(modResult?.failure?.allowed).toBe(false);
});
it('should allow other plugins to continue when one has a registration error', async () => {
const pluginAInit = jest.fn(async () => {});
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerInit({
deps: {},
init: pluginAInit,
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should have started successfully
expect(pluginAInit).toHaveBeenCalled();
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should have a registration failure
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"Plugin 'plugin-b' is already registered",
);
});
it('should permit registration errors for plugins with onPluginBootFailure: continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
plugins: { test: { onPluginBootFailure: 'continue' } },
},
},
},
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const { result } = await init.start();
const plugin = result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(true);
});
it('should attribute duplicate extension point errors to the correct plugin', async () => {
const extensionPoint = createExtensionPoint<string>({ id: 'shared-ext' });
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'a');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'b');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should succeed (registered first)
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should fail due to duplicate extension point
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"ExtensionPoint with ID 'shared-ext' is already registered",
);
});
it('should attribute invalid registration type errors to plugin when pluginId is available', async () => {
const init = new BackendInitializer(baseFactories);
// Create a fake registration with an invalid type but valid pluginId
const fakeFeature = {
$$type: '@backstage/BackendFeature' as const,
version: 'v1' as const,
featureType: 'registrations' as const,
getRegistrations: () => [
{
type: 'invalid-type',
pluginId: 'broken-plugin',
init: { deps: {}, func: async () => {} },
extensionPoints: [],
},
],
};
init.add(fakeFeature as any);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(
p => p.pluginId === 'broken-plugin',
);
expect(plugin?.failure?.error.message).toBe(
"Invalid registration type 'invalid-type'",
);
});
@@ -310,77 +310,6 @@ export class BackendInitializer {
// Initialize all root scoped services
await this.#serviceRegistry.initializeEagerServicesWithScope('root');
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
// Enumerate all registrations
for (const feature of this.#registrations) {
for (const r of feature.getRegistrations()) {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
}
}
const pluginIds = [...pluginInits.keys()];
const rootConfig = await this.#serviceRegistry.get(
coreServices.rootConfig,
'root',
@@ -390,15 +319,32 @@ export class BackendInitializer {
'root',
);
const allRegistrations = this.#registrations.flatMap(f =>
f.getRegistrations(),
);
const allPluginIds = [
...new Set(
allRegistrations.flatMap(r =>
'pluginId' in r && typeof r.pluginId === 'string' ? [r.pluginId] : [],
),
),
];
const resultCollector = createInitializationResultCollector({
pluginIds,
pluginIds: allPluginIds,
logger: rootLogger,
allowBootFailurePredicate: createAllowBootFailurePredicate(rootConfig),
});
const { pluginInits, moduleInits } = this.#enumerateRegistrations(
allRegistrations,
resultCollector,
);
// All plugins are initialized in parallel
await Promise.all(
pluginIds.map(async pluginId => {
[...pluginInits.keys()].map(async pluginId => {
try {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
@@ -491,6 +437,104 @@ export class BackendInitializer {
return { result };
}
#enumerateRegistrations(
allRegistrations: ReturnType<
InternalBackendRegistrations['getRegistrations']
>,
resultCollector: ReturnType<typeof createInitializationResultCollector>,
): {
pluginInits: Map<string, BackendRegisterInit>;
moduleInits: Map<string, Map<string, BackendRegisterInit>>;
} {
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
for (const r of allRegistrations) {
const addedExtensionPointIds: string[] = [];
try {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
addedExtensionPointIds.push(extRef.id);
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
addedExtensionPointIds.push(extReg.extensionPoint.id);
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
} catch (error: unknown) {
assertError(error);
// Clean up partially registered extension points
for (const id of addedExtensionPointIds) {
this.#extensionPoints.delete(id);
}
if ('pluginId' in r && 'moduleId' in r) {
resultCollector.onPluginModuleResult(r.pluginId, r.moduleId, error);
} else if ('pluginId' in r) {
pluginInits.delete(r.pluginId);
moduleInits.delete(r.pluginId);
resultCollector.onPluginResult(r.pluginId, error);
} else {
throw error;
}
}
}
return { pluginInits, moduleInits };
}
// It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit
async stop(): Promise<void> {
instanceRegistry.unregister(this);
+50
View File
@@ -1,5 +1,55 @@
# @backstage/backend-defaults
## 0.16.0-next.1
### Minor Changes
- 0e7d8f9: The scheduler service now uses the metrics service to create metrics, providing plugin-scoped attribution.
- 527cf88: **BREAKING** Removed deprecated `BitbucketUrlReader`. Use the `BitbucketCloudUrlReader` or the `BitbucketServerUrlReader` instead.
### Patch Changes
- 62f0a53: 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.
- Updated dependencies
- @backstage/cli-node@0.2.19-next.1
- @backstage/integration@2.0.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/config-loader@1.10.9-next.0
- @backstage/errors@1.2.7
- @backstage/integration-aws-node@0.1.20
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-node@0.10.11-next.0
## 0.15.3-next.0
### Patch Changes
- 6738cf0: build(deps): bump `minimatch` from 9.0.5 to 10.2.1
- d933f62: Add configurable throttling and retry mechanism for GitLab integration.
- b99158a: Fixed `yarn backstage-cli config:check --strict --config app-config.yaml` config validation error by adding
an optional `default` type discriminator to PostgreSQL connection configuration,
allowing `config:check` to properly validate `default` connection configurations.
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-node@0.2.19-next.0
- @backstage/integration@1.21.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration-aws-node@0.1.20
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-node@0.10.11-next.0
## 0.15.2
### Patch Changes
+30
View File
@@ -1127,6 +1127,36 @@ export interface Config {
headers?: { [name: string]: string };
};
/**
* Options for the metrics service.
*/
metrics?: {
/**
* Plugin-specific metrics configuration. Each plugin can override meter metadata.
*/
plugin?: {
[pluginId: string]: {
/**
* Meter configuration for this plugin.
*/
meter?: {
/**
* Custom meter name. If not set, defaults to backstage-plugin-{pluginId}.
*/
name?: string;
/**
* Version for the meter.
*/
version?: string;
/**
* Schema URL for the meter.
*/
schemaUrl?: string;
};
};
};
};
/**
* Options to configure the default RootLoggerService.
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-defaults",
"version": "0.15.2",
"version": "0.16.0-next.1",
"description": "Backend defaults used by Backstage backend apps",
"backstage": {
"role": "node-library"
@@ -5,6 +5,7 @@
```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
@@ -22,6 +23,13 @@ export const actionsServiceFactory: ServiceFactory<
'singleton'
>;
// @alpha
export const metricsServiceFactory: ServiceFactory<
MetricsService,
'plugin',
'singleton'
>;
// @alpha
export const rootSystemMetadataServiceFactory: ServiceFactory<
RootSystemMetadataService,
@@ -6,6 +6,7 @@
import { DatabaseService } from '@backstage/backend-plugin-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
@@ -17,6 +18,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -10,7 +10,6 @@ import { AzureCredentialsManager } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BitbucketCloudIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
import { BitbucketServerIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import { GerritIntegration } from '@backstage/integration';
@@ -190,38 +189,6 @@ export class BitbucketServerUrlReader implements UrlReaderService {
toString(): string;
}
// @public @deprecated
export class BitbucketUrlReader implements UrlReaderService {
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse>;
// (undocumented)
readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse>;
// (undocumented)
search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export class FetchUrlReader implements UrlReaderService {
static factory: ReaderFactory;
@@ -38,6 +38,7 @@ import { eventsServiceFactory } from '@backstage/plugin-events-node';
import {
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
} from '@backstage/backend-defaults/alpha';
import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory';
@@ -66,6 +67,7 @@ export const defaultServiceFactories = [
// alpha services
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
// Unexported alpha services kept around for compatibility reasons
instanceMetadataServiceFactory,
@@ -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;
@@ -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',
},
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { metrics } from '@opentelemetry/api';
import { DefaultMetricsService } from './DefaultMetricsService';
const mockGetMeter = jest.spyOn(metrics, 'getMeter');
describe('DefaultMetricsService', () => {
beforeEach(() => {
mockGetMeter.mockClear();
});
describe('create', () => {
it('should create a MetricsService with name only', () => {
const service = DefaultMetricsService.create({ name: 'test-meter' });
expect(mockGetMeter).toHaveBeenCalledTimes(1);
expect(mockGetMeter).toHaveBeenCalledWith('test-meter', undefined, {
schemaUrl: undefined,
});
expect(service).toBeDefined();
});
it('should create a MetricsService with name, version, and schemaUrl', () => {
const service = DefaultMetricsService.create({
name: 'test-meter',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
});
expect(mockGetMeter).toHaveBeenCalledTimes(1);
expect(mockGetMeter).toHaveBeenCalledWith('test-meter', '1.2.3', {
schemaUrl: 'https://example.com/schema',
});
expect(service).toBeDefined();
});
});
describe('metric instruments', () => {
it('should create a counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createCounter('my_counter', {
description: 'A test counter',
unit: 'bytes',
});
expect(counter).toBeDefined();
expect(counter.add).toBeDefined();
});
it('should create an up-down counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const upDownCounter = service.createUpDownCounter('my_updown');
expect(upDownCounter).toBeDefined();
expect(upDownCounter.add).toBeDefined();
});
it('should create a histogram', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const histogram = service.createHistogram('my_histogram');
expect(histogram).toBeDefined();
expect(histogram.record).toBeDefined();
});
it('should create a gauge', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const gauge = service.createGauge('my_gauge');
expect(gauge).toBeDefined();
expect(gauge.record).toBeDefined();
});
it('should create an observable counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createObservableCounter('my_observable_counter');
expect(counter).toBeDefined();
expect(counter.addCallback).toBeDefined();
expect(counter.removeCallback).toBeDefined();
});
it('should create an observable up-down counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createObservableUpDownCounter(
'my_observable_updown',
);
expect(counter).toBeDefined();
expect(counter.addCallback).toBeDefined();
});
it('should create an observable gauge', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const gauge = service.createObservableGauge('my_observable_gauge');
expect(gauge).toBeDefined();
expect(gauge.addCallback).toBeDefined();
});
});
});
@@ -0,0 +1,123 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Meter, metrics } from '@opentelemetry/api';
import {
MetricsService,
MetricAttributes,
MetricOptions,
MetricsServiceCounter,
MetricsServiceUpDownCounter,
MetricsServiceHistogram,
MetricsServiceGauge,
MetricsServiceObservableCounter,
MetricsServiceObservableGauge,
MetricsServiceObservableUpDownCounter,
} from '@backstage/backend-plugin-api/alpha';
/**
* Options for creating a {@link DefaultMetricsService}.
*
* @alpha
*/
export interface DefaultMetricsServiceOptions {
name: string;
version?: string;
schemaUrl?: string;
}
/**
* Default implementation of the {@link MetricsService} interface.
*
* This implementation provides a thin wrapper around the OpenTelemetry Meter API.
*
* @alpha
*/
export class DefaultMetricsService implements MetricsService {
private readonly meter: Meter;
private constructor(opts: DefaultMetricsServiceOptions) {
// The meter name sets the OpenTelemetry Instrumentation Scope which identifies the source of metrics in telemetry backends.
this.meter = metrics.getMeter(opts.name, opts.version, {
schemaUrl: opts.schemaUrl,
});
}
/**
* Creates a new {@link MetricsService} instance.
*
* @param opts - Options for configuring the meter scope
* @returns A new MetricsService instance
*/
static create(opts: DefaultMetricsServiceOptions): MetricsService {
return new DefaultMetricsService(opts);
}
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes> {
return this.meter.createCounter(name, opts);
}
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes> {
return this.meter.createUpDownCounter(name, opts);
}
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes> {
return this.meter.createHistogram(name, opts);
}
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes> {
return this.meter.createGauge(name, opts);
}
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes> {
return this.meter.createObservableCounter(name, opts);
}
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes> {
return this.meter.createObservableUpDownCounter(name, opts);
}
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes> {
return this.meter.createObservableGauge(name, opts);
}
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { metricsServiceFactory } from './metricsServiceFactory';
@@ -0,0 +1,130 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
mockServices,
ServiceFactoryTester,
} from '@backstage/backend-test-utils';
import { metricsServiceFactory } from './metricsServiceFactory';
import { DefaultMetricsService } from './DefaultMetricsService';
describe('metricsServiceFactory', () => {
let createSpy: jest.SpyInstance;
beforeEach(() => {
createSpy = jest.spyOn(DefaultMetricsService, 'create');
});
afterEach(() => {
jest.restoreAllMocks();
});
const defaultServices = [
mockServices.rootConfig.factory(),
metricsServiceFactory,
];
it('should use backstage-plugin-{pluginId} as meter name when no config is set', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: defaultServices,
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'backstage-plugin-my-plugin',
version: undefined,
schemaUrl: undefined,
});
});
it('should use custom name from config', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
metrics: {
plugin: {
'my-plugin': {
meter: {
name: 'custom-metrics-name',
},
},
},
},
},
},
}),
metricsServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'custom-metrics-name',
version: undefined,
schemaUrl: undefined,
});
});
it('should accept version and schemaUrl from config', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
metrics: {
plugin: {
'my-plugin': {
meter: {
name: 'my-plugin-metrics',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
},
},
},
},
},
},
}),
metricsServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'my-plugin-metrics',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
});
});
it('should implement the full MetricsService interface', async () => {
const subject = await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: defaultServices,
}).getSubject('test-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'backstage-plugin-test-plugin',
version: undefined,
schemaUrl: undefined,
});
expect(subject.createCounter).toBeDefined();
expect(subject.createUpDownCounter).toBeDefined();
expect(subject.createHistogram).toBeDefined();
expect(subject.createGauge).toBeDefined();
expect(subject.createObservableCounter).toBeDefined();
expect(subject.createObservableUpDownCounter).toBeDefined();
expect(subject.createObservableGauge).toBeDefined();
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultMetricsService } from './DefaultMetricsService';
/**
* Service factory for collecting plugin-scoped metrics.
*
* @alpha
*/
export const metricsServiceFactory = createServiceFactory({
service: metricsServiceRef,
deps: {
config: coreServices.rootConfig,
pluginMetadata: coreServices.pluginMetadata,
},
factory: ({ config, pluginMetadata }) => {
const pluginId = pluginMetadata.getId();
const meterConfig = config.getOptionalConfig(
`backend.metrics.plugin.${pluginId}.meter`,
);
const scopeName = `backstage-plugin-${pluginId}`;
const name = meterConfig?.getOptionalString('name') ?? scopeName;
const version = meterConfig?.getOptionalString('version');
const schemaUrl = meterConfig?.getOptionalString('schemaUrl');
return DefaultMetricsService.create({ name, version, schemaUrl });
},
});
@@ -16,4 +16,5 @@
export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { metricsServiceFactory } from './entrypoints/metrics';
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
@@ -20,6 +20,7 @@ import waitForExpect from 'wait-for-expect';
import { DefaultSchedulerService } from './DefaultSchedulerService';
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -32,6 +33,7 @@ describe('TaskScheduler', () => {
getId: () => 'test',
} satisfies PluginMetadataService;
const testScopedSignal = createTestScopedSignal();
const metrics = metricsServiceMock.mock();
it.each(databases.eachSupportedId())(
'can return a working v1 plugin impl, %p',
@@ -42,6 +44,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -71,6 +74,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -27,6 +27,7 @@ import { Duration } from 'luxon';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
/**
* Default implementation of the task scheduler service.
@@ -37,6 +38,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -67,6 +69,7 @@ export class DefaultSchedulerService {
options.pluginMetadata.getId(),
databaseFactory,
options.logger,
options.metrics,
options.rootLifecycle,
);
@@ -27,6 +27,7 @@ import {
parseDuration,
} from './PluginTaskSchedulerImpl';
import { createDeferred } from '@backstage/types';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -56,6 +57,7 @@ describe('PluginTaskManagerImpl', () => {
'myplugin',
async () => knex,
mockServices.logger.mock(),
metricsServiceMock.mock(),
{
addShutdownHook,
addBeforeShutdownHook: jest.fn(),
@@ -24,7 +24,13 @@ import {
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Counter, Histogram, Gauge, metrics, trace } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import {
MetricsService,
MetricsServiceCounter,
MetricsServiceGauge,
MetricsServiceHistogram,
} from '@backstage/backend-plugin-api/alpha';
import { Knex } from 'knex';
import { Duration } from 'luxon';
import express from 'express';
@@ -45,10 +51,10 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
private readonly allScheduledTasks: SchedulerServiceTaskDescriptor[] = [];
private readonly shutdownInitiated: Promise<boolean>;
private readonly counter: Counter;
private readonly duration: Histogram;
private readonly lastStarted: Gauge;
private readonly lastCompleted: Gauge;
private readonly counter: MetricsServiceCounter;
private readonly duration: MetricsServiceHistogram;
private readonly lastStarted: MetricsServiceGauge;
private readonly lastCompleted: MetricsServiceGauge;
private readonly pluginId: string;
private readonly databaseFactory: () => Promise<Knex>;
@@ -58,24 +64,27 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
pluginId: string,
databaseFactory: () => Promise<Knex>,
logger: LoggerService,
metrics: MetricsService,
rootLifecycle: RootLifecycleService,
) {
this.pluginId = pluginId;
this.databaseFactory = databaseFactory;
this.logger = logger;
const meter = metrics.getMeter('default');
this.counter = meter.createCounter('backend_tasks.task.runs.count', {
this.counter = metrics.createCounter('backend_tasks.task.runs.count', {
description: 'Total number of times a task has been run',
});
this.duration = meter.createHistogram('backend_tasks.task.runs.duration', {
description: 'Histogram of task run durations',
unit: 'seconds',
});
this.lastStarted = meter.createGauge('backend_tasks.task.runs.started', {
this.duration = metrics.createHistogram(
'backend_tasks.task.runs.duration',
{
description: 'Histogram of task run durations',
unit: 'seconds',
},
);
this.lastStarted = metrics.createGauge('backend_tasks.task.runs.started', {
description: 'Epoch timestamp seconds when the task was last started',
unit: 'seconds',
});
this.lastCompleted = meter.createGauge(
this.lastCompleted = metrics.createGauge(
'backend_tasks.task.runs.completed',
{
description: 'Epoch timestamp seconds when the task was last completed',
@@ -18,6 +18,7 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';
import { DefaultSchedulerService } from './lib/DefaultSchedulerService';
/**
@@ -37,6 +38,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle: coreServices.rootLifecycle,
httpRouter: coreServices.httpRouter,
pluginMetadata: coreServices.pluginMetadata,
metrics: metricsServiceRef,
},
async factory({
database,
@@ -44,6 +46,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
}) {
return DefaultSchedulerService.create({
database,
@@ -51,6 +54,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
});
},
});
@@ -1,656 +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 { ConfigReader } from '@backstage/config';
import {
BitbucketIntegration,
readBitbucketIntegrationConfig,
} from '@backstage/integration';
import {
createMockDirectory,
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'node:path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
import getRawBody from 'raw-body';
import { UrlReaderServiceReadUrlResponse } from '@backstage/backend-plugin-api';
const logger = mockServices.logger.mock();
describe('BitbucketUrlReader.factory', () => {
it('only apply integration configs not inherited from bitbucketCloud or bitbucketServer', () => {
const config = new ConfigReader({
integrations: {
bitbucket: [],
bitbucketCloud: [
{
username: 'username',
appPassword: 'password',
},
],
bitbucketServer: [
{
host: 'bitbucket-server.local',
token: 'test-token',
},
],
},
});
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: config,
});
const tuples = BitbucketUrlReader.factory({
config,
logger,
treeResponseFactory,
});
expect(tuples).toHaveLength(0);
});
});
describe('BitbucketUrlReader', () => {
const mockDir = createMockDirectory({ mockOsTmpDir: true });
beforeEach(mockDir.clear);
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const bitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const worker = setupServer();
registerMswTestHooks(worker);
describe('readUrl', () => {
it('should be able to readUrl via buffer without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl using provided token', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer manual-token',
);
return res(ctx.status(200), ctx.body('foo'));
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ token: 'manual-token' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl via stream without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const fromStream = await getRawBody(result.stream!());
expect(fromStream.toString()).toBe('foo');
});
it('should be able to readUrl with matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'matching-etag-value',
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'matching-etag-value' },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl without matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'previous-etag-value',
);
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'new-etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'previous-etag-value' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.etag).toBe('new-etag-value');
});
it('should be able to readUrl via buffer without If-Modified-Since', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
expect(buffer.toString()).toBe('foo');
});
it('should be throw not modified when If-Modified-Since returns a 304', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl when If-Modified-Since is before Last-Modified', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(
ctx.status(200),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
ctx.body('foo'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
});
});
describe('read', () => {
it('rejects unknown targets', async () => {
await expect(
bitbucketProcessor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
});
});
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(2);
const mkDocsFile = await files[0].content();
const indexMarkdownFile = await files[1].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
});
it('creates a directory with the wanted files', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('uses private bitbucket host', async () => {
const response = await hostedBitbucketProcessor.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('returns the wanted files from an archive with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('creates a directory with the wanted files with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: '12ab34cd56ef' },
);
};
await expect(fnBitbucket).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: 'outdatedetag123abc' },
);
expect(response.etag).toBe('12ab34cd56ef');
});
});
describe('search hosted', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/docs/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
describe('search private', () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
it('should work for exact URLs', async () => {
hostedBitbucketProcessor.readUrl = jest.fn().mockResolvedValue({
buffer: async () => Buffer.from('content'),
etag: 'etag',
} as UrlReaderServiceReadUrlResponse);
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect(result.etag).toBe('etag');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect((await result.files[0].content()).toString()).toEqual('content');
});
});
});
@@ -1,313 +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 {
UrlReaderService,
UrlReaderServiceReadTreeOptions,
UrlReaderServiceReadTreeResponse,
UrlReaderServiceReadUrlOptions,
UrlReaderServiceReadUrlResponse,
UrlReaderServiceSearchOptions,
UrlReaderServiceSearchResponse,
} from '@backstage/backend-plugin-api';
import {
assertError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import {
BitbucketIntegration,
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ReaderFactory, ReadTreeResponseFactory } from './types';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket v1 and v2 APIs, such
* as the one exposed by Bitbucket Cloud itself.
*
* @public
* @deprecated in favor of BitbucketCloudUrlReader and BitbucketServerUrlReader
*/
export class BitbucketUrlReader implements UrlReaderService {
static factory: ReaderFactory = ({ config, logger, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucket
.list()
.filter(
item =>
!integrations.bitbucketCloud.byHost(item.config.host) &&
!integrations.bitbucketServer.byHost(item.config.host),
)
.map(integration => {
const reader = new BitbucketUrlReader(integration, logger, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
private readonly integration: BitbucketIntegration;
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory };
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.integration = integration;
this.deps = deps;
const { host, token, username, appPassword } = integration.config;
const replacement =
host === 'bitbucket.org' ? 'bitbucketCloud' : 'bitbucketServer';
logger.warn(
`[Deprecated] Please migrate from "integrations.bitbucket" to "integrations.${replacement}".`,
);
if (!token && username && !appPassword) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
);
}
}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
private getCredentials = async (options?: {
token?: string;
}): Promise<{ headers: Record<string, string> }> => {
if (options?.token) {
return {
headers: {
Authorization: `Bearer ${options.token}`,
},
};
}
return await getBitbucketRequestOptions(this.integration.config);
};
async readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse> {
const { etag, lastModifiedAfter, signal } = options ?? {};
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config);
const requestOptions = await this.getCredentials(options);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), {
headers: {
...requestOptions.headers,
...(etag && { 'If-None-Match': etag }),
...(lastModifiedAfter && {
'If-Modified-Since': lastModifiedAfter.toUTCString(),
}),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
// removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return ReadUrlResponseFactory.fromResponse(response);
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
async readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse> {
const { filepath } = parseGitUrl(url);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketDownloadUrl(
url,
this.integration.config,
);
const archiveBitbucketResponse = await fetch(
downloadUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!archiveBitbucketResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`;
if (archiveBitbucketResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
response: archiveBitbucketResponse,
subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
async search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse> {
const { filepath } = parseGitUrl(url);
// If it's a direct URL we use readUrl instead
if (!filepath?.match(/[*?]/)) {
try {
const data = await this.readUrl(url, options);
return {
files: [
{
url: url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt,
},
],
etag: data.etag ?? '',
};
} catch (error) {
assertError(error);
if (error.name === 'NotFoundError') {
return {
files: [],
etag: '',
};
}
throw error;
}
}
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = trimEnd(url.replace(filepath, ''), '/');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(path),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
lastModifiedAt: file.lastModifiedAt,
})),
};
}
toString() {
const { host, token, username, appPassword } = this.integration.config;
let authed = Boolean(token);
if (!authed) {
authed = Boolean(username && appPassword);
}
return `bitbucket{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<string> {
const { resource, name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, this.integration.config);
}
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
const commitsApiUrl = isHosted
? `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
: `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
if (commitsResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commits = await commitsResponse.json();
if (isHosted) {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].hash
) {
return commits.values[0].hash.substring(0, 12);
}
} else {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].id
) {
return commits.values[0].id.substring(0, 12);
}
}
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
@@ -24,7 +24,6 @@ import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GerritUrlReader } from './GerritUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
@@ -92,7 +91,6 @@ export class UrlReaders {
AzureUrlReader.factory,
BitbucketCloudUrlReader.factory,
BitbucketServerUrlReader.factory,
BitbucketUrlReader.factory,
GerritUrlReader.factory,
GithubUrlReader.factory,
GiteaUrlReader.factory,
@@ -16,7 +16,6 @@
export { AzureUrlReader } from './AzureUrlReader';
export { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
export { BitbucketUrlReader } from './BitbucketUrlReader';
export { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
export { GerritUrlReader } from './GerritUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
@@ -1,5 +1,60 @@
# @backstage/backend-dynamic-feature-service
## 0.8.0-next.1
### Minor Changes
- 0fbcf23: Migrated OpenAPI schemas to 3.1.
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-backend@3.5.0-next.1
- @backstage/cli-common@0.2.0-next.1
- @backstage/cli-node@0.2.19-next.1
- @backstage/backend-defaults@0.16.0-next.1
- @backstage/plugin-events-backend@0.6.0-next.1
- @backstage/plugin-scaffolder-node@0.13.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/config-loader@1.10.9-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-app-node@0.1.43-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-search-common@1.2.22
## 0.7.10-next.0
### Patch Changes
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/cli-node@0.2.19-next.0
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/plugin-catalog-backend@3.5.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-app-node@0.1.43-next.0
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-backend@0.5.12-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-scaffolder-node@0.12.6-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-search-common@1.2.22
## 0.7.9
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-dynamic-feature-service",
"version": "0.7.9",
"version": "0.8.0-next.1",
"description": "Backstage dynamic feature service",
"backstage": {
"role": "node-library"
@@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.1.0
info:
title: .backstage/dynamic-features
version: '1'
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -26,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -21,7 +21,7 @@ import { createValidatedOpenApiRouterFromGeneratedEndpointMap } from '@backstage
import { EndpointMap } from './apis';
export const spec = {
openapi: '3.0.3',
openapi: '3.1.0',
info: {
title: '.backstage/dynamic-features',
version: '1',
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* 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.
@@ -1,5 +1,14 @@
# @backstage/backend-openapi-utils
## 0.6.7-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
## 0.6.6
### Patch Changes
+2
View File
@@ -4,6 +4,8 @@
This package is meant to provide a typed Express router for an OpenAPI spec. Based on the [`oatx`](https://github.com/varanauskas/oatx) library and adapted to override Express values.
Only supports OpenAPI 3.1 specifications.
## Getting Started
### Configuration
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-openapi-utils",
"version": "0.6.6",
"version": "0.6.7-next.0",
"description": "OpenAPI typescript support.",
"backstage": {
"role": "node-library"
@@ -54,6 +54,7 @@ export function getOpenApiSpecRoute(baseUrl: string) {
/**
* Create a router with validation middleware. This is used by typing methods to create an
* "OpenAPI router" with all of the expected validation + metadata.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -115,6 +116,7 @@ function createRouterWithValidation(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -132,6 +134,7 @@ export function createValidatedOpenApiRouter<T extends RequiredDoc>(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
+14
View File
@@ -1,5 +1,19 @@
# @backstage/backend-plugin-api
## 1.7.1-next.0
### Patch Changes
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
## 1.7.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-plugin-api",
"version": "1.7.0",
"version": "1.7.1-next.0",
"description": "Core API used by Backstage backend plugins",
"backstage": {
"role": "node-library"
@@ -103,6 +103,150 @@ export const actionsServiceRef: ServiceRef<
'singleton'
>;
// @alpha
export interface MetricAdvice {
explicitBucketBoundaries?: number[];
}
// @alpha
export interface MetricAttributes {
// (undocumented)
[attributeKey: string]: MetricAttributeValue | undefined;
}
// @alpha
export type MetricAttributeValue =
| string
| number
| boolean
| Array<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
// @alpha
export interface MetricOptions {
advice?: MetricAdvice;
description?: string;
unit?: string;
}
// @alpha
export interface MetricsService {
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes>;
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes>;
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes>;
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
}
// @alpha
export interface MetricsServiceCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
add(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
record(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceHistogram<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
record(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceObservable<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
addCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
// (undocumented)
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
// @alpha
export type MetricsServiceObservableCallback<
TAttributes extends MetricAttributes = MetricAttributes,
> = (
observableResult: MetricsServiceObservableResult<TAttributes>,
) => void | Promise<void>;
// @alpha
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export interface MetricsServiceObservableResult<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
observe(value: number, attributes?: TAttributes): void;
}
// @alpha
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export const metricsServiceRef: ServiceRef<
MetricsService,
'plugin',
'singleton'
>;
// @alpha
export interface MetricsServiceUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
add(value: number, attributes?: TAttributes): void;
}
// @public (undocumented)
export interface RootSystemMetadataService {
// (undocumented)
@@ -0,0 +1,273 @@
/*
* 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.
*/
/**
* Attribute values that can be attached to metric measurements.
*
* @alpha
*/
export type MetricAttributeValue =
| string
| number
| boolean
| Array<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
/**
* A set of key-value pairs that can be attached to metric measurements.
*
* @alpha
*/
export interface MetricAttributes {
[attributeKey: string]: MetricAttributeValue | undefined;
}
/**
* Advisory options that influence aggregation configuration.
*
* @alpha
*/
export interface MetricAdvice {
/**
* Hint the explicit bucket boundaries for histogram aggregation.
*/
explicitBucketBoundaries?: number[];
}
/**
* Options for creating a metric instrument.
*
* @alpha
*/
export interface MetricOptions {
/**
* The description of the Metric.
*/
description?: string;
/**
* The unit of the Metric values.
*/
unit?: string;
/**
* Advisory options that influence aggregation configuration.
*/
advice?: MetricAdvice;
}
/**
* A counter metric that only supports non-negative increments.
*
* @alpha
*/
export interface MetricsServiceCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
add(value: number, attributes?: TAttributes): void;
}
/**
* A counter metric that supports both positive and negative increments.
*
* @alpha
*/
export interface MetricsServiceUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
add(value: number, attributes?: TAttributes): void;
}
/**
* A histogram metric for recording distributions of values.
*
* @alpha
*/
export interface MetricsServiceHistogram<
TAttributes extends MetricAttributes = MetricAttributes,
> {
record(value: number, attributes?: TAttributes): void;
}
/**
* A gauge metric for recording instantaneous values.
*
* @alpha
*/
export interface MetricsServiceGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> {
record(value: number, attributes?: TAttributes): void;
}
/**
* The result object passed to observable metric callbacks.
*
* @alpha
*/
export interface MetricsServiceObservableResult<
TAttributes extends MetricAttributes = MetricAttributes,
> {
observe(value: number, attributes?: TAttributes): void;
}
/**
* A callback function for observable metrics. Called whenever a metric
* collection is initiated.
*
* @alpha
*/
export type MetricsServiceObservableCallback<
TAttributes extends MetricAttributes = MetricAttributes,
> = (
observableResult: MetricsServiceObservableResult<TAttributes>,
) => void | Promise<void>;
/**
* An observable metric instrument that reports values via callbacks.
*
* @alpha
*/
export interface MetricsServiceObservable<
TAttributes extends MetricAttributes = MetricAttributes,
> {
addCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
/**
* An observable counter metric that reports non-negative sums via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable counter metric that reports sums that can go up or down
* via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable gauge metric that reports instantaneous values via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* A service that provides a facility for emitting metrics.
*
* @alpha
*/
export interface MetricsService {
/**
* Creates a new counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The counter metric.
*/
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
/**
* Creates a new up-down counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The up-down counter metric.
*/
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
/**
* Creates a new histogram metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The histogram metric.
*/
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
/**
* Creates a new gauge metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The gauge metric.
*/
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
/**
* Creates a new observable counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable counter metric.
*/
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes>;
/**
* Creates a new observable up-down counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable up-down counter metric.
*/
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes>;
/**
* Creates a new observable gauge metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable gauge metric.
*/
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes>;
}
@@ -27,8 +27,27 @@ export type {
export type { ActionsService, ActionsServiceAction } from './ActionsService';
export type {
MetricsService,
MetricAdvice,
MetricAttributes,
MetricAttributeValue,
MetricOptions,
MetricsServiceCounter,
MetricsServiceUpDownCounter,
MetricsServiceHistogram,
MetricsServiceGauge,
MetricsServiceObservable,
MetricsServiceObservableCallback,
MetricsServiceObservableCounter,
MetricsServiceObservableGauge,
MetricsServiceObservableResult,
MetricsServiceObservableUpDownCounter,
} from './MetricsService';
export {
actionsRegistryServiceRef,
actionsServiceRef,
metricsServiceRef,
rootSystemMetadataServiceRef,
} from './refs';
@@ -56,3 +56,14 @@ export const rootSystemMetadataServiceRef = createServiceRef<
id: 'alpha.core.rootSystemMetadata',
scope: 'root',
});
/**
* Service for managing metrics.
*
* @alpha
*/
export const metricsServiceRef = createServiceRef<
import('./MetricsService').MetricsService
>({
id: 'alpha.core.metrics',
});
+32
View File
@@ -1,5 +1,37 @@
# @backstage/backend-test-utils
## 1.11.1-next.1
### Patch Changes
- 62f0a53: 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.
- Updated dependencies
- @backstage/backend-defaults@0.16.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
## 1.11.1-next.0
### Patch Changes
- 1ee5b28: Adds a new metrics service mock to be leveraged in tests
- Updated dependencies
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
## 1.11.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-test-utils",
"version": "1.11.0",
"version": "1.11.1-next.1",
"description": "Test helpers library for Backstage backends",
"backstage": {
"role": "node-library"
@@ -12,6 +12,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @alpha (undocumented)
@@ -43,6 +44,16 @@ export namespace actionsServiceMock {
) => ServiceMock<ActionsService>;
}
// @alpha (undocumented)
export namespace metricsServiceMock {
const // (undocumented)
factory: () => ServiceFactory<MetricsService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<MetricsService> | undefined,
) => ServiceMock<MetricsService>;
}
// @alpha
export class MockActionsRegistry
implements ActionsRegistryService, ActionsService
@@ -0,0 +1,59 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceMock } from './alphaCreateServiceMock';
import {
MetricsService,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { metricsServiceFactory } from '@backstage/backend-defaults/alpha';
/**
* @alpha
*/
export namespace metricsServiceMock {
export const factory = () => metricsServiceFactory;
export const mock = createServiceMock<MetricsService>(
metricsServiceRef,
() => ({
createCounter: jest.fn().mockImplementation(() => ({
add: jest.fn(),
})),
createUpDownCounter: jest.fn().mockImplementation(() => ({
add: jest.fn(),
})),
createHistogram: jest.fn().mockImplementation(() => ({
record: jest.fn(),
})),
createGauge: jest.fn().mockImplementation(() => ({
record: jest.fn(),
})),
createObservableCounter: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
createObservableUpDownCounter: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
createObservableGauge: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
}),
);
}
@@ -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<
@@ -17,4 +17,5 @@
export { actionsRegistryServiceMock } from './ActionsRegistryServiceMock';
export { MockActionsRegistry } from './MockActionsRegistry';
export { actionsServiceMock } from './ActionsServiceMock';
export { metricsServiceMock } from './MetricsServiceMock';
export { type ServiceMock } from './alphaCreateServiceMock';
@@ -43,6 +43,7 @@ import { HostDiscovery } from '@backstage/backend-defaults/discovery';
import {
actionsRegistryServiceMock,
actionsServiceMock,
metricsServiceMock,
} from '../alpha/services';
/** @public */
@@ -92,6 +93,7 @@ export const defaultServiceFactories = [
// Alpha services
actionsRegistryServiceMock.factory(),
actionsServiceMock.factory(),
metricsServiceMock.factory(),
];
/**
+84
View File
@@ -1,5 +1,89 @@
# example-backend
## 0.0.48-next.1
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-backend@3.5.0-next.1
- @backstage/plugin-auth-backend@0.27.1-next.1
- @backstage/plugin-techdocs-backend@2.1.6-next.1
- @backstage/backend-defaults@0.16.0-next.1
- @backstage/plugin-scaffolder-backend@3.2.0-next.1
- @backstage/plugin-mcp-actions-backend@0.1.10-next.1
- @backstage/plugin-events-backend@0.6.0-next.1
- @backstage/plugin-search-backend@2.1.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/plugin-kubernetes-backend@0.21.2-next.1
- @backstage/plugin-search-backend-module-catalog@0.3.13-next.1
- @backstage/plugin-search-backend-module-techdocs@0.4.12-next.1
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/catalog-model@1.7.6
- @backstage/plugin-app-backend@0.5.12-next.0
- @backstage/plugin-auth-backend-module-github-provider@0.5.1-next.0
- @backstage/plugin-auth-backend-module-guest-provider@0.2.17-next.0
- @backstage/plugin-auth-backend-module-openshift-provider@0.1.5-next.0
- @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.12-next.0
- @backstage/plugin-catalog-backend-module-openapi@0.2.20-next.1
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.18-next.1
- @backstage/plugin-catalog-backend-module-unprocessed@0.6.9-next.0
- @backstage/plugin-devtools-backend@0.5.15-next.0
- @backstage/plugin-events-backend-module-google-pubsub@0.2.1-next.0
- @backstage/plugin-notifications-backend@0.6.3-next.0
- @backstage/plugin-permission-backend@0.7.10-next.0
- @backstage/plugin-permission-backend-module-allow-all-policy@0.2.17-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-proxy-backend@0.6.11-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.7-next.1
- @backstage/plugin-scaffolder-backend-module-notifications@0.1.20-next.1
- @backstage/plugin-search-backend-module-elasticsearch@1.8.1-next.0
- @backstage/plugin-search-backend-module-explore@0.3.12-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-signals-backend@0.3.13-next.0
## 0.0.48-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/plugin-auth-backend@0.27.1-next.0
- @backstage/plugin-catalog-backend@3.5.0-next.0
- @backstage/plugin-scaffolder-backend@3.1.4-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/plugin-mcp-actions-backend@0.1.10-next.0
- @backstage/catalog-model@1.7.6
- @backstage/plugin-app-backend@0.5.12-next.0
- @backstage/plugin-auth-backend-module-github-provider@0.5.1-next.0
- @backstage/plugin-auth-backend-module-guest-provider@0.2.17-next.0
- @backstage/plugin-auth-backend-module-openshift-provider@0.1.5-next.0
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.12-next.0
- @backstage/plugin-catalog-backend-module-openapi@0.2.20-next.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.18-next.0
- @backstage/plugin-catalog-backend-module-unprocessed@0.6.9-next.0
- @backstage/plugin-devtools-backend@0.5.15-next.0
- @backstage/plugin-events-backend@0.5.12-next.0
- @backstage/plugin-events-backend-module-google-pubsub@0.2.1-next.0
- @backstage/plugin-kubernetes-backend@0.21.2-next.0
- @backstage/plugin-notifications-backend@0.6.3-next.0
- @backstage/plugin-permission-backend@0.7.10-next.0
- @backstage/plugin-permission-backend-module-allow-all-policy@0.2.17-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-proxy-backend@0.6.11-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.7-next.0
- @backstage/plugin-scaffolder-backend-module-notifications@0.1.20-next.0
- @backstage/plugin-search-backend@2.0.13-next.0
- @backstage/plugin-search-backend-module-catalog@0.3.13-next.0
- @backstage/plugin-search-backend-module-elasticsearch@1.8.1-next.0
- @backstage/plugin-search-backend-module-explore@0.3.12-next.0
- @backstage/plugin-search-backend-module-techdocs@0.4.12-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-signals-backend@0.3.13-next.0
- @backstage/plugin-techdocs-backend@2.1.6-next.0
## 0.0.47
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.0.47",
"version": "0.0.48-next.1",
"backstage": {
"role": "backend"
},
+30
View File
@@ -1,5 +1,35 @@
# @backstage/catalog-client
## 1.14.0-next.1
### Minor Changes
- 972f686: Added support for the `query` field in `getEntitiesByRefs` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
- 56c908e: Added support for the `query` field in `getEntityFacets` requests, enabling predicate-based filtering with `$all`, `$any`, `$not`, `$exists`, `$in`, `$contains`, and `$hasPrefix` operators.
- 0fbcf23: Migrated OpenAPI schemas to 3.1.
- 51e23eb: 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.
### Patch Changes
- Updated dependencies
- @backstage/catalog-model@1.7.6
- @backstage/errors@1.2.7
- @backstage/filter-predicates@0.1.0
## 1.13.1-next.0
### Patch Changes
- d2494d6: Minor update to catalog client docs
- Updated dependencies
- @backstage/catalog-model@1.7.6
- @backstage/errors@1.2.7
- @backstage/filter-predicates@0.1.0
## 1.13.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-client",
"version": "1.13.0",
"version": "1.14.0-next.1",
"description": "An isomorphic client for the catalog backend",
"backstage": {
"role": "common-library"
+5 -2
View File
@@ -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: [
+180 -9
View File
@@ -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
*/
@@ -246,9 +260,9 @@ export class DefaultApiClient {
/**
* Get all entities matching a given filter.
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it\&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param limit - Number of records to return in the response.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param offset - Number of records to skip in the query page.
* @param after - Pointer to the previous page of results.
* @param order -
@@ -277,12 +291,12 @@ export class DefaultApiClient {
/**
* Search for entities by a given query.
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it\&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param limit - Number of records to return in the response.
* @param offset - Number of records to skip in the query page.
* @param orderField - By default the entities are returned ordered by their internal uid. You can customize the &#x60;orderField&#x60; query parameters to affect that ordering. For example, to return entities by their name: &#x60;/entities/by-query?orderField&#x3D;metadata.name,asc&#x60; Each parameter can be followed by &#x60;asc&#x60; for ascending lexicographical order or &#x60;desc&#x60; for descending (reverse) lexicographical order.
* @param cursor - You may pass the &#x60;cursor&#x60; query parameters to perform cursor based pagination through the set of entities. The value of &#x60;cursor&#x60; will be returned in the response, under the &#x60;pageInfo&#x60; property: &#x60;&#x60;&#x60;json \&quot;pageInfo\&quot;: { \&quot;nextCursor\&quot;: \&quot;a-cursor\&quot;, \&quot;prevCursor\&quot;: \&quot;another-cursor\&quot; } &#x60;&#x60;&#x60; If &#x60;nextCursor&#x60; exists, it can be used to retrieve the next batch of entities. Following the same approach, if &#x60;prevCursor&#x60; exists, it can be used to retrieve the previous batch of entities. - [&#x60;filter&#x60;](#filtering), for selecting only a subset of all entities - [&#x60;fields&#x60;](#field-selection), for selecting only parts of the full data structure of each entity - &#x60;limit&#x60; for limiting the number of entities returned (20 is the default) - [&#x60;orderField&#x60;](#ordering), for deciding the order of the entities - &#x60;fullTextFilter&#x60; **NOTE**: [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] and &#x60;cursor&#x60; are mutually exclusive. This means that, it isn&#39;t possible to change any of [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] when passing &#x60;cursor&#x60; as query parameters, as changing any of these properties will affect pagination. If any of &#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60; is specified together with &#x60;cursor&#x60;, only the latter is taken into consideration.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param cursor - You may pass the &#x60;cursor&#x60; query parameters to perform cursor based pagination through the set of entities. The value of &#x60;cursor&#x60; will be returned in the response, under the &#x60;pageInfo&#x60; property: &#x60;&#x60;&#x60;json \&quot;pageInfo\&quot;: { \&quot;nextCursor\&quot;: \&quot;a-cursor\&quot;, \&quot;prevCursor\&quot;: \&quot;another-cursor\&quot; } &#x60;&#x60;&#x60; If &#x60;nextCursor&#x60; exists, it can be used to retrieve the next batch of entities. Following the same approach, if &#x60;prevCursor&#x60; exists, it can be used to retrieve the previous batch of entities. - [&#x60;filter&#x60;](#filtering), for selecting only a subset of all entities - [&#x60;fields&#x60;](#field-selection), for selecting only parts of the full data structure of each entity - &#x60;limit&#x60; for limiting the number of entities returned (20 is the default) - [&#x60;orderField&#x60;](#ordering), for deciding the order of the entities - &#x60;fullTextFilter&#x60; **NOTE**: [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] and &#x60;cursor&#x60; are mutually exclusive. This means that, it isn\&#39;t possible to change any of [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] when passing &#x60;cursor&#x60; as query parameters, as changing any of these properties will affect pagination. If any of &#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60; is specified together with &#x60;cursor&#x60;, only the latter is taken into consideration.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param fullTextFilterTerm - Text search term.
* @param fullTextFilterFields - A comma separated list of fields to sort returned results by.
*/
@@ -310,7 +324,7 @@ export class DefaultApiClient {
/**
* Get a batch set of entities given an array of entityRefs.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param getEntitiesByRefsRequest -
*/
public async getEntitiesByRefs(
@@ -337,7 +351,7 @@ export class DefaultApiClient {
}
/**
* Get an entity's ancestry by entity ref.
* Get an entity\'s ancestry by entity ref.
* @param kind -
* @param namespace -
* @param name -
@@ -425,7 +439,7 @@ export class DefaultApiClient {
/**
* Get all entity facets that match the given filters.
* @param facet -
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
*/
public async getEntityFacets(
// @ts-ignore
@@ -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 -
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
import { LocationSpec } from '../models/LocationSpec.model';
/**
* If the folder pointed to already contained catalog info yaml files, they are read and emitted like this so that the frontend can inform the user that it located them and can make sure to register them as well if they weren't already
* If the folder pointed to already contained catalog info yaml files, they are read and emitted like this so that the frontend can inform the user that it located them and can make sure to register them as well if they weren\'t already
* @public
*/
export interface AnalyzeLocationExistingEntity {
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { AnalyzeLocationEntityField } from '../models/AnalyzeLocationEntityField.model';
import { RecursivePartialEntity } from '../models/RecursivePartialEntity.model';
/**
* This is some form of representation of what the analyzer could deduce. We should probably have a chat about how this can best be conveyed to the frontend. It'll probably contain a (possibly incomplete) entity, plus enough info for the frontend to know what form data to show to the user for overriding/completing the info.
* This is some form of representation of what the analyzer could deduce. We should probably have a chat about how this can best be conveyed to the frontend. It\'ll probably contain a (possibly incomplete) entity, plus enough info for the frontend to know what form data to show to the user for overriding/completing the info.
* @public
*/
export interface AnalyzeLocationGenerateEntity {
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { LocationInput } from '../models/LocationInput.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { AnalyzeLocationExistingEntity } from '../models/AnalyzeLocationExistingEntity.model';
import { AnalyzeLocationGenerateEntity } from '../models/AnalyzeLocationGenerateEntity.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
import { Location } from '../models/Location.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { NullableEntity } from '../models/NullableEntity.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntitiesQueryResponsePageInfo } from '../models/EntitiesQueryResponsePageInfo.model';
import { Entity } from '../models/Entity.model';
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityMeta } from '../models/EntityMeta.model';
import { EntityRelation } from '../models/EntityRelation.model';
/**
* The parts of the format that's common to all versions/kinds of entity.
* The parts of the format that\'s common to all versions/kinds of entity.
* @public
*/
export interface Entity {
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityAncestryResponseItemsInner } from '../models/EntityAncestryResponseItemsInner.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityFacet } from '../models/EntityFacet.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityLink } from '../models/EntityLink.model';
/**
@@ -26,7 +25,6 @@ import { EntityLink } from '../models/EntityLink.model';
*/
export interface EntityMeta {
[key: string]: any;
/**
* A list of external hyperlinks related to the entity.
*/
@@ -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 };
}
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Location } from '../models/Location.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Location } from '../models/Location.model';
import { LocationsQueryResponsePageInfo } from '../models/LocationsQueryResponsePageInfo.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { ErrorError } from '../models/ErrorError.model';
import { ErrorRequest } from '../models/ErrorRequest.model';
import { ErrorResponse } from '../models/ErrorResponse.model';
@@ -27,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityMeta } from '../models/EntityMeta.model';
import { EntityRelation } from '../models/EntityRelation.model';
/**
* The parts of the format that's common to all versions/kinds of entity.
* The parts of the format that\'s common to all versions/kinds of entity.
* @public
*/
export type NullableEntity = {
@@ -0,0 +1,37 @@
/*
* 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 };
}
@@ -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>;
}
@@ -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';
@@ -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 };
}
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { RecursivePartialEntityMeta } from '../models/RecursivePartialEntityMeta.model';
import { RecursivePartialEntityRelation } from '../models/RecursivePartialEntityRelation.model';

Some files were not shown because too many files have changed in this diff Show More