Merge branch 'master' into canon-tables
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Added a new `catalog.disableRelationsCompatibility` configuration option that avoids JSON deserialization and serialization if possible when reading entities. This significantly reduces the memory usage of the catalog, and slightly increases performance, but it removes the backwards compatibility processing that ensures that both `entity.relation[].target` and `entity.relation[].targetRef` are present in returned entities.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Fix server response time by moving the lifecycle startup hooks back to the plugin lifecycle service.
|
||||
@@ -2,4 +2,4 @@
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Use a join based strategy for filtering, when having small page sizes
|
||||
Internal refactor of filter parsing logic.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/backend-defaults': minor
|
||||
---
|
||||
|
||||
**BREAKING PRODUCERS**: The `LifecycleMiddlewareOptions.startupRequestPauseTimeout` has been removed. Use the `backend.lifecycle.startupRequestPauseTimeout` setting in your `app-config.yaml` file to customize how the `createLifecycleMiddleware` function should behave. Also the root config service is required as an option when calling the `createLifecycleMiddleware` function:
|
||||
|
||||
```diff
|
||||
- createLifecycleMiddleware({ lifecycle, startupRequestPauseTimeout })
|
||||
+ createLifecycleMiddleware({ config, lifecycle })
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
'@backstage/plugin-config-schema': patch
|
||||
---
|
||||
|
||||
Internal refactor to break potential circular imports
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Revert `css-loader@v7` bump
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-github': patch
|
||||
---
|
||||
|
||||
Change `github:environment:create` action to request and use a token when resolving reviewer entity refs from the Backstage catalog.
|
||||
@@ -3,4 +3,3 @@
|
||||
---
|
||||
|
||||
Remove use of the `stoppable` library on the `DefaultRootHttpRouterService` as Node's native http server [close](https://nodejs.org/api/http.html#serverclosecallback) method already drains requests.
|
||||
Also, we pass a new `lifecycleMiddleware` to the `rootHttpRouterServiceFactory` configure function that must be called manually if you don't call `applyDefaults`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
---
|
||||
|
||||
Support fetch pod metrics with custom resources
|
||||
@@ -97,6 +97,7 @@ deliverables
|
||||
denormalized
|
||||
dependabot
|
||||
deps
|
||||
deserialization
|
||||
destructured
|
||||
destructuring
|
||||
Deutsche
|
||||
|
||||
+240
-225
@@ -49,6 +49,97 @@ module.exports = {
|
||||
'getting-started/keeping-backstage-updated',
|
||||
],
|
||||
'Core Features': [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Auth and Identity',
|
||||
items: [
|
||||
'auth/index',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Included providers',
|
||||
items: [
|
||||
'auth/auth0/provider',
|
||||
'auth/atlassian/provider',
|
||||
'auth/aws-alb/provider',
|
||||
'auth/microsoft/provider',
|
||||
'auth/microsoft/easy-auth',
|
||||
'auth/bitbucket/provider',
|
||||
'auth/bitbucketServer/provider',
|
||||
'auth/cloudflare/provider',
|
||||
'auth/github/provider',
|
||||
'auth/gitlab/provider',
|
||||
'auth/google/provider',
|
||||
'auth/google/gcp-iap-auth',
|
||||
'auth/guest/provider',
|
||||
'auth/okta/provider',
|
||||
'auth/oauth2-proxy/provider',
|
||||
'auth/onelogin/provider',
|
||||
'auth/vmware-cloud/provider',
|
||||
],
|
||||
},
|
||||
'auth/identity-resolver',
|
||||
'auth/oauth',
|
||||
'auth/oidc',
|
||||
'auth/add-auth-provider',
|
||||
'auth/service-to-service-auth',
|
||||
'auth/autologout',
|
||||
'auth/troubleshooting',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Kubernetes',
|
||||
items: [
|
||||
'features/kubernetes/overview',
|
||||
'features/kubernetes/installation',
|
||||
'features/kubernetes/configuration',
|
||||
'features/kubernetes/authentication',
|
||||
'features/kubernetes/authentication-strategies',
|
||||
'features/kubernetes/troubleshooting',
|
||||
'features/kubernetes/proxy',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Notifications',
|
||||
items: ['notifications/index'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Permissions',
|
||||
items: [
|
||||
'permissions/overview',
|
||||
'permissions/concepts',
|
||||
'permissions/getting-started',
|
||||
'permissions/writing-a-policy',
|
||||
'permissions/frontend-integration',
|
||||
'permissions/custom-rules',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial: using Permissions in your plugin',
|
||||
items: [
|
||||
'permissions/plugin-authors/01-setup',
|
||||
'permissions/plugin-authors/02-adding-a-basic-permission-check',
|
||||
'permissions/plugin-authors/03-adding-a-resource-permission-check',
|
||||
'permissions/plugin-authors/04-authorizing-access-to-paginated-data',
|
||||
'permissions/plugin-authors/05-frontend-authorization',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Search',
|
||||
items: [
|
||||
'features/search/search-overview',
|
||||
'features/search/getting-started',
|
||||
'features/search/concepts',
|
||||
'features/search/architecture',
|
||||
'features/search/search-engines',
|
||||
'features/search/collators',
|
||||
'features/search/how-to-guides',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Software Catalog',
|
||||
@@ -70,19 +161,6 @@ module.exports = {
|
||||
'features/software-catalog/faq',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Kubernetes',
|
||||
items: [
|
||||
'features/kubernetes/overview',
|
||||
'features/kubernetes/installation',
|
||||
'features/kubernetes/configuration',
|
||||
'features/kubernetes/authentication',
|
||||
'features/kubernetes/authentication-strategies',
|
||||
'features/kubernetes/troubleshooting',
|
||||
'features/kubernetes/proxy',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Software Templates',
|
||||
@@ -104,19 +182,6 @@ module.exports = {
|
||||
'features/software-templates/experimental',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Backstage Search',
|
||||
items: [
|
||||
'features/search/search-overview',
|
||||
'features/search/getting-started',
|
||||
'features/search/concepts',
|
||||
'features/search/architecture',
|
||||
'features/search/search-engines',
|
||||
'features/search/collators',
|
||||
'features/search/how-to-guides',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'TechDocs',
|
||||
@@ -285,189 +350,142 @@ module.exports = {
|
||||
'conf/writing',
|
||||
'conf/defining',
|
||||
],
|
||||
Notifications: ['notifications/index'],
|
||||
'Auth and identity': [
|
||||
'auth/index',
|
||||
Framework: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Included providers',
|
||||
label: 'Backend System',
|
||||
items: [
|
||||
'auth/auth0/provider',
|
||||
'auth/atlassian/provider',
|
||||
'auth/aws-alb/provider',
|
||||
'auth/microsoft/provider',
|
||||
'auth/microsoft/easy-auth',
|
||||
'auth/bitbucket/provider',
|
||||
'auth/bitbucketServer/provider',
|
||||
'auth/cloudflare/provider',
|
||||
'auth/github/provider',
|
||||
'auth/gitlab/provider',
|
||||
'auth/google/provider',
|
||||
'auth/google/gcp-iap-auth',
|
||||
'auth/guest/provider',
|
||||
'auth/okta/provider',
|
||||
'auth/oauth2-proxy/provider',
|
||||
'auth/onelogin/provider',
|
||||
'auth/vmware-cloud/provider',
|
||||
'backend-system/index',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
'backend-system/architecture/index',
|
||||
'backend-system/architecture/services',
|
||||
'backend-system/architecture/plugins',
|
||||
'backend-system/architecture/extension-points',
|
||||
'backend-system/architecture/modules',
|
||||
'backend-system/architecture/feature-loaders',
|
||||
'backend-system/architecture/naming-patterns',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Backends',
|
||||
items: [
|
||||
'backend-system/building-backends/index',
|
||||
'backend-system/building-backends/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Plugins & Modules',
|
||||
items: [
|
||||
'backend-system/building-plugins-and-modules/index',
|
||||
'backend-system/building-plugins-and-modules/testing',
|
||||
'backend-system/building-plugins-and-modules/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Core Services',
|
||||
items: [
|
||||
'backend-system/core-services/index',
|
||||
'backend-system/core-services/auth',
|
||||
'backend-system/core-services/cache',
|
||||
'backend-system/core-services/database',
|
||||
'backend-system/core-services/discovery',
|
||||
'backend-system/core-services/http-auth',
|
||||
'backend-system/core-services/http-router',
|
||||
'backend-system/core-services/identity',
|
||||
'backend-system/core-services/lifecycle',
|
||||
'backend-system/core-services/logger',
|
||||
'backend-system/core-services/permissions',
|
||||
'backend-system/core-services/plugin-metadata',
|
||||
'backend-system/core-services/root-config',
|
||||
'backend-system/core-services/root-health',
|
||||
'backend-system/core-services/root-http-router',
|
||||
'backend-system/core-services/root-lifecycle',
|
||||
'backend-system/core-services/root-logger',
|
||||
'backend-system/core-services/scheduler',
|
||||
'backend-system/core-services/token-manager',
|
||||
'backend-system/core-services/url-reader',
|
||||
'backend-system/core-services/user-info',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'auth/identity-resolver',
|
||||
'auth/oauth',
|
||||
'auth/oidc',
|
||||
'auth/add-auth-provider',
|
||||
'auth/service-to-service-auth',
|
||||
'auth/autologout',
|
||||
'auth/troubleshooting',
|
||||
],
|
||||
Permissions: [
|
||||
'permissions/overview',
|
||||
'permissions/concepts',
|
||||
'permissions/getting-started',
|
||||
'permissions/writing-a-policy',
|
||||
'permissions/frontend-integration',
|
||||
'permissions/custom-rules',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial: using Permissions in your plugin',
|
||||
label: 'New Frontend System',
|
||||
items: [
|
||||
'permissions/plugin-authors/01-setup',
|
||||
'permissions/plugin-authors/02-adding-a-basic-permission-check',
|
||||
'permissions/plugin-authors/03-adding-a-resource-permission-check',
|
||||
'permissions/plugin-authors/04-authorizing-access-to-paginated-data',
|
||||
'permissions/plugin-authors/05-frontend-authorization',
|
||||
'frontend-system/index',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
'frontend-system/architecture/index',
|
||||
'frontend-system/architecture/app',
|
||||
'frontend-system/architecture/plugins',
|
||||
'frontend-system/architecture/extensions',
|
||||
'frontend-system/architecture/extension-blueprints',
|
||||
'frontend-system/architecture/extension-overrides',
|
||||
'frontend-system/architecture/references',
|
||||
'frontend-system/architecture/utility-apis',
|
||||
'frontend-system/architecture/routes',
|
||||
'frontend-system/architecture/naming-patterns',
|
||||
'frontend-system/architecture/migrations',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Plugins',
|
||||
items: [
|
||||
'frontend-system/building-plugins/index',
|
||||
'frontend-system/building-plugins/testing',
|
||||
'frontend-system/building-plugins/common-extension-blueprints',
|
||||
'frontend-system/building-plugins/built-in-data-refs',
|
||||
'frontend-system/building-plugins/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Apps',
|
||||
items: [
|
||||
'frontend-system/building-apps/index',
|
||||
'frontend-system/building-apps/configuring-extensions',
|
||||
'frontend-system/building-apps/built-in-extensions',
|
||||
'frontend-system/building-apps/plugin-conversion',
|
||||
'frontend-system/building-apps/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Utility APIs',
|
||||
items: [
|
||||
'frontend-system/utility-apis/index',
|
||||
'frontend-system/utility-apis/creating',
|
||||
'frontend-system/utility-apis/consuming',
|
||||
'frontend-system/utility-apis/configuring',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Tooling: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Backstage CLI',
|
||||
items: [
|
||||
'Backstage CLI': [
|
||||
'tooling/cli/overview',
|
||||
'tooling/cli/build-system',
|
||||
'tooling/cli/commands',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Local Development',
|
||||
items: [
|
||||
'tooling/local-dev/linking-local-packages',
|
||||
'tooling/local-dev/debugging',
|
||||
'tooling/local-dev/profiling',
|
||||
],
|
||||
},
|
||||
'tooling/package-metadata',
|
||||
],
|
||||
'New Backend System': [
|
||||
'backend-system/index',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
'backend-system/architecture/index',
|
||||
'backend-system/architecture/services',
|
||||
'backend-system/architecture/plugins',
|
||||
'backend-system/architecture/extension-points',
|
||||
'backend-system/architecture/modules',
|
||||
'backend-system/architecture/feature-loaders',
|
||||
'backend-system/architecture/naming-patterns',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Backends',
|
||||
items: [
|
||||
'backend-system/building-backends/index',
|
||||
'backend-system/building-backends/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Plugins & Modules',
|
||||
items: [
|
||||
'backend-system/building-plugins-and-modules/index',
|
||||
'backend-system/building-plugins-and-modules/testing',
|
||||
'backend-system/building-plugins-and-modules/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Core Services',
|
||||
items: [
|
||||
'backend-system/core-services/index',
|
||||
'backend-system/core-services/auth',
|
||||
'backend-system/core-services/cache',
|
||||
'backend-system/core-services/database',
|
||||
'backend-system/core-services/discovery',
|
||||
'backend-system/core-services/http-auth',
|
||||
'backend-system/core-services/http-router',
|
||||
'backend-system/core-services/identity',
|
||||
'backend-system/core-services/lifecycle',
|
||||
'backend-system/core-services/logger',
|
||||
'backend-system/core-services/permissions',
|
||||
'backend-system/core-services/plugin-metadata',
|
||||
'backend-system/core-services/root-config',
|
||||
'backend-system/core-services/root-health',
|
||||
'backend-system/core-services/root-http-router',
|
||||
'backend-system/core-services/root-lifecycle',
|
||||
'backend-system/core-services/root-logger',
|
||||
'backend-system/core-services/scheduler',
|
||||
'backend-system/core-services/token-manager',
|
||||
'backend-system/core-services/url-reader',
|
||||
'backend-system/core-services/user-info',
|
||||
],
|
||||
},
|
||||
],
|
||||
'New Frontend System': [
|
||||
'frontend-system/index',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
'frontend-system/architecture/index',
|
||||
'frontend-system/architecture/app',
|
||||
'frontend-system/architecture/plugins',
|
||||
'frontend-system/architecture/extensions',
|
||||
'frontend-system/architecture/extension-blueprints',
|
||||
'frontend-system/architecture/extension-overrides',
|
||||
'frontend-system/architecture/references',
|
||||
'frontend-system/architecture/utility-apis',
|
||||
'frontend-system/architecture/routes',
|
||||
'frontend-system/architecture/naming-patterns',
|
||||
'frontend-system/architecture/migrations',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Plugins',
|
||||
items: [
|
||||
'frontend-system/building-plugins/index',
|
||||
'frontend-system/building-plugins/testing',
|
||||
'frontend-system/building-plugins/common-extension-blueprints',
|
||||
'frontend-system/building-plugins/built-in-data-refs',
|
||||
'frontend-system/building-plugins/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Building Apps',
|
||||
items: [
|
||||
'frontend-system/building-apps/index',
|
||||
'frontend-system/building-apps/configuring-extensions',
|
||||
'frontend-system/building-apps/built-in-extensions',
|
||||
'frontend-system/building-apps/plugin-conversion',
|
||||
'frontend-system/building-apps/migrating',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Utility APIs',
|
||||
items: [
|
||||
'frontend-system/utility-apis/index',
|
||||
'frontend-system/utility-apis/creating',
|
||||
'frontend-system/utility-apis/consuming',
|
||||
'frontend-system/utility-apis/configuring',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Local Development',
|
||||
items: [
|
||||
'tooling/local-dev/linking-local-packages',
|
||||
'tooling/local-dev/debugging',
|
||||
'tooling/local-dev/profiling',
|
||||
],
|
||||
},
|
||||
'tooling/package-metadata',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -477,19 +495,6 @@ module.exports = {
|
||||
'dls/contributing-to-storybook',
|
||||
'dls/figma',
|
||||
],
|
||||
'API Reference': [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Guides',
|
||||
items: ['api/utility-apis'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'API Reference',
|
||||
items: ['reference/index'],
|
||||
},
|
||||
'api/deprecations',
|
||||
],
|
||||
Tutorials: [
|
||||
'tutorials/quickstart-app-plugin',
|
||||
'tutorials/react-router-stable-migration',
|
||||
@@ -505,22 +510,7 @@ module.exports = {
|
||||
'tutorials/enable-public-entry',
|
||||
'tutorials/setup-opentelemetry',
|
||||
],
|
||||
'Architecture Decision Records (ADRs)': [
|
||||
'architecture-decisions/adrs-overview',
|
||||
'architecture-decisions/adrs-adr001',
|
||||
'architecture-decisions/adrs-adr002',
|
||||
'architecture-decisions/adrs-adr003',
|
||||
'architecture-decisions/adrs-adr004',
|
||||
'architecture-decisions/adrs-adr005',
|
||||
'architecture-decisions/adrs-adr006',
|
||||
'architecture-decisions/adrs-adr007',
|
||||
'architecture-decisions/adrs-adr008',
|
||||
'architecture-decisions/adrs-adr009',
|
||||
'architecture-decisions/adrs-adr010',
|
||||
'architecture-decisions/adrs-adr011',
|
||||
'architecture-decisions/adrs-adr012',
|
||||
'architecture-decisions/adrs-adr013',
|
||||
],
|
||||
|
||||
FAQ: ['faq/index', 'faq/product', 'faq/technical'],
|
||||
Accessibility: ['accessibility/index'],
|
||||
Contribute: [
|
||||
@@ -528,7 +518,32 @@ module.exports = {
|
||||
'contribute/getting-involved',
|
||||
'contribute/project-structure',
|
||||
],
|
||||
References: ['references/glossary'],
|
||||
References: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Architecture Decision Records (ADRs)',
|
||||
items: [
|
||||
'architecture-decisions/adrs-overview',
|
||||
'architecture-decisions/adrs-adr001',
|
||||
'architecture-decisions/adrs-adr002',
|
||||
'architecture-decisions/adrs-adr003',
|
||||
'architecture-decisions/adrs-adr004',
|
||||
'architecture-decisions/adrs-adr005',
|
||||
'architecture-decisions/adrs-adr006',
|
||||
'architecture-decisions/adrs-adr007',
|
||||
'architecture-decisions/adrs-adr008',
|
||||
'architecture-decisions/adrs-adr009',
|
||||
'architecture-decisions/adrs-adr010',
|
||||
'architecture-decisions/adrs-adr011',
|
||||
'architecture-decisions/adrs-adr012',
|
||||
'architecture-decisions/adrs-adr013',
|
||||
],
|
||||
},
|
||||
'api/deprecations',
|
||||
'references/glossary',
|
||||
'api/utility-apis',
|
||||
'reference/index',
|
||||
],
|
||||
},
|
||||
releases: {
|
||||
'Release Notes': releases.map(release => `releases/${release}`),
|
||||
|
||||
@@ -10,7 +10,6 @@ import express from 'express';
|
||||
import { HttpAuthService } from '@backstage/backend-plugin-api';
|
||||
import { HttpRouterService } from '@backstage/backend-plugin-api';
|
||||
import { HttpRouterServiceAuthPolicy } from '@backstage/backend-plugin-api';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { RequestHandler } from 'express';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
@@ -51,9 +50,10 @@ export const httpRouterServiceFactory: ServiceFactory<
|
||||
|
||||
// @public
|
||||
export interface LifecycleMiddlewareOptions {
|
||||
// (undocumented)
|
||||
config: RootConfigService;
|
||||
// (undocumented)
|
||||
lifecycle: LifecycleService;
|
||||
startupRequestPauseTimeout?: HumanDuration;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
@@ -134,8 +134,6 @@ export interface RootHttpRouterConfigureContext {
|
||||
// (undocumented)
|
||||
lifecycle: LifecycleService;
|
||||
// (undocumented)
|
||||
lifecycleMiddleware: RequestHandler;
|
||||
// (undocumented)
|
||||
logger: LoggerService;
|
||||
// (undocumented)
|
||||
middleware: MiddlewareFactory;
|
||||
|
||||
-27
@@ -78,31 +78,4 @@ describe('createLifecycleMiddleware', () => {
|
||||
new ServiceUnavailableError('Service has not started up yet'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delay service shutdown for the default timeout duration', async () => {
|
||||
jest.useFakeTimers();
|
||||
const defaultTimeout = 30000;
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
createLifecycleMiddleware({ lifecycle });
|
||||
const beforeShutdownPromise = lifecycle.beforeShutdown().then(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
jest.advanceTimersByTime(defaultTimeout);
|
||||
return expect(beforeShutdownPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delay service shutdown for the configured timeout duration - time in human duration', async () => {
|
||||
jest.useFakeTimers();
|
||||
const configuredTimeout = 20000;
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
createLifecycleMiddleware({
|
||||
lifecycle,
|
||||
serverShutdownDelay: { milliseconds: configuredTimeout },
|
||||
});
|
||||
const beforeShutdownPromise = lifecycle.beforeShutdown().then(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
jest.advanceTimersByTime(configuredTimeout);
|
||||
return expect(beforeShutdownPromise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
+1
-17
@@ -34,12 +34,6 @@ export interface LifecycleMiddlewareOptions {
|
||||
* Defaults to 5 seconds.
|
||||
*/
|
||||
startupRequestPauseTimeout?: HumanDuration;
|
||||
/**
|
||||
* The maximum time that the server will wait for stop accepting traffic, before returning an error.
|
||||
*
|
||||
* Defaults to 0 seconds.
|
||||
*/
|
||||
serverShutdownDelay?: HumanDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,8 +53,7 @@ export interface LifecycleMiddlewareOptions {
|
||||
export function createLifecycleMiddleware(
|
||||
options: LifecycleMiddlewareOptions,
|
||||
): RequestHandler {
|
||||
const { lifecycle, startupRequestPauseTimeout, serverShutdownDelay } =
|
||||
options;
|
||||
const { lifecycle, startupRequestPauseTimeout } = options;
|
||||
|
||||
let state: 'init' | 'up' | 'down' = 'init';
|
||||
const waiting = new Set<{
|
||||
@@ -79,15 +72,6 @@ export function createLifecycleMiddleware(
|
||||
}
|
||||
});
|
||||
|
||||
lifecycle.addBeforeShutdownHook(async () => {
|
||||
const timeoutMs = durationToMilliseconds(
|
||||
serverShutdownDelay ?? DEFAULT_SERVER_SHUTDOWN_TIMEOUT,
|
||||
);
|
||||
return await new Promise(resolve => {
|
||||
setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
});
|
||||
|
||||
lifecycle.addShutdownHook(async () => {
|
||||
state = 'down';
|
||||
for (const item of waiting) {
|
||||
+15
-3
@@ -20,10 +20,14 @@ import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { ServiceUnavailableError } from '@backstage/errors';
|
||||
|
||||
describe('createLifecycleMiddleware', () => {
|
||||
const config = mockServices.rootConfig.mock();
|
||||
it('should pause requests when plugin is not ready', async () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
|
||||
const middleware = createLifecycleMiddleware({ lifecycle });
|
||||
const middleware = createLifecycleMiddleware({
|
||||
config,
|
||||
lifecycle,
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
middleware({} as any, {} as any, next);
|
||||
@@ -41,7 +45,7 @@ describe('createLifecycleMiddleware', () => {
|
||||
|
||||
it('should throw ServiceUnavailableError after shutdown', async () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
const middleware = createLifecycleMiddleware({ lifecycle });
|
||||
const middleware = createLifecycleMiddleware({ config, lifecycle });
|
||||
|
||||
const next = jest.fn();
|
||||
middleware({} as any, {} as any, next);
|
||||
@@ -65,7 +69,15 @@ describe('createLifecycleMiddleware', () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
const middleware = createLifecycleMiddleware({
|
||||
lifecycle,
|
||||
startupRequestPauseTimeout: { milliseconds: 1 },
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
lifecycle: {
|
||||
startupRequestPauseTimeout: { milliseconds: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
+15
-8
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
RootConfigService,
|
||||
LifecycleService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { readDurationFromConfig } from '@backstage/config';
|
||||
import { ServiceUnavailableError } from '@backstage/errors';
|
||||
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
|
||||
import { RequestHandler } from 'express';
|
||||
@@ -26,13 +30,8 @@ export const DEFAULT_TIMEOUT = { seconds: 5 };
|
||||
* @public
|
||||
*/
|
||||
export interface LifecycleMiddlewareOptions {
|
||||
config: RootConfigService;
|
||||
lifecycle: LifecycleService;
|
||||
/**
|
||||
* The maximum time that paused requests will wait for the service to start, before returning an error.
|
||||
*
|
||||
* Defaults to 5 seconds.
|
||||
*/
|
||||
startupRequestPauseTimeout?: HumanDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +51,7 @@ export interface LifecycleMiddlewareOptions {
|
||||
export function createLifecycleMiddleware(
|
||||
options: LifecycleMiddlewareOptions,
|
||||
): RequestHandler {
|
||||
const { lifecycle, startupRequestPauseTimeout = DEFAULT_TIMEOUT } = options;
|
||||
const { config, lifecycle } = options;
|
||||
|
||||
let state: 'init' | 'up' | 'down' = 'init';
|
||||
const waiting = new Set<{
|
||||
@@ -81,6 +80,14 @@ export function createLifecycleMiddleware(
|
||||
waiting.clear();
|
||||
});
|
||||
|
||||
let startupRequestPauseTimeout: HumanDuration = DEFAULT_TIMEOUT;
|
||||
|
||||
if (config.has('backend.lifecycle.startupRequestPauseTimeout')) {
|
||||
startupRequestPauseTimeout = readDurationFromConfig(config, {
|
||||
key: 'backend.lifecycle.startupRequestPauseTimeout',
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = durationToMilliseconds(startupRequestPauseTimeout);
|
||||
|
||||
return (_req, _res, next) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HttpRouterServiceAuthPolicy,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
createLifecycleMiddleware,
|
||||
createCookieAuthRefreshMiddleware,
|
||||
createCredentialsBarrier,
|
||||
createAuthIntegrationRouter,
|
||||
@@ -42,11 +43,12 @@ export const httpRouterServiceFactory = createServiceFactory({
|
||||
deps: {
|
||||
plugin: coreServices.pluginMetadata,
|
||||
config: coreServices.rootConfig,
|
||||
lifecycle: coreServices.lifecycle,
|
||||
rootHttpRouter: coreServices.rootHttpRouter,
|
||||
auth: coreServices.auth,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
},
|
||||
async factory({ auth, httpAuth, config, plugin, rootHttpRouter }) {
|
||||
async factory({ auth, httpAuth, config, plugin, rootHttpRouter, lifecycle }) {
|
||||
const router = PromiseRouter();
|
||||
|
||||
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
|
||||
@@ -57,6 +59,7 @@ export const httpRouterServiceFactory = createServiceFactory({
|
||||
});
|
||||
|
||||
router.use(createAuthIntegrationRouter({ auth }));
|
||||
router.use(createLifecycleMiddleware({ config, lifecycle }));
|
||||
router.use(credentialsBarrier.middleware);
|
||||
router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
|
||||
|
||||
|
||||
+9
-15
@@ -207,20 +207,21 @@ describe('rootHttpRouterServiceFactory', () => {
|
||||
it('should wait the server to shutdown', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const serverStopMock = jest.fn();
|
||||
|
||||
let app: Express | undefined = undefined;
|
||||
const lifecycleMock = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
|
||||
const tester = ServiceFactoryTester.from(
|
||||
rootHttpRouterServiceFactory({
|
||||
configure(options) {
|
||||
console.log('configure');
|
||||
options.app.use(options.healthRouter);
|
||||
options.app.use(options.lifecycleMiddleware);
|
||||
options.app.get('/test', (_req, res) => {
|
||||
res.status(200).send({ status: 'ok' }).end();
|
||||
});
|
||||
options.app.use(options.middleware.error());
|
||||
app = options.app;
|
||||
options.server.addListener('close', serverStopMock);
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -306,18 +307,6 @@ describe('rootHttpRouterServiceFactory', () => {
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
// No longer accepting requests after shutdown
|
||||
await request(app!)
|
||||
.get('/test')
|
||||
.expect(503, {
|
||||
error: {
|
||||
name: 'ServiceUnavailableError',
|
||||
message: 'Service is shutting down',
|
||||
},
|
||||
request: { method: 'GET', url: '/test' },
|
||||
response: { statusCode: 503 },
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get('/.backstage/health/v1/liveness')
|
||||
.expect(200, { status: 'ok' });
|
||||
@@ -327,6 +316,11 @@ describe('rootHttpRouterServiceFactory', () => {
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
return await expect(beforeShutdownPromise).resolves.toBeUndefined();
|
||||
return expect(
|
||||
beforeShutdownPromise.then(() => {
|
||||
expect(serverStopMock).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-25
@@ -30,9 +30,8 @@ import {
|
||||
} from './http';
|
||||
import { DefaultRootHttpRouter } from './DefaultRootHttpRouter';
|
||||
import { createHealthRouter } from './createHealthRouter';
|
||||
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
import { readDurationFromConfig } from '@backstage/config';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -46,7 +45,6 @@ export interface RootHttpRouterConfigureContext {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
healthRouter: RequestHandler;
|
||||
lifecycleMiddleware: RequestHandler;
|
||||
applyDefaults: () => void;
|
||||
}
|
||||
|
||||
@@ -95,26 +93,6 @@ const rootHttpRouterServiceFactoryWithOptions = (
|
||||
|
||||
const healthRouter = createHealthRouter({ config, health });
|
||||
|
||||
let startupRequestPauseTimeout: HumanDuration | undefined;
|
||||
if (config.has('backend.lifecycle.startupRequestPauseTimeout')) {
|
||||
startupRequestPauseTimeout = readDurationFromConfig(config, {
|
||||
key: 'backend.lifecycle.startupRequestPauseTimeout',
|
||||
});
|
||||
}
|
||||
|
||||
let serverShutdownDelay: HumanDuration | undefined;
|
||||
if (config.has('backend.lifecycle.serverShutdownDelay')) {
|
||||
serverShutdownDelay = readDurationFromConfig(config, {
|
||||
key: 'backend.lifecycle.serverShutdownDelay',
|
||||
});
|
||||
}
|
||||
|
||||
const lifecycleMiddleware = createLifecycleMiddleware({
|
||||
lifecycle,
|
||||
startupRequestPauseTimeout,
|
||||
serverShutdownDelay,
|
||||
});
|
||||
|
||||
const server = await createHttpServer(
|
||||
app,
|
||||
readHttpServerOptions(config.getOptionalConfig('backend')),
|
||||
@@ -130,7 +108,6 @@ const rootHttpRouterServiceFactoryWithOptions = (
|
||||
logger,
|
||||
lifecycle,
|
||||
healthRouter,
|
||||
lifecycleMiddleware,
|
||||
applyDefaults() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.set('json spaces', 2);
|
||||
@@ -140,13 +117,24 @@ const rootHttpRouterServiceFactoryWithOptions = (
|
||||
app.use(middleware.compression());
|
||||
app.use(middleware.logging());
|
||||
app.use(healthRouter);
|
||||
app.use(lifecycleMiddleware);
|
||||
app.use(routes);
|
||||
app.use(middleware.notFound());
|
||||
app.use(middleware.error());
|
||||
},
|
||||
});
|
||||
|
||||
if (config.has('backend.lifecycle.serverShutdownDelay')) {
|
||||
const serverShutdownDelay = readDurationFromConfig(config, {
|
||||
key: 'backend.lifecycle.serverShutdownDelay',
|
||||
});
|
||||
lifecycle.addBeforeShutdownHook(async () => {
|
||||
const timeoutMs = durationToMilliseconds(serverShutdownDelay);
|
||||
return await new Promise(resolve => {
|
||||
setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
lifecycle.addShutdownHook(() => server.stop());
|
||||
|
||||
await server.start();
|
||||
|
||||
@@ -36,18 +36,18 @@
|
||||
|
||||
.button.primary:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 0 1px var(--canon-outline-focus);
|
||||
box-shadow: inset 0 0 0 1px var(--canon-outline-focus);
|
||||
color: var(--canon-text-primary);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 0 1px var(--canon-outline);
|
||||
box-shadow: inset 0 0 0 1px var(--canon-outline);
|
||||
color: var(--canon-text-primary);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
box-shadow: 0 0 0 1px var(--canon-outline-hover);
|
||||
box-shadow: inset 0 0 0 1px var(--canon-outline-hover);
|
||||
}
|
||||
|
||||
.button.tertiary {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"commander": "^12.0.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"css-loader": "^7.0.0",
|
||||
"css-loader": "^6.5.1",
|
||||
"ctrlc-windows": "^2.1.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild-loader": "^4.0.0",
|
||||
|
||||
Vendored
+10
@@ -138,6 +138,16 @@ export interface Config {
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Disables the compatibility layer for relations in returned entities that
|
||||
* ensures that all relations objects have both `target` and `targetRef`.
|
||||
*
|
||||
* Enabling this option significantly reduces the memory usage of the
|
||||
* catalog, and slightly increases performance, but may break consumers that
|
||||
* rely on the existence of `target` in the relations objects.
|
||||
*/
|
||||
disableRelationsCompatibility?: boolean;
|
||||
|
||||
/**
|
||||
* The strategy to use for entities that are orphaned, i.e. no longer have
|
||||
* any other entities or providers referencing them. The default value is
|
||||
|
||||
@@ -52,8 +52,22 @@ export type EntitiesRequest = {
|
||||
credentials: BackstageCredentials;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encapsulates either a deserialized or serialized entities to be sent in a response.
|
||||
* @internal
|
||||
*/
|
||||
export type EntitiesResponseItems =
|
||||
| {
|
||||
type: 'object';
|
||||
entities: (Entity | null)[];
|
||||
}
|
||||
| {
|
||||
type: 'raw';
|
||||
entities: (string | null)[];
|
||||
};
|
||||
|
||||
export type EntitiesResponse = {
|
||||
entities: Entity[];
|
||||
entities: EntitiesResponseItems;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
@@ -86,7 +100,7 @@ export interface EntitiesBatchResponse {
|
||||
* The list of entities, in the same order as the refs in the request. Entries
|
||||
* that are null signify that no entity existed with that ref.
|
||||
*/
|
||||
items: Array<Entity | null>;
|
||||
items: EntitiesResponseItems;
|
||||
}
|
||||
|
||||
export type EntityAncestryResponse = {
|
||||
@@ -224,7 +238,7 @@ export interface QueryEntitiesResponse {
|
||||
/**
|
||||
* The entities for the current pagination request
|
||||
*/
|
||||
items: Entity[];
|
||||
items: EntitiesResponseItems;
|
||||
|
||||
pageInfo: {
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
}),
|
||||
).toEqual({
|
||||
entities: [],
|
||||
entities: { type: 'object', entities: [] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
items: [null],
|
||||
items: { type: 'object', entities: [null] },
|
||||
});
|
||||
|
||||
expect(fakeCatalog.entitiesBatch).not.toHaveBeenCalled();
|
||||
@@ -174,7 +174,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
items: [],
|
||||
items: { type: 'object', entities: [] },
|
||||
pageInfo: {},
|
||||
totalItems: 0,
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
];
|
||||
|
||||
fakeCatalog.queryEntities.mockResolvedValue({
|
||||
items: entities,
|
||||
items: { type: 'object', entities },
|
||||
pageInfo: {
|
||||
nextCursor: {
|
||||
isPrevious: false,
|
||||
@@ -254,7 +254,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
items: entities,
|
||||
items: { type: 'object', entities: entities },
|
||||
totalItems: 4,
|
||||
pageInfo: {
|
||||
nextCursor: {
|
||||
@@ -290,7 +290,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
items: entities,
|
||||
items: { type: 'object', entities: entities },
|
||||
totalItems: 4,
|
||||
pageInfo: {
|
||||
nextCursor: {
|
||||
@@ -338,7 +338,9 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
conditions: { rule: 'IS_ENTITY_KIND', params: { kinds: ['b'] } },
|
||||
},
|
||||
]);
|
||||
fakeCatalog.entities.mockResolvedValue({ entities: [] });
|
||||
fakeCatalog.entities.mockResolvedValue({
|
||||
entities: { type: 'object', entities: [] },
|
||||
});
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
@@ -360,7 +362,10 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
},
|
||||
]);
|
||||
fakeCatalog.entities.mockResolvedValue({
|
||||
entities: [{ kind: 'b', namespace: 'default', name: 'my-component' }],
|
||||
entities: {
|
||||
type: 'object',
|
||||
entities: [{ kind: 'b', namespace: 'default', name: 'my-component' }],
|
||||
},
|
||||
});
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
|
||||
@@ -60,7 +60,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
entities: [],
|
||||
entities: { type: 'object', entities: [] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
};
|
||||
}
|
||||
@@ -92,7 +92,10 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
items: new Array(request.entityRefs.length).fill(null),
|
||||
items: {
|
||||
type: 'object',
|
||||
entities: new Array(request.entityRefs.length).fill(null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
items: [],
|
||||
items: { type: 'object', entities: [] },
|
||||
pageInfo: {},
|
||||
totalItems: 0,
|
||||
};
|
||||
@@ -208,7 +211,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
allOf: [permissionFilter, basicEntityFilter({ 'metadata.uid': uid })],
|
||||
},
|
||||
});
|
||||
if (entities.length === 0) {
|
||||
if (entities.entities.length === 0) {
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
UrlReaderService,
|
||||
SchedulerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { entitiesResponseToObjects } from './response';
|
||||
|
||||
/**
|
||||
* This is a duplicate of the alpha `CatalogPermissionRule` type, for use in the stable API.
|
||||
@@ -485,6 +486,10 @@ export class CatalogBuilder {
|
||||
discovery,
|
||||
});
|
||||
|
||||
const disableRelationsCompatibility = config.getOptionalBoolean(
|
||||
'catalog.disableRelationsCompatibility',
|
||||
);
|
||||
|
||||
const policy = this.buildEntityPolicy();
|
||||
const processors = this.buildProcessors();
|
||||
const parser = this.parser || defaultEntityDataParser;
|
||||
@@ -521,6 +526,7 @@ export class CatalogBuilder {
|
||||
database: dbClient,
|
||||
logger,
|
||||
stitcher,
|
||||
disableRelationsCompatibility,
|
||||
});
|
||||
|
||||
let permissionsService: PermissionsService;
|
||||
@@ -566,7 +572,10 @@ export class CatalogBuilder {
|
||||
},
|
||||
});
|
||||
|
||||
const entitiesByRef = keyBy(entities, stringifyEntityRef);
|
||||
const entitiesByRef = keyBy(
|
||||
entitiesResponseToObjects(entities),
|
||||
stringifyEntityRef,
|
||||
);
|
||||
|
||||
return resourceRefs.map(
|
||||
resourceRef =>
|
||||
@@ -629,6 +638,7 @@ export class CatalogBuilder {
|
||||
auth,
|
||||
httpAuth,
|
||||
permissionsService,
|
||||
disableRelationsCompatibility,
|
||||
});
|
||||
|
||||
await connectEntityProviders(providerDatabase, entityProviders);
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Stitcher } from '../stitching/types';
|
||||
import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog';
|
||||
import { EntitiesRequest } from '../catalog/types';
|
||||
import { buildEntitySearch } from '../database/operations/stitcher/buildEntitySearch';
|
||||
import { entitiesResponseToObjects } from './response';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
@@ -310,10 +311,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
const testFilter = {
|
||||
key: 'spec.test',
|
||||
};
|
||||
const { entities } = await catalog.entities({
|
||||
const res = await catalog.entities({
|
||||
filter: testFilter,
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0]).toEqual(entity2);
|
||||
@@ -351,10 +353,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
key: 'spec.test',
|
||||
},
|
||||
};
|
||||
const { entities } = await catalog.entities({
|
||||
const res = await catalog.entities({
|
||||
filter: testFilter,
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0]).toEqual(entity1);
|
||||
@@ -416,7 +419,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
values: ['red'],
|
||||
},
|
||||
};
|
||||
const { entities } = await catalog.entities({
|
||||
const res = await catalog.entities({
|
||||
filter: {
|
||||
allOf: [
|
||||
testFilter1,
|
||||
@@ -427,6 +430,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
},
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(2);
|
||||
expect(entities).toContainEqual(entity2);
|
||||
@@ -465,7 +469,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
const testFilter2 = {
|
||||
key: 'metadata.desc',
|
||||
};
|
||||
const { entities } = await catalog.entities({
|
||||
const res = await catalog.entities({
|
||||
filter: {
|
||||
not: {
|
||||
allOf: [testFilter1, testFilter2],
|
||||
@@ -474,6 +478,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities).toContainEqual(entity1);
|
||||
@@ -509,10 +514,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
key: 'kind',
|
||||
values: [],
|
||||
};
|
||||
const { entities } = await catalog.entities({
|
||||
const res = await catalog.entities({
|
||||
filter: testFilter,
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(0);
|
||||
},
|
||||
@@ -553,10 +559,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
stitcher,
|
||||
});
|
||||
|
||||
const { entities } = await catalog.entities();
|
||||
const res = await catalog.entities();
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(
|
||||
entities.find(e => e.metadata.name === 'one')!.relations,
|
||||
entities.find(e => e?.metadata.name === 'one')!.relations,
|
||||
).toEqual([
|
||||
{
|
||||
type: 'r',
|
||||
@@ -565,7 +572,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
entities.find(e => e.metadata.name === 'two')!.relations,
|
||||
entities.find(e => e?.metadata.name === 'two')!.relations,
|
||||
).toEqual([
|
||||
{
|
||||
type: 'r',
|
||||
@@ -615,7 +622,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
return catalog
|
||||
.entities({ ...request, credentials: mockCredentials.none() })
|
||||
.then(response =>
|
||||
response.entities.map(e => e.metadata.name).toSorted(),
|
||||
entitiesResponseToObjects(response.entities)
|
||||
.map(e => e!.metadata.name)
|
||||
.toSorted(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -678,7 +687,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
): Promise<string[]> {
|
||||
return catalog
|
||||
.entities({ ...request, credentials: mockCredentials.none() })
|
||||
.then(response => response.entities.map(e => e.metadata.name));
|
||||
.then(response =>
|
||||
entitiesResponseToObjects(response.entities).map(
|
||||
e => e!.metadata.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await expect(
|
||||
@@ -764,7 +777,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
stitcher,
|
||||
});
|
||||
|
||||
const { items } = await catalog.entitiesBatch({
|
||||
const res = await catalog.entitiesBatch({
|
||||
entityRefs: [
|
||||
'k:default/two',
|
||||
'k:default/one',
|
||||
@@ -775,6 +788,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
],
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const items = entitiesResponseToObjects(res.items);
|
||||
|
||||
expect(items.map(e => e && stringifyEntityRef(e))).toEqual([
|
||||
'k:default/two',
|
||||
@@ -819,11 +833,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
stitcher,
|
||||
});
|
||||
|
||||
const { items } = await catalog.entitiesBatch({
|
||||
const res = await catalog.entitiesBatch({
|
||||
entityRefs: ['k:default/two', 'k:default/one'],
|
||||
filter: { key: 'spec.owner', values: ['me'] },
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
const items = entitiesResponseToObjects(res.items);
|
||||
|
||||
expect(items.map(e => e && stringifyEntityRef(e))).toEqual([
|
||||
'k:default/two',
|
||||
@@ -881,7 +896,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response1 = await catalog.queryEntities(request1);
|
||||
expect(response1.items).toEqual([entityFrom('A'), entityFrom('B')]);
|
||||
expect(entitiesResponseToObjects(response1.items)).toEqual([
|
||||
entityFrom('A'),
|
||||
entityFrom('B'),
|
||||
]);
|
||||
expect(response1.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response1.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response1.totalItems).toBe(names.length);
|
||||
@@ -893,7 +911,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response2 = await catalog.queryEntities(request2);
|
||||
expect(response2.items).toEqual([entityFrom('C'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response2.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response2.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response2.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response2.totalItems).toBe(names.length);
|
||||
@@ -905,7 +926,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response3 = await catalog.queryEntities(request3);
|
||||
expect(response3.items).toEqual([entityFrom('E'), entityFrom('F')]);
|
||||
expect(entitiesResponseToObjects(response3.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('F'),
|
||||
]);
|
||||
expect(response3.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response3.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response3.totalItems).toBe(names.length);
|
||||
@@ -917,7 +941,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response4 = await catalog.queryEntities(request4);
|
||||
expect(response4.items).toEqual([entityFrom('C'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response4.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response4.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response4.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response4.totalItems).toBe(names.length);
|
||||
@@ -929,7 +956,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response5 = await catalog.queryEntities(request5);
|
||||
expect(response5.items).toEqual([entityFrom('A'), entityFrom('B')]);
|
||||
expect(entitiesResponseToObjects(response5.items)).toEqual([
|
||||
entityFrom('A'),
|
||||
entityFrom('B'),
|
||||
]);
|
||||
expect(response5.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response5.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response5.totalItems).toBe(names.length);
|
||||
@@ -941,7 +971,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response6 = await catalog.queryEntities(request6);
|
||||
expect(response6.items).toEqual([entityFrom('C'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response6.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response6.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response6.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response6.totalItems).toBe(names.length);
|
||||
@@ -953,7 +986,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response7 = await catalog.queryEntities(request7);
|
||||
expect(response7.items).toEqual([entityFrom('E'), entityFrom('F')]);
|
||||
expect(entitiesResponseToObjects(response7.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('F'),
|
||||
]);
|
||||
expect(response7.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response7.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response7.totalItems).toBe(names.length);
|
||||
@@ -965,7 +1001,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response7bis = await catalog.queryEntities(request7bis);
|
||||
expect(response7bis.items).toEqual([
|
||||
expect(entitiesResponseToObjects(response7bis.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('F'),
|
||||
entityFrom('G'),
|
||||
@@ -981,7 +1017,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response8 = await catalog.queryEntities(request8);
|
||||
expect(response8.items).toEqual([entityFrom('G')]);
|
||||
expect(entitiesResponseToObjects(response8.items)).toEqual([
|
||||
entityFrom('G'),
|
||||
]);
|
||||
expect(response8.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response8.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response8.totalItems).toBe(names.length);
|
||||
@@ -1035,7 +1073,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response1 = await catalog.queryEntities(request1);
|
||||
expect(response1.items).toEqual([entityFrom('G'), entityFrom('F')]);
|
||||
expect(entitiesResponseToObjects(response1.items)).toEqual([
|
||||
entityFrom('G'),
|
||||
entityFrom('F'),
|
||||
]);
|
||||
expect(response1.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response1.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response1.totalItems).toBe(names.length);
|
||||
@@ -1047,7 +1088,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response2 = await catalog.queryEntities(request2);
|
||||
expect(response2.items).toEqual([entityFrom('E'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response2.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response2.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response2.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response2.totalItems).toBe(names.length);
|
||||
@@ -1059,7 +1103,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response3 = await catalog.queryEntities(request3);
|
||||
expect(response3.items).toEqual([entityFrom('C'), entityFrom('B')]);
|
||||
expect(entitiesResponseToObjects(response3.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('B'),
|
||||
]);
|
||||
expect(response3.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response3.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response3.totalItems).toBe(names.length);
|
||||
@@ -1072,7 +1119,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
};
|
||||
const response4 = await catalog.queryEntities(request4);
|
||||
|
||||
expect(response4.items).toEqual([entityFrom('E'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response4.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response4.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response4.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response4.totalItems).toBe(names.length);
|
||||
@@ -1084,7 +1134,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response5 = await catalog.queryEntities(request5);
|
||||
expect(response5.items).toEqual([entityFrom('G'), entityFrom('F')]);
|
||||
expect(entitiesResponseToObjects(response5.items)).toEqual([
|
||||
entityFrom('G'),
|
||||
entityFrom('F'),
|
||||
]);
|
||||
expect(response5.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response5.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response5.totalItems).toBe(names.length);
|
||||
@@ -1096,7 +1149,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response6 = await catalog.queryEntities(request6);
|
||||
expect(response6.items).toEqual([entityFrom('E'), entityFrom('D')]);
|
||||
expect(entitiesResponseToObjects(response6.items)).toEqual([
|
||||
entityFrom('E'),
|
||||
entityFrom('D'),
|
||||
]);
|
||||
expect(response6.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response6.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response6.totalItems).toBe(names.length);
|
||||
@@ -1108,7 +1164,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response7 = await catalog.queryEntities(request7);
|
||||
expect(response7.items).toEqual([entityFrom('C'), entityFrom('B')]);
|
||||
expect(entitiesResponseToObjects(response7.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('B'),
|
||||
]);
|
||||
expect(response7.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response7.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response7.totalItems).toBe(names.length);
|
||||
@@ -1120,7 +1179,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response7bis = await catalog.queryEntities(request7bis);
|
||||
expect(response7bis.items).toEqual([
|
||||
expect(entitiesResponseToObjects(response7bis.items)).toEqual([
|
||||
entityFrom('C'),
|
||||
entityFrom('B'),
|
||||
entityFrom('A'),
|
||||
@@ -1136,7 +1195,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response8 = await catalog.queryEntities(request8);
|
||||
expect(response8.items).toEqual([entityFrom('A')]);
|
||||
expect(entitiesResponseToObjects(response8.items)).toEqual([
|
||||
entityFrom('A'),
|
||||
]);
|
||||
expect(response8.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response8.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response8.totalItems).toBe(names.length);
|
||||
@@ -1188,7 +1249,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response = await catalog.queryEntities(request);
|
||||
expect(response.items).toEqual([
|
||||
expect(entitiesResponseToObjects(response.items)).toEqual([
|
||||
entityFrom('atcatss'),
|
||||
entityFrom('cat'),
|
||||
entityFrom('dogcat'),
|
||||
@@ -1246,7 +1307,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response = await catalog.queryEntities(request);
|
||||
expect(response.items).toEqual(entities);
|
||||
expect(entitiesResponseToObjects(response.items)).toEqual(entities);
|
||||
expect(response.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response.totalItems).toBe(1);
|
||||
@@ -1301,7 +1362,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response = await catalog.queryEntities(request);
|
||||
expect(response.items).toEqual([
|
||||
expect(entitiesResponseToObjects(response.items)).toEqual([
|
||||
entityFrom('1', { uid: 'id1', title: 'cat' }),
|
||||
entityFrom('2', { uid: 'id2', title: 'atcatss' }),
|
||||
entityFrom('4', { uid: 'id4', title: 'dogcat' }),
|
||||
@@ -1314,7 +1375,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
...request,
|
||||
limit: 2,
|
||||
});
|
||||
expect(paginatedResponse.items).toEqual([
|
||||
expect(entitiesResponseToObjects(paginatedResponse.items)).toEqual([
|
||||
entityFrom('1', { uid: 'id1', title: 'cat' }),
|
||||
entityFrom('2', { uid: 'id2', title: 'atcatss' }),
|
||||
]);
|
||||
@@ -1326,7 +1387,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
cursor: paginatedResponse.pageInfo.nextCursor!,
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
expect(paginatedResponseNext.items).toEqual([
|
||||
expect(entitiesResponseToObjects(paginatedResponseNext.items)).toEqual([
|
||||
entityFrom('4', { uid: 'id4', title: 'dogcat' }),
|
||||
]);
|
||||
expect(paginatedResponseNext.pageInfo.nextCursor).toBeUndefined();
|
||||
@@ -1404,7 +1465,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
};
|
||||
const response = await catalog.queryEntities(request);
|
||||
|
||||
expect(response.items).toEqual([
|
||||
expect(entitiesResponseToObjects(response.items)).toEqual([
|
||||
entityFrom('KingOfTheJungle', { uid: 'id0', title: 'lion' }),
|
||||
entityFrom('NotKingOfTheJungle', { uid: 'id1', title: 'cat' }),
|
||||
entityFrom('NotACatKing', { uid: 'id2', title: 'atcatss' }),
|
||||
@@ -1418,7 +1479,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
...request,
|
||||
limit: 2,
|
||||
});
|
||||
expect(paginatedResponse.items).toEqual([
|
||||
expect(entitiesResponseToObjects(paginatedResponse.items)).toEqual([
|
||||
entityFrom('KingOfTheJungle', { uid: 'id0', title: 'lion' }),
|
||||
entityFrom('NotKingOfTheJungle', { uid: 'id1', title: 'cat' }),
|
||||
]);
|
||||
@@ -1430,7 +1491,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
cursor: paginatedResponse.pageInfo.nextCursor!,
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
expect(paginatedResponseNext.items).toEqual([
|
||||
expect(entitiesResponseToObjects(paginatedResponseNext.items)).toEqual([
|
||||
entityFrom('NotACatKing', { uid: 'id2', title: 'atcatss' }),
|
||||
entityFrom('123', { uid: 'id3', title: 'king' }),
|
||||
]);
|
||||
@@ -1474,7 +1535,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response = await catalog.queryEntities(request);
|
||||
expect(response).toEqual({ totalItems: 20, items: [], pageInfo: {} });
|
||||
expect(response).toEqual({
|
||||
totalItems: 20,
|
||||
items: { type: 'raw', entities: [] },
|
||||
pageInfo: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1509,7 +1574,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
let response = await catalog.queryEntities(request);
|
||||
expect(response).toEqual({
|
||||
totalItems: 0,
|
||||
items: expect.objectContaining({ length: 10 }),
|
||||
items: {
|
||||
type: 'raw',
|
||||
entities: expect.objectContaining({ length: 10 }),
|
||||
},
|
||||
pageInfo: { nextCursor: expect.anything() },
|
||||
});
|
||||
response = await catalog.queryEntities({
|
||||
@@ -1518,7 +1586,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
});
|
||||
expect(response).toEqual({
|
||||
totalItems: 0,
|
||||
items: expect.objectContaining({ length: 5 }),
|
||||
items: {
|
||||
type: 'raw',
|
||||
entities: expect.objectContaining({ length: 5 }),
|
||||
},
|
||||
pageInfo: { prevCursor: expect.anything() },
|
||||
});
|
||||
},
|
||||
@@ -1553,7 +1624,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response1 = await catalog.queryEntities(request1);
|
||||
expect(response1.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response1.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('AA'),
|
||||
]);
|
||||
@@ -1568,7 +1639,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response2 = await catalog.queryEntities(request2);
|
||||
expect(response2.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response2.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('AA'),
|
||||
]);
|
||||
@@ -1583,7 +1654,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response3 = await catalog.queryEntities(request3);
|
||||
expect(response3.items).toEqual([entityFrom('CC'), entityFrom('DD')]);
|
||||
expect(entitiesResponseToObjects(response3.items)).toEqual([
|
||||
entityFrom('CC'),
|
||||
entityFrom('DD'),
|
||||
]);
|
||||
expect(response3.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response3.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response3.totalItems).toBe(6);
|
||||
@@ -1595,7 +1669,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response4 = await catalog.queryEntities(request4);
|
||||
expect(response4.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response4.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('AA'),
|
||||
]);
|
||||
@@ -1610,7 +1684,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response5 = await catalog.queryEntities(request5);
|
||||
expect(response5.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response5.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('AA'),
|
||||
]);
|
||||
@@ -1678,7 +1752,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response1 = await catalog.queryEntities(request1);
|
||||
expect(response1.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response1.items)).toMatchObject([
|
||||
entityFrom('AA', { uid: '1', kind: 'included' }),
|
||||
entityFrom('AA', { uid: '2', kind: 'included' }),
|
||||
]);
|
||||
@@ -1693,7 +1767,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response2 = await catalog.queryEntities(request2);
|
||||
expect(response2.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response2.items)).toMatchObject([
|
||||
entityFrom('AA', { uid: '4', kind: 'included' }),
|
||||
entityFrom('AA', { uid: '5', kind: 'included' }),
|
||||
]);
|
||||
@@ -1737,7 +1811,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response1 = await catalog.queryEntities(request1);
|
||||
expect(response1.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response1.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('CC'),
|
||||
]);
|
||||
@@ -1752,7 +1826,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response2 = await catalog.queryEntities(request2);
|
||||
expect(response2.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response2.items)).toMatchObject([
|
||||
entityFrom('DD'),
|
||||
entityFrom('AA', { namespace: 'namespace2' }),
|
||||
]);
|
||||
@@ -1767,7 +1841,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response3 = await catalog.queryEntities(request3);
|
||||
expect(response3.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response3.items)).toMatchObject([
|
||||
entityFrom('AA', { namespace: 'namespace3' }),
|
||||
entityFrom('AA', { namespace: 'namespace4' }),
|
||||
]);
|
||||
@@ -1782,7 +1856,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response4 = await catalog.queryEntities(request4);
|
||||
expect(response4.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response4.items)).toMatchObject([
|
||||
entityFrom('DD'),
|
||||
entityFrom('AA', { namespace: 'namespace2' }),
|
||||
]);
|
||||
@@ -1797,7 +1871,7 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
};
|
||||
const response5 = await catalog.queryEntities(request5);
|
||||
expect(response5.items).toMatchObject([
|
||||
expect(entitiesResponseToObjects(response5.items)).toMatchObject([
|
||||
entityFrom('AA'),
|
||||
entityFrom('CC'),
|
||||
]);
|
||||
@@ -1830,7 +1904,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
orderFields: [{ field: 'metadata.title', order: 'asc' }],
|
||||
credentials: mockCredentials.none(),
|
||||
})
|
||||
.then(r => r.items.map(e => e.metadata.name)),
|
||||
.then(r =>
|
||||
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
|
||||
),
|
||||
).resolves.toEqual(['CC', 'BB', 'AA']); // 'AA' has no title, ends up last
|
||||
|
||||
await expect(
|
||||
@@ -1839,7 +1915,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
orderFields: [{ field: 'metadata.title', order: 'desc' }],
|
||||
credentials: mockCredentials.none(),
|
||||
})
|
||||
.then(r => r.items.map(e => e.metadata.name)),
|
||||
.then(r =>
|
||||
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
|
||||
),
|
||||
).resolves.toEqual(['BB', 'CC', 'AA']); // 'AA' has no title, ends up last
|
||||
},
|
||||
);
|
||||
@@ -1869,7 +1947,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
limit: 10,
|
||||
credentials: mockCredentials.none(),
|
||||
})
|
||||
.then(r => r.items.map(e => e.metadata.name)),
|
||||
.then(r =>
|
||||
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
|
||||
),
|
||||
).resolves.toEqual(['AA', 'BB']);
|
||||
|
||||
// simulate a situation where stitching is not yet complete
|
||||
@@ -1884,7 +1964,9 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
limit: 10,
|
||||
credentials: mockCredentials.none(),
|
||||
})
|
||||
.then(r => r.items.map(e => e.metadata.name)),
|
||||
.then(r =>
|
||||
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
|
||||
),
|
||||
).resolves.toEqual(['BB']);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,13 +45,14 @@ import {
|
||||
import { Stitcher } from '../stitching/types';
|
||||
|
||||
import {
|
||||
expandLegacyCompoundRelationRefsInResponse,
|
||||
expandLegacyCompoundRelationsInEntity,
|
||||
isQueryEntitiesCursorRequest,
|
||||
isQueryEntitiesInitialRequest,
|
||||
} from './util';
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery';
|
||||
import { processRawEntitiesResult } from './response';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
|
||||
@@ -104,15 +105,20 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
private readonly database: Knex;
|
||||
private readonly logger: LoggerService;
|
||||
private readonly stitcher: Stitcher;
|
||||
private readonly disableRelationsCompatibility: boolean;
|
||||
|
||||
constructor(options: {
|
||||
database: Knex;
|
||||
logger: LoggerService;
|
||||
stitcher: Stitcher;
|
||||
disableRelationsCompatibility?: boolean;
|
||||
}) {
|
||||
this.database = options.database;
|
||||
this.logger = options.logger;
|
||||
this.stitcher = options.stitcher;
|
||||
this.disableRelationsCompatibility = Boolean(
|
||||
options.disableRelationsCompatibility,
|
||||
);
|
||||
}
|
||||
|
||||
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
|
||||
@@ -142,7 +148,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
targetQuery: entitiesQuery,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: db,
|
||||
strategy: limit !== undefined && limit <= 500 ? 'join' : 'in',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,16 +196,19 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
let entities: Entity[] = rows.map(e => JSON.parse(e.final_entity!));
|
||||
|
||||
if (request?.fields) {
|
||||
entities = entities.map(e => request.fields!(e));
|
||||
}
|
||||
|
||||
expandLegacyCompoundRelationRefsInResponse(entities);
|
||||
|
||||
return {
|
||||
entities,
|
||||
entities: processRawEntitiesResult(
|
||||
rows.map(r => r.final_entity!),
|
||||
this.disableRelationsCompatibility
|
||||
? request?.fields
|
||||
: e => {
|
||||
expandLegacyCompoundRelationsInEntity(e);
|
||||
if (request?.fields) {
|
||||
return request.fields(e);
|
||||
}
|
||||
return e;
|
||||
},
|
||||
),
|
||||
pageInfo,
|
||||
};
|
||||
}
|
||||
@@ -208,7 +216,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
async entitiesBatch(
|
||||
request: EntitiesBatchRequest,
|
||||
): Promise<EntitiesBatchResponse> {
|
||||
const lookup = new Map<string, Entity>();
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
for (const chunk of lodashChunk(request.entityRefs, 200)) {
|
||||
let query = this.database<DbFinalEntitiesRow>('final_entities')
|
||||
@@ -228,17 +236,13 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
|
||||
for (const row of await query) {
|
||||
lookup.set(row.entityRef, row.entity ? JSON.parse(row.entity) : null);
|
||||
lookup.set(row.entityRef, row.entity ? row.entity : null);
|
||||
}
|
||||
}
|
||||
|
||||
let items = request.entityRefs.map(ref => lookup.get(ref) ?? null);
|
||||
const items = request.entityRefs.map(ref => lookup.get(ref) ?? null);
|
||||
|
||||
if (request.fields) {
|
||||
items = items.map(e => e && request.fields!(e));
|
||||
}
|
||||
|
||||
return { items };
|
||||
return { items: processRawEntitiesResult(items, request.fields) };
|
||||
}
|
||||
|
||||
async queryEntities(
|
||||
@@ -305,7 +309,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
targetQuery: inner,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: this.database,
|
||||
strategy: limit <= 500 ? 'join' : 'in',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,12 +510,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const items = rows
|
||||
.map(e => JSON.parse(e.final_entity!))
|
||||
.map(e => (request.fields ? request.fields(e) : e));
|
||||
|
||||
return {
|
||||
items,
|
||||
items: processRawEntitiesResult(
|
||||
rows.map(r => r.final_entity!),
|
||||
request.fields,
|
||||
),
|
||||
pageInfo: {
|
||||
...(!!prevCursor && { prevCursor }),
|
||||
...(!!nextCursor && { nextCursor }),
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items: [entities[0]],
|
||||
items: { type: 'object', entities: [entities[0]] },
|
||||
pageInfo: {},
|
||||
totalItems: 1,
|
||||
});
|
||||
@@ -149,7 +149,7 @@ describe('createRouter readonly disabled', () => {
|
||||
|
||||
it('parses single and multiple request parameters and passes them down', async () => {
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items: [],
|
||||
items: { type: 'object', entities: [] },
|
||||
pageInfo: {},
|
||||
totalItems: 0,
|
||||
});
|
||||
@@ -185,7 +185,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items,
|
||||
items: { type: 'object', entities: items },
|
||||
totalItems: 100,
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
});
|
||||
@@ -203,7 +203,7 @@ describe('createRouter readonly disabled', () => {
|
||||
|
||||
it('parses initial request', async () => {
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items: [],
|
||||
items: { type: 'object', entities: [] },
|
||||
pageInfo: {},
|
||||
totalItems: 0,
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe('createRouter readonly disabled', () => {
|
||||
|
||||
it('parses encoded params request', async () => {
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items: [],
|
||||
items: { type: 'object', entities: [] },
|
||||
pageInfo: {},
|
||||
totalItems: 0,
|
||||
});
|
||||
@@ -283,7 +283,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items,
|
||||
items: { type: 'object', entities: items },
|
||||
totalItems: 100,
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
});
|
||||
@@ -312,7 +312,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items,
|
||||
items: { type: 'object', entities: items },
|
||||
totalItems: 100,
|
||||
pageInfo: {
|
||||
nextCursor: mockCursor({ fullTextFilter: { term: 'mySearch' } }),
|
||||
@@ -354,7 +354,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items,
|
||||
items: { type: 'object', entities: items },
|
||||
totalItems: 100,
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
});
|
||||
@@ -379,7 +379,7 @@ describe('createRouter readonly disabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items,
|
||||
items: { type: 'object', entities: items },
|
||||
totalItems: 100,
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
});
|
||||
@@ -402,7 +402,7 @@ describe('createRouter readonly disabled', () => {
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entities.mockResolvedValue({
|
||||
entities: [entity],
|
||||
entities: { type: 'object', entities: [entity] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('createRouter readonly disabled', () => {
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
entitiesCatalog.entities.mockResolvedValue({
|
||||
entities: [],
|
||||
entities: { type: 'object', entities: [] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('createRouter readonly disabled', () => {
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({
|
||||
items: [entity],
|
||||
items: { type: 'object', entities: [entity] },
|
||||
});
|
||||
|
||||
const response = await request(app).get('/entities/by-name/k/ns/n');
|
||||
@@ -462,7 +462,7 @@ describe('createRouter readonly disabled', () => {
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({
|
||||
items: [null],
|
||||
items: { type: 'object', entities: [null] },
|
||||
});
|
||||
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
@@ -533,7 +533,9 @@ describe('createRouter readonly disabled', () => {
|
||||
},
|
||||
};
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({ items: [entity] });
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({
|
||||
items: { type: 'object', entities: [entity] },
|
||||
});
|
||||
const response = await request(app)
|
||||
.post('/entities/by-refs?filter=kind=Component')
|
||||
.set('Content-Type', 'application/json')
|
||||
@@ -858,7 +860,7 @@ describe('createRouter readonly disabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRouter readonly enabled', () => {
|
||||
describe('createRouter readonly and raw json enabled', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
let app: express.Express;
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
@@ -881,6 +883,7 @@ describe('createRouter readonly enabled', () => {
|
||||
getLocationByEntity: jest.fn(),
|
||||
};
|
||||
const router = await createRouter({
|
||||
disableRelationsCompatibility: true,
|
||||
entitiesCatalog,
|
||||
locationService,
|
||||
logger: mockServices.logger.mock(),
|
||||
@@ -908,7 +911,7 @@ describe('createRouter readonly enabled', () => {
|
||||
];
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValueOnce({
|
||||
items: [entities[0]],
|
||||
items: { type: 'raw', entities: [JSON.stringify(entities[0])] },
|
||||
pageInfo: {},
|
||||
totalItems: 1,
|
||||
});
|
||||
@@ -1128,7 +1131,7 @@ describe('NextRouter permissioning', () => {
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entities.mockResolvedValueOnce({
|
||||
entities: [spideySense],
|
||||
entities: { type: 'object', entities: [spideySense] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, NotFoundError, serializeError } from '@backstage/errors';
|
||||
import { InputError, serializeError } from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import yn from 'yn';
|
||||
import { z } from 'zod';
|
||||
@@ -41,10 +41,9 @@ import { parseEntityFacetParams } from './request/parseEntityFacetParams';
|
||||
import { parseEntityOrderParams } from './request/parseEntityOrderParams';
|
||||
import { LocationService, RefreshService } from './types';
|
||||
import {
|
||||
createEntityArrayJsonStream,
|
||||
disallowReadonlyMode,
|
||||
encodeCursor,
|
||||
expandLegacyCompoundRelationRefsInResponse,
|
||||
expandLegacyCompoundRelationsInEntity,
|
||||
locationInput,
|
||||
validateRequestBody,
|
||||
} from './util';
|
||||
@@ -60,6 +59,12 @@ import {
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import { AuthorizedValidationService } from './AuthorizedValidationService';
|
||||
import { DeferredPromise, createDeferred } from '@backstage/types';
|
||||
import {
|
||||
createEntityArrayJsonStream,
|
||||
processEntitiesResponseItems,
|
||||
writeEntitiesResponse,
|
||||
writeSingleEntityResponse,
|
||||
} from './response';
|
||||
|
||||
/**
|
||||
* Options used by {@link createRouter}.
|
||||
@@ -80,6 +85,7 @@ export interface RouterOptions {
|
||||
auth: AuthService;
|
||||
httpAuth: HttpAuthService;
|
||||
permissionsService: PermissionsService;
|
||||
disableRelationsCompatibility?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +113,7 @@ export async function createRouter(
|
||||
permissionsService,
|
||||
auth,
|
||||
httpAuth,
|
||||
disableRelationsCompatibility = false,
|
||||
} = options;
|
||||
|
||||
const readonlyEnabled =
|
||||
@@ -164,7 +171,7 @@ export async function createRouter(
|
||||
res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`);
|
||||
}
|
||||
|
||||
res.json(entities);
|
||||
await writeEntitiesResponse(res, entities);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,12 +210,17 @@ export async function createRouter(
|
||||
: { credentials, fields, limit, cursor },
|
||||
);
|
||||
|
||||
if (result.items.length) {
|
||||
if (result.items.entities.length) {
|
||||
await locks?.writeLock;
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
expandLegacyCompoundRelationRefsInResponse(result.items);
|
||||
if (!disableRelationsCompatibility) {
|
||||
result.items = processEntitiesResponseItems(
|
||||
result.items,
|
||||
expandLegacyCompoundRelationsInEntity,
|
||||
);
|
||||
}
|
||||
if (!responseStream.send(result.items)) {
|
||||
// The kernel buffer is full. Create the lock but do not await it
|
||||
// yet - we can better spend our time going to the next round of
|
||||
@@ -241,8 +253,8 @@ export async function createRouter(
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
res.json({
|
||||
items,
|
||||
await writeEntitiesResponse(res, items, entities => ({
|
||||
items: entities,
|
||||
totalItems,
|
||||
pageInfo: {
|
||||
...(pageInfo.nextCursor && {
|
||||
@@ -252,7 +264,7 @@ export async function createRouter(
|
||||
prevCursor: encodeCursor(pageInfo.prevCursor),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}));
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
@@ -260,10 +272,7 @@ export async function createRouter(
|
||||
filter: basicEntityFilter({ 'metadata.uid': uid }),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
if (!entities.length) {
|
||||
throw new NotFoundError(`No entity with uid ${uid}`);
|
||||
}
|
||||
res.status(200).json(entities[0]);
|
||||
writeSingleEntityResponse(res, entities, `No entity with uid ${uid}`);
|
||||
})
|
||||
.delete('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
@@ -278,12 +287,11 @@ export async function createRouter(
|
||||
entityRefs: [stringifyEntityRef({ kind, namespace, name })],
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
if (!items[0]) {
|
||||
throw new NotFoundError(
|
||||
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
|
||||
);
|
||||
}
|
||||
res.status(200).json(items[0]);
|
||||
writeSingleEntityResponse(
|
||||
res,
|
||||
items,
|
||||
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
|
||||
);
|
||||
})
|
||||
.get(
|
||||
'/entities/by-name/:kind/:namespace/:name/ancestry',
|
||||
@@ -298,13 +306,15 @@ export async function createRouter(
|
||||
)
|
||||
.post('/entities/by-refs', async (req, res) => {
|
||||
const request = entitiesBatchRequest(req);
|
||||
const response = await entitiesCatalog.entitiesBatch({
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: request.entityRefs,
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
fields: parseEntityTransformParams(req.query, request.fields),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
res.status(200).json(response);
|
||||
await writeEntitiesResponse(res, items, entities => ({
|
||||
items: entities,
|
||||
}));
|
||||
})
|
||||
.get('/entity-facets', async (req, res) => {
|
||||
const response = await entitiesCatalog.facets({
|
||||
|
||||
@@ -33,12 +33,6 @@ function isOrEntityFilter(
|
||||
return filter.hasOwnProperty('anyOf');
|
||||
}
|
||||
|
||||
function isAndEntityFilter(
|
||||
filter: EntityFilter,
|
||||
): filter is { allOf: EntityFilter[] } {
|
||||
return filter.hasOwnProperty('allOf');
|
||||
}
|
||||
|
||||
function isNegationEntityFilter(
|
||||
filter: EntityFilter,
|
||||
): filter is { not: EntityFilter } {
|
||||
@@ -122,102 +116,6 @@ function applyInStrategy(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies filtering through a number of JOINs with the search table. Example:
|
||||
*
|
||||
* ```
|
||||
* SELECT * FROM final_entities
|
||||
* LEFT OUTER JOIN search AS filter_0
|
||||
* ON filter_0.entity_id = final_entities.entity_id
|
||||
* AND filter_0.key = 'kind'
|
||||
* LEFT OUTER JOIN search AS filter_1
|
||||
* ON filter_1.entity_id = final_entities.entity_id
|
||||
* AND filter_1.key = 'spec.lifecycle'
|
||||
* WHERE (filter_0.value = 'component' AND filter_1.value = 'production')
|
||||
* AND final_entities.final_entity IS NOT NULL
|
||||
* ```
|
||||
*
|
||||
* This strategy has very good performance on nested medium complexity queries
|
||||
* on pg, but can be slow on sqlite. It also has much larger variance than the
|
||||
* IN strategy: for small page sizes (< 500 or so, depending on circumstances)
|
||||
* it generates a fast plan, but then at some threshold switches over to scans
|
||||
* which suddenly lead to much worse performance than IN. Therefore it can be
|
||||
* important to pick carefully between the strategies.
|
||||
*/
|
||||
function applyJoinStrategy(
|
||||
filter: EntityFilter,
|
||||
targetQuery: Knex.QueryBuilder,
|
||||
onEntityIdField: string,
|
||||
): Knex.QueryBuilder {
|
||||
// First we traverse the entire query tree to gather up all of the unique keys
|
||||
// that are tested against, and make sure to make an outer join on the search
|
||||
// table for each of them. As we do so, collect the table aliases made along
|
||||
// the way. In the end, this map may contain for example
|
||||
// `{ 'kind': 'filter_0', 'spec.lifecycle': 'filter_1' }`
|
||||
const keyToSearchTableAlias = new Map<string, string>();
|
||||
function recursiveMakeJoinAliases(filterNode: EntityFilter) {
|
||||
if (isNegationEntityFilter(filterNode)) {
|
||||
recursiveMakeJoinAliases(filterNode.not);
|
||||
} else if (isOrEntityFilter(filterNode)) {
|
||||
filterNode.anyOf.forEach(recursiveMakeJoinAliases);
|
||||
} else if (isAndEntityFilter(filterNode)) {
|
||||
filterNode.allOf.forEach(recursiveMakeJoinAliases);
|
||||
} else {
|
||||
const key = filterNode.key.toLowerCase();
|
||||
if (!keyToSearchTableAlias.has(key)) {
|
||||
const alias = `filter_${keyToSearchTableAlias.size}`;
|
||||
keyToSearchTableAlias.set(key, alias);
|
||||
targetQuery.leftOuterJoin({ [alias]: 'search' }, inner =>
|
||||
inner
|
||||
.on(`${alias}.entity_id`, onEntityIdField)
|
||||
.andOnVal(`${alias}.key`, key),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
recursiveMakeJoinAliases(filter);
|
||||
|
||||
// Then we traverse the query tree again, this time building up the actual
|
||||
// WHERE query based on values from the aliases above
|
||||
function recursiveBuildQuery(
|
||||
queryBuilder: Knex.QueryBuilder,
|
||||
filterNode: EntityFilter,
|
||||
) {
|
||||
if (isNegationEntityFilter(filterNode)) {
|
||||
queryBuilder.whereNot(inner =>
|
||||
recursiveBuildQuery(inner, filterNode.not),
|
||||
);
|
||||
} else if (isOrEntityFilter(filterNode)) {
|
||||
// This extra nesting is needed to make sure that the ORs are grouped
|
||||
// separately and not "leak" next to ANDs in the caller's query.
|
||||
queryBuilder.andWhere(inner => {
|
||||
for (const subFilter of filterNode.anyOf) {
|
||||
inner.orWhere(inner2 => recursiveBuildQuery(inner2, subFilter));
|
||||
}
|
||||
});
|
||||
} else if (isAndEntityFilter(filterNode)) {
|
||||
for (const subFilter of filterNode.allOf) {
|
||||
queryBuilder.andWhere(inner => recursiveBuildQuery(inner, subFilter));
|
||||
}
|
||||
} else {
|
||||
const key = filterNode.key.toLowerCase();
|
||||
const values = filterNode.values?.map(v => v.toLowerCase());
|
||||
const column = `${keyToSearchTableAlias.get(key)}.value`;
|
||||
if (!values) {
|
||||
queryBuilder.whereNotNull(column);
|
||||
} else if (values.length === 1) {
|
||||
// Null check needed since NULL = 'string' evaluates to NULL, not FALSE
|
||||
queryBuilder.whereNotNull(column).andWhere(column, values[0]);
|
||||
} else {
|
||||
queryBuilder.whereIn(column, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
recursiveBuildQuery(targetQuery, filter);
|
||||
|
||||
return targetQuery;
|
||||
}
|
||||
|
||||
// The actual exported function
|
||||
export function applyEntityFilterToQuery(options: {
|
||||
filter: EntityFilter;
|
||||
@@ -226,17 +124,7 @@ export function applyEntityFilterToQuery(options: {
|
||||
knex: Knex;
|
||||
strategy?: 'in' | 'join';
|
||||
}): Knex.QueryBuilder {
|
||||
const {
|
||||
filter,
|
||||
targetQuery,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
strategy = 'in',
|
||||
} = options;
|
||||
if (strategy === 'in') {
|
||||
return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false);
|
||||
} else if (strategy === 'join') {
|
||||
return applyJoinStrategy(filter, targetQuery, onEntityIdField);
|
||||
}
|
||||
throw new Error(`Unsupported filtering strategy ${strategy}`);
|
||||
const { filter, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntitiesResponseItems } from '../../catalog/types';
|
||||
import { Response } from 'express';
|
||||
|
||||
export interface EntityArrayJsonStream {
|
||||
send(entities: EntitiesResponseItems): boolean;
|
||||
complete(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// Helps stream EntitiesResponseItems[] as a JSON response stream to avoid performance issues
|
||||
export function createEntityArrayJsonStream(
|
||||
res: Response,
|
||||
): EntityArrayJsonStream {
|
||||
// Imitate the httpRouter behavior of pretty-printing in development
|
||||
const prettyPrint = process.env.NODE_ENV === 'development';
|
||||
let firstSend = true;
|
||||
let completed = false;
|
||||
|
||||
return {
|
||||
send(response) {
|
||||
if (firstSend) {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.status(200);
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
if (response.type === 'raw') {
|
||||
let needsDrain = false;
|
||||
for (const item of response.entities) {
|
||||
const prefix = firstSend ? '[' : ',';
|
||||
firstSend = false;
|
||||
needsDrain ||= !res.write(prefix + item, 'utf8');
|
||||
}
|
||||
return !needsDrain;
|
||||
}
|
||||
|
||||
let data: string;
|
||||
if (prettyPrint) {
|
||||
data = JSON.stringify(response.entities, null, 2);
|
||||
data = firstSend ? data.slice(0, -2) : `,\n${data.slice(2, -2)}`;
|
||||
} else {
|
||||
data = JSON.stringify(response.entities);
|
||||
data = firstSend ? data.slice(0, -1) : `,${data.slice(1, -1)}`;
|
||||
}
|
||||
|
||||
firstSend = false;
|
||||
return res.write(data, 'utf8');
|
||||
},
|
||||
complete() {
|
||||
if (firstSend) {
|
||||
res.json([]);
|
||||
} else {
|
||||
res.end(prettyPrint ? '\n]' : ']', 'utf8');
|
||||
}
|
||||
completed = true;
|
||||
},
|
||||
close() {
|
||||
if (!completed) {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+8
-19
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,21 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import React from 'react';
|
||||
import { MetadataView } from './MetadataView';
|
||||
import { SchemaViewProps } from './types';
|
||||
|
||||
export function ScalarView({ schema }: SchemaViewProps) {
|
||||
return (
|
||||
<>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export {
|
||||
processRawEntitiesResult,
|
||||
processEntitiesResponseItems,
|
||||
entitiesResponseToObjects,
|
||||
} from './process';
|
||||
export { writeSingleEntityResponse, writeEntitiesResponse } from './write';
|
||||
export { createEntityArrayJsonStream } from './createEntityArrayJsonStream';
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
entitiesResponseToObjects,
|
||||
processEntitiesResponseItems,
|
||||
processRawEntitiesResult,
|
||||
} from './process';
|
||||
|
||||
const mockTransform = (entity: Entity): Entity => ({
|
||||
...entity,
|
||||
kind: `transformed-${entity.kind}`,
|
||||
});
|
||||
|
||||
describe('processRawEntitiesResult', () => {
|
||||
it('should prefer keeping results in raw form', () => {
|
||||
expect(processRawEntitiesResult(['{"kind":"test"}', null])).toEqual({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"test"}', null],
|
||||
});
|
||||
|
||||
expect(
|
||||
processRawEntitiesResult(['{"kind":"test"}', null], mockTransform),
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'transformed-test' }, null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEntitiesResponseItems', () => {
|
||||
it('should transform entities in object form', () => {
|
||||
expect(
|
||||
processEntitiesResponseItems({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'test' } as Entity, null],
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'test' }, null],
|
||||
});
|
||||
|
||||
expect(
|
||||
processEntitiesResponseItems(
|
||||
{
|
||||
type: 'object',
|
||||
entities: [{ kind: 'test' } as Entity, null],
|
||||
},
|
||||
mockTransform,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'transformed-test' }, null],
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform entities in raw form', () => {
|
||||
expect(
|
||||
processEntitiesResponseItems({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"test"}', null],
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"test"}', null],
|
||||
});
|
||||
|
||||
expect(
|
||||
processEntitiesResponseItems(
|
||||
{
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"test"}', null],
|
||||
},
|
||||
mockTransform,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'transformed-test' }, null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('entitiesResponseToObjects', () => {
|
||||
it('should convert entities in object form', () => {
|
||||
expect(
|
||||
entitiesResponseToObjects({
|
||||
type: 'object',
|
||||
entities: [null, { kind: 'test' } as Entity],
|
||||
}),
|
||||
).toEqual([null, { kind: 'test' }]);
|
||||
});
|
||||
|
||||
it('should convert entities in raw form', () => {
|
||||
expect(
|
||||
entitiesResponseToObjects({
|
||||
type: 'raw',
|
||||
entities: [null, '{"kind":"test"}'],
|
||||
}),
|
||||
).toEqual([null, { kind: 'test' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntitiesResponseItems } from '../../catalog/types';
|
||||
|
||||
export function processRawEntitiesResult(
|
||||
serializedEntities: (string | null)[],
|
||||
transform?: (entity: Entity) => Entity,
|
||||
): EntitiesResponseItems {
|
||||
if (transform) {
|
||||
return {
|
||||
type: 'object',
|
||||
entities: serializedEntities.map(e =>
|
||||
e !== null ? transform(JSON.parse(e)) : e,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'raw',
|
||||
entities: serializedEntities,
|
||||
};
|
||||
}
|
||||
|
||||
export function processEntitiesResponseItems(
|
||||
response: EntitiesResponseItems,
|
||||
transform?: (entity: Entity) => Entity,
|
||||
): EntitiesResponseItems {
|
||||
if (!transform) {
|
||||
return response;
|
||||
}
|
||||
if (response.type === 'raw') {
|
||||
return processRawEntitiesResult(response.entities, transform);
|
||||
}
|
||||
return {
|
||||
type: 'object',
|
||||
entities: response.entities.map(e => (e !== null ? transform(e) : e)),
|
||||
};
|
||||
}
|
||||
|
||||
export function entitiesResponseToObjects(
|
||||
response: EntitiesResponseItems,
|
||||
): (Entity | null)[] {
|
||||
if (response.type === 'object') {
|
||||
return response.entities;
|
||||
}
|
||||
return response.entities.map(e => (e !== null ? JSON.parse(e) : e));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { mockErrorHandler } from '@backstage/backend-test-utils';
|
||||
import request from 'supertest';
|
||||
import { writeEntitiesResponse, writeSingleEntityResponse } from './write';
|
||||
|
||||
describe('writeSingleEntityResponse', () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.get('/echo', (req, res) => {
|
||||
writeSingleEntityResponse(res, req.body, 'not found');
|
||||
});
|
||||
app.use(mockErrorHandler());
|
||||
|
||||
describe('in object form', () => {
|
||||
it('should write a single entity', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'Component' }, { kind: 'User' }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({ kind: 'Component' });
|
||||
});
|
||||
|
||||
it('should write a missing entity', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({ type: 'object', entities: [null] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'NotFoundError', message: 'not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should write no entities', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({ type: 'object', entities: [] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'NotFoundError', message: 'not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('in raw form', () => {
|
||||
it('should write a single entity', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"Component"}', '{"kind":"User"}'],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({ kind: 'Component' });
|
||||
});
|
||||
|
||||
it('should write a missing entity', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({ type: 'raw', entities: [null] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'NotFoundError', message: 'not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should write no entities', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({ type: 'raw', entities: [] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'NotFoundError', message: 'not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeEntitiesResponse', () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.get('/echo', (req, res) => {
|
||||
writeEntitiesResponse(res, req.body);
|
||||
});
|
||||
app.get('/wrapped', (req, res) => {
|
||||
writeEntitiesResponse(res, req.body, entities => ({
|
||||
page: 1,
|
||||
items: entities,
|
||||
totalItems: 1337,
|
||||
}));
|
||||
});
|
||||
app.use(mockErrorHandler());
|
||||
|
||||
describe('in object form', () => {
|
||||
it('should return empty list', async () => {
|
||||
const res = await request(app).get('/echo').send({
|
||||
type: 'object',
|
||||
entities: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return mixed objects', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'Component' }, null, { kind: 'User' }, null],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual([
|
||||
{ kind: 'Component' },
|
||||
null,
|
||||
{ kind: 'User' },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should wrap response of empty list', async () => {
|
||||
const res = await request(app)
|
||||
.get('/wrapped')
|
||||
.send({ type: 'object', entities: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({ page: 1, items: [], totalItems: 1337 });
|
||||
});
|
||||
|
||||
it('should wrap response of mixed list', async () => {
|
||||
const res = await request(app)
|
||||
.get('/wrapped')
|
||||
.send({
|
||||
type: 'object',
|
||||
entities: [{ kind: 'Component' }, null, { kind: 'User' }, null],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
page: 1,
|
||||
items: [{ kind: 'Component' }, null, { kind: 'User' }, null],
|
||||
totalItems: 1337,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('in raw form', () => {
|
||||
it('should return empty list', async () => {
|
||||
const res = await request(app).get('/echo').send({
|
||||
type: 'raw',
|
||||
entities: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return mixed objects', async () => {
|
||||
const res = await request(app)
|
||||
.get('/echo')
|
||||
.send({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"Component"}', null, '{"kind":"User"}', null],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual([
|
||||
{ kind: 'Component' },
|
||||
null,
|
||||
{ kind: 'User' },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should wrap response of empty list', async () => {
|
||||
const res = await request(app)
|
||||
.get('/wrapped')
|
||||
.send({ type: 'raw', entities: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({ page: 1, items: [], totalItems: 1337 });
|
||||
});
|
||||
|
||||
it('should wrap response of mixed list', async () => {
|
||||
const res = await request(app)
|
||||
.get('/wrapped')
|
||||
.send({
|
||||
type: 'raw',
|
||||
entities: ['{"kind":"Component"}', null, '{"kind":"User"}', null],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
page: 1,
|
||||
items: [{ kind: 'Component' }, null, { kind: 'User' }, null],
|
||||
totalItems: 1337,
|
||||
});
|
||||
});
|
||||
|
||||
it('should write a large wrapped response', async () => {
|
||||
const entityMock = JSON.stringify({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component',
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'url:https://example.com',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
owner: 'me',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/wrapped')
|
||||
.send({
|
||||
type: 'raw',
|
||||
entities: Array(300).fill(entityMock),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.type).toBe('application/json');
|
||||
expect(res.header['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
page: 1,
|
||||
items: expect.objectContaining({ length: 300 }),
|
||||
totalItems: 1337,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Response } from 'express';
|
||||
import { EntitiesResponseItems } from '../../catalog/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
||||
|
||||
export function writeSingleEntityResponse(
|
||||
res: Response,
|
||||
response: EntitiesResponseItems,
|
||||
notFoundMessage: string,
|
||||
) {
|
||||
if (response.type === 'object') {
|
||||
if (!response.entities[0]) {
|
||||
throw new NotFoundError(notFoundMessage);
|
||||
}
|
||||
|
||||
res.json(response.entities[0]);
|
||||
} else {
|
||||
if (!response.entities[0]) {
|
||||
throw new NotFoundError(notFoundMessage);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', JSON_CONTENT_TYPE);
|
||||
res.end(response.entities[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeEntitiesResponse(
|
||||
res: Response,
|
||||
response: EntitiesResponseItems,
|
||||
responseWrapper?: (entities: JsonValue) => JsonValue,
|
||||
) {
|
||||
if (response.type === 'object') {
|
||||
res.json(
|
||||
responseWrapper
|
||||
? responseWrapper?.(response.entities)
|
||||
: response.entities,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', JSON_CONTENT_TYPE);
|
||||
|
||||
// responseWrapper allows the caller to render the entities within an object
|
||||
let trailing = '';
|
||||
if (responseWrapper) {
|
||||
const marker = `__MARKER_${Math.random().toString(36).slice(2, 10)}__`;
|
||||
const wrapped = JSON.stringify(responseWrapper(marker));
|
||||
const parts = wrapped.split(`"${marker}"`);
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(
|
||||
`Entity items response was incorrectly wrapped into ${parts.length} different parts`,
|
||||
);
|
||||
}
|
||||
res.write(parts[0], 'utf8');
|
||||
trailing = parts[1];
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const entity of response.entities) {
|
||||
const prefix = first ? '[' : ',';
|
||||
first = false;
|
||||
|
||||
const needsDrain = !res.write(prefix + entity, 'utf8');
|
||||
if (needsDrain) {
|
||||
const closed = await new Promise<boolean>(resolve => {
|
||||
function onContinue() {
|
||||
res.off('drain', onContinue);
|
||||
res.off('close', onClose);
|
||||
resolve(false);
|
||||
}
|
||||
function onClose() {
|
||||
res.off('drain', onContinue);
|
||||
res.off('close', onClose);
|
||||
resolve(true);
|
||||
}
|
||||
res.on('drain', onContinue);
|
||||
res.on('close', onClose);
|
||||
});
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.end(`${first ? '[' : ''}]${trailing}`);
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { InputError, NotAllowedError } from '@backstage/errors';
|
||||
import { Request, Response } from 'express';
|
||||
import { Request } from 'express';
|
||||
import lodash from 'lodash';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
@@ -148,75 +148,21 @@ export function decodeCursor(encodedCursor: string) {
|
||||
// sure that all adopters have re-stitched their entities so that the new
|
||||
// targetRef field is present on them, and that they have stopped consuming
|
||||
// the now-removed old field
|
||||
// TODO(jhaals): Remove this in April 2022
|
||||
export function expandLegacyCompoundRelationRefsInResponse(
|
||||
entities: Entity[],
|
||||
): void {
|
||||
for (const entity of entities) {
|
||||
if (entity.relations) {
|
||||
for (const relation of entity.relations as any) {
|
||||
if (!relation.targetRef && relation.target) {
|
||||
// This is the case where an old-form entity, not yet stitched with
|
||||
// the updated code, was in the database
|
||||
relation.targetRef = stringifyEntityRef(relation.target);
|
||||
} else if (!relation.target && relation.targetRef) {
|
||||
// This is the case where a new-form entity, stitched with the
|
||||
// updated code, was in the database but we still want to produce
|
||||
// the old data shape as well for compatibility reasons
|
||||
relation.target = parseEntityRef(relation.targetRef);
|
||||
}
|
||||
// TODO(patriko): Remove this in catalog 2.0
|
||||
export function expandLegacyCompoundRelationsInEntity(entity: Entity): Entity {
|
||||
if (entity.relations) {
|
||||
for (const relation of entity.relations as any) {
|
||||
if (!relation.targetRef && relation.target) {
|
||||
// This is the case where an old-form entity, not yet stitched with
|
||||
// the updated code, was in the database
|
||||
relation.targetRef = stringifyEntityRef(relation.target);
|
||||
} else if (!relation.target && relation.targetRef) {
|
||||
// This is the case where a new-form entity, stitched with the
|
||||
// updated code, was in the database but we still want to produce
|
||||
// the old data shape as well for compatibility reasons
|
||||
relation.target = parseEntityRef(relation.targetRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface EntityArrayJsonStream {
|
||||
send(entities: Entity[]): boolean;
|
||||
complete(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// Helps stream Entity[] as a JSON response stream to avoid performance issues
|
||||
export function createEntityArrayJsonStream(
|
||||
res: Response,
|
||||
): EntityArrayJsonStream {
|
||||
// Imitate the httpRouter behavior of pretty-printing in development
|
||||
const prettyPrint = process.env.NODE_ENV === 'development';
|
||||
let firstSend = true;
|
||||
let completed = false;
|
||||
|
||||
return {
|
||||
send(entities) {
|
||||
if (firstSend) {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.status(200);
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
let data: string;
|
||||
if (prettyPrint) {
|
||||
data = JSON.stringify(entities, null, 2);
|
||||
data = firstSend ? data.slice(0, -2) : `,\n${data.slice(2, -2)}`;
|
||||
} else {
|
||||
data = JSON.stringify(entities);
|
||||
data = firstSend ? data.slice(0, -1) : `,${data.slice(1, -1)}`;
|
||||
}
|
||||
|
||||
firstSend = false;
|
||||
return res.write(data);
|
||||
},
|
||||
complete() {
|
||||
if (firstSend) {
|
||||
res.json([]);
|
||||
} else {
|
||||
res.end(prettyPrint ? '\n]' : ']');
|
||||
}
|
||||
completed = true;
|
||||
},
|
||||
close() {
|
||||
if (!completed) {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { createHash } from 'crypto';
|
||||
import { Knex } from 'knex';
|
||||
import { EntitiesCatalog } from '../catalog/types';
|
||||
@@ -55,6 +54,7 @@ import { DefaultStitcher } from '../stitching/DefaultStitcher';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { DatabaseManager } from '@backstage/backend-common';
|
||||
import { entitiesResponseToObjects } from '../service/response';
|
||||
|
||||
const voidLogger = mockServices.logger.mock();
|
||||
|
||||
@@ -198,7 +198,7 @@ class TestHarness {
|
||||
readonly #proxyProgressTracker: ProxyProgressTracker;
|
||||
|
||||
static async create(options?: {
|
||||
config?: JsonObject;
|
||||
disableRelationsCompatibility?: boolean;
|
||||
logger?: LoggerService;
|
||||
db?: Knex;
|
||||
permissions?: PermissionEvaluator;
|
||||
@@ -208,21 +208,19 @@ class TestHarness {
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity>;
|
||||
}) {
|
||||
const config = new ConfigReader(
|
||||
options?.config ?? {
|
||||
backend: {
|
||||
database: {
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
stitchingStrategy: {
|
||||
mode: 'immediate',
|
||||
},
|
||||
const config = new ConfigReader({
|
||||
backend: {
|
||||
database: {
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
},
|
||||
},
|
||||
);
|
||||
catalog: {
|
||||
stitchingStrategy: {
|
||||
mode: 'immediate',
|
||||
},
|
||||
},
|
||||
});
|
||||
const logger = options?.logger ?? mockServices.logger.mock();
|
||||
const db =
|
||||
options?.db ??
|
||||
@@ -279,6 +277,7 @@ class TestHarness {
|
||||
database: db,
|
||||
logger,
|
||||
stitcher,
|
||||
disableRelationsCompatibility: options?.disableRelationsCompatibility,
|
||||
});
|
||||
const proxyProgressTracker = new ProxyProgressTracker(
|
||||
new NoopProgressTracker(),
|
||||
@@ -365,7 +364,12 @@ class TestHarness {
|
||||
|
||||
async getOutputEntities(): Promise<Record<string, Entity>> {
|
||||
const { entities } = await this.#catalog.entities();
|
||||
return Object.fromEntries(entities.map(e => [stringifyEntityRef(e), e]));
|
||||
return Object.fromEntries(
|
||||
entitiesResponseToObjects(entities).map(e => [
|
||||
stringifyEntityRef(e!),
|
||||
e!,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async refresh(options: RefreshOptions) {
|
||||
@@ -781,4 +785,57 @@ describe('Catalog Backend Integration', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return valid responses in raw JSON mode', async () => {
|
||||
const harness = await TestHarness.create({
|
||||
disableRelationsCompatibility: true,
|
||||
});
|
||||
|
||||
const entityA = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'a',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'url:.',
|
||||
'backstage.io/managed-by-origin-location': 'url:.',
|
||||
},
|
||||
},
|
||||
};
|
||||
const entityB = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'b',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'url:.',
|
||||
'backstage.io/managed-by-origin-location': 'url:.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await harness.setInputEntities([entityA, entityB]);
|
||||
await expect(harness.process()).resolves.toEqual({});
|
||||
|
||||
await expect(harness.getOutputEntities()).resolves.toEqual({
|
||||
'component:default/a': {
|
||||
...entityA,
|
||||
metadata: {
|
||||
...entityA.metadata,
|
||||
etag: expect.any(String),
|
||||
uid: expect.any(String),
|
||||
},
|
||||
relations: [],
|
||||
},
|
||||
'component:default/b': {
|
||||
...entityB,
|
||||
metadata: {
|
||||
...entityB.metadata,
|
||||
etag: expect.any(String),
|
||||
uid: expect.any(String),
|
||||
},
|
||||
relations: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ import {
|
||||
useEntityList,
|
||||
} from '../../hooks/useEntityListProvider';
|
||||
import { EntityFilter } from '../../types';
|
||||
import { reduceBackendCatalogFilters } from '../../utils';
|
||||
import { reduceBackendCatalogFilters } from '../../utils/filters';
|
||||
|
||||
/** @public */
|
||||
export type AllowedEntityFilters<T extends DefaultEntityFilters> = {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMemo, useRef } from 'react';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { useEntityList } from '../../hooks';
|
||||
import { reduceCatalogFilters } from '../../utils';
|
||||
import { reduceCatalogFilters } from '../../utils/filters';
|
||||
|
||||
export function useAllEntitiesCount() {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
@@ -21,7 +21,7 @@ import useAsync from 'react-use/esm/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { EntityOwnerFilter, EntityUserFilter } from '../../filters';
|
||||
import { useEntityList } from '../../hooks';
|
||||
import { CatalogFilters, reduceCatalogFilters } from '../../utils';
|
||||
import { CatalogFilters, reduceCatalogFilters } from '../../utils/filters';
|
||||
import useAsyncFn from 'react-use/esm/useAsyncFn';
|
||||
import useDeepCompareEffect from 'react-use/esm/useDeepCompareEffect';
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import useAsync from 'react-use/esm/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { EntityUserFilter } from '../../filters';
|
||||
import { useEntityList, useStarredEntities } from '../../hooks';
|
||||
import { reduceCatalogFilters } from '../../utils';
|
||||
import { reduceCatalogFilters } from '../../utils/filters';
|
||||
|
||||
export function useStarredEntitiesCount() {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { AlphaEntity } from '@backstage/catalog-model/alpha';
|
||||
import { EntityFilter, UserListFilterKind } from './types';
|
||||
import { getEntityRelations } from './utils';
|
||||
import { getEntityRelations } from './utils/getEntityRelations';
|
||||
|
||||
/**
|
||||
* Filter entities based on Kind.
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
reduceBackendCatalogFilters,
|
||||
reduceCatalogFilters,
|
||||
reduceEntityFilters,
|
||||
} from '../utils';
|
||||
} from '../utils/filters';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { QueryEntitiesResponse } from '@backstage/catalog-client';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './filters';
|
||||
|
||||
export { getEntityRelations } from './getEntityRelations';
|
||||
export { getEntitySourceLocation } from './getEntitySourceLocation';
|
||||
export type { EntitySourceLocation } from './getEntitySourceLocation';
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React from 'react';
|
||||
import { ChildView } from './ChildView';
|
||||
import { MetadataView } from './MetadataView';
|
||||
import { SchemaViewProps } from './types';
|
||||
|
||||
export function ArrayView({ path, depth, schema }: SchemaViewProps) {
|
||||
const itemDepth = depth + 1;
|
||||
const itemPath = path ? `${path}[]` : '[]';
|
||||
const itemSchema = schema.items;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box marginBottom={4}>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</Box>
|
||||
<Typography variant="overline">Items</Typography>
|
||||
<ChildView
|
||||
lastChild
|
||||
path={itemPath}
|
||||
depth={itemDepth}
|
||||
schema={itemSchema as Schema | undefined}
|
||||
/>
|
||||
{schema.additionalItems && schema.additionalItems !== true && (
|
||||
<>
|
||||
<Typography variant="overline">Additional Items</Typography>
|
||||
<ChildView
|
||||
path={itemPath}
|
||||
depth={itemDepth}
|
||||
schema={schema.additionalItems}
|
||||
lastChild
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Chip from '@material-ui/core/Chip';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useScrollTargets } from '../ScrollTargetsContext/ScrollTargetsContext';
|
||||
import { SchemaView } from './SchemaView';
|
||||
|
||||
export interface MetadataViewRowProps {
|
||||
label: string;
|
||||
text?: string;
|
||||
data?: JsonValue;
|
||||
}
|
||||
|
||||
function titleVariant(depth: number) {
|
||||
if (depth <= 1) {
|
||||
return 'h2';
|
||||
} else if (depth === 2) {
|
||||
return 'h3';
|
||||
} else if (depth === 3) {
|
||||
return 'h4';
|
||||
} else if (depth === 4) {
|
||||
return 'h5';
|
||||
}
|
||||
return 'h6';
|
||||
}
|
||||
|
||||
const useChildViewStyles = makeStyles(theme => ({
|
||||
title: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
chip: {
|
||||
marginLeft: theme.spacing(1),
|
||||
marginRight: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export function ChildView({
|
||||
path,
|
||||
depth,
|
||||
schema,
|
||||
required,
|
||||
lastChild,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
schema?: Schema;
|
||||
required?: boolean;
|
||||
lastChild?: boolean;
|
||||
}) {
|
||||
const classes = useChildViewStyles();
|
||||
const titleRef = useRef<HTMLElement>(null);
|
||||
const scroll = useScrollTargets();
|
||||
|
||||
useEffect(() => {
|
||||
return scroll?.setScrollListener(path, () => {
|
||||
titleRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
}, [scroll, path]);
|
||||
|
||||
const chips = new Array<JSX.Element>();
|
||||
const chipProps = { size: 'small' as const, classes: { root: classes.chip } };
|
||||
|
||||
if (required) {
|
||||
chips.push(
|
||||
<Chip label="required" color="default" key="required" {...chipProps} />,
|
||||
);
|
||||
}
|
||||
|
||||
const visibility = (schema as { visibility?: string })?.visibility;
|
||||
if (visibility === 'frontend') {
|
||||
chips.push(
|
||||
<Chip label="frontend" color="primary" key="visibility" {...chipProps} />,
|
||||
);
|
||||
} else if (visibility === 'secret') {
|
||||
chips.push(
|
||||
<Chip label="secret" color="secondary" key="visibility" {...chipProps} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingBottom={lastChild ? 4 : 8} display="flex" flexDirection="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box paddingLeft={2} flex={1}>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
marginBottom={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
ref={titleRef}
|
||||
variant={titleVariant(depth)}
|
||||
classes={{ root: classes.title }}
|
||||
>
|
||||
{path}
|
||||
</Typography>
|
||||
{chips.length > 0 && <Box marginLeft={1} />}
|
||||
{chips}
|
||||
</Box>
|
||||
{schema && (
|
||||
<SchemaView path={path} depth={depth} schema={schema as Schema} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React from 'react';
|
||||
import { ChildView } from './ChildView';
|
||||
|
||||
export function MatchView({
|
||||
path,
|
||||
depth,
|
||||
schema,
|
||||
label,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
schema: Schema[];
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="overline">{label}</Typography>
|
||||
{schema.map((optionSchema, index) => (
|
||||
<ChildView
|
||||
key={index}
|
||||
path={`${path}/${index + 1}`}
|
||||
depth={depth + 1}
|
||||
schema={optionSchema}
|
||||
lastChild={index === schema.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React from 'react';
|
||||
|
||||
export interface MetadataViewRowProps {
|
||||
label: string;
|
||||
text?: string;
|
||||
data?: JsonValue;
|
||||
}
|
||||
|
||||
export function MetadataViewRow({ label, text, data }: MetadataViewRowProps) {
|
||||
if (text === undefined && data === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell style={{ width: 160 }}>
|
||||
<Typography variant="body1" noWrap style={{ fontWeight: 900 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body1">
|
||||
{data ? JSON.stringify(data) : text}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataView({ schema }: { schema: Schema }) {
|
||||
return (
|
||||
<Paper variant="outlined" square style={{ width: '100%' }}>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<MetadataViewRow label="Type" data={schema.type} />
|
||||
<MetadataViewRow label="Allowed values" data={schema.enum} />
|
||||
{schema.additionalProperties === true && (
|
||||
<MetadataViewRow label="Additional Properties" text="true" />
|
||||
)}
|
||||
{schema.additionalItems === true && (
|
||||
<MetadataViewRow label="Additional Items" text="true" />
|
||||
)}
|
||||
<MetadataViewRow label="Format" text={schema.format} />
|
||||
<MetadataViewRow
|
||||
label="Pattern"
|
||||
text={schema.pattern && String(schema.pattern)}
|
||||
/>
|
||||
<MetadataViewRow label="Minimum" data={schema.minimum} />
|
||||
<MetadataViewRow label="Maximum" data={schema.maximum} />
|
||||
<MetadataViewRow
|
||||
label="Exclusive minimum"
|
||||
data={schema.exclusiveMinimum}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Exclusive maximum"
|
||||
data={schema.exclusiveMaximum}
|
||||
/>
|
||||
<MetadataViewRow label="Multiple of" data={schema.multipleOf} />
|
||||
<MetadataViewRow
|
||||
label="Maximum number of items"
|
||||
data={schema.maxItems}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Minimum number of items"
|
||||
data={schema.minItems}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Maximum number of properties"
|
||||
data={schema.maxProperties}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Minimum number of properties"
|
||||
data={schema.minProperties}
|
||||
/>
|
||||
<MetadataViewRow label="Maximum Length" data={schema.maxLength} />
|
||||
<MetadataViewRow label="Minimum Length" data={schema.minLength} />
|
||||
<MetadataViewRow
|
||||
label="Items must be unique"
|
||||
data={schema.uniqueItems}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import React from 'react';
|
||||
import { ChildView } from './ChildView';
|
||||
import { MetadataView } from './MetadataView';
|
||||
import { SchemaViewProps } from './types';
|
||||
|
||||
function isRequired(name: string, required?: boolean | string[]) {
|
||||
if (required === true) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(required)) {
|
||||
return required.includes(name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ObjectView({ path, depth, schema }: SchemaViewProps) {
|
||||
const properties = Object.entries(schema.properties ?? {});
|
||||
const patternProperties = Object.entries(schema.patternProperties ?? {});
|
||||
|
||||
return (
|
||||
<>
|
||||
{depth > 0 && (
|
||||
<Box marginBottom={4}>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</Box>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<>
|
||||
{depth > 0 && <Typography variant="overline">Properties</Typography>}
|
||||
{properties.map(([name, propSchema], index) => (
|
||||
<ChildView
|
||||
key={name}
|
||||
path={path ? `${path}.${name}` : name}
|
||||
depth={depth + 1}
|
||||
schema={propSchema}
|
||||
lastChild={index === properties.length - 1}
|
||||
required={isRequired(name, schema.required)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{patternProperties.length > 0 && (
|
||||
<>
|
||||
{depth > 0 && (
|
||||
<Typography variant="overline">Pattern Properties</Typography>
|
||||
)}
|
||||
{patternProperties.map(([name, propSchema], index) => (
|
||||
<ChildView
|
||||
key={name}
|
||||
path={path ? `${path}.<${name}>` : name}
|
||||
depth={depth + 1}
|
||||
schema={propSchema}
|
||||
lastChild={index === patternProperties.length - 1}
|
||||
required={isRequired(name, schema.required)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{schema.additionalProperties && schema.additionalProperties !== true && (
|
||||
<>
|
||||
<Typography variant="overline">Additional Properties</Typography>
|
||||
<ChildView
|
||||
path={`${path}.*`}
|
||||
depth={depth + 1}
|
||||
schema={schema.additionalProperties}
|
||||
lastChild
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ArrayView } from './ArrayView';
|
||||
import { MatchView } from './MatchView';
|
||||
import { ObjectView } from './ObjectView';
|
||||
import { ScalarView } from './ScalarView';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Chip from '@material-ui/core/Chip';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useScrollTargets } from '../ScrollTargetsContext/ScrollTargetsContext';
|
||||
import { SchemaViewProps } from './types';
|
||||
|
||||
export function SchemaView(props: SchemaViewProps) {
|
||||
@@ -60,3 +69,337 @@ export function SchemaView(props: SchemaViewProps) {
|
||||
return <ScalarView {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ArrayView({ path, depth, schema }: SchemaViewProps) {
|
||||
const itemDepth = depth + 1;
|
||||
const itemPath = path ? `${path}[]` : '[]';
|
||||
const itemSchema = schema.items;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box marginBottom={4}>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</Box>
|
||||
<Typography variant="overline">Items</Typography>
|
||||
<ChildView
|
||||
lastChild
|
||||
path={itemPath}
|
||||
depth={itemDepth}
|
||||
schema={itemSchema as Schema | undefined}
|
||||
/>
|
||||
{schema.additionalItems && schema.additionalItems !== true && (
|
||||
<>
|
||||
<Typography variant="overline">Additional Items</Typography>
|
||||
<ChildView
|
||||
path={itemPath}
|
||||
depth={itemDepth}
|
||||
schema={schema.additionalItems}
|
||||
lastChild
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isRequired(name: string, required?: boolean | string[]) {
|
||||
if (required === true) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(required)) {
|
||||
return required.includes(name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ObjectView({ path, depth, schema }: SchemaViewProps) {
|
||||
const properties = Object.entries(schema.properties ?? {});
|
||||
const patternProperties = Object.entries(schema.patternProperties ?? {});
|
||||
|
||||
return (
|
||||
<>
|
||||
{depth > 0 && (
|
||||
<Box marginBottom={4}>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</Box>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<>
|
||||
{depth > 0 && <Typography variant="overline">Properties</Typography>}
|
||||
{properties.map(([name, propSchema], index) => (
|
||||
<ChildView
|
||||
key={name}
|
||||
path={path ? `${path}.${name}` : name}
|
||||
depth={depth + 1}
|
||||
schema={propSchema}
|
||||
lastChild={index === properties.length - 1}
|
||||
required={isRequired(name, schema.required)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{patternProperties.length > 0 && (
|
||||
<>
|
||||
{depth > 0 && (
|
||||
<Typography variant="overline">Pattern Properties</Typography>
|
||||
)}
|
||||
{patternProperties.map(([name, propSchema], index) => (
|
||||
<ChildView
|
||||
key={name}
|
||||
path={path ? `${path}.<${name}>` : name}
|
||||
depth={depth + 1}
|
||||
schema={propSchema}
|
||||
lastChild={index === patternProperties.length - 1}
|
||||
required={isRequired(name, schema.required)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{schema.additionalProperties && schema.additionalProperties !== true && (
|
||||
<>
|
||||
<Typography variant="overline">Additional Properties</Typography>
|
||||
<ChildView
|
||||
path={`${path}.*`}
|
||||
depth={depth + 1}
|
||||
schema={schema.additionalProperties}
|
||||
lastChild
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataViewRowProps {
|
||||
label: string;
|
||||
text?: string;
|
||||
data?: JsonValue;
|
||||
}
|
||||
|
||||
function titleVariant(depth: number) {
|
||||
if (depth <= 1) {
|
||||
return 'h2';
|
||||
} else if (depth === 2) {
|
||||
return 'h3';
|
||||
} else if (depth === 3) {
|
||||
return 'h4';
|
||||
} else if (depth === 4) {
|
||||
return 'h5';
|
||||
}
|
||||
return 'h6';
|
||||
}
|
||||
|
||||
const useChildViewStyles = makeStyles(theme => ({
|
||||
title: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
chip: {
|
||||
marginLeft: theme.spacing(1),
|
||||
marginRight: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
function ChildView({
|
||||
path,
|
||||
depth,
|
||||
schema,
|
||||
required,
|
||||
lastChild,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
schema?: Schema;
|
||||
required?: boolean;
|
||||
lastChild?: boolean;
|
||||
}) {
|
||||
const classes = useChildViewStyles();
|
||||
const titleRef = useRef<HTMLElement>(null);
|
||||
const scroll = useScrollTargets();
|
||||
|
||||
useEffect(() => {
|
||||
return scroll?.setScrollListener(path, () => {
|
||||
titleRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
}, [scroll, path]);
|
||||
|
||||
const chips = new Array<JSX.Element>();
|
||||
const chipProps = { size: 'small' as const, classes: { root: classes.chip } };
|
||||
|
||||
if (required) {
|
||||
chips.push(
|
||||
<Chip label="required" color="default" key="required" {...chipProps} />,
|
||||
);
|
||||
}
|
||||
|
||||
const visibility = (schema as { visibility?: string })?.visibility;
|
||||
if (visibility === 'frontend') {
|
||||
chips.push(
|
||||
<Chip label="frontend" color="primary" key="visibility" {...chipProps} />,
|
||||
);
|
||||
} else if (visibility === 'secret') {
|
||||
chips.push(
|
||||
<Chip label="secret" color="secondary" key="visibility" {...chipProps} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingBottom={lastChild ? 4 : 8} display="flex" flexDirection="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box paddingLeft={2} flex={1}>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
marginBottom={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
ref={titleRef}
|
||||
variant={titleVariant(depth)}
|
||||
classes={{ root: classes.title }}
|
||||
>
|
||||
{path}
|
||||
</Typography>
|
||||
{chips.length > 0 && <Box marginLeft={1} />}
|
||||
{chips}
|
||||
</Box>
|
||||
{schema && (
|
||||
<SchemaView path={path} depth={depth} schema={schema as Schema} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchView({
|
||||
path,
|
||||
depth,
|
||||
schema,
|
||||
label,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
schema: Schema[];
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="overline">{label}</Typography>
|
||||
{schema.map((optionSchema, index) => (
|
||||
<ChildView
|
||||
key={index}
|
||||
path={`${path}/${index + 1}`}
|
||||
depth={depth + 1}
|
||||
schema={optionSchema}
|
||||
lastChild={index === schema.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarView({ schema }: SchemaViewProps) {
|
||||
return (
|
||||
<>
|
||||
{schema.description && (
|
||||
<Box marginBottom={4}>
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<MetadataView schema={schema} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataViewRowProps {
|
||||
label: string;
|
||||
text?: string;
|
||||
data?: JsonValue;
|
||||
}
|
||||
|
||||
function MetadataViewRow({ label, text, data }: MetadataViewRowProps) {
|
||||
if (text === undefined && data === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell style={{ width: 160 }}>
|
||||
<Typography variant="body1" noWrap style={{ fontWeight: 900 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body1">
|
||||
{data ? JSON.stringify(data) : text}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataView({ schema }: { schema: Schema }) {
|
||||
return (
|
||||
<Paper variant="outlined" square style={{ width: '100%' }}>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<MetadataViewRow label="Type" data={schema.type} />
|
||||
<MetadataViewRow label="Allowed values" data={schema.enum} />
|
||||
{schema.additionalProperties === true && (
|
||||
<MetadataViewRow label="Additional Properties" text="true" />
|
||||
)}
|
||||
{schema.additionalItems === true && (
|
||||
<MetadataViewRow label="Additional Items" text="true" />
|
||||
)}
|
||||
<MetadataViewRow label="Format" text={schema.format} />
|
||||
<MetadataViewRow
|
||||
label="Pattern"
|
||||
text={schema.pattern && String(schema.pattern)}
|
||||
/>
|
||||
<MetadataViewRow label="Minimum" data={schema.minimum} />
|
||||
<MetadataViewRow label="Maximum" data={schema.maximum} />
|
||||
<MetadataViewRow
|
||||
label="Exclusive minimum"
|
||||
data={schema.exclusiveMinimum}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Exclusive maximum"
|
||||
data={schema.exclusiveMaximum}
|
||||
/>
|
||||
<MetadataViewRow label="Multiple of" data={schema.multipleOf} />
|
||||
<MetadataViewRow
|
||||
label="Maximum number of items"
|
||||
data={schema.maxItems}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Minimum number of items"
|
||||
data={schema.minItems}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Maximum number of properties"
|
||||
data={schema.maxProperties}
|
||||
/>
|
||||
<MetadataViewRow
|
||||
label="Minimum number of properties"
|
||||
data={schema.minProperties}
|
||||
/>
|
||||
<MetadataViewRow label="Maximum Length" data={schema.maxLength} />
|
||||
<MetadataViewRow label="Minimum Length" data={schema.minLength} />
|
||||
<MetadataViewRow
|
||||
label="Items must be unique"
|
||||
data={schema.uniqueItems}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
registerMswTestHooks,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import {
|
||||
CustomResourceFetchResponse,
|
||||
FetchResponse,
|
||||
KubernetesRequestAuth,
|
||||
ObjectsByEntityResponse,
|
||||
@@ -1428,5 +1429,78 @@ describe('KubernetesFanOutHandler', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('fetch pod metrics when pods used', async () => {
|
||||
getClustersByEntity.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
clusters: [
|
||||
{
|
||||
name: 'test-cluster',
|
||||
title: 'cluster-title',
|
||||
url: '',
|
||||
authMetadata: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
sut = getKubernetesFanOutHandler([]);
|
||||
|
||||
const resources: CustomResourceFetchResponse[] = [
|
||||
{
|
||||
type: 'customresources',
|
||||
resources: [
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Pod',
|
||||
metadata: {
|
||||
namespace: `ns-test-component-test-cluster`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
fetchObjectsForService.mockImplementation(async () => ({
|
||||
responses: resources,
|
||||
errors: [],
|
||||
}));
|
||||
|
||||
const result = await sut.getCustomResourcesByEntity(
|
||||
{
|
||||
entity,
|
||||
auth: {},
|
||||
customResources: [
|
||||
{
|
||||
group: '',
|
||||
apiVersion: 'v1',
|
||||
plural: 'pods',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ credentials: mockCredentials },
|
||||
);
|
||||
|
||||
expect(fetchObjectsForService).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ type: 'anonymous' },
|
||||
new Set(['ns-test-component-test-cluster']),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toStrictEqual<ObjectsByEntityResponse>({
|
||||
items: [
|
||||
{
|
||||
cluster: {
|
||||
name: 'test-cluster',
|
||||
title: 'cluster-title',
|
||||
},
|
||||
errors: [],
|
||||
podMetrics: [POD_METRICS_FIXTURE],
|
||||
resources,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +147,11 @@ export interface KubernetesFanOutHandlerOptions
|
||||
export interface KubernetesRequestBody extends ObjectsByEntityRequest {}
|
||||
|
||||
const isPodFetchResponse = (fr: FetchResponse): fr is PodFetchResponse =>
|
||||
fr.type === 'pods';
|
||||
fr.type === 'pods' ||
|
||||
(fr.type === 'customresources' &&
|
||||
fr.resources.length > 0 &&
|
||||
fr.resources[0].apiVersion === 'v1' &&
|
||||
fr.resources[0].kind === 'Pod');
|
||||
const isString = (str: string | undefined): str is string => str !== undefined;
|
||||
|
||||
const numberOrBigIntToNumberOrString = (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
@@ -103,6 +104,7 @@ export function createGithubDeployKeyAction(options: {
|
||||
export function createGithubEnvironmentAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
catalogClient?: CatalogApi;
|
||||
auth?: AuthService;
|
||||
}): TemplateAction<
|
||||
{
|
||||
repoUrl: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
@@ -71,6 +72,14 @@ describe('github:environment:create', () => {
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
|
||||
const credentials = mockCredentials.user();
|
||||
|
||||
const token = mockCredentials.service.token({
|
||||
onBehalfOf: credentials,
|
||||
targetPluginId: 'catalog',
|
||||
});
|
||||
|
||||
let action: TemplateAction<any>;
|
||||
|
||||
const mockContext = createMockActionContext({
|
||||
@@ -78,6 +87,7 @@ describe('github:environment:create', () => {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
name: 'envname',
|
||||
},
|
||||
secrets: { backstageToken: token },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -122,6 +132,7 @@ describe('github:environment:create', () => {
|
||||
action = createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient: mockCatalogClient as CatalogApi,
|
||||
auth: mockServices.auth(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -453,6 +464,13 @@ describe('github:environment:create', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockCatalogClient.getEntitiesByRefs).toHaveBeenCalledWith(
|
||||
{
|
||||
entityRefs: ['group:default/team-a', 'user:default/johndoe'],
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
|
||||
@@ -26,6 +26,7 @@ import Sodium from 'libsodium-wrappers';
|
||||
import { examples } from './gitHubEnvironment.examples';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
|
||||
@@ -35,8 +36,9 @@ import { Entity } from '@backstage/catalog-model';
|
||||
export function createGithubEnvironmentAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
catalogClient?: CatalogApi;
|
||||
auth?: AuthService;
|
||||
}) {
|
||||
const { integrations, catalogClient } = options;
|
||||
const { integrations, catalogClient, auth } = options;
|
||||
// For more information on how to define custom actions, see
|
||||
// https://backstage.io/docs/features/software-templates/writing-custom-actions
|
||||
return createTemplateAction<{
|
||||
@@ -140,7 +142,8 @@ export function createGithubEnvironmentAction(options: {
|
||||
reviewers: {
|
||||
title: 'Reviewers',
|
||||
type: 'array',
|
||||
description: 'Reviewers for this environment',
|
||||
description:
|
||||
'Reviewers for this environment. Must be a list of Backstage entity references.',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
@@ -163,6 +166,11 @@ export function createGithubEnvironmentAction(options: {
|
||||
reviewers,
|
||||
} = ctx.input;
|
||||
|
||||
const { token } = (await auth?.getPluginRequestToken({
|
||||
onBehalfOf: await ctx.getInitiatorCredentials(),
|
||||
targetPluginId: 'catalog',
|
||||
})) ?? { token: ctx.secrets?.backstageToken };
|
||||
|
||||
// When environment creation step is executed right after a repo publish step, the repository might not be available immediately.
|
||||
// Add a 2-second delay before initiating the steps in this action.
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
@@ -190,9 +198,14 @@ export function createGithubEnvironmentAction(options: {
|
||||
if (reviewers) {
|
||||
let reviewersEntityRefs: Array<Entity | undefined> = [];
|
||||
// Fetch reviewers from Catalog
|
||||
const catalogResponse = await catalogClient?.getEntitiesByRefs({
|
||||
entityRefs: reviewers,
|
||||
});
|
||||
const catalogResponse = await catalogClient?.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: reviewers,
|
||||
},
|
||||
{
|
||||
token,
|
||||
},
|
||||
);
|
||||
if (catalogResponse?.items?.length) {
|
||||
reviewersEntityRefs = catalogResponse.items;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,9 @@ export const githubModule = createBackendModule({
|
||||
scaffolder: scaffolderActionsExtensionPoint,
|
||||
config: coreServices.rootConfig,
|
||||
discovery: coreServices.discovery,
|
||||
auth: coreServices.auth,
|
||||
},
|
||||
async init({ scaffolder, config, discovery }) {
|
||||
async init({ scaffolder, config, discovery, auth }) {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
const githubCredentialsProvider =
|
||||
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
|
||||
@@ -75,6 +76,7 @@ export const githubModule = createBackendModule({
|
||||
createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient,
|
||||
auth,
|
||||
}),
|
||||
createGithubIssuesLabelAction({
|
||||
integrations,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useAsync, useMountEffect } from '@react-hookz/web';
|
||||
import { useApiHolder, useElementFilter } from '@backstage/core-plugin-api';
|
||||
import { useApi, useElementFilter } from '@backstage/core-plugin-api';
|
||||
import { formFieldsApiRef } from '../next';
|
||||
import { FieldExtensionOptions } from '../extensions';
|
||||
import {
|
||||
@@ -33,10 +33,9 @@ export const useCustomFieldExtensions = <
|
||||
outlet: React.ReactNode,
|
||||
) => {
|
||||
// Get custom fields created with FormFieldBlueprint
|
||||
const apiHolder = useApiHolder();
|
||||
const formFieldsApi = apiHolder.get(formFieldsApiRef);
|
||||
const formFieldsApi = useApi(formFieldsApiRef);
|
||||
const [{ result: blueprintFields }, methods] = useAsync(
|
||||
formFieldsApi?.getFormFields ?? (async () => []),
|
||||
formFieldsApi.getFormFields,
|
||||
[],
|
||||
);
|
||||
useMountEffect(methods.execute);
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Router } from './Router';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
TestAppOptions,
|
||||
} from '@backstage/test-utils';
|
||||
import {
|
||||
createScaffolderFieldExtension,
|
||||
ScaffolderFieldExtensions,
|
||||
@@ -26,12 +30,26 @@ import {
|
||||
ScaffolderLayouts,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { TemplateListPage, TemplateWizardPage } from '../../alpha/components';
|
||||
import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
jest.mock('../../alpha/components', () => ({
|
||||
TemplateWizardPage: jest.fn(() => null),
|
||||
TemplateListPage: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const wrapInApisAndRender = (
|
||||
element: React.ReactElement,
|
||||
opts?: TestAppOptions,
|
||||
) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[[formFieldsApiRef, { getFormFields: async () => [] }]]}
|
||||
>
|
||||
{element}
|
||||
</TestApiProvider>,
|
||||
opts,
|
||||
);
|
||||
|
||||
describe('Router', () => {
|
||||
beforeEach(() => {
|
||||
(TemplateWizardPage as jest.Mock).mockClear();
|
||||
@@ -39,13 +57,13 @@ describe('Router', () => {
|
||||
});
|
||||
describe('/', () => {
|
||||
it('should render the TemplateListPage', async () => {
|
||||
await renderInTestApp(<Router />);
|
||||
await wrapInApisAndRender(<Router />);
|
||||
|
||||
expect(TemplateListPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render user-provided TemplateListPage', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
const { getByText } = await wrapInApisAndRender(
|
||||
<Router
|
||||
components={{
|
||||
EXPERIMENTAL_TemplateListPageComponent: () => <>foobar</>,
|
||||
@@ -62,7 +80,7 @@ describe('Router', () => {
|
||||
|
||||
describe('/templates/:templateName', () => {
|
||||
it('should render the TemplateWizard page', async () => {
|
||||
await renderInTestApp(<Router />, {
|
||||
await wrapInApisAndRender(<Router />, {
|
||||
routeEntries: ['/templates/default/foo'],
|
||||
});
|
||||
|
||||
@@ -70,7 +88,7 @@ describe('Router', () => {
|
||||
});
|
||||
|
||||
it('should render user-provided TemplateWizardPage', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
const { getByText } = await wrapInApisAndRender(
|
||||
<Router
|
||||
components={{
|
||||
EXPERIMENTAL_TemplateWizardPageComponent: () => <>foobar</>,
|
||||
@@ -87,13 +105,14 @@ describe('Router', () => {
|
||||
it('should pass through the FormProps property', async () => {
|
||||
const transformErrorsMock = jest.fn();
|
||||
|
||||
await renderInTestApp(
|
||||
await wrapInApisAndRender(
|
||||
<Router
|
||||
formProps={{
|
||||
transformErrors: transformErrorsMock,
|
||||
noHtml5Validate: true,
|
||||
}}
|
||||
/>,
|
||||
|
||||
{
|
||||
routeEntries: ['/templates/default/foo'],
|
||||
},
|
||||
@@ -118,7 +137,7 @@ describe('Router', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
await wrapInApisAndRender(
|
||||
<Router>
|
||||
<ScaffolderFieldExtensions>
|
||||
<CustomFieldExtension />
|
||||
@@ -146,7 +165,7 @@ describe('Router', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
await wrapInApisAndRender(
|
||||
<Router>
|
||||
<ScaffolderLayouts>
|
||||
<Layout />
|
||||
|
||||
@@ -4027,7 +4027,7 @@ __metadata:
|
||||
commander: ^12.0.0
|
||||
cross-fetch: ^4.0.0
|
||||
cross-spawn: ^7.0.3
|
||||
css-loader: ^7.0.0
|
||||
css-loader: ^6.5.1
|
||||
ctrlc-windows: ^2.1.0
|
||||
del: ^8.0.0
|
||||
esbuild: ^0.24.0
|
||||
@@ -26339,7 +26339,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-loader@npm:^6.7.1":
|
||||
"css-loader@npm:^6.5.1, css-loader@npm:^6.7.1":
|
||||
version: 6.11.0
|
||||
resolution: "css-loader@npm:6.11.0"
|
||||
dependencies:
|
||||
@@ -26363,30 +26363,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-loader@npm:^7.0.0":
|
||||
version: 7.1.2
|
||||
resolution: "css-loader@npm:7.1.2"
|
||||
dependencies:
|
||||
icss-utils: ^5.1.0
|
||||
postcss: ^8.4.33
|
||||
postcss-modules-extract-imports: ^3.1.0
|
||||
postcss-modules-local-by-default: ^4.0.5
|
||||
postcss-modules-scope: ^3.2.0
|
||||
postcss-modules-values: ^4.0.0
|
||||
postcss-value-parser: ^4.2.0
|
||||
semver: ^7.5.4
|
||||
peerDependencies:
|
||||
"@rspack/core": 0.x || 1.x
|
||||
webpack: ^5.27.0
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 15bfd90d778ddab90ee1d04c8c8bcc13ea6c0791d01b52b09d1b1c753b3410f7a7788a510d93726a9878e70b7c1a140f21efdf5c96e1857872107551d3897822
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-select@npm:^4.1.3, css-select@npm:^4.2.1":
|
||||
version: 4.3.0
|
||||
resolution: "css-select@npm:4.3.0"
|
||||
|
||||
Reference in New Issue
Block a user