diff --git a/.changeset/location-entity-ref-backend.md b/.changeset/location-entity-ref-backend.md new file mode 100644 index 0000000000..a48244f697 --- /dev/null +++ b/.changeset/location-entity-ref-backend.md @@ -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. diff --git a/.changeset/location-entity-ref-expose.md b/.changeset/location-entity-ref-expose.md new file mode 100644 index 0000000000..7847c1f793 --- /dev/null +++ b/.changeset/location-entity-ref-expose.md @@ -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. diff --git a/.changeset/location-entity-ref-node.md b/.changeset/location-entity-ref-node.md new file mode 100644 index 0000000000..d862a6cb70 --- /dev/null +++ b/.changeset/location-entity-ref-node.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +Added `updateLocation` method to `CatalogService` for updating the type and target of an existing location. diff --git a/packages/catalog-client/report-testUtils.api.md b/packages/catalog-client/report-testUtils.api.md index f53abba669..9cd218dc9a 100644 --- a/packages/catalog-client/report-testUtils.api.md +++ b/packages/catalog-client/report-testUtils.api.md @@ -84,6 +84,14 @@ export class InMemoryCatalogClient implements CatalogApi { _request?: QueryLocationsInitialRequest, ): AsyncIterable; // (undocumented) + updateLocation( + _id: string, + _location: { + type?: string; + target: string; + }, + ): Promise; + // (undocumented) validateEntity( _entity: Entity, _locationRef: string, diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 73eb280d87..c54d765a3e 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -102,6 +102,14 @@ export interface CatalogApi { request?: QueryLocationsInitialRequest, options?: CatalogRequestOptions, ): AsyncIterable; + updateLocation( + id: string, + location: { + type?: string; + target: string; + }, + options?: CatalogRequestOptions, + ): Promise; validateEntity( entity: Entity, locationRef: string, @@ -196,6 +204,14 @@ export class CatalogClient implements CatalogApi { request?: QueryLocationsInitialRequest, options?: CatalogRequestOptions, ): AsyncIterable; + updateLocation( + id: string, + location: { + type?: string; + target: string; + }, + options?: CatalogRequestOptions, + ): Promise; 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 }; diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 70265bd6cb..1a676caf2e 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -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[]; diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index cf363e1823..0b93b777f2 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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 { + 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} */ diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 4b3e088fe2..279ba21a76 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -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> { + 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), + }); + } } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/Location.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/Location.model.ts index 2fd434076a..02070590c8 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/Location.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/Location.model.ts @@ -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-. + */ + entityRef: string; } diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index dd7f269188..665c79a014 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -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 { + throw new NotImplementedError('Method not implemented.'); + } + async getLocationByEntity( _entityRef: string | CompoundEntityRef, ): Promise { diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index d6d5392fcf..06e6902197 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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-`. */ + entityRef: string; }; /** @@ -829,6 +831,19 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * 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; + /** * Gets a location associated with an entity. * diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index 2825c29528..3d2a126f9b 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -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': [] }, diff --git a/plugins/catalog-backend-module-github/src/analyzers/GithubLocationAnalyzer.test.ts b/plugins/catalog-backend-module-github/src/analyzers/GithubLocationAnalyzer.test.ts index 9fccfd57eb..2d979e3b53 100644 --- a/plugins/catalog-backend-module-github/src/analyzers/GithubLocationAnalyzer.test.ts +++ b/plugins/catalog-backend-module-github/src/analyzers/GithubLocationAnalyzer.test.ts @@ -59,6 +59,7 @@ describe('GithubLocationAnalyzer', () => { id: 'test', target: location.target, type: location.type ?? 'url', + entityRef: 'location:default/generated-test', }, exists: false, entities: [ diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts index c52c122369..01a974c7ef 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -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('locations').delete(); for (const location of locations) { await knex('locations').insert({ - ...location, + id: location.id, + type: location.type, + target: location.target, location_entity_ref: computeLocationEntityRef( location.type, location.target, diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 044926f8e7..6a5f829f22 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -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 { - 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 { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + + const updated = await this.db.transaction(async tx => { + const [old] = await tx('locations') + .where({ id }) + .select(); + + if (!old) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + await tx('locations').where({ id }).update({ + type: location.type, + target: location.target, + }); + + const [row] = await tx('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 { @@ -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; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index d0266e4d46..0b86a75bfa 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -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-. 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: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index 81a952a166..9e67e075c6 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -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; }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/Location.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/Location.model.ts index 2fd434076a..02070590c8 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/Location.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/Location.model.ts @@ -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-. + */ + entityRef: string; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 6b92c7ae63..05ec8450c1 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -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-.', + }, }, - 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'], diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index 6d889ed990..e705f88386 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -25,6 +25,7 @@ describe('AuthorizedLocationService', () => { listLocations: jest.fn(), queryLocations: jest.fn(), getLocation: jest.fn(), + updateLocation: jest.fn(), deleteLocation: jest.fn(), getLocationByEntity: jest.fn(), }; diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 1b18d50324..cd6853ac3f 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -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 { + 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 }, diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 3cce0efc3f..412fe77341 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -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, { diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 7effaa2e88..7dc98dabc4 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -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 { return this.store.getLocation(id); } + + updateLocation( + id: string, + location: LocationInput, + _options: { credentials: BackstageCredentials }, + ): Promise { + return this.store.updateLocation(id, location); + } + deleteLocation(id: string): Promise { 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, }; } diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index cddf0f7a38..289e75585c 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -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('locations').delete(); for (const location of locations) { await knex('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('locations').delete(); for (const location of locations) { await knex('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, }); } diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index f996469a41..020e395976 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -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; diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index c949da1eda..d14b721a06 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -52,6 +52,11 @@ export interface LocationService { id: string, options: { credentials: BackstageCredentials }, ): Promise; + updateLocation( + id: string, + location: LocationInput, + options: { credentials: BackstageCredentials }, + ): Promise; deleteLocation( id: string, options: { credentials: BackstageCredentials }, @@ -98,6 +103,7 @@ export interface LocationStore { query?: FilterPredicate; }): Promise<{ items: Location[]; totalItems: number }>; getLocation(id: string): Promise; + updateLocation(id: string, location: LocationInput): Promise; deleteLocation(id: string): Promise; getLocationByEntity(entityRef: CompoundEntityRef | string): Promise; } diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 4c61d4f6a0..1791f66184 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -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: [ { diff --git a/plugins/catalog-node/report-testUtils.api.md b/plugins/catalog-node/report-testUtils.api.md index 65e4d0267a..848084cc67 100644 --- a/plugins/catalog-node/report-testUtils.api.md +++ b/plugins/catalog-node/report-testUtils.api.md @@ -126,6 +126,15 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi { options?: CatalogServiceRequestOptions | CatalogRequestOptions, ): AsyncIterable; // (undocumented) + updateLocation( + id: string, + location: { + type?: string; + target: string; + }, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): Promise; + // (undocumented) validateEntity( entity: Entity, locationRef: string, diff --git a/plugins/catalog-node/report.api.md b/plugins/catalog-node/report.api.md index 0c27645796..8a01810e4c 100644 --- a/plugins/catalog-node/report.api.md +++ b/plugins/catalog-node/report.api.md @@ -267,6 +267,15 @@ export interface CatalogService { options: CatalogServiceRequestOptions, ): AsyncIterable; // (undocumented) + updateLocation( + id: string, + location: { + type?: string; + target: string; + }, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) validateEntity( entity: Entity, locationRef: string, diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts index be9616d7f6..1a5ed13492 100644 --- a/plugins/catalog-node/src/catalogService.ts +++ b/plugins/catalog-node/src/catalogService.ts @@ -140,6 +140,12 @@ export interface CatalogService { options: CatalogServiceRequestOptions, ): Promise; + updateLocation( + id: string, + location: { type?: string; target: string }, + options: CatalogServiceRequestOptions, + ): Promise; + 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 { + return this.#catalogApi.updateLocation( + id, + location, + await this.#getOptions(options), + ); + } + async getLocationByEntity( entityRef: string | CompoundEntityRef, options: CatalogServiceRequestOptions, diff --git a/plugins/catalog-node/src/testUtils/catalogServiceMock.ts b/plugins/catalog-node/src/testUtils/catalogServiceMock.ts index 59d817c012..626ad23623 100644 --- a/plugins/catalog-node/src/testUtils/catalogServiceMock.ts +++ b/plugins/catalog-node/src/testUtils/catalogServiceMock.ts @@ -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(), diff --git a/plugins/catalog-node/src/testUtils/types.ts b/plugins/catalog-node/src/testUtils/types.ts index e819af1b90..6e2fe9ef53 100644 --- a/plugins/catalog-node/src/testUtils/types.ts +++ b/plugins/catalog-node/src/testUtils/types.ts @@ -134,6 +134,12 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi { options?: CatalogServiceRequestOptions | CatalogRequestOptions, ): Promise; + updateLocation( + id: string, + location: { type?: string; target: string }, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): Promise; + getLocationByEntity( entityRef: string | CompoundEntityRef, options?: CatalogServiceRequestOptions | CatalogRequestOptions, diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index 675cf7ddad..dfd310cd1c 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -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(() => { diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index 86ac5bc2bf..0ae37f10f2 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -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(),