Refactor catalog collator, extracted abstract class and entity processor to make it possible to re-use functionality

Signed-off-by: Ilya Savich <isavich@box.com>
This commit is contained in:
Ilya Savich
2022-12-09 15:58:53 +01:00
parent be93704a90
commit 2380506364
9 changed files with 472 additions and 290 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Refactored catalog collator, extracted abstract class and entity processor to make it possible to re-use functionality
+43 -15
View File
@@ -167,6 +167,42 @@ export class CatalogBuilder {
useLegacySingleProcessorValidation(): this;
}
// @public (undocumented)
export abstract class CatalogCollatorFactory
implements DocumentCollatorFactory
{
protected constructor(options: CatalogCollatorFactoryOptions);
// (undocumented)
static fromConfig(
_config: Config,
_options: CatalogCollatorFactoryCreateOptions,
): CatalogCollatorFactory;
// (undocumented)
getCollator(): Promise<Readable>;
// (undocumented)
readonly type: string;
// (undocumented)
readonly visibilityPermission: Permission;
}
// @public (undocumented)
export type CatalogCollatorFactoryCreateOptions = Omit<
CatalogCollatorFactoryOptions,
'entityProcessor' | 'type'
>;
// @public (undocumented)
export type CatalogCollatorFactoryOptions = {
type: string;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
entityProcessor: CatalogCollatorEntityProcessor;
locationTemplate?: string;
filter?: GetEntitiesRequest['filter'];
batchSize?: number;
catalogClient?: CatalogApi;
};
// @alpha
export const catalogConditions: Conditions<{
hasAnnotation: PermissionRule<
@@ -351,29 +387,17 @@ export class DefaultCatalogCollator {
}
// @public (undocumented)
export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
export class DefaultCatalogCollatorFactory extends CatalogCollatorFactory {
// (undocumented)
static fromConfig(
_config: Config,
options: DefaultCatalogCollatorFactoryOptions,
): DefaultCatalogCollatorFactory;
// (undocumented)
getCollator(): Promise<Readable>;
// (undocumented)
readonly type: string;
// (undocumented)
readonly visibilityPermission: Permission;
}
// @public (undocumented)
export type DefaultCatalogCollatorFactoryOptions = {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
locationTemplate?: string;
filter?: GetEntitiesRequest['filter'];
batchSize?: number;
catalogClient?: CatalogApi;
};
export type DefaultCatalogCollatorFactoryOptions =
CatalogCollatorFactoryCreateOptions;
export { DeferredEntity };
@@ -583,4 +607,8 @@ export class UrlReaderProcessor implements CatalogProcessor {
cache: CatalogProcessorCache,
): Promise<boolean>;
}
// Warnings were encountered during analysis:
//
// src/search/CatalogCollatorFactory.d.ts:14:5 - (ae-forgotten-export) The symbol "CatalogCollatorEntityProcessor" needs to be exported by the entry point index.d.ts
```
@@ -14,16 +14,9 @@
* limitations under the License.
*/
import { Entity, isUserEntity, isGroupEntity } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { CatalogEntityDocument } from '@backstage/plugin-catalog-common';
export function getDocumentText(entity: Entity): string {
const documentTexts: string[] = [];
documentTexts.push(entity.metadata.description || '');
if (isUserEntity(entity) || isGroupEntity(entity)) {
if (entity.spec?.profile?.displayName) {
documentTexts.push(entity.spec.profile.displayName);
}
}
return documentTexts.join(' : ');
export interface CatalogCollatorEntityProcessor {
process(entity: Entity, locationTemplate: string): CatalogEntityDocument;
}
@@ -0,0 +1,132 @@
/*
* Copyright 2022 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 {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import {
CatalogApi,
CatalogClient,
GetEntitiesRequest,
} from '@backstage/catalog-client';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import {
catalogEntityReadPermission,
CatalogEntityDocument,
} from '@backstage/plugin-catalog-common';
import { Permission } from '@backstage/plugin-permission-common';
import { Readable } from 'stream';
import { CatalogCollatorEntityProcessor } from './CatalogCollatorEntityProcessor';
import { Config } from '@backstage/config';
/** @public */
export type CatalogCollatorFactoryOptions = {
type: string;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
entityProcessor: CatalogCollatorEntityProcessor;
locationTemplate?: string;
filter?: GetEntitiesRequest['filter'];
batchSize?: number;
catalogClient?: CatalogApi;
};
/** @public */
export type CatalogCollatorFactoryCreateOptions = Omit<
CatalogCollatorFactoryOptions,
'entityProcessor' | 'type'
>;
/** @public */
export abstract class CatalogCollatorFactory
implements DocumentCollatorFactory
{
public readonly type: string;
public readonly visibilityPermission: Permission =
catalogEntityReadPermission;
private locationTemplate: string;
private filter?: GetEntitiesRequest['filter'];
private batchSize: number;
private readonly catalogClient: CatalogApi;
private tokenManager: TokenManager;
private entityProcessor: CatalogCollatorEntityProcessor;
static fromConfig(
_config: Config,
_options: CatalogCollatorFactoryCreateOptions,
): CatalogCollatorFactory {
throw new Error('Method should be implemented');
}
protected constructor(options: CatalogCollatorFactoryOptions) {
const {
type,
batchSize,
discovery,
locationTemplate,
filter,
catalogClient,
tokenManager,
entityProcessor,
} = options;
this.type = type;
this.locationTemplate =
locationTemplate || '/catalog/:namespace/:kind/:name';
this.filter = filter;
this.batchSize = batchSize || 500;
this.catalogClient =
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.tokenManager = tokenManager;
this.entityProcessor = entityProcessor;
}
async getCollator(): Promise<Readable> {
return Readable.from(this.execute());
}
private async *execute(): AsyncGenerator<CatalogEntityDocument> {
const { token } = await this.tokenManager.getToken();
let entitiesRetrieved = 0;
let moreEntitiesToGet = true;
// Offset/limit pagination is used on the Catalog Client in order to
// limit (and allow some control over) memory used by the search backend
// at index-time.
while (moreEntitiesToGet) {
const entities = (
await this.catalogClient.getEntities(
{
filter: this.filter,
limit: this.batchSize,
offset: entitiesRetrieved,
},
{ token },
)
).items;
// Control looping through entity batches.
moreEntitiesToGet = entities.length === this.batchSize;
entitiesRetrieved += entities.length;
for (const entity of entities) {
yield this.entityProcessor.process(entity, this.locationTemplate);
}
}
}
}
@@ -0,0 +1,193 @@
/*
* Copyright 2022 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 { DefaultCatalogCollatorEntityProcessor } from './DefaultCatalogCollatorEntityProcessor';
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
title: 'Test Entity',
name: 'test-entity',
description: 'The expected description',
namespace: 'namespace',
},
spec: {
type: 'some-type',
lifecycle: 'experimental',
owner: 'someone',
},
};
const userEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'test-user-entity',
description: 'The expected user description',
},
spec: {
profile: {
displayName: 'User 1',
},
},
};
const locationTemplate = '/catalog/:namespace/:kind/:name';
describe('DefaultCatalogCollatorEntityProcessor', () => {
const entityProcessor = new DefaultCatalogCollatorEntityProcessor();
describe('process', () => {
it('maps a returned entity', async () => {
const document = entityProcessor.process(entity, locationTemplate);
expect(document).toMatchObject({
title: entity.metadata.title,
location: '/catalog/namespace/component/test-entity',
text: entity.metadata.description,
namespace: entity.metadata.namespace,
componentType: entity.spec.type,
lifecycle: entity.spec.lifecycle,
owner: entity.spec.owner,
authorization: {
resourceRef: 'component:namespace/test-entity',
},
});
});
it('maps a returned entity with default fallback', async () => {
const entityWithoutTitle = {
...entity,
metadata: {
...entity.metadata,
title: undefined,
namespace: undefined,
},
spec: {
type: undefined,
lifecycle: undefined,
owner: undefined,
},
};
const document = entityProcessor.process(
entityWithoutTitle,
locationTemplate,
);
expect(document).toMatchObject({
title: entity.metadata.name,
location: '/catalog/default/component/test-entity',
text: entity.metadata.description,
namespace: 'default',
componentType: 'other',
lifecycle: '',
owner: '',
authorization: {
resourceRef: 'component:default/test-entity',
},
});
});
it('maps a returned entity with custom locationTemplate', async () => {
const document = entityProcessor.process(entity, '/catalog/:name');
expect(document).toMatchObject({
title: entity.metadata.title,
location: '/catalog/test-entity',
text: entity.metadata.description,
namespace: entity.metadata.namespace,
componentType: entity.spec.type,
lifecycle: entity.spec.lifecycle,
owner: entity.spec.owner,
authorization: {
resourceRef: 'component:namespace/test-entity',
},
});
});
it('maps a returned user entity', async () => {
const document = entityProcessor.process(userEntity, locationTemplate);
expect(document).toMatchObject({
title: userEntity.metadata.name,
location: '/catalog/default/user/test-user-entity',
text: `${userEntity.metadata.description} : ${userEntity.spec.profile.displayName}`,
namespace: 'default',
componentType: 'other',
lifecycle: '',
owner: '',
authorization: {
resourceRef: 'user:default/test-user-entity',
},
});
});
it('maps a returned user entity without display name', async () => {
const testEntity = {
...userEntity,
spec: undefined,
};
const document = entityProcessor.process(testEntity, locationTemplate);
expect(document).toMatchObject({
title: userEntity.metadata.name,
location: '/catalog/default/user/test-user-entity',
text: userEntity.metadata.description,
namespace: 'default',
componentType: 'other',
lifecycle: '',
owner: '',
authorization: {
resourceRef: 'user:default/test-user-entity',
},
});
});
it('maps a returned group entity', async () => {
const groupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'test-group-entity',
description: 'The expected group description',
},
spec: {
profile: {
displayName: 'Group 1',
},
},
};
const document = entityProcessor.process(groupEntity, locationTemplate);
expect(document).toMatchObject({
title: groupEntity.metadata.name,
location: '/catalog/default/group/test-group-entity',
text: `${groupEntity.metadata.description} : ${groupEntity.spec.profile.displayName}`,
namespace: 'default',
componentType: 'other',
lifecycle: '',
owner: '',
authorization: {
resourceRef: 'group:default/test-group-entity',
},
});
});
});
});
@@ -0,0 +1,78 @@
/*
* Copyright 2022 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,
isGroupEntity,
isUserEntity,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { CatalogEntityDocument } from '@backstage/plugin-catalog-common';
import { CatalogCollatorEntityProcessor } from './CatalogCollatorEntityProcessor';
export class DefaultCatalogCollatorEntityProcessor
implements CatalogCollatorEntityProcessor
{
public process(
entity: Entity,
locationTemplate: string,
): CatalogEntityDocument {
return {
title: entity.metadata.title ?? entity.metadata.name,
location: this.applyArgsToFormat(locationTemplate, {
namespace: entity.metadata.namespace || 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
text: this.getDocumentText(entity),
componentType: entity.spec?.type?.toString() || 'other',
type: entity.spec?.type?.toString() || 'other',
namespace: entity.metadata.namespace || 'default',
kind: entity.kind,
lifecycle: (entity.spec?.lifecycle as string) || '',
owner: (entity.spec?.owner as string) || '',
authorization: {
resourceRef: stringifyEntityRef(entity),
},
};
}
private applyArgsToFormat(
format: string,
args: Record<string, string>,
): string {
let formatted = format;
for (const [key, value] of Object.entries(args)) {
formatted = formatted.replace(`:${key}`, value);
}
return formatted.toLowerCase();
}
private getDocumentText(entity: Entity): string {
const documentTexts: string[] = [];
documentTexts.push(entity.metadata.description || '');
if (isUserEntity(entity) || isGroupEntity(entity)) {
if (entity.spec?.profile?.displayName) {
documentTexts.push(entity.spec.profile.displayName);
}
}
return documentTexts.join(' : ');
}
}
@@ -14,133 +14,27 @@
* limitations under the License.
*/
import {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import {
CatalogApi,
CatalogClient,
GetEntitiesRequest,
} from '@backstage/catalog-client';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import {
catalogEntityReadPermission,
CatalogEntityDocument,
} from '@backstage/plugin-catalog-common';
import { Permission } from '@backstage/plugin-permission-common';
import { Readable } from 'stream';
import { getDocumentText } from './util';
CatalogCollatorFactory,
CatalogCollatorFactoryCreateOptions,
} from './CatalogCollatorFactory';
import { DefaultCatalogCollatorEntityProcessor } from './DefaultCatalogCollatorEntityProcessor';
/** @public */
export type DefaultCatalogCollatorFactoryOptions = {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
locationTemplate?: string;
filter?: GetEntitiesRequest['filter'];
batchSize?: number;
catalogClient?: CatalogApi;
};
export type DefaultCatalogCollatorFactoryOptions =
CatalogCollatorFactoryCreateOptions;
/** @public */
export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'software-catalog';
public readonly visibilityPermission: Permission =
catalogEntityReadPermission;
private locationTemplate: string;
private filter?: GetEntitiesRequest['filter'];
private batchSize: number;
private readonly catalogClient: CatalogApi;
private tokenManager: TokenManager;
export class DefaultCatalogCollatorFactory extends CatalogCollatorFactory {
static fromConfig(
_config: Config,
options: DefaultCatalogCollatorFactoryOptions,
) {
return new DefaultCatalogCollatorFactory(options);
}
private constructor(options: DefaultCatalogCollatorFactoryOptions) {
const {
batchSize,
discovery,
locationTemplate,
filter,
catalogClient,
tokenManager,
} = options;
this.locationTemplate =
locationTemplate || '/catalog/:namespace/:kind/:name';
this.filter = filter;
this.batchSize = batchSize || 500;
this.catalogClient =
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.tokenManager = tokenManager;
}
async getCollator(): Promise<Readable> {
return Readable.from(this.execute());
}
private applyArgsToFormat(
format: string,
args: Record<string, string>,
): string {
let formatted = format;
for (const [key, value] of Object.entries(args)) {
formatted = formatted.replace(`:${key}`, value);
}
return formatted.toLowerCase();
}
private async *execute(): AsyncGenerator<CatalogEntityDocument> {
const { token } = await this.tokenManager.getToken();
let entitiesRetrieved = 0;
let moreEntitiesToGet = true;
// Offset/limit pagination is used on the Catalog Client in order to
// limit (and allow some control over) memory used by the search backend
// at index-time.
while (moreEntitiesToGet) {
const entities = (
await this.catalogClient.getEntities(
{
filter: this.filter,
limit: this.batchSize,
offset: entitiesRetrieved,
},
{ token },
)
).items;
// Control looping through entity batches.
moreEntitiesToGet = entities.length === this.batchSize;
entitiesRetrieved += entities.length;
for (const entity of entities) {
yield {
title: entity.metadata.title ?? entity.metadata.name,
location: this.applyArgsToFormat(this.locationTemplate, {
namespace: entity.metadata.namespace || 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
text: getDocumentText(entity),
componentType: entity.spec?.type?.toString() || 'other',
type: entity.spec?.type?.toString() || 'other',
namespace: entity.metadata.namespace || 'default',
kind: entity.kind,
lifecycle: (entity.spec?.lifecycle as string) || '',
owner: (entity.spec?.owner as string) || '',
authorization: {
resourceRef: stringifyEntityRef(entity),
},
};
}
}
return new DefaultCatalogCollatorFactory({
...options,
type: 'software-catalog',
entityProcessor: new DefaultCatalogCollatorEntityProcessor(),
});
}
}
@@ -16,6 +16,11 @@
export { DefaultCatalogCollatorFactory } from './DefaultCatalogCollatorFactory';
export type { DefaultCatalogCollatorFactoryOptions } from './DefaultCatalogCollatorFactory';
export { CatalogCollatorFactory } from './CatalogCollatorFactory';
export type {
CatalogCollatorFactoryOptions,
CatalogCollatorFactoryCreateOptions,
} from './CatalogCollatorFactory';
/**
* todo(backstage/techdocs-core): stop exporting this in a future release.
@@ -1,146 +0,0 @@
/*
* Copyright 2022 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 {
ComponentEntity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { getDocumentText } from './util';
describe('getDocumentText', () => {
describe('kind is not User or Group', () => {
test('contains description if set', () => {
const entity = createComponent();
entity.metadata.description = 'The expected description';
const actual = getDocumentText(entity);
expect(actual).toContain(entity.metadata.description);
});
test('is empty if description is not set', () => {
const entity = createComponent();
const actual = getDocumentText(entity);
expect(actual).toEqual('');
});
});
describe('kind is User', () => {
test('contains display name if set', () => {
const entity = createUser();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.spec.profile?.displayName);
});
test('contains description if set', () => {
const entity = createUser();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.metadata.description);
});
test('contains both description and display name if both are set', () => {
const entity = createUser();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.spec.profile?.displayName);
expect(actual).toContain(entity.metadata.description);
});
test('is empty if description and display name are not set', () => {
const entity = createUser();
delete entity.metadata.description;
delete entity.spec.profile?.displayName;
const actual = getDocumentText(entity);
expect(actual).toEqual('');
});
});
describe('kind is Group', () => {
test('contains display name if set', () => {
const entity = createGroup();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.spec.profile?.displayName);
});
test('contains description if set', () => {
const entity = createGroup();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.metadata.description);
});
test('contains both description and display name if both are set', () => {
const entity = createGroup();
const actual = getDocumentText(entity);
expect(actual).toContain(entity.spec.profile?.displayName);
expect(actual).toContain(entity.metadata.description);
});
test('is empty if description and display name are not set', () => {
const entity = createGroup();
delete entity.metadata.description;
delete entity.spec.profile?.displayName;
const actual = getDocumentText(entity);
expect(actual).toEqual('');
});
});
});
function createGroup(): GroupEntity {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'group-1',
description: 'The expected description',
},
spec: {
type: 'team',
profile: {
displayName: 'Group 1',
},
children: [],
},
};
}
function createUser(): UserEntity {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'user-1',
description: 'The expected description',
},
spec: {
profile: {
displayName: 'User 1',
},
},
};
}
function createComponent(): ComponentEntity {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'component-1',
},
spec: {
lifecycle: 'experimental',
owner: 'someone',
type: 'service',
},
};
}