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
+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(