search - Removed legacy backend support

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Fixed typo

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated API report

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Moved types

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated changesets

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Fixed type error

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated changeset based on feedback

Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
Andre Wanlin
2025-03-01 17:13:57 -06:00
parent 85df833fe3
commit d5c4a9db09
27 changed files with 40 additions and 800 deletions
-9
View File
@@ -34,15 +34,6 @@ export type {
OutOfTheBoxDeploymentOptions,
} from './service';
export {
DefaultTechDocsCollator,
DefaultTechDocsCollatorFactory,
} from './search';
export type {
TechDocsCollatorFactoryOptions,
TechDocsCollatorOptions,
} from './search';
/**
* @public
* @deprecated import from `@backstage/plugin-techdocs-node` instead
@@ -1,215 +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 { TokenManager, loggerToWinstonLogger } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
import {
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { ConfigReader } from '@backstage/config';
import { TECHDOCS_ANNOTATION } from '@backstage/plugin-techdocs-common';
const logger = loggerToWinstonLogger(mockServices.logger.mock());
const mockSearchDocIndex = {
config: {
lang: ['en'],
min_search_length: 3,
prebuild_index: false,
separator: '[\\s\\-]+',
},
docs: [
{
location: '',
text: 'docs docs docs',
title: 'Home',
},
{
location: 'local-development/',
text: 'Docs for first subtitle',
title: 'Local development',
},
{
location: 'local-development/#development',
text: 'Docs for sub-subtitle',
title: 'Development',
},
],
};
const expectedEntities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
title: 'Test Entity with Docs!',
name: 'test-entity-with-docs',
description: 'Documented description',
annotations: {
[TECHDOCS_ANNOTATION]: './',
},
},
spec: {
type: 'dog',
lifecycle: 'experimental',
owner: 'someone',
},
},
];
describe('TechDocs Collator', () => {
const worker = setupServer();
registerMswTestHooks(worker);
describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
const mockDiscoveryApi = mockServices.discovery.mock({
getBaseUrl: async () => 'http://test-backend',
});
let mockTokenManager: jest.Mocked<TokenManager>;
let collator: DefaultTechDocsCollator;
beforeEach(() => {
jest.clearAllMocks();
mockTokenManager = {
getToken: jest.fn().mockResolvedValue({ token: '' }),
authenticate: jest.fn(),
};
const mockConfig = new ConfigReader({
techdocs: {
legacyUseCaseSensitiveTripletPaths: true,
},
});
collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
logger,
legacyPathCasing: true,
});
worker.use(
http.get(
'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
() => HttpResponse.json(mockSearchDocIndex),
),
http.get('http://test-backend/entities', () =>
HttpResponse.json(expectedEntities),
),
);
});
it('fetches from the configured catalog and tech docs services', async () => {
const documents = await collator.execute();
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
});
it('should create documents for each tech docs search index', async () => {
const documents = await collator.execute();
const entity = expectedEntities[0];
documents.forEach((document, idx) => {
expect(document).toMatchObject({
title: mockSearchDocIndex.docs[idx].title,
location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
text: mockSearchDocIndex.docs[idx].text,
namespace: 'default',
entityTitle: entity!.metadata.title,
componentType: entity!.spec!.type,
lifecycle: entity!.spec!.lifecycle,
owner: '',
kind: entity.kind,
name: entity.metadata.name,
});
});
});
});
describe('DefaultTechDocsCollator', () => {
const mockDiscoveryApi = mockServices.discovery.mock({
getBaseUrl: async () => 'http://test-backend',
});
let mockTokenManager: jest.Mocked<TokenManager>;
let collator: DefaultTechDocsCollator;
beforeEach(() => {
mockTokenManager = {
getToken: jest.fn().mockResolvedValue({ token: '' }),
authenticate: jest.fn(),
};
collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
logger,
});
worker.use(
http.get(
'http://test-backend/static/docs/default/component/test-entity-with-docs/search/search_index.json',
() => HttpResponse.json(mockSearchDocIndex),
),
http.get('http://test-backend/entities', () =>
HttpResponse.json(expectedEntities),
),
);
});
it('should create documents for each tech docs search index', async () => {
const documents = await collator.execute();
const entity = expectedEntities[0];
documents.forEach((document, idx) => {
expect(document).toMatchObject({
title: mockSearchDocIndex.docs[idx].title,
location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
text: mockSearchDocIndex.docs[idx].text,
namespace: 'default',
entityTitle: entity!.metadata.title,
componentType: entity!.spec!.type,
lifecycle: entity!.spec!.lifecycle,
owner: '',
kind: entity.kind.toLocaleLowerCase('en-US'),
name: entity.metadata.name.toLocaleLowerCase('en-US'),
authorization: {
resourceRef: `component:default/${entity.metadata.name}`,
},
});
});
});
it('maps a returned entity with a custom locationTemplate', async () => {
const mockConfig = new ConfigReader({
techdocs: {
legacyUseCaseSensitiveTripletPaths: true,
},
});
// Provide an alternate location template.
collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
locationTemplate: '/software/:name',
logger,
});
const documents = await collator.execute();
expect(documents[0]).toMatchObject({
location: '/software/test-entity-with-docs',
});
});
});
});
@@ -1,222 +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 { TokenManager } from '@backstage/backend-common';
import {
Entity,
parseEntityRef,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import unescape from 'lodash/unescape';
import { Logger } from 'winston';
import pLimit from 'p-limit';
import { Config } from '@backstage/config';
import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/alpha';
import { Permission } from '@backstage/plugin-permission-common';
import {
CatalogApi,
CatalogClient,
CATALOG_FILTER_EXISTS,
} from '@backstage/catalog-client';
import { TechDocsDocument } from '@backstage/plugin-techdocs-node';
import { TECHDOCS_ANNOTATION } from '@backstage/plugin-techdocs-common';
import { DiscoveryService } from '@backstage/backend-plugin-api';
interface MkSearchIndexDoc {
title: string;
text: string;
location: string;
}
/**
* Options to configure the TechDocs collator
*
* @public
*/
export type TechDocsCollatorOptions = {
discovery: DiscoveryService;
logger: Logger;
tokenManager: TokenManager;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
legacyPathCasing?: boolean;
};
type EntityInfo = {
name: string;
namespace: string;
kind: string;
};
/**
* A search collator responsible for gathering and transforming TechDocs documents.
*
* @public
* @deprecated Upgrade to a more recent `@backstage/plugin-search-backend-node` and
* use `DefaultTechDocsCollatorFactory` instead.
*/
export class DefaultTechDocsCollator {
public readonly type: string = 'techdocs';
public readonly visibilityPermission: Permission =
catalogEntityReadPermission;
private constructor(
private readonly legacyPathCasing: boolean,
private readonly options: TechDocsCollatorOptions,
) {}
static fromConfig(config: Config, options: TechDocsCollatorOptions) {
const legacyPathCasing =
config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
return new DefaultTechDocsCollator(legacyPathCasing, options);
}
async execute() {
const {
parallelismLimit,
discovery,
tokenManager,
catalogClient,
locationTemplate,
logger,
} = this.options;
const limit = pLimit(parallelismLimit ?? 10);
const techDocsBaseUrl = await discovery.getBaseUrl('techdocs');
const { token } = await tokenManager.getToken();
const entities = await (
catalogClient ?? new CatalogClient({ discoveryApi: discovery })
).getEntities(
{
filter: {
[`metadata.annotations.${TECHDOCS_ANNOTATION}`]:
CATALOG_FILTER_EXISTS,
},
fields: [
'kind',
'namespace',
'metadata.annotations',
'metadata.name',
'metadata.title',
'metadata.namespace',
'spec.type',
'spec.lifecycle',
'relations',
],
},
{ token },
);
const docPromises = entities.items.map((entity: Entity) =>
limit(async (): Promise<TechDocsDocument[]> => {
const entityInfo = DefaultTechDocsCollator.handleEntityInfoCasing(
this.legacyPathCasing ?? false,
{
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
},
);
try {
const { token: newToken } = await tokenManager.getToken();
const searchIndexResponse = await fetch(
DefaultTechDocsCollator.constructDocsIndexUrl(
techDocsBaseUrl,
entityInfo,
),
{
headers: {
Authorization: `Bearer ${newToken}`,
},
},
);
const searchIndex = await searchIndexResponse.json();
return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({
title: unescape(doc.title),
text: unescape(doc.text || ''),
location: this.applyArgsToFormat(
locationTemplate || '/docs/:namespace/:kind/:name/:path',
{
...entityInfo,
path: doc.location,
},
),
path: doc.location,
...entityInfo,
entityTitle: entity.metadata.title,
componentType: entity.spec?.type?.toString() || 'other',
lifecycle: (entity.spec?.lifecycle as string) || '',
owner: getSimpleEntityOwnerString(entity),
authorization: {
resourceRef: stringifyEntityRef(entity),
},
}));
} catch (e) {
logger.debug(
`Failed to retrieve tech docs search index for entity ${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}`,
e,
);
return [];
}
}),
);
return (await Promise.all(docPromises)).flat();
}
protected 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;
}
private static constructDocsIndexUrl(
techDocsBaseUrl: string,
entityInfo: { kind: string; namespace: string; name: string },
) {
return `${techDocsBaseUrl}/static/docs/${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}/search/search_index.json`;
}
private static handleEntityInfoCasing(
legacyPaths: boolean,
entityInfo: EntityInfo,
): EntityInfo {
return legacyPaths
? entityInfo
: Object.entries(entityInfo).reduce((acc, [key, value]) => {
return { ...acc, [key]: value.toLocaleLowerCase('en-US') };
}, {} as EntityInfo);
}
}
function getSimpleEntityOwnerString(entity: Entity): string {
if (entity.relations) {
const owner = entity.relations.find(r => r.type === RELATION_OWNED_BY);
if (owner) {
const { name } = parseEntityRef(owner.targetRef);
return name;
}
}
return '';
}
@@ -1,36 +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.
*/
/**
* todo(backstage/techdocs-core): stop exporting these in a future release.
*/
export { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
export type { TechDocsCollatorOptions } from './DefaultTechDocsCollator';
import { DefaultTechDocsCollatorFactory as _DefaultTechDocsCollatorFactory } from '@backstage/plugin-search-backend-module-techdocs';
import type { TechDocsCollatorFactoryOptions as _TechDocsCollatorFactoryOptions } from '@backstage/plugin-search-backend-module-techdocs';
/**
* @public
* @deprecated import from `@backstage/plugin-search-backend-module-techdocs` instead
*/
export type TechDocsCollatorFactoryOptions = _TechDocsCollatorFactoryOptions;
/**
* @public
* @deprecated import from `@backstage/plugin-search-backend-module-techdocs` instead
*/
export const DefaultTechDocsCollatorFactory = _DefaultTechDocsCollatorFactory;