[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:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Add API to get location by entity
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user