diff --git a/.changeset/rotten-needles-relax.md b/.changeset/rotten-needles-relax.md new file mode 100644 index 0000000000..ef389402aa --- /dev/null +++ b/.changeset/rotten-needles-relax.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +The `catalogServiceRef` now has its own accompanying `CatalogService` interface that requires Backstage `credentials` objects to be passed. This new version of the `catalogServiceRef` has been promoted and is now available via the main `@backstage/plugin-catalog-node` entry point. + +The old `catalogServiceRef` with the old `CatalogApi` type is still available from the `/alpha` entry point. diff --git a/.changeset/selfish-ads-count.md b/.changeset/selfish-ads-count.md new file mode 100644 index 0000000000..2f2f0597dd --- /dev/null +++ b/.changeset/selfish-ads-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +The `mockServices.discovery.factory()` factory now uses the mocked discovery service as its implementation, avoid the need for configuration. diff --git a/docs/backend-system/building-plugins-and-modules/08-migrating.md b/docs/backend-system/building-plugins-and-modules/08-migrating.md index 8b2ea17de8..ea86854b18 100644 --- a/docs/backend-system/building-plugins-and-modules/08-migrating.md +++ b/docs/backend-system/building-plugins-and-modules/08-migrating.md @@ -39,7 +39,7 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; -import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; import { Router } from 'express'; import { KubernetesBuilder } from './KubernetesBuilder'; diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 789b75de9d..55632f4734 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -16,10 +16,7 @@ import { cacheServiceFactory } from '@backstage/backend-defaults/cache'; import { databaseServiceFactory } from '@backstage/backend-defaults/database'; -import { - HostDiscovery, - discoveryServiceFactory, -} from '@backstage/backend-defaults/discovery'; +import { HostDiscovery } from '@backstage/backend-defaults/discovery'; import { httpRouterServiceFactory } from '@backstage/backend-defaults/httpRouter'; import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle'; import { loggerServiceFactory } from '@backstage/backend-defaults/logger'; @@ -273,7 +270,12 @@ export namespace mockServices { ); } export namespace discovery { - export const factory = () => discoveryServiceFactory; + export const factory = () => + createServiceFactory({ + service: coreServices.discovery, + deps: {}, + factory: () => discovery(), + }); export const mock = simpleMock(coreServices.discovery, () => ({ getBaseUrl: jest.fn(), getExternalBaseUrl: jest.fn(), diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index 351b0cd58b..526bacefe4 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -71,6 +71,7 @@ }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "msw": "^1.0.0" } } diff --git a/plugins/catalog-node/report-alpha.api.md b/plugins/catalog-node/report-alpha.api.md index ba02079ceb..044bc14de3 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -95,7 +95,7 @@ export interface CatalogProcessingExtensionPoint { // @alpha (undocumented) export const catalogProcessingExtensionPoint: ExtensionPoint; -// @alpha +// @alpha @deprecated (undocumented) export const catalogServiceRef: ServiceRef; // (No @packageDocumentation comment for this package) diff --git a/plugins/catalog-node/report.api.md b/plugins/catalog-node/report.api.md index 43209aa862..e196fff230 100644 --- a/plugins/catalog-node/report.api.md +++ b/plugins/catalog-node/report.api.md @@ -5,15 +5,30 @@ ```ts /// +import { AddLocationRequest } from '@backstage/catalog-client'; +import { AddLocationResponse } from '@backstage/catalog-client'; import { AnalyzeLocationExistingEntity } from '@backstage/plugin-catalog-common'; import { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; +import { GetEntitiesByRefsRequest } from '@backstage/catalog-client'; +import { GetEntitiesByRefsResponse } from '@backstage/catalog-client'; +import { GetEntitiesRequest } from '@backstage/catalog-client'; +import { GetEntitiesResponse } from '@backstage/catalog-client'; +import { GetEntityAncestorsRequest } from '@backstage/catalog-client'; +import { GetEntityAncestorsResponse } from '@backstage/catalog-client'; +import { GetEntityFacetsRequest } from '@backstage/catalog-client'; +import { GetEntityFacetsResponse } from '@backstage/catalog-client'; import { JsonValue } from '@backstage/types'; +import { Location as Location_2 } from '@backstage/catalog-client'; import { LocationEntityV1alpha1 } from '@backstage/catalog-model'; import { LocationSpec as LocationSpec_2 } from '@backstage/plugin-catalog-common'; +import { QueryEntitiesRequest } from '@backstage/catalog-client'; +import { QueryEntitiesResponse } from '@backstage/catalog-client'; +import { ServiceRef } from '@backstage/backend-plugin-api'; +import { ValidateEntityResponse } from '@backstage/catalog-client'; // @public (undocumented) export type AnalyzeOptions = { @@ -102,6 +117,94 @@ export type CatalogProcessorResult = | CatalogProcessorErrorResult | CatalogProcessorRefreshKeysResult; +// @public +export interface CatalogService { + // (undocumented) + addLocation( + location: AddLocationRequest, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getEntities( + request: GetEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getEntityAncestors( + request: GetEntityAncestorsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getEntityByRef( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getEntityFacets( + request: GetEntityFacetsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getLocationByEntity( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + getLocationByRef( + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + queryEntities( + request: QueryEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + refreshEntity( + entityRef: string, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + removeEntityByUid( + uid: string, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + removeLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) + validateEntity( + entity: Entity, + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise; +} + +// @public +export const catalogServiceRef: ServiceRef< + CatalogService, + 'plugin', + 'singleton' +>; + +// @public (undocumented) +export interface CatalogServiceRequestOptions { + // (undocumented) + credentials: BackstageCredentials; +} + // @public export type DeferredEntity = { entity: Entity; diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts index b75ec48a7f..63b817fbb3 100644 --- a/plugins/catalog-node/src/alpha.ts +++ b/plugins/catalog-node/src/alpha.ts @@ -14,7 +14,32 @@ * limitations under the License. */ -export { catalogServiceRef } from './catalogService'; +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { catalogServiceRef as _catalogServiceRef } from './catalogService'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; + +/** + * @alpha + * @deprecated Use {@link @backstage/plugin-catalog-node#catalogServiceRef} instead + */ +export const catalogServiceRef = createServiceRef({ + id: 'catalog-client', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + discoveryApi: coreServices.discovery, + }, + async factory({ discoveryApi }) { + return new CatalogClient({ discoveryApi }); + }, + }), +}); + export type { CatalogLocationsExtensionPoint } from './extensions'; export { catalogLocationsExtensionPoint } from './extensions'; export type { CatalogProcessingExtensionPoint } from './extensions'; diff --git a/plugins/catalog-node/src/catalogService.test.ts b/plugins/catalog-node/src/catalogService.test.ts index cf7b10009c..b34de6c17c 100644 --- a/plugins/catalog-node/src/catalogService.test.ts +++ b/plugins/catalog-node/src/catalogService.test.ts @@ -14,12 +14,26 @@ * limitations under the License. */ -import { createBackendModule } from '@backstage/backend-plugin-api'; -import { startTestBackend } from '@backstage/backend-test-utils'; -import { CatalogClient } from '@backstage/catalog-client'; +import { + createBackendModule, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { + ServiceFactoryTester, + mockCredentials, + mockServices, + registerMswTestHooks, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; import { catalogServiceRef } from './catalogService'; describe('catalogServiceRef', () => { + const server = setupServer(); + registerMswTestHooks(server); + it('should return a catalogClient', async () => { expect.assertions(1); const testModule = createBackendModule({ @@ -31,7 +45,7 @@ describe('catalogServiceRef', () => { catalog: catalogServiceRef, }, async init({ catalog }) { - expect(catalog).toBeInstanceOf(CatalogClient); + expect(catalog.getEntities).toBeDefined(); }, }); }, @@ -41,4 +55,66 @@ describe('catalogServiceRef', () => { features: [testModule], }); }); + + it('should inject token from user credentials', async () => { + expect.assertions(1); + + server.use( + rest.get('http://localhost/api/catalog/entities', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'catalog', + }), + ); + return res(ctx.json({})); + }), + ); + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const catalogService = await tester.getService(catalogServiceRef); + + await catalogService.getEntities( + {}, + { credentials: mockCredentials.user() }, + ); + }); + + it('should inject token from service credentials', async () => { + expect.assertions(1); + + server.use( + rest.get('http://localhost/api/catalog/entities', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.service(), + targetPluginId: 'catalog', + }), + ); + return res(ctx.json({})); + }), + ); + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const catalogService = await tester.getService(catalogServiceRef); + + await catalogService.getEntities( + {}, + { credentials: mockCredentials.service() }, + ); + }); }); diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts index 9a8ff55e1a..5e8c920dad 100644 --- a/plugins/catalog-node/src/catalogService.ts +++ b/plugins/catalog-node/src/catalogService.ts @@ -18,23 +18,302 @@ import { createServiceFactory, createServiceRef, coreServices, + BackstageCredentials, + AuthService, } from '@backstage/backend-plugin-api'; -import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogClient, + CatalogRequestOptions, + GetEntitiesByRefsRequest, + GetEntitiesByRefsResponse, + GetEntitiesRequest, + GetEntitiesResponse, + GetEntityAncestorsRequest, + GetEntityAncestorsResponse, + GetEntityFacetsRequest, + GetEntityFacetsResponse, + Location, + QueryEntitiesRequest, + QueryEntitiesResponse, + ValidateEntityResponse, +} from '@backstage/catalog-client'; +import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; + +/** + * @public + */ +export interface CatalogServiceRequestOptions { + credentials: BackstageCredentials; +} + +/** + * A version of the {@link @backstage/catalog-client#CatalogApi | CatalogApi} that + * requires backend credentials to be passed instead of a token. + * + * @public + */ +export interface CatalogService { + getEntities( + request: GetEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise; + + getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + + queryEntities( + request: QueryEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise; + + getEntityAncestors( + request: GetEntityAncestorsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + + getEntityByRef( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise; + + removeEntityByUid( + uid: string, + options: CatalogServiceRequestOptions, + ): Promise; + + refreshEntity( + entityRef: string, + options: CatalogServiceRequestOptions, + ): Promise; + + getEntityFacets( + request: GetEntityFacetsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + + getLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise; + + getLocationByRef( + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise; + + addLocation( + location: AddLocationRequest, + options: CatalogServiceRequestOptions, + ): Promise; + + removeLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise; + + getLocationByEntity( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise; + + validateEntity( + entity: Entity, + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise; +} + +class DefaultCatalogService implements CatalogService { + readonly #auth: AuthService; + readonly #catalogApi: CatalogApi; + + constructor({ + catalogApi, + auth, + }: { + catalogApi: CatalogApi; + auth: AuthService; + }) { + this.#catalogApi = catalogApi; + this.#auth = auth; + } + + async getEntities( + request: GetEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getEntities( + request, + await this.#getOptions(options), + ); + } + + async getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getEntitiesByRefs( + request, + await this.#getOptions(options), + ); + } + + async queryEntities( + request: QueryEntitiesRequest | undefined, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.queryEntities( + request, + await this.#getOptions(options), + ); + } + + async getEntityAncestors( + request: GetEntityAncestorsRequest, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getEntityAncestors( + request, + await this.#getOptions(options), + ); + } + + async getEntityByRef( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getEntityByRef( + entityRef, + await this.#getOptions(options), + ); + } + + async removeEntityByUid( + uid: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.removeEntityByUid( + uid, + await this.#getOptions(options), + ); + } + + async refreshEntity( + entityRef: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.refreshEntity( + entityRef, + await this.#getOptions(options), + ); + } + + async getEntityFacets( + request: GetEntityFacetsRequest, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getEntityFacets( + request, + await this.#getOptions(options), + ); + } + + async getLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getLocationById( + id, + await this.#getOptions(options), + ); + } + + async getLocationByRef( + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getLocationByRef( + locationRef, + await this.#getOptions(options), + ); + } + + async addLocation( + location: AddLocationRequest, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.addLocation( + location, + await this.#getOptions(options), + ); + } + + async removeLocationById( + id: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.removeLocationById( + id, + await this.#getOptions(options), + ); + } + + async getLocationByEntity( + entityRef: string | CompoundEntityRef, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.getLocationByEntity( + entityRef, + await this.#getOptions(options), + ); + } + + async validateEntity( + entity: Entity, + locationRef: string, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.validateEntity( + entity, + locationRef, + await this.#getOptions(options), + ); + } + + async #getOptions( + options: CatalogServiceRequestOptions, + ): Promise { + return this.#auth.getPluginRequestToken({ + onBehalfOf: options.credentials, + targetPluginId: 'catalog', + }); + } +} /** * The catalogService provides the catalog API. - * @alpha + * + * @public */ -export const catalogServiceRef = createServiceRef({ +export const catalogServiceRef = createServiceRef({ id: 'catalog-client', defaultFactory: async service => createServiceFactory({ service, deps: { + auth: coreServices.auth, discoveryApi: coreServices.discovery, }, - async factory({ discoveryApi }) { - return new CatalogClient({ discoveryApi }); + async factory({ auth, discoveryApi }) { + return new DefaultCatalogService({ + auth, + catalogApi: new CatalogClient({ discoveryApi }), + }); }, }), }); diff --git a/plugins/catalog-node/src/index.ts b/plugins/catalog-node/src/index.ts index 2dd8b5fd5a..9a95b708ba 100644 --- a/plugins/catalog-node/src/index.ts +++ b/plugins/catalog-node/src/index.ts @@ -23,3 +23,8 @@ export * from './api'; export * from './conversion'; export * from './processing'; +export { + catalogServiceRef, + type CatalogService, + type CatalogServiceRequestOptions, +} from './catalogService'; diff --git a/yarn.lock b/yarn.lock index 2624ff89d2..2e6aad1366 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5928,6 +5928,7 @@ __metadata: "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" + msw: ^1.0.0 languageName: unknown linkType: soft