diff --git a/.changeset/large-moons-speak.md b/.changeset/large-moons-speak.md new file mode 100644 index 0000000000..efd94c5df3 --- /dev/null +++ b/.changeset/large-moons-speak.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +Add API to get location by entity diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 22feceabec..9fd9426a94 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -50,6 +50,10 @@ export interface CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise; + getLocationByEntity( + entityRef: CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise; getLocationById( id: string, options?: CatalogRequestOptions, @@ -120,6 +124,10 @@ export class CatalogClient implements CatalogApi { request: GetEntityFacetsRequest, options?: CatalogRequestOptions, ): Promise; + getLocationByEntity( + entityRef: CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise; getLocationById( id: string, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index b8212fde96..c36b3d96b5 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -617,6 +617,71 @@ describe('CatalogClient', () => { }); }); + describe('getLocationByEntity', () => { + const defaultResponse = { + data: { + kind: 'c', + namespace: 'ns', + name: 'n', + }, + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/locations/by-entity/c/ns/n`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + }); + + it('should locations from correct endpoint', async () => { + const response = await client.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { token }, + ); + expect(response).toEqual(defaultResponse); + }); + + it('forwards authorization token', async () => { + expect.assertions(1); + + server.use( + rest.get( + `${mockBaseUrl}/locations/by-entity/c/ns/n`, + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json(defaultResponse)); + }, + ), + ); + + await client.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { token }, + ); + }); + + it('skips authorization header if token is omitted', async () => { + expect.assertions(1); + + server.use( + rest.get( + `${mockBaseUrl}/locations/by-entity/c/ns/n`, + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBeNull(); + return res(ctx.json(defaultResponse)); + }, + ), + ); + + await client.getLocationByEntity({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + }); + describe('validateEntity', () => { it('returns valid false when validation fails', async () => { server.use( diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 0d6bd6b926..eb99fa7830 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -88,6 +88,18 @@ export class CatalogClient implements CatalogApi { ); } + /** + * {@inheritdoc CatalogApi.getLocationByEntity} + */ + async getLocationByEntity( + entityRef: CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise { + return await this.requestOptional( + await this.apiClient.getLocationByEntity({ path: entityRef }, options), + ); + } + /** * {@inheritdoc CatalogApi.getEntities} */ diff --git a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts index 65b8699370..8c78d4c721 100644 --- a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts +++ b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts @@ -461,6 +461,42 @@ export class DefaultApiClient { }); } + /** + * Get a location for entity. + * @param kind + * @param namespace + * @param name + */ + public async getLocationByEntity( + // @ts-ignore + request: { + path: { + kind: string; + namespace: string; + name: string; + }; + }, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/locations/by-entity/{kind}/{namespace}/{name}`; + + const uri = parser.parse(uriTemplate).expand({ + kind: request.path.kind, + namespace: request.path.namespace, + name: request.path.name, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'GET', + }); + } + /** * Get all locations */ diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 5285bda2f7..6d9f95b940 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -629,6 +629,17 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * Gets a location associated with an entity. + * + * @param entityRef - A reference to an entity + * @param options - Additional options + */ + getLocationByEntity( + entityRef: CompoundEntityRef, + options?: CatalogRequestOptions, + ): Promise; + /** * Validate entity and its location. * diff --git a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts index cf5db78203..267abce046 100644 --- a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.ts @@ -18,7 +18,11 @@ import { Location } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; -import { DbLocationsRow } from '../../database/tables'; +import { + DbLocationsRow, + DbRefreshStateRow, + DbSearchRow, +} from '../../database/tables'; import { getEntityLocationRef } from '../../processing/util'; import { EntityProvider, @@ -26,6 +30,11 @@ import { } from '@backstage/plugin-catalog-node'; import { locationSpecToLocationEntity } from '../../util/conversion'; import { LocationInput, LocationStore } from '../../service/types'; +import { + ANNOTATION_ORIGIN_LOCATION, + CompoundEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; @@ -111,6 +120,47 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { }); } + async getLocationByEntity(entityRef: CompoundEntityRef): Promise { + const entityRefString = stringifyEntityRef(entityRef); + + const [entity] = await this.db('refresh_state') + .where({ entity_ref: entityRefString }) + .select('entity_id') + .limit(1); + if (!entity) { + throw new NotFoundError(`found no entity for ref ${entityRefString}`); + } + + const [locationKeyValue] = await this.db('search') + .where({ + entity_id: entity.entity_id, + key: `metadata.annotations.${ANNOTATION_ORIGIN_LOCATION}`, + }) + .select('value') + .limit(1); + if (!locationKeyValue) { + throw new NotFoundError( + `found no origin annotation for ref ${entityRefString}`, + ); + } + const [type, ...rest] = locationKeyValue.value?.split(':') ?? []; + const target = rest.join(':'); + + // const kind, target = split[0], split[1]; + const [location] = await this.db('locations') + .where({ type, target }) + .select() + .limit(1); + + // select * from locations where type = 'split(prev)[0]' + if (!location) { + throw new NotFoundError( + `Found no location with type ${type} and target ${target}`, + ); + } + return location; + } + private get connection(): EntityProviderConnection { if (!this._connection) { throw new Error('location store is not initialized'); diff --git a/plugins/catalog-backend/src/schema/openapi.generated.ts b/plugins/catalog-backend/src/schema/openapi.generated.ts index fe64c75373..102558c9f6 100644 --- a/plugins/catalog-backend/src/schema/openapi.generated.ts +++ b/plugins/catalog-backend/src/schema/openapi.generated.ts @@ -1420,6 +1420,62 @@ export const spec = { ], }, }, + '/locations/by-entity/{kind}/{namespace}/{name}': { + get: { + operationId: 'getLocationByEntity', + description: 'Get a location for entity.', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Location', + }, + }, + }, + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + parameters: [ + { + in: 'path', + name: 'kind', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'namespace', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'name', + required: true, + allowReserved: true, + schema: { + type: 'string', + }, + }, + ], + }, + }, '/analyze-location': { post: { operationId: 'AnalyzeLocation', diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index d3ef718286..fa0f7ec371 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1057,6 +1057,41 @@ paths: allowReserved: true schema: type: string + /locations/by-entity/{kind}/{namespace}/{name}: + get: + operationId: getLocationByEntity + description: Get a location for entity. + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/Location' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + parameters: + - in: path + name: kind + required: true + allowReserved: true + schema: + type: string + - in: path + name: namespace + required: true + allowReserved: true + schema: + type: string + - in: path + name: name + required: true + allowReserved: true + schema: + type: string /analyze-location: post: operationId: AnalyzeLocation diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index e613f25cb2..c2ffee8003 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -24,6 +24,7 @@ describe('AuthorizedLocationService', () => { listLocations: jest.fn(), getLocation: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const fakePermissionApi = { authorize: jest.fn(), @@ -144,4 +145,36 @@ describe('AuthorizedLocationService', () => { ).rejects.toThrow(NotAllowedError); }); }); + + describe('getLocationByEntity', () => { + it('calls underlying service to get location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + await service.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'Bearer authtoken', + }, + ); + + expect(fakeLocationService.getLocationByEntity).toHaveBeenCalledWith({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + + it('throws error on DENY', async () => { + mockDeny(); + const service = createService(); + + await expect(() => + service.getLocationByEntity( + { kind: 'c', namespace: 'ns', name: 'n' }, + { authorizationToken: 'Bearer authtoken' }, + ), + ).rejects.toThrow(NotFoundError); + }); + }); }); diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 68bb1553a8..fe4547a8fc 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -15,7 +15,7 @@ */ import { Location } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { NotAllowedError, NotFoundError } from '@backstage/errors'; import { catalogLocationCreatePermission, @@ -111,4 +111,21 @@ export class AuthorizedLocationService implements LocationService { return this.locationService.deleteLocation(id); } + + async getLocationByEntity( + entityRef: CompoundEntityRef, + options?: { authorizationToken?: string | undefined } | undefined, + ): Promise { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationReadPermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + throw new NotFoundError(); + } + return this.locationService.getLocationByEntity(entityRef); + } } diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 6d70923556..1c17f271fb 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -28,6 +28,7 @@ describe('DefaultLocationServiceTest', () => { createLocation: jest.fn(), listLocations: jest.fn(), getLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const locationService = new DefaultLocationService(store, orchestrator); @@ -347,4 +348,19 @@ describe('DefaultLocationServiceTest', () => { expect(store.getLocation).toHaveBeenCalledWith('123'); }); }); + + describe('getLocationByEntity', () => { + it('should call locationStore.getLocationByEntity', async () => { + await locationService.getLocationByEntity({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + expect(store.getLocationByEntity).toHaveBeenCalledWith({ + kind: 'c', + namespace: 'ns', + name: 'n', + }); + }); + }); }); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 6603ebc08b..5ce9456212 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -19,6 +19,7 @@ import { ANNOTATION_LOCATION, ANNOTATION_ORIGIN_LOCATION, stringifyEntityRef, + CompoundEntityRef, } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; import { CatalogProcessingOrchestrator } from '../processing/types'; @@ -68,6 +69,10 @@ export class DefaultLocationService implements LocationService { return this.store.deleteLocation(id); } + getLocationByEntity(entityRef: CompoundEntityRef): Promise { + return this.store.getLocationByEntity(entityRef); + } + private async processEntities( unprocessedEntities: DeferredEntity[], ): Promise { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 05ebf6e1a9..e08d3b1676 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -63,6 +63,7 @@ describe('createRouter readonly disabled', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; refreshService = { refresh: jest.fn() }; orchestrator = { process: jest.fn() }; @@ -621,6 +622,36 @@ describe('createRouter readonly disabled', () => { }); }); + describe('GET /locations/by-entity/:kind/:namespace/:name', () => { + it('happy path: gets location by entity ref', async () => { + const location: Location = { + id: 'foo', + type: 'url', + target: 'example.com', + }; + locationService.getLocationByEntity.mockResolvedValueOnce(location); + + const response = await request(app) + .get('/locations/by-entity/c/ns/n') + .set('authorization', 'Bearer someauthtoken'); + + expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); + expect(locationService.getLocationByEntity).toHaveBeenCalledWith( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'someauthtoken', + }, + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id: 'foo', + target: 'example.com', + type: 'url', + }); + }); + }); + describe('POST /validate-entity', () => { describe('valid entity', () => { it('returns 200', async () => { @@ -753,6 +784,7 @@ describe('createRouter readonly enabled', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; const router = await createRouter({ entitiesCatalog, @@ -911,6 +943,36 @@ describe('createRouter readonly enabled', () => { expect(response.status).toEqual(403); }); }); + + describe('GET /locations/by-entity/:kind/:namespace/:name', () => { + it('happy path: gets location by entity ref', async () => { + const location: Location = { + id: 'foo', + type: 'url', + target: 'example.com', + }; + locationService.getLocationByEntity.mockResolvedValueOnce(location); + + const response = await request(app) + .get('/locations/by-entity/c/ns/n') + .set('authorization', 'Bearer someauthtoken'); + + expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); + expect(locationService.getLocationByEntity).toHaveBeenCalledWith( + { kind: 'c', namespace: 'ns', name: 'n' }, + { + authorizationToken: 'someauthtoken', + }, + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id: 'foo', + target: 'example.com', + type: 'url', + }); + }); + }); }); describe('NextRouter permissioning', () => { @@ -944,6 +1006,7 @@ describe('NextRouter permissioning', () => { createLocation: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), }; refreshService = { refresh: jest.fn() }; const router = await createRouter({ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 30c1fd8e3f..70b15c640d 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -290,6 +290,18 @@ export async function createRouter( ), }); res.status(204).end(); + }) + .get('/locations/by-entity/:kind/:namespace/:name', async (req, res) => { + const { kind, namespace, name } = req.params; + const output = await locationService.getLocationByEntity( + { kind, namespace, name }, + { + authorizationToken: getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ), + }, + ); + res.status(200).json(output); }); } diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index f6cfa2e783..db4dfc7f34 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; /** @@ -48,6 +48,10 @@ export interface LocationService { id: string, options?: { authorizationToken?: string }, ): Promise; + getLocationByEntity( + entityRef: CompoundEntityRef, + options?: { authorizationToken?: string }, + ): Promise; } /** @@ -82,4 +86,5 @@ export interface LocationStore { listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; + getLocationByEntity(entityRef: CompoundEntityRef): Promise; }