feat(catalog): expose entityRef on Location type and add PUT /locations/:id

- Add `entityRef` field to all Location API responses, carrying the stable
  entity ref (e.g. `location:default/generated-<sha1hex>`) that was
  already persisted to the `location_entity_ref` column.
- Make `entityRef` filterable via `POST /locations/by-query`.
- Add `PUT /locations/:id` endpoint that updates the `type`/`target` of
  an existing location and issues the corresponding delta mutation so the
  catalog entity is updated in-place without changing its entity ref.
- Wire `updateLocation` through `CatalogApi`, `CatalogService`,
  `CatalogClient`, `LocationService`, `LocationStore`, and their
  implementations and mocks.

Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
This commit is contained in:
Fredrik Adelöw
2026-04-07 16:36:06 +02:00
parent 587981973c
commit c384fff709
34 changed files with 615 additions and 48 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Location responses now include an `entityRef` field with the stable entity reference for each location. The `entityRef` field is also filterable via `POST /locations/by-query`. Added `PUT /locations/:id` endpoint for updating the type and target of an existing location.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
Added `entityRef` field to the `Location` type, exposing the stable entity reference for each registered location. Added `updateLocation` method to `CatalogApi` for updating the type and target of an existing location.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': minor
---
Added `updateLocation` method to `CatalogService` for updating the type and target of an existing location.
@@ -84,6 +84,14 @@ export class InMemoryCatalogClient implements CatalogApi {
_request?: QueryLocationsInitialRequest,
): AsyncIterable<Location_2[]>;
// (undocumented)
updateLocation(
_id: string,
_location: {
type?: string;
target: string;
},
): Promise<Location_2>;
// (undocumented)
validateEntity(
_entity: Entity,
_locationRef: string,
+17
View File
@@ -102,6 +102,14 @@ export interface CatalogApi {
request?: QueryLocationsInitialRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
updateLocation(
id: string,
location: {
type?: string;
target: string;
},
options?: CatalogRequestOptions,
): Promise<Location_2>;
validateEntity(
entity: Entity,
locationRef: string,
@@ -196,6 +204,14 @@ export class CatalogClient implements CatalogApi {
request?: QueryLocationsInitialRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
updateLocation(
id: string,
location: {
type?: string;
target: string;
},
options?: CatalogRequestOptions,
): Promise<Location_2>;
validateEntity(
entity: Entity,
locationRef: string,
@@ -307,6 +323,7 @@ type Location_2 = {
id: string;
type: string;
target: string;
entityRef: string;
};
export { Location_2 as Location };
@@ -1107,6 +1107,7 @@ describe('CatalogClient', () => {
id: '42',
type: 'url',
target: 'https://example.com',
entityRef: 'location:default/generated-42',
},
},
{
@@ -1114,6 +1115,7 @@ describe('CatalogClient', () => {
id: '43',
type: 'url',
target: 'https://example.com',
entityRef: 'location:default/generated-43',
},
},
] satisfies GetLocations200ResponseInner[];
@@ -612,6 +612,27 @@ export class CatalogClient implements CatalogApi {
.find(l => locationRef === stringifyLocationRef(l));
}
/**
* {@inheritdoc CatalogApi.updateLocation}
*/
async updateLocation(
id: string,
location: { type?: string; target: string },
options?: CatalogRequestOptions,
): Promise<Location> {
const { type = 'url', target } = location;
const response = await this.apiClient.updateLocation(
{ path: { id }, body: { type, target } },
options,
);
if (response.status !== 200) {
throw await ResponseError.fromResponse(response);
}
return response.json();
}
/**
* {@inheritdoc CatalogApi.removeLocationById}
*/
@@ -40,6 +40,7 @@ import { CreateLocationRequest } from '../models/CreateLocationRequest.model';
import { GetLocations200ResponseInner } from '../models/GetLocations200ResponseInner.model';
import { GetLocationsByQueryRequest } from '../models/GetLocationsByQueryRequest.model';
import { Location } from '../models/Location.model';
import { LocationInput } from '../models/LocationInput.model';
import { LocationsQueryResponse } from '../models/LocationsQueryResponse.model';
/**
@@ -217,6 +218,15 @@ export type GetLocations = {};
export type GetLocationsByQuery = {
body: GetLocationsByQueryRequest;
};
/**
* @public
*/
export type UpdateLocation = {
path: {
id: string;
};
body: LocationInput;
};
/**
* @public
@@ -747,4 +757,32 @@ export class DefaultApiClient {
body: JSON.stringify(request.body),
});
}
/**
* Update the type and target of an existing location by id.
* @param id -
* @param locationInput -
*/
public async updateLocation(
// @ts-ignore
request: UpdateLocation,
options?: RequestOptions,
): Promise<TypedResponse<Location>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/locations/{id}`;
const uri = parser.parse(uriTemplate).expand({
id: request.path.id,
});
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'PUT',
body: JSON.stringify(request.body),
});
}
}
@@ -26,4 +26,8 @@ export interface Location {
target: string;
type: string;
id: string;
/**
* The entity ref of the corresponding Location kind entity, e.g. location:default/generated-<sha1hex>.
*/
entityRef: string;
}
@@ -584,6 +584,13 @@ export class InMemoryCatalogClient implements CatalogApi {
throw new NotImplementedError('Method not implemented.');
}
async updateLocation(
_id: string,
_location: { type?: string; target: string },
): Promise<Location> {
throw new NotImplementedError('Method not implemented.');
}
async getLocationByEntity(
_entityRef: string | CompoundEntityRef,
): Promise<Location | undefined> {
+15
View File
@@ -372,6 +372,8 @@ export type Location = {
id: string;
type: string;
target: string;
/** The entity ref of the corresponding Location kind entity, e.g. `location:default/generated-<sha1hex>`. */
entityRef: string;
};
/**
@@ -829,6 +831,19 @@ export interface CatalogApi {
options?: CatalogRequestOptions,
): Promise<void>;
/**
* Updates the type and target of an existing registered location.
*
* @param id - The location ID to update
* @param location - The new type and target for the location
* @param options - Additional options
*/
updateLocation(
id: string,
location: { type?: string; target: string },
options?: CatalogRequestOptions,
): Promise<Location>;
/**
* Gets a location associated with an entity.
*
@@ -58,7 +58,12 @@ describe('DefaultApiExplorerPage', () => {
],
}),
getLocationByRef: () =>
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
Promise.resolve({
id: 'id',
type: 'url',
target: 'url',
entityRef: 'location:default/generated-id',
}),
getEntitiesByRefs: () => Promise.resolve({ items: [] }),
getEntityFacets: async () => ({
facets: { 'relations.ownedBy': [] },
@@ -59,6 +59,7 @@ describe('GithubLocationAnalyzer', () => {
id: 'test',
target: location.target,
type: location.type ?? 'url',
entityRef: 'location:default/generated-test',
},
exists: false,
entities: [
@@ -321,6 +321,8 @@ describe('DefaultLocationStore', () => {
id: locationId,
type: 'url',
target: 'https://example.com',
entityRef:
'location:default/generated-7ade06d301ec98b80352203e9969e7640dc618b8',
});
await expect(
@@ -848,23 +850,31 @@ describe('DefaultLocationStore', () => {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/packages/catalog-model/catalog-info.yaml',
entityRef:
'location:default/generated-0ecbc46527aae891650cc1ad4eb17e15391fa96a',
};
const l2 = {
id: '00000000-0000-0000-0000-000000000002',
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/plugins/catalog/catalog-info.yaml',
entityRef:
'location:default/generated-888dd2d9775aaf5b722ebdece23c21e2541e90ce',
};
const l3 = {
id: '00000000-0000-0000-0000-000000000003',
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/plugins/scaffolder/catalog-info.yaml',
entityRef:
'location:default/generated-d4255ab29a8321cb6eae30cee45969a272e1206e',
};
const l4 = {
id: '00000000-0000-0000-0000-000000000004',
type: 'file',
target: '/tmp/catalog-info.yaml',
entityRef:
'location:default/generated-d14ac9f97f7d042d45b2130dcf3d087e000f07f2',
};
it.each(databases.eachSupportedId())(
@@ -878,7 +888,9 @@ describe('DefaultLocationStore', () => {
await knex<DbLocationsRow>('locations').delete();
for (const location of locations) {
await knex<DbLocationsRow>('locations').insert({
...location,
id: location.id,
type: location.type,
target: location.target,
location_entity_ref: computeLocationEntityRef(
location.type,
location.target,
@@ -133,15 +133,23 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
});
}
return { id: location.id, type: location.type, target: location.target };
return {
id: location.id,
type: location.type,
target: location.target,
entityRef: location.location_entity_ref,
};
}
async listLocations(): Promise<Location[]> {
return (await this.locations()).map(({ id, type, target }) => ({
id,
type,
target,
}));
return (await this.locations()).map(
({ id, type, target, location_entity_ref }) => ({
id,
type,
target,
entityRef: location_entity_ref,
}),
);
}
async queryLocations(options: {
@@ -179,6 +187,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
id: item.id,
target: item.target,
type: item.type,
entityRef: item.location_entity_ref,
})),
totalItems: Number(count),
};
@@ -192,8 +201,60 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
if (!items.length) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
const { id: rowId, type, target } = items[0];
return { id: rowId, type, target };
const { id: rowId, type, target, location_entity_ref } = items[0];
return { id: rowId, type, target, entityRef: location_entity_ref };
}
async updateLocation(id: string, location: LocationInput): Promise<Location> {
if (!this.connection) {
throw new Error('location store is not initialized');
}
const updated = await this.db.transaction(async tx => {
const [old] = await tx<DbLocationsRow>('locations')
.where({ id })
.select();
if (!old) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
await tx<DbLocationsRow>('locations').where({ id }).update({
type: location.type,
target: location.target,
});
const [row] = await tx<DbLocationsRow>('locations')
.where({ id })
.select();
return { old, row };
});
const oldEntity = locationSpecToLocationEntity({
location: updated.old,
locationEntityRef: updated.old.location_entity_ref,
});
const newEntity = locationSpecToLocationEntity({
location: updated.row,
locationEntityRef: updated.row.location_entity_ref,
});
await this.connection.applyMutation({
type: 'delta',
added: [
{ entity: newEntity, locationKey: getEntityLocationRef(newEntity) },
],
removed: [
{ entity: oldEntity, locationKey: getEntityLocationRef(oldEntity) },
],
});
return {
id: updated.row.id,
type: updated.row.type,
target: updated.row.target,
entityRef: updated.row.location_entity_ref,
};
}
async deleteLocation(id: string): Promise<void> {
@@ -264,6 +325,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
id: locationRow.id,
type: locationRow.type,
target: locationRow.target,
entityRef: locationRow.location_entity_ref,
};
}
@@ -692,13 +754,15 @@ function applyLocationFilterToQuery(
for (const [keyAnyCase, value] of entries) {
const key = keyAnyCase.toLocaleLowerCase('en-US');
if (!['id', 'type', 'target'].includes(key)) {
if (!['id', 'type', 'target', 'entityref'].includes(key)) {
throw new InputError(
`Invalid filter predicate, expected key to be 'id', 'type', or 'target', got '${keyAnyCase}'`,
`Invalid filter predicate, expected key to be 'id', 'type', 'target', or 'entityRef', got '${keyAnyCase}'`,
);
}
result = applyFilterValueToQuery(clientType, result, key, value);
// Map the API field name to the underlying column name
const column = key === 'entityref' ? 'location_entity_ref' : key;
result = applyFilterValueToQuery(clientType, result, column, value);
}
return result;
@@ -571,10 +571,14 @@ components:
type: string
id:
type: string
entityRef:
type: string
description: The entity ref of the corresponding Location kind entity, e.g. location:default/generated-<sha1hex>.
required:
- target
- type
- id
- entityRef
description: Entity location for a specific entity.
additionalProperties: false
LocationSpec:
@@ -1377,6 +1381,35 @@ paths:
required: true
schema:
type: string
put:
operationId: UpdateLocation
tags:
- Locations
description: Update the type and target of an existing location by id.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LocationInput'
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/Location'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
parameters:
- in: path
name: id
required: true
schema:
type: string
delete:
operationId: DeleteLocation
tags:
@@ -156,6 +156,7 @@ import { CreateLocationRequest } from '../models/CreateLocationRequest.model';
import { GetLocations200ResponseInner } from '../models/GetLocations200ResponseInner.model';
import { GetLocationsByQueryRequest } from '../models/GetLocationsByQueryRequest.model';
import { Location } from '../models/Location.model';
import { LocationInput } from '../models/LocationInput.model';
import { LocationsQueryResponse } from '../models/LocationsQueryResponse.model';
/**
@@ -218,6 +219,16 @@ export type GetLocationsByQuery = {
body: GetLocationsByQueryRequest;
response: LocationsQueryResponse | Error;
};
/**
* @public
*/
export type UpdateLocation = {
path: {
id: string;
};
body: LocationInput;
response: Location | Error;
};
export type EndpointMap = {
'#_delete|/entities/by-uid/{uid}': DeleteEntityByUid;
@@ -257,4 +268,6 @@ export type EndpointMap = {
'#get|/locations': GetLocations;
'#post|/locations/by-query': GetLocationsByQuery;
'#put|/locations/{id}': UpdateLocation;
};
@@ -26,4 +26,8 @@ export interface Location {
target: string;
type: string;
id: string;
/**
* The entity ref of the corresponding Location kind entity, e.g. location:default/generated-<sha1hex>.
*/
entityRef: string;
}
@@ -529,8 +529,13 @@ export const spec = {
id: {
type: 'string',
},
entityRef: {
type: 'string',
description:
'The entity ref of the corresponding Location kind entity, e.g. location:default/generated-<sha1hex>.',
},
},
required: ['target', 'type', 'id'],
required: ['target', 'type', 'id', 'entityRef'],
description: 'Entity location for a specific entity.',
additionalProperties: false,
},
@@ -1654,6 +1659,53 @@ export const spec = {
},
],
},
put: {
operationId: 'UpdateLocation',
tags: ['Locations'],
description:
'Update the type and target of an existing location by id.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/LocationInput',
},
},
},
},
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Location',
},
},
},
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
{
JWT: [],
},
],
parameters: [
{
in: 'path',
name: 'id',
required: true,
schema: {
type: 'string',
},
},
],
},
delete: {
operationId: 'DeleteLocation',
tags: ['Locations'],
@@ -25,6 +25,7 @@ describe('AuthorizedLocationService', () => {
listLocations: jest.fn(),
queryLocations: jest.fn(),
getLocation: jest.fn(),
updateLocation: jest.fn(),
deleteLocation: jest.fn(),
getLocationByEntity: jest.fn(),
};
@@ -123,6 +123,25 @@ export class AuthorizedLocationService implements LocationService {
return this.locationService.getLocation(id, options);
}
async updateLocation(
id: string,
location: LocationInput,
options: { credentials: BackstageCredentials },
): Promise<Location> {
const authorizationResponse = (
await this.permissionApi.authorize(
[{ permission: catalogLocationCreatePermission }],
{ credentials: options.credentials },
)
)[0];
if (authorizationResponse.result === AuthorizeResult.DENY) {
throw new NotAllowedError();
}
return this.locationService.updateLocation(id, location, options);
}
async deleteLocation(
id: string,
options: { credentials: BackstageCredentials },
@@ -29,6 +29,7 @@ describe('DefaultLocationServiceTest', () => {
listLocations: jest.fn(),
queryLocations: jest.fn(),
getLocation: jest.fn(),
updateLocation: jest.fn(),
getLocationByEntity: jest.fn(),
};
const locationService = new DefaultLocationService(store, orchestrator);
@@ -144,7 +145,11 @@ describe('DefaultLocationServiceTest', () => {
});
store.listLocations.mockResolvedValueOnce([
{ id: '137', ...locationSpec },
{
id: '137',
...locationSpec,
entityRef: 'location:default/generated-137',
},
]);
const result = await locationService.createLocation(
@@ -226,7 +231,12 @@ describe('DefaultLocationServiceTest', () => {
});
store.listLocations.mockResolvedValueOnce([
{ id: '987', type: 'url', target: 'https://example.com' },
{
id: '987',
type: 'url',
target: 'https://example.com',
entityRef: 'location:default/generated-987',
},
]);
const result = await locationService.createLocation(
@@ -245,6 +255,7 @@ describe('DefaultLocationServiceTest', () => {
store.createLocation.mockResolvedValue({
...locationSpec,
id: '123',
entityRef: 'location:default/generated-123',
});
await expect(
@@ -255,6 +266,7 @@ describe('DefaultLocationServiceTest', () => {
id: '123',
target: 'https://backstage.io/catalog-info.yaml',
type: 'url',
entityRef: 'location:default/generated-123',
},
});
expect(store.createLocation).toHaveBeenCalledWith(
@@ -275,6 +287,7 @@ describe('DefaultLocationServiceTest', () => {
store.createLocation.mockResolvedValue({
...locationSpec,
id: '123',
entityRef: 'location:default/generated-123',
});
const locationServiceAllowingUnknownType = new DefaultLocationService(
@@ -293,6 +306,7 @@ describe('DefaultLocationServiceTest', () => {
id: '123',
target: 'https://backstage.io/catalog-info.yaml',
type: 'unknown',
entityRef: 'location:default/generated-123',
},
});
expect(store.createLocation).toHaveBeenCalledWith(
@@ -325,6 +339,7 @@ describe('DefaultLocationServiceTest', () => {
store.createLocation.mockResolvedValueOnce({
id: 'existing-id',
...locationSpec,
entityRef: 'location:default/generated-existing-id',
});
const result = await locationService.createLocation(locationSpec, false, {
@@ -333,7 +348,11 @@ describe('DefaultLocationServiceTest', () => {
});
expect(result).toEqual({
location: { id: 'existing-id', ...locationSpec },
location: {
id: 'existing-id',
...locationSpec,
entityRef: 'location:default/generated-existing-id',
},
entities: [],
});
expect(store.createLocation).toHaveBeenCalledWith(locationSpec, {
@@ -25,7 +25,10 @@ import {
import { Location } from '@backstage/catalog-client';
import { CatalogProcessingOrchestrator } from '../processing/types';
import { LocationInput, LocationService, LocationStore } from './types';
import { locationSpecToMetadataName } from '../util/conversion';
import {
computeLocationEntityRef,
locationSpecToMetadataName,
} from '../util/conversion';
import { InputError } from '@backstage/errors';
import { DeferredEntity } from '@backstage/plugin-catalog-node';
import { FilterPredicate } from '@backstage/filter-predicates';
@@ -99,6 +102,15 @@ export class DefaultLocationService implements LocationService {
getLocation(id: string): Promise<Location> {
return this.store.getLocation(id);
}
updateLocation(
id: string,
location: LocationInput,
_options: { credentials: BackstageCredentials },
): Promise<Location> {
return this.store.updateLocation(id, location);
}
deleteLocation(id: string): Promise<void> {
return this.store.deleteLocation(id);
}
@@ -182,7 +194,11 @@ export class DefaultLocationService implements LocationService {
return {
exists: await existsPromise,
location: { ...spec, id: `${spec.type}:${spec.target}` },
location: {
...spec,
id: `${spec.type}:${spec.target}`,
entityRef: computeLocationEntityRef(spec.type, spec.target),
},
entities,
};
}
@@ -77,6 +77,7 @@ describe('createRouter readonly disabled', () => {
createLocation: jest.fn(),
queryLocations: jest.fn(),
listLocations: jest.fn(),
updateLocation: jest.fn(),
deleteLocation: jest.fn(),
getLocationByEntity: jest.fn(),
};
@@ -800,7 +801,12 @@ describe('createRouter readonly disabled', () => {
describe('GET /locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [
{ id: 'foo', type: 'url', target: 'example.com' },
{
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
},
];
locationService.listLocations.mockResolvedValueOnce(locations);
@@ -811,7 +817,14 @@ describe('createRouter readonly disabled', () => {
});
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ data: { id: 'foo', target: 'example.com', type: 'url' } },
{
data: {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
},
},
]);
});
});
@@ -822,6 +835,7 @@ describe('createRouter readonly disabled', () => {
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
};
locationService.getLocation.mockResolvedValueOnce(location);
@@ -836,6 +850,7 @@ describe('createRouter readonly disabled', () => {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
});
});
});
@@ -863,7 +878,11 @@ describe('createRouter readonly disabled', () => {
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
entities: [],
});
@@ -879,7 +898,11 @@ describe('createRouter readonly disabled', () => {
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
}),
);
});
@@ -891,7 +914,11 @@ describe('createRouter readonly disabled', () => {
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
entities: [],
});
@@ -907,7 +934,11 @@ describe('createRouter readonly disabled', () => {
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
}),
);
});
@@ -920,6 +951,7 @@ describe('createRouter readonly disabled', () => {
createLocation: jest.fn(),
queryLocations: jest.fn(),
listLocations: jest.fn(),
updateLocation: jest.fn(),
deleteLocation: jest.fn(),
getLocationByEntity: jest.fn(),
};
@@ -943,8 +975,18 @@ describe('createRouter readonly disabled', () => {
it('happy path: queries locations without pagination', async () => {
const locations: Location[] = [
{ id: 'loc1', type: 'url', target: 'https://example.com/a' },
{ id: 'loc2', type: 'url', target: 'https://example.com/b' },
{
id: 'loc1',
type: 'url',
target: 'https://example.com/a',
entityRef: 'location:default/generated-loc1',
},
{
id: 'loc2',
type: 'url',
target: 'https://example.com/b',
entityRef: 'location:default/generated-loc2',
},
];
locationService.queryLocations.mockResolvedValueOnce({
items: locations,
@@ -970,7 +1012,12 @@ describe('createRouter readonly disabled', () => {
it('happy path: queries locations with filter', async () => {
const locations: Location[] = [
{ id: 'loc1', type: 'url', target: 'https://example.com/a' },
{
id: 'loc1',
type: 'url',
target: 'https://example.com/a',
entityRef: 'location:default/generated-loc1',
},
];
locationService.queryLocations.mockResolvedValueOnce({
items: locations,
@@ -997,9 +1044,24 @@ describe('createRouter readonly disabled', () => {
it('returns nextCursor when more results exist', async () => {
const locations: Location[] = [
{ id: 'loc1', type: 'url', target: 'https://example.com/a' },
{ id: 'loc2', type: 'url', target: 'https://example.com/b' },
{ id: 'loc3', type: 'url', target: 'https://example.com/c' },
{
id: 'loc1',
type: 'url',
target: 'https://example.com/a',
entityRef: 'location:default/generated-loc1',
},
{
id: 'loc2',
type: 'url',
target: 'https://example.com/b',
entityRef: 'location:default/generated-loc2',
},
{
id: 'loc3',
type: 'url',
target: 'https://example.com/c',
entityRef: 'location:default/generated-loc3',
},
];
locationService.queryLocations.mockResolvedValueOnce({
items: locations,
@@ -1032,7 +1094,12 @@ describe('createRouter readonly disabled', () => {
it('uses cursor for pagination', async () => {
const locations: Location[] = [
{ id: 'loc3', type: 'url', target: 'https://example.com/c' },
{
id: 'loc3',
type: 'url',
target: 'https://example.com/c',
entityRef: 'location:default/generated-loc3',
},
];
locationService.queryLocations.mockResolvedValueOnce({
items: locations,
@@ -1115,6 +1182,7 @@ describe('createRouter readonly disabled', () => {
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
};
locationService.getLocationByEntity.mockResolvedValueOnce(location);
@@ -1132,6 +1200,7 @@ describe('createRouter readonly disabled', () => {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
});
});
});
@@ -1367,6 +1436,7 @@ describe('createRouter readonly and raw json enabled', () => {
createLocation: jest.fn(),
listLocations: jest.fn(),
queryLocations: jest.fn(),
updateLocation: jest.fn(),
deleteLocation: jest.fn(),
getLocationByEntity: jest.fn(),
};
@@ -1427,7 +1497,12 @@ describe('createRouter readonly and raw json enabled', () => {
describe('GET /locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [
{ id: 'foo', type: 'url', target: 'example.com' },
{
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
},
];
locationService.listLocations.mockResolvedValueOnce(locations);
@@ -1439,7 +1514,14 @@ describe('createRouter readonly and raw json enabled', () => {
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ data: { id: 'foo', target: 'example.com', type: 'url' } },
{
data: {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
},
},
]);
});
});
@@ -1450,6 +1532,7 @@ describe('createRouter readonly and raw json enabled', () => {
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
};
locationService.getLocation.mockResolvedValueOnce(location);
@@ -1464,6 +1547,7 @@ describe('createRouter readonly and raw json enabled', () => {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
});
});
});
@@ -1492,7 +1576,11 @@ describe('createRouter readonly and raw json enabled', () => {
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
entities: [],
});
@@ -1508,7 +1596,11 @@ describe('createRouter readonly and raw json enabled', () => {
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
location: {
id: 'a',
...spec,
entityRef: 'location:default/generated-a',
},
}),
);
});
@@ -1528,6 +1620,7 @@ describe('createRouter readonly and raw json enabled', () => {
id: 'foo',
type: 'url',
target: 'example.com',
entityRef: 'location:default/generated-foo',
};
locationService.getLocationByEntity.mockResolvedValueOnce(location);
@@ -1545,6 +1638,7 @@ describe('createRouter readonly and raw json enabled', () => {
id: 'foo',
target: 'example.com',
type: 'url',
entityRef: 'location:default/generated-foo',
});
});
});
@@ -1610,26 +1704,36 @@ describe('POST /locations/by-query works end to end', () => {
id: '00000000-0000-0000-0000-000000000001',
type: 'url',
target: 'https://example.com/a.yaml',
entityRef:
'location:default/generated-17fb6f13ffb6251438be1e4f37c6482b83ede45c',
},
{
id: '00000000-0000-0000-0000-000000000002',
type: 'url',
target: 'https://example.com/b.yaml',
entityRef:
'location:default/generated-50316008e1cdf0dfc8740b7691661615a3588ae5',
},
{
id: '00000000-0000-0000-0000-000000000003',
type: 'url',
target: 'https://example.com/c.yaml',
entityRef:
'location:default/generated-f50fac82cafdc5ec095faee0a33ced6d9286fe08',
},
{
id: '00000000-0000-0000-0000-000000000004',
type: 'url',
target: 'https://example.com/d.yaml',
entityRef:
'location:default/generated-45aa6e8abd2e13841ddf91bd04249460cbe55a47',
},
{
id: '00000000-0000-0000-0000-000000000005',
type: 'url',
target: 'https://example.com/e.yaml',
entityRef:
'location:default/generated-c991bf07f54891933929eadce219b11fd32eaa5a',
},
];
@@ -1637,11 +1741,10 @@ describe('POST /locations/by-query works end to end', () => {
await knex<DbLocationsRow>('locations').delete();
for (const location of locations) {
await knex<DbLocationsRow>('locations').insert({
...location,
location_entity_ref: computeLocationEntityRef(
location.type,
location.target,
),
id: location.id,
type: location.type,
target: location.target,
location_entity_ref: location.entityRef,
});
}
@@ -1694,16 +1797,22 @@ describe('POST /locations/by-query works end to end', () => {
id: '00000000-0000-0000-0000-000000000001',
type: 'url',
target: 'https://example.com/a.yaml',
entityRef:
'location:default/generated-17fb6f13ffb6251438be1e4f37c6482b83ede45c',
},
{
id: '00000000-0000-0000-0000-000000000002',
type: 'file',
target: '/tmp/b.yaml',
entityRef:
'location:default/generated-49cac033ce5406c6fefc6ac16cf70f90852d9257',
},
{
id: '00000000-0000-0000-0000-000000000003',
type: 'url',
target: 'https://example.com/c.yaml',
entityRef:
'location:default/generated-f50fac82cafdc5ec095faee0a33ced6d9286fe08',
},
];
@@ -1711,11 +1820,10 @@ describe('POST /locations/by-query works end to end', () => {
await knex<DbLocationsRow>('locations').delete();
for (const location of locations) {
await knex<DbLocationsRow>('locations').insert({
...location,
location_entity_ref: computeLocationEntityRef(
location.type,
location.target,
),
id: location.id,
type: location.type,
target: location.target,
location_entity_ref: location.entityRef,
});
}
@@ -747,6 +747,36 @@ export async function createRouter(
throw err;
}
})
.put('/locations/:id', async (req, res) => {
const { id } = req.params;
const location = await validateRequestBody(req, locationInput);
const auditorEvent = await auditor.createEvent({
eventId: 'location-mutate',
severityLevel: 'medium',
request: req,
meta: {
actionType: 'update',
id,
location,
},
});
disallowReadonlyMode(readonlyEnabled);
try {
const output = await locationService.updateLocation(id, location, {
credentials: await httpAuth.credentials(req),
});
await auditorEvent?.success({ meta: { location: output } });
res.status(200).json(output);
} catch (err) {
await auditorEvent?.fail({ error: err });
throw err;
}
})
.delete('/locations/:id', async (req, res) => {
const { id } = req.params;
@@ -52,6 +52,11 @@ export interface LocationService {
id: string,
options: { credentials: BackstageCredentials },
): Promise<Location>;
updateLocation(
id: string,
location: LocationInput,
options: { credentials: BackstageCredentials },
): Promise<Location>;
deleteLocation(
id: string,
options: { credentials: BackstageCredentials },
@@ -98,6 +103,7 @@ export interface LocationStore {
query?: FilterPredicate;
}): Promise<{ items: Location[]; totalItems: number }>;
getLocation(id: string): Promise<Location>;
updateLocation(id: string, location: LocationInput): Promise<Location>;
deleteLocation(id: string): Promise<void>;
getLocationByEntity(entityRef: CompoundEntityRef | string): Promise<Location>;
}
@@ -124,6 +124,7 @@ describe('CatalogImportClient', () => {
id: 'id-0',
type: 'url',
target: 'http://example.com/folder/catalog-info.yaml',
entityRef: 'location:default/generated-id-0',
},
entities: [
{
@@ -172,6 +173,7 @@ describe('CatalogImportClient', () => {
type: 'url',
target:
'https://dev.azure.com/any-org/any-project/_git/any-repository?path=%2Fcatalog-info.yaml',
entityRef: 'location:default/generated-id-0',
},
entities: [
{
@@ -221,6 +223,7 @@ describe('CatalogImportClient', () => {
id: 'id-0',
type: 'url',
target: 'http://example.com/folder/catalog-info.yaml?branch=test',
entityRef: 'location:default/generated-id-0',
},
entities: [
{
@@ -126,6 +126,15 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi {
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
// (undocumented)
updateLocation(
id: string,
location: {
type?: string;
target: string;
},
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): Promise<Location_2>;
// (undocumented)
validateEntity(
entity: Entity,
locationRef: string,
+9
View File
@@ -267,6 +267,15 @@ export interface CatalogService {
options: CatalogServiceRequestOptions,
): AsyncIterable<Location_2[]>;
// (undocumented)
updateLocation(
id: string,
location: {
type?: string;
target: string;
},
options: CatalogServiceRequestOptions,
): Promise<Location_2>;
// (undocumented)
validateEntity(
entity: Entity,
locationRef: string,
@@ -140,6 +140,12 @@ export interface CatalogService {
options: CatalogServiceRequestOptions,
): Promise<void>;
updateLocation(
id: string,
location: { type?: string; target: string },
options: CatalogServiceRequestOptions,
): Promise<Location>;
getLocationByEntity(
entityRef: string | CompoundEntityRef,
options: CatalogServiceRequestOptions,
@@ -327,6 +333,18 @@ class DefaultCatalogService implements CatalogService {
);
}
async updateLocation(
id: string,
location: { type?: string; target: string },
options: CatalogServiceRequestOptions,
): Promise<Location> {
return this.#catalogApi.updateLocation(
id,
location,
await this.#getOptions(options),
);
}
async getLocationByEntity(
entityRef: string | CompoundEntityRef,
options: CatalogServiceRequestOptions,
@@ -76,6 +76,7 @@ export namespace catalogServiceMock {
getLocationByRef: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
updateLocation: jest.fn(),
getLocationByEntity: jest.fn(),
validateEntity: jest.fn(),
analyzeLocation: jest.fn(),
@@ -134,6 +134,12 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi {
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): Promise<void>;
updateLocation(
id: string,
location: { type?: string; target: string },
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): Promise<Location>;
getLocationByEntity(
entityRef: string | CompoundEntityRef,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
@@ -72,7 +72,12 @@ describe('useUnregisterEntityDialogState', () => {
expect(rendered.result.current).toEqual({ type: 'loading' });
resolveLocation({ type: 'url', target: 'https://example.com', id: 'x' });
resolveLocation({
type: 'url',
target: 'https://example.com',
id: 'x',
entityRef: 'location:default/generated-x',
});
resolveColocatedEntities([entity]);
await waitFor(() => {
@@ -94,7 +99,12 @@ describe('useUnregisterEntityDialogState', () => {
wrapper: Wrapper,
});
resolveLocation({ type: 'bootstrap', target: 'bootstrap', id: 'x' });
resolveLocation({
type: 'bootstrap',
target: 'bootstrap',
id: 'x',
entityRef: '',
});
resolveColocatedEntities([]);
await waitFor(() => {
@@ -78,6 +78,7 @@ export namespace catalogApiMock {
getLocationByRef: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
updateLocation: jest.fn(),
getLocationByEntity: jest.fn(),
validateEntity: jest.fn(),
analyzeLocation: jest.fn(),