[CatalogBackend] Add API to get location by entity

Introduce API route to get location by entity. Solves #21826.

Signed-off-by: Rickard Dybeck <dybeck@spotify.com>
This commit is contained in:
Rickard Dybeck
2023-12-21 12:01:16 -05:00
parent 8198e5a5db
commit 43dad25429
16 changed files with 433 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-backend': minor
---
Add API to get location by entity
+8
View File
@@ -50,6 +50,10 @@ export interface CatalogApi {
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse>;
getLocationByEntity(
entityRef: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Location_2 | undefined>;
getLocationById(
id: string,
options?: CatalogRequestOptions,
@@ -120,6 +124,10 @@ export class CatalogClient implements CatalogApi {
request: GetEntityFacetsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityFacetsResponse>;
getLocationByEntity(
entityRef: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Location_2 | undefined>;
getLocationById(
id: string,
options?: CatalogRequestOptions,
@@ -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(
@@ -88,6 +88,18 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* {@inheritdoc CatalogApi.getLocationByEntity}
*/
async getLocationByEntity(
entityRef: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Location | undefined> {
return await this.requestOptional(
await this.apiClient.getLocationByEntity({ path: entityRef }, options),
);
}
/**
* {@inheritdoc CatalogApi.getEntities}
*/
@@ -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<TypedResponse<Location>> {
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
*/
+11
View File
@@ -629,6 +629,17 @@ export interface CatalogApi {
options?: CatalogRequestOptions,
): Promise<void>;
/**
* Gets a location associated with an entity.
*
* @param entityRef - A reference to an entity
* @param options - Additional options
*/
getLocationByEntity(
entityRef: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Location | undefined>;
/**
* Validate entity and its location.
*
@@ -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<Location> {
const entityRefString = stringifyEntityRef(entityRef);
const [entity] = await this.db<DbRefreshStateRow>('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<DbSearchRow>('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<DbLocationsRow>('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');
@@ -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',
@@ -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
@@ -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);
});
});
});
@@ -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<Location> {
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);
}
}
@@ -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',
});
});
});
});
@@ -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<Location> {
return this.store.getLocationByEntity(entityRef);
}
private async processEntities(
unprocessedEntities: DeferredEntity[],
): Promise<Entity[]> {
@@ -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({
@@ -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);
});
}
+6 -1
View File
@@ -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<void>;
getLocationByEntity(
entityRef: CompoundEntityRef,
options?: { authorizationToken?: string },
): Promise<Location>;
}
/**
@@ -82,4 +86,5 @@ export interface LocationStore {
listLocations(): Promise<Location[]>;
getLocation(id: string): Promise<Location>;
deleteLocation(id: string): Promise<void>;
getLocationByEntity(entityRef: CompoundEntityRef): Promise<Location>;
}