Merge pull request #33747 from backstage/freben/migrate-techdocs-from-alpha-catalog-service-ref

Migrate techdocs plugins from alpha to stable catalogServiceRef
This commit is contained in:
Patrik Oldsberg
2026-04-07 13:54:08 +02:00
committed by GitHub
9 changed files with 92 additions and 202 deletions
@@ -0,0 +1,6 @@
---
'@backstage/plugin-search-backend-module-techdocs': patch
'@backstage/plugin-techdocs-backend': patch
---
Migrated internal usage of the deprecated `catalogServiceRef` from `@backstage/plugin-catalog-node/alpha` to the stable `catalogServiceRef` from `@backstage/plugin-catalog-node`.
@@ -21,6 +21,7 @@ import {
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { Readable } from 'node:stream';
@@ -85,10 +86,12 @@ describe('DefaultTechDocsCollatorFactory', () => {
const mockDiscoveryApi = mockServices.discovery.mock({
getBaseUrl: async () => 'http://test-backend',
});
const mockCatalog = catalogServiceMock({ entities: expectedEntities });
const options = {
logger,
discovery: mockDiscoveryApi,
auth: mockServices.auth(),
catalog: mockCatalog,
};
it('has expected type', () => {
@@ -112,31 +115,6 @@ describe('DefaultTechDocsCollatorFactory', () => {
'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
),
rest.get('http://test-backend/entities', (req, res, ctx) => {
// Imitate offset/limit pagination.
const offset = parseInt(
req.url.searchParams.get('offset') || '0',
10,
);
const limit = parseInt(
req.url.searchParams.get('limit') || '500',
10,
);
// Limit 50 corresponds to a case testing pagination.
if (limit === 50) {
// Return 50 copies of invalid entities on the first request.
if (offset === 0) {
return res(ctx.status(200), ctx.json(Array(50).fill({})));
}
// Then just the regular 2 on the second.
return res(ctx.status(200), ctx.json(expectedEntities));
}
return res(
ctx.status(200),
ctx.json(expectedEntities.slice(offset, limit + offset)),
);
}),
);
});
@@ -147,7 +125,6 @@ describe('DefaultTechDocsCollatorFactory', () => {
it('fetches from the configured catalog and tech docs services', async () => {
const pipeline = TestPipeline.fromCollator(collator);
const { documents } = await pipeline.execute();
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
});
@@ -184,6 +161,7 @@ describe('DefaultTechDocsCollatorFactory', () => {
discovery: mockDiscoveryApi,
logger,
auth: mockServices.auth(),
catalog: mockCatalog,
});
collator = await factory.getCollator();
@@ -208,9 +186,9 @@ describe('DefaultTechDocsCollatorFactory', () => {
});
it('paginates through catalog entities using batchSize', async () => {
// A parallelismLimit of 1 is a catalog limit of 50 per request. Code
// above in the /entities handler ensures valid entities are only
// returned on the second page.
// parallelismLimit of 1 → batchSize of 50 per request.
// First page returns exactly 50 (no techdocs annotation) triggering a
// second request; second page returns the real entity with annotation.
const _config = new ConfigReader({
...config.get(),
search: {
@@ -221,17 +199,24 @@ describe('DefaultTechDocsCollatorFactory', () => {
},
},
});
factory = DefaultTechDocsCollatorFactory.fromConfig(_config, options);
const paginationCatalog = catalogServiceMock({ entities: [] });
jest
.spyOn(paginationCatalog, 'getEntities')
.mockResolvedValueOnce({ items: Array(50).fill({}) })
.mockResolvedValueOnce({ items: expectedEntities });
factory = DefaultTechDocsCollatorFactory.fromConfig(_config, {
...options,
catalog: paginationCatalog,
});
collator = await factory.getCollator();
const pipeline = TestPipeline.fromCollator(collator);
const { documents } = await pipeline.execute();
// Only 1 entity with TechDocs configured multiplied by 3 pages.
expect(paginationCatalog.getEntities).toHaveBeenCalledTimes(2);
// First page: 50 entities with no techdocs annotation → 0 docs
// Second page: 1 entity × 3 search index docs → 3 docs
expect(documents).toHaveLength(3);
expect(_config.get('search.collators.techdocs.parallelismLimit')).toEqual(
1,
);
});
describe('with legacyPathCasing configuration', () => {
@@ -16,8 +16,6 @@
import {
CATALOG_FILTER_EXISTS,
CatalogApi,
CatalogClient,
EntityFilterQuery,
} from '@backstage/catalog-client';
import {
@@ -45,6 +43,7 @@ import {
DiscoveryService,
LoggerService,
} from '@backstage/backend-plugin-api';
import { CatalogService } from '@backstage/plugin-catalog-node';
/**
* Options to configure the TechDocs collator factory
@@ -56,7 +55,7 @@ export type TechDocsCollatorFactoryOptions = {
logger: LoggerService;
auth: AuthService;
locationTemplate?: string;
catalogClient?: CatalogApi;
catalog: CatalogService;
parallelismLimit?: number;
legacyPathCasing?: boolean;
entityTransformer?: TechDocsCollatorEntityTransformer;
@@ -86,7 +85,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory {
private locationTemplate: string;
private readonly logger: LoggerService;
private readonly auth: AuthService;
private readonly catalogClient: CatalogApi;
private readonly catalog: CatalogService;
private readonly parallelismLimit: number;
private readonly legacyPathCasing: boolean;
private entityTransformer: TechDocsCollatorEntityTransformer;
@@ -99,9 +98,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory {
this.locationTemplate =
options.locationTemplate || '/docs/:namespace/:kind/:name/:path';
this.logger = options.logger.child({ documentType: this.type });
this.catalogClient =
options.catalogClient ||
new CatalogClient({ discoveryApi: options.discovery });
this.catalog = options.catalog;
this.parallelismLimit = options.parallelismLimit ?? 10;
this.legacyPathCasing = options.legacyPathCasing ?? false;
this.entityTransformer = options.entityTransformer ?? (() => ({}));
@@ -147,13 +144,9 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory {
// parallelism limit to simplify configuration.
const batchSize = this.parallelismLimit * 50;
while (moreEntitiesToGet) {
const { token: catalogToken } = await this.auth.getPluginRequestToken({
onBehalfOf: await this.auth.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});
const credentials = await this.auth.getOwnServiceCredentials();
const entities = (
await this.catalogClient.getEntities(
await this.catalog.getEntities(
{
filter: {
'metadata.annotations.backstage.io/techdocs-ref':
@@ -163,7 +156,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory {
limit: batchSize,
offset: entitiesRetrieved,
},
{ token: catalogToken },
{ credentials },
)
).items;
@@ -27,7 +27,7 @@ import {
} from '@backstage/backend-plugin-api';
import { EntityFilterQuery } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { searchIndexRegistryExtensionPoint } from '@backstage/plugin-search-backend-node/alpha';
import { DefaultTechDocsCollatorFactory } from './collators/DefaultTechDocsCollatorFactory';
import {
@@ -162,7 +162,7 @@ export default createBackendModule({
discovery,
auth,
logger,
catalogClient: catalog,
catalog,
entityTransformer,
documentTransformer,
customCatalogApiFilters,
+2 -2
View File
@@ -34,7 +34,7 @@ import {
techdocsPreparerExtensionPoint,
techdocsPublisherExtensionPoint,
} from '@backstage/plugin-techdocs-node';
import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import * as winston from 'winston';
import { createRouter } from './service/router';
@@ -166,7 +166,7 @@ export const techdocsPlugin = createBackendPlugin({
discovery,
httpAuth,
auth,
catalogClient: catalog,
catalog,
}),
);
@@ -16,13 +16,11 @@
import { CachedEntityLoader } from './CachedEntityLoader';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { mockServices } from '@backstage/backend-test-utils';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
describe('CachedEntityLoader', () => {
const cache = mockServices.cache.mock();
const auth = mockServices.auth.mock();
const entityName: CompoundEntityRef = {
kind: 'component',
@@ -39,23 +37,8 @@ describe('CachedEntityLoader', () => {
},
};
const token = 'test-token';
const userCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'user',
userEntityRef: 'user:default/test-user',
},
};
const pluginCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'plugin',
subject: 'plugin:test-plugin',
},
};
const userCredentials = mockCredentials.user('user:default/test-user');
const serviceCredentials = mockCredentials.service('plugin:test-plugin');
afterEach(() => {
jest.resetAllMocks();
@@ -64,14 +47,17 @@ describe('CachedEntityLoader', () => {
it('writes entities to cache for user credentials', async () => {
cache.get.mockResolvedValue(undefined);
const catalog = catalogServiceMock({ entities: [entity] });
auth.isPrincipal.mockReturnValue(true);
jest.spyOn(catalog, 'getEntityByRef');
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(entityName, {
credentials: userCredentials,
});
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/test-user',
`catalog:component:default/test:${userCredentials}`,
entity,
{ ttl: 5000 },
);
@@ -81,10 +67,9 @@ describe('CachedEntityLoader', () => {
const catalog = catalogServiceMock();
jest.spyOn(catalog, 'getEntityByRef');
cache.get.mockResolvedValue(entity);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
expect(catalog.getEntityByRef).not.toHaveBeenCalled();
@@ -93,33 +78,14 @@ describe('CachedEntityLoader', () => {
it('does not cache missing entities', async () => {
const catalog = catalogServiceMock({ entities: [] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toBeUndefined();
expect(cache.set).not.toHaveBeenCalled();
});
it('uses entity ref as cache key for service credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName, undefined);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:plugin:test-plugin',
entity,
{
ttl: 5000,
},
);
});
it('calls the catalog if the cache read takes too long', async () => {
cache.get.mockImplementation(
() =>
@@ -128,80 +94,48 @@ describe('CachedEntityLoader', () => {
}),
);
const catalog = catalogServiceMock({ entities: [entity] });
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
});
it('creates different cache keys for different users', async () => {
it('creates different cache keys for different credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const anotherUserCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'user',
userEntityRef: 'user:default/another-user',
},
};
const anotherUserCredentials = mockCredentials.user(
'user:default/another-user',
);
await loader.load(userCredentials, entityName, token);
await loader.load(anotherUserCredentials, entityName, token);
await loader.load(userCredentials, entityName);
await loader.load(anotherUserCredentials, entityName);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/test-user',
`catalog:component:default/test:${userCredentials}`,
entity,
{ ttl: 5000 },
);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/another-user',
`catalog:component:default/test:${anotherUserCredentials}`,
entity,
{ ttl: 5000 },
);
});
it('creates cache key with service subject for service credentials', async () => {
it('uses service credentials as cache key for service credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName, token);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(serviceCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:plugin:test-plugin',
entity,
{ ttl: 5000 },
);
expect(auth.isPrincipal).toHaveBeenCalledWith(pluginCredentials, 'user');
expect(auth.isPrincipal).toHaveBeenCalledWith(pluginCredentials, 'service');
});
it('handles credentials that are neither user nor service', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(false);
const unknownCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'unknown' as any,
},
};
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(unknownCredentials, entityName, token);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test',
`catalog:component:default/test:${serviceCredentials}`,
entity,
{ ttl: 5000 },
);
@@ -15,31 +15,27 @@
*/
import {
AuthService,
BackstageCredentials,
CacheService,
} from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import {
Entity,
CompoundEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { CatalogService } from '@backstage/plugin-catalog-node';
export type CachedEntityLoaderOptions = {
auth: AuthService;
catalog: CatalogApi;
catalog: CatalogService;
cache: CacheService;
};
export class CachedEntityLoader {
private readonly auth: AuthService;
private readonly catalog: CatalogApi;
private readonly catalog: CatalogService;
private readonly cache: CacheService;
private readonly readTimeout = 1000;
constructor({ auth, catalog, cache }: CachedEntityLoaderOptions) {
this.auth = auth;
constructor({ catalog, cache }: CachedEntityLoaderOptions) {
this.catalog = catalog;
this.cache = cache;
}
@@ -47,7 +43,6 @@ export class CachedEntityLoader {
async load(
credentials: BackstageCredentials,
entityRef: CompoundEntityRef,
token: string | undefined,
): Promise<Entity | undefined> {
const cacheKey = this.getCacheKey(entityRef, credentials);
let result = await this.getFromCache(cacheKey);
@@ -56,7 +51,7 @@ export class CachedEntityLoader {
return result;
}
result = await this.catalog.getEntityByRef(entityRef, { token });
result = await this.catalog.getEntityByRef(entityRef, { credentials });
if (result) {
this.cache.set(cacheKey, result, { ttl: 5000 });
@@ -78,14 +73,10 @@ export class CachedEntityLoader {
entityName: CompoundEntityRef,
credentials: BackstageCredentials,
): string {
const key = ['catalog', stringifyEntityRef(entityName)];
if (this.auth.isPrincipal(credentials, 'user')) {
key.push(credentials.principal.userEntityRef);
} else if (this.auth.isPrincipal(credentials, 'service')) {
key.push(credentials.principal.subject);
}
return key.join(':');
return [
'catalog',
stringifyEntityRef(entityName),
String(credentials), // these have a well defined toString method
].join(':');
}
}
@@ -28,8 +28,8 @@ import { CachedEntityLoader } from './CachedEntityLoader';
import { createEventStream, createRouter, RouterOptions } from './router';
import { TechDocsCache } from '../cache';
import { mockErrorHandler, mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
jest.mock('@backstage/catalog-client');
jest.mock('./CachedEntityLoader');
jest.mock('./DocsSynchronizer');
jest.mock('../cache/TechDocsCache');
@@ -107,6 +107,7 @@ describe('createRouter', () => {
const docsBuildStrategy: jest.Mocked<DocsBuildStrategy> = {
shouldBuild: jest.fn(),
};
const mockCatalogService = catalogServiceMock();
const outOfTheBoxOptions = {
preparers,
generators,
@@ -124,6 +125,7 @@ describe('createRouter', () => {
docsBuildStrategy,
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
catalog: mockCatalogService,
};
const recommendedOptions = {
publisher,
@@ -134,6 +136,7 @@ describe('createRouter', () => {
docsBuildStrategy,
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
catalog: mockCatalogService,
};
beforeEach(() => {
+13 -35
View File
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Config, readDurationFromConfig } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
@@ -41,6 +40,7 @@ import {
HttpAuthService,
LoggerService,
} from '@backstage/backend-plugin-api';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { durationToMilliseconds } from '@backstage/types';
/**
@@ -60,7 +60,7 @@ export type OutOfTheBoxDeploymentOptions = {
cache: CacheService;
docsBuildStrategy?: DocsBuildStrategy;
buildLogTransport?: winston.transport;
catalogClient?: CatalogApi;
catalog: CatalogService;
httpAuth: HttpAuthService;
auth: AuthService;
};
@@ -79,7 +79,7 @@ export type RecommendedDeploymentOptions = {
cache: CacheService;
docsBuildStrategy?: DocsBuildStrategy;
buildLogTransport?: winston.transport;
catalogClient?: CatalogApi;
catalog: CatalogService;
httpAuth: HttpAuthService;
auth: AuthService;
};
@@ -114,10 +114,9 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const { publisher, config, logger, discovery, httpAuth, auth } = options;
const { publisher, config, logger, discovery, httpAuth, auth, catalog } =
options;
const catalogClient =
options.catalogClient ?? new CatalogClient({ discoveryApi: discovery });
const docsBuildStrategy =
options.docsBuildStrategy ?? DefaultDocsBuildStrategy.fromConfig(config);
const buildLogTransport = options.buildLogTransport;
@@ -125,8 +124,7 @@ export async function createRouter(
// Entities are cached to optimize the /static/docs request path, which can be called many times
// when loading a single techdocs page.
const entityLoader = new CachedEntityLoader({
auth,
catalog: catalogClient,
catalog,
cache: options.cache,
});
@@ -163,13 +161,8 @@ export async function createRouter(
const credentials = await httpAuth.credentials(req);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog',
});
// Verify that the related entity exists and the current user has permission to view it.
const entity = await entityLoader.load(credentials, entityName, token);
const entity = await entityLoader.load(credentials, entityName);
if (!entity) {
throw new NotFoundError(
@@ -202,12 +195,7 @@ export async function createRouter(
const credentials = await httpAuth.credentials(req);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog',
});
const entity = await entityLoader.load(credentials, entityName, token);
const entity = await entityLoader.load(credentials, entityName);
if (!entity) {
throw new NotFoundError(
@@ -240,17 +228,12 @@ export async function createRouter(
const credentials = await httpAuth.credentials(req);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog',
const entity = await entityLoader.load(credentials, {
kind,
namespace,
name,
});
const entity = await entityLoader.load(
credentials,
{ kind, namespace, name },
token,
);
if (!entity?.metadata?.uid) {
throw new NotFoundError('Entity metadata UID missing');
}
@@ -315,12 +298,7 @@ export async function createRouter(
allowLimitedAccess: true,
});
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog',
});
const entity = await entityLoader.load(credentials, entityName, token);
const entity = await entityLoader.load(credentials, entityName);
if (!entity) {
throw new NotFoundError(