Migrate techdocs plugins from alpha catalogServiceRef to stable

Migrated `@backstage/plugin-techdocs-backend` and
`@backstage/plugin-search-backend-module-techdocs` to use the stable
`catalogServiceRef` from `@backstage/plugin-catalog-node` instead of
the deprecated one from `@backstage/plugin-catalog-node/alpha`.

This also updates `CachedEntityLoader`, `DefaultTechDocsCollatorFactory`,
and the TechDocs router to use `CatalogService` (credentials-based) instead
of `CatalogApi` (token-based).

Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
This commit is contained in:
Fredrik Adelöw
2026-04-03 22:53:27 +02:00
parent c59dccbaf5
commit 5e32f77884
9 changed files with 52 additions and 89 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(),
catalogClient: 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(),
catalogClient: mockCatalog,
});
collator = await factory.getCollator();
@@ -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;
catalogClient?: 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 catalogClient: CatalogService;
private readonly parallelismLimit: number;
private readonly legacyPathCasing: boolean;
private entityTransformer: TechDocsCollatorEntityTransformer;
@@ -99,9 +98,10 @@ 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 });
if (!options.catalogClient) {
throw new Error('catalogClient is required');
}
this.catalogClient = options.catalogClient;
this.parallelismLimit = options.parallelismLimit ?? 10;
this.legacyPathCasing = options.legacyPathCasing ?? false;
this.entityTransformer = options.entityTransformer ?? (() => ({}));
@@ -147,10 +147,7 @@ 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(
@@ -163,7 +160,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 {
+1 -1
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';
@@ -39,8 +39,6 @@ describe('CachedEntityLoader', () => {
},
};
const token = 'test-token';
const userCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
@@ -67,7 +65,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
@@ -84,7 +82,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
expect(catalog.getEntityByRef).not.toHaveBeenCalled();
@@ -96,7 +94,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const result = await loader.load(userCredentials, entityName);
expect(result).toBeUndefined();
expect(cache.set).not.toHaveBeenCalled();
@@ -108,7 +106,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName, undefined);
const result = await loader.load(pluginCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
@@ -131,7 +129,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(userCredentials, entityName, token);
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
});
@@ -151,8 +149,8 @@ describe('CachedEntityLoader', () => {
},
};
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',
@@ -172,7 +170,7 @@ describe('CachedEntityLoader', () => {
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName, token);
const result = await loader.load(pluginCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
@@ -197,7 +195,7 @@ describe('CachedEntityLoader', () => {
};
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(unknownCredentials, entityName, token);
const result = await loader.load(unknownCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
@@ -19,22 +19,22 @@ import {
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;
@@ -47,7 +47,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 +55,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 });
@@ -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(),
catalogClient: mockCatalogService,
};
const recommendedOptions = {
publisher,
@@ -134,6 +136,7 @@ describe('createRouter', () => {
docsBuildStrategy,
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
catalogClient: mockCatalogService,
};
beforeEach(() => {
+14 -32
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;
catalogClient?: CatalogService;
httpAuth: HttpAuthService;
auth: AuthService;
};
@@ -79,7 +79,7 @@ export type RecommendedDeploymentOptions = {
cache: CacheService;
docsBuildStrategy?: DocsBuildStrategy;
buildLogTransport?: winston.transport;
catalogClient?: CatalogApi;
catalogClient?: CatalogService;
httpAuth: HttpAuthService;
auth: AuthService;
};
@@ -116,8 +116,10 @@ export async function createRouter(
const router = Router();
const { publisher, config, logger, discovery, httpAuth, auth } = options;
const catalogClient =
options.catalogClient ?? new CatalogClient({ discoveryApi: discovery });
if (!options.catalogClient) {
throw new Error('catalogClient is required');
}
const catalogClient = options.catalogClient;
const docsBuildStrategy =
options.docsBuildStrategy ?? DefaultDocsBuildStrategy.fromConfig(config);
const buildLogTransport = options.buildLogTransport;
@@ -163,13 +165,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 +199,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 +232,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 +302,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(