diff --git a/.changeset/rotten-needles-relax.md b/.changeset/rotten-needles-relax.md new file mode 100644 index 0000000000..403e06146a --- /dev/null +++ b/.changeset/rotten-needles-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +The `catalogServiceRef` now have its own accompanying `CatalogService` interface, which also supports passing Backstage `credentials` objects in addition to tokens. 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..de5bea0811 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -3,20 +3,37 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AddLocationRequest } from '@backstage/catalog-client'; +import { AddLocationResponse } from '@backstage/catalog-client'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CatalogProcessor } from '@backstage/plugin-catalog-node'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; +import { CatalogRequestOptions } from '@backstage/catalog-client'; +import { CompoundEntityRef } from '@backstage/catalog-model'; import { EntitiesSearchFilter } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +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 { Location as Location_2 } from '@backstage/catalog-client'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import { Permission } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PermissionRuleParams } from '@backstage/plugin-permission-common'; import { PlaceholderResolver } from '@backstage/plugin-catalog-node'; +import { QueryEntitiesRequest } from '@backstage/catalog-client'; +import { QueryEntitiesResponse } from '@backstage/catalog-client'; import { ScmLocationAnalyzer } from '@backstage/plugin-catalog-node'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { ValidateEntityResponse } from '@backstage/catalog-client'; import { Validators } from '@backstage/catalog-model'; // @alpha (undocumented) @@ -95,8 +112,14 @@ export interface CatalogProcessingExtensionPoint { // @alpha (undocumented) export const catalogProcessingExtensionPoint: ExtensionPoint; +// Warning: (ae-forgotten-export) The symbol "CatalogService" needs to be exported by the entry point alpha.d.ts +// // @alpha -export const catalogServiceRef: ServiceRef; +export const catalogServiceRef: ServiceRef< + CatalogService, + 'plugin', + 'singleton' +>; // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog-node/src/catalogService.test.ts b/plugins/catalog-node/src/catalogService.test.ts index cf7b10009c..387817df98 100644 --- a/plugins/catalog-node/src/catalogService.test.ts +++ b/plugins/catalog-node/src/catalogService.test.ts @@ -14,12 +14,27 @@ * limitations under the License. */ -import { createBackendModule } from '@backstage/backend-plugin-api'; -import { startTestBackend } from '@backstage/backend-test-utils'; +import { + createBackendModule, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { + ServiceFactoryTester, + mockCredentials, + mockServices, + registerMswTestHooks, + startTestBackend, +} from '@backstage/backend-test-utils'; import { CatalogClient } from '@backstage/catalog-client'; +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({ @@ -41,4 +56,119 @@ 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() }, + ); + }); + + it('should call with token', async () => { + expect.assertions(1); + + server.use( + rest.get('http://localhost/api/catalog/entities', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.user.header(), + ); + 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( + {}, + { + token: mockCredentials.user.token(), + }, + ); + }); + + it('should call without credentials', async () => { + expect.assertions(1); + + server.use( + rest.get('http://localhost/api/catalog/entities', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBeFalsy(); + 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(); + }); }); diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts index 9a8ff55e1a..a018483ca4 100644 --- a/plugins/catalog-node/src/catalogService.ts +++ b/plugins/catalog-node/src/catalogService.ts @@ -18,23 +18,269 @@ import { createServiceFactory, createServiceRef, coreServices, + BackstageCredentials, + DiscoveryService, + 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 extends CatalogRequestOptions { + credentials?: BackstageCredentials; +} + +/** + * A version of the {@link CatalogApi | CatalogApi} that + * accepts backend credentials in addition to a token. + * + * @public + */ +export interface CatalogService extends CatalogApi { + getEntities( + request?: GetEntitiesRequest, + options?: CatalogServiceRequestOptions, + ): Promise; + + getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options?: CatalogServiceRequestOptions, + ): Promise; + + queryEntities( + request?: QueryEntitiesRequest, + 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 extends CatalogClient { + readonly #auth: AuthService; + + constructor({ + discoveryApi, + auth, + }: { + discoveryApi: DiscoveryService; + auth: AuthService; + }) { + super({ discoveryApi }); + this.#auth = auth; + } + + async getEntities( + request?: GetEntitiesRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getEntities(request, await this.#getOptions(options)); + } + + async getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getEntitiesByRefs(request, await this.#getOptions(options)); + } + + async queryEntities( + request?: QueryEntitiesRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.queryEntities(request, await this.#getOptions(options)); + } + + async getEntityAncestors( + request: GetEntityAncestorsRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getEntityAncestors(request, await this.#getOptions(options)); + } + + async getEntityByRef( + entityRef: string | CompoundEntityRef, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getEntityByRef(entityRef, await this.#getOptions(options)); + } + + async removeEntityByUid( + uid: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.removeEntityByUid(uid, await this.#getOptions(options)); + } + + async refreshEntity( + entityRef: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.refreshEntity(entityRef, await this.#getOptions(options)); + } + + async getEntityFacets( + request: GetEntityFacetsRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getEntityFacets(request, await this.#getOptions(options)); + } + + async getLocationById( + id: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getLocationById(id, await this.#getOptions(options)); + } + + async getLocationByRef( + locationRef: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getLocationByRef(locationRef, await this.#getOptions(options)); + } + + async addLocation( + location: AddLocationRequest, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.addLocation(location, await this.#getOptions(options)); + } + + async removeLocationById( + id: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.removeLocationById(id, await this.#getOptions(options)); + } + + async getLocationByEntity( + entityRef: string | CompoundEntityRef, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.getLocationByEntity( + entityRef, + await this.#getOptions(options), + ); + } + + async validateEntity( + entity: Entity, + locationRef: string, + options?: CatalogServiceRequestOptions, + ): Promise { + return super.validateEntity( + entity, + locationRef, + await this.#getOptions(options), + ); + } + + async #getOptions( + options?: CatalogServiceRequestOptions, + ): Promise { + if (options?.token) { + return options; + } + if (options?.credentials) { + return this.#auth.getPluginRequestToken({ + onBehalfOf: options.credentials, + targetPluginId: 'catalog', + }); + } + return options; + } +} /** * 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(deps) { + return new DefaultCatalogService(deps); }, }), }); diff --git a/yarn.lock b/yarn.lock index af909aa8c1..f25ccf3289 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5929,6 +5929,7 @@ __metadata: "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" + msw: ^1.0.0 languageName: unknown linkType: soft