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-catalog-react.md b/.changeset/location-entity-ref-catalog-react.md new file mode 100644 index 0000000000..780922dcf2 --- /dev/null +++ b/.changeset/location-entity-ref-catalog-react.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Updated `catalogApiMock` to include the new `updateLocation` method stub, keeping it in sync with the `CatalogApi` interface. diff --git a/.changeset/location-entity-ref-expose.md b/.changeset/location-entity-ref-expose.md new file mode 100644 index 0000000000..1cd1c989a0 --- /dev/null +++ b/.changeset/location-entity-ref-expose.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +**BREAKING PRODUCERS**: Added required `entityRef` field to the `Location` type, exposing the stable entity reference for each registered location. Any code that produces `Location` objects must now include this field. 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..af5d9825af --- /dev/null +++ b/.changeset/location-entity-ref-node.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +**BREAKING PRODUCERS**: Added `updateLocation` method to `CatalogService` for updating the type and target of an existing location. Any code that implements `CatalogService` must now provide this method. 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..38ef1ed130 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[]; @@ -1134,11 +1136,13 @@ describe('CatalogClient', () => { id: '42', type: 'url', target: 'https://example.com', + entityRef: 'location:default/generated-42', }, { id: '43', type: 'url', target: 'https://example.com', + entityRef: 'location:default/generated-43', }, ], }); 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..b11687e33f 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -272,6 +272,83 @@ describe('DefaultLocationStore', () => { ); }); + describe('updateLocation', () => { + it.each(databases.eachSupportedId())( + 'throws if the location does not exist, %p', + async databaseId => { + const { store } = await createLocationStore(databaseId); + const id = uuid(); + await expect(() => + store.updateLocation(id, { + type: 'url', + target: 'https://example.com', + }), + ).rejects.toThrow(new RegExp(`Found no location with ID ${id}`)); + }, + ); + + it.each(databases.eachSupportedId())( + 'throws ConflictError when updating to a type+target already used by another location, %p', + async databaseId => { + const { store } = await createLocationStore(databaseId); + + await store.createLocation({ + type: 'url', + target: 'https://example.com/a', + }); + const b = await store.createLocation({ + type: 'url', + target: 'https://example.com/b', + }); + + await expect(() => + store.updateLocation(b.id, { + type: 'url', + target: 'https://example.com/a', + }), + ).rejects.toThrow(/already exists/); + }, + ); + + it.each(databases.eachSupportedId())( + 'updates type and target and issues a delta mutation with the new entity, %p', + async databaseId => { + const { store, connection } = await createLocationStore(databaseId); + + const created = await store.createLocation({ + type: 'url', + target: 'https://example.com/old', + }); + + jest.clearAllMocks(); + + const updated = await store.updateLocation(created.id, { + type: 'url', + target: 'https://example.com/new', + }); + + expect(updated.id).toBe(created.id); + expect(updated.type).toBe('url'); + expect(updated.target).toBe('https://example.com/new'); + // entityRef (location_entity_ref) is stable across updates + expect(updated.entityRef).toBe(created.entityRef); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [], + added: [ + { + entity: expect.objectContaining({ + spec: { type: 'url', target: 'https://example.com/new' }, + }), + locationKey: 'url:https://example.com/new', + }, + ], + }); + }, + ); + }); + describe('getLocationByEntity', () => { it.each(databases.eachSupportedId())( 'loads correctly, %p', @@ -321,6 +398,8 @@ describe('DefaultLocationStore', () => { id: locationId, type: 'url', target: 'https://example.com', + entityRef: + 'location:default/generated-7ade06d301ec98b80352203e9969e7640dc618b8', }); await expect( @@ -848,23 +927,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 +965,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..519835439f 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,82 @@ 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'); + } + + // MySQL doesn't support UPDATE ... RETURNING. MySQL also reports 0 affected + // rows when the new values are identical to the old ones, so we can't rely + // on the row count to detect existence. Instead we SELECT to check existence + // first and then UPDATE inside a transaction. + let row: DbLocationsRow | undefined; + if (this.db.client.config.client.includes('mysql')) { + await this.db.transaction(async tx => { + [row] = await tx('locations').where({ id }).select(); + if (!row) { + return; + } + + const [conflict] = await tx('locations') + .where({ type: location.type, target: location.target }) + .whereNot({ id }) + .select(); + if (conflict) { + throw new ConflictError( + `Location ${location.type}:${location.target} already exists`, + ); + } + + await tx('locations') + .where({ id }) + .update({ type: location.type, target: location.target }); + row = { ...row, type: location.type, target: location.target }; + }); + } else { + await this.db.transaction(async tx => { + const [conflict] = await tx('locations') + .where({ type: location.type, target: location.target }) + .whereNot({ id }) + .select(); + if (conflict) { + throw new ConflictError( + `Location ${location.type}:${location.target} already exists`, + ); + } + + [row] = await tx('locations') + .where({ id }) + .update({ type: location.type, target: location.target }) + .returning('*'); + }); + } + + if (!row) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + const entity = locationSpecToLocationEntity({ + location: row, + locationEntityRef: row.location_entity_ref, + }); + + await this.connection.applyMutation({ + type: 'delta', + added: [{ entity, locationKey: getEntityLocationRef(entity) }], + removed: [], + }); + + return { + id: row.id, + type: row.type, + target: row.target, + entityRef: row.location_entity_ref, + }; } async deleteLocation(id: string): Promise { @@ -264,6 +347,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { id: locationRow.id, type: locationRow.type, target: locationRow.target, + entityRef: locationRow.location_entity_ref, }; } @@ -692,13 +776,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..6c25f85f1f 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(), }; @@ -124,6 +125,35 @@ describe('AuthorizedLocationService', () => { }); }); + describe('updateLocation', () => { + it('calls underlying service to update location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + const spec = { type: 'type', target: 'target' }; + await service.updateLocation('id', spec, mockOptions); + + expect(fakeLocationService.updateLocation).toHaveBeenCalledWith( + 'id', + spec, + mockOptions, + ); + }); + + it('throws NotAllowedError on DENY', async () => { + mockDeny(); + const service = createService(); + + await expect(() => + service.updateLocation( + 'id', + { type: 'type', target: 'target' }, + mockOptions, + ), + ).rejects.toThrow(NotAllowedError); + }); + }); + describe('deleteLocation', () => { it('calls underlying service to delete location on ALLOW', async () => { mockAllow(); 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..3df46ceae5 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -18,6 +18,7 @@ import { DefaultLocationService } from './DefaultLocationService'; import { CatalogProcessingOrchestrator } from '../processing/types'; import { LocationStore } from './types'; import { InputError } from '@backstage/errors'; +import { mockCredentials } from '@backstage/backend-test-utils'; describe('DefaultLocationServiceTest', () => { const orchestrator: jest.Mocked = { @@ -29,6 +30,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 +146,11 @@ describe('DefaultLocationServiceTest', () => { }); store.listLocations.mockResolvedValueOnce([ - { id: '137', ...locationSpec }, + { + id: '137', + ...locationSpec, + entityRef: 'location:default/generated-137', + }, ]); const result = await locationService.createLocation( @@ -226,7 +232,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 +256,7 @@ describe('DefaultLocationServiceTest', () => { store.createLocation.mockResolvedValue({ ...locationSpec, id: '123', + entityRef: 'location:default/generated-123', }); await expect( @@ -255,6 +267,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 +288,7 @@ describe('DefaultLocationServiceTest', () => { store.createLocation.mockResolvedValue({ ...locationSpec, id: '123', + entityRef: 'location:default/generated-123', }); const locationServiceAllowingUnknownType = new DefaultLocationService( @@ -293,6 +307,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 +340,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 +349,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, { @@ -398,6 +418,41 @@ describe('DefaultLocationServiceTest', () => { }); }); + describe('updateLocation', () => { + it('should not allow locations of disallowed types', async () => { + await expect( + locationService.updateLocation( + 'some-id', + { type: 'unknown', target: 'https://backstage.io/catalog-info.yaml' }, + { credentials: mockCredentials.none() }, + ), + ).rejects.toThrow(InputError); + }); + + it('should delegate to store for allowed types', async () => { + const updated = { + id: 'some-id', + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + entityRef: 'location:default/generated-abc', + }; + store.updateLocation.mockResolvedValue(updated); + + await expect( + locationService.updateLocation( + 'some-id', + { type: 'url', target: 'https://backstage.io/catalog-info.yaml' }, + { credentials: mockCredentials.none() }, + ), + ).resolves.toEqual(updated); + + expect(store.updateLocation).toHaveBeenCalledWith('some-id', { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }); + }); + }); + describe('getLocationByEntity', () => { it('should call locationStore.getLocationByEntity', async () => { await locationService.getLocationByEntity({ diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 7effaa2e88..103b5ff5a0 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,22 @@ export class DefaultLocationService implements LocationService { getLocation(id: string): Promise { return this.store.getLocation(id); } + + async updateLocation( + id: string, + location: LocationInput, + _options: { credentials: BackstageCredentials }, + ): Promise { + if (!this.options.allowedLocationTypes.includes(location.type)) { + throw new InputError( + `Registered locations must be of an allowed type ${JSON.stringify( + this.options.allowedLocationTypes, + )}`, + ); + } + return this.store.updateLocation(id, location); + } + deleteLocation(id: string): Promise { return this.store.deleteLocation(id); } @@ -182,7 +201,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..7092572ae6 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,6 @@ import request from 'supertest'; import { Cursor, EntitiesCatalog } from '../catalog/types'; import { applyDatabaseMigrations } from '../database/migrations'; import { DbLocationsRow } from '../database/tables'; -import { computeLocationEntityRef } from '../util/conversion'; import { CatalogProcessingOrchestrator } from '../processing/types'; import { DefaultLocationStore } from '../providers/DefaultLocationStore'; import { createRouter } from './createRouter'; @@ -77,6 +76,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 +800,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 +816,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 +834,7 @@ describe('createRouter readonly disabled', () => { id: 'foo', type: 'url', target: 'example.com', + entityRef: 'location:default/generated-foo', }; locationService.getLocation.mockResolvedValueOnce(location); @@ -836,6 +849,7 @@ describe('createRouter readonly disabled', () => { id: 'foo', target: 'example.com', type: 'url', + entityRef: 'location:default/generated-foo', }); }); }); @@ -863,7 +877,11 @@ describe('createRouter readonly disabled', () => { }; locationService.createLocation.mockResolvedValue({ - location: { id: 'a', ...spec }, + location: { + id: 'a', + ...spec, + entityRef: 'location:default/generated-a', + }, entities: [], }); @@ -879,7 +897,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 +913,11 @@ describe('createRouter readonly disabled', () => { }; locationService.createLocation.mockResolvedValue({ - location: { id: 'a', ...spec }, + location: { + id: 'a', + ...spec, + entityRef: 'location:default/generated-a', + }, entities: [], }); @@ -907,7 +933,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 +950,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 +974,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 +1011,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 +1043,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 +1093,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, @@ -1109,12 +1175,50 @@ describe('createRouter readonly disabled', () => { }); }); + describe('PUT /locations/:id', () => { + it('rejects malformed body', async () => { + const response = await request(app) + .put('/locations/foo') + .send({ typez: 'url', target: 'https://example.com' }); + + expect(locationService.updateLocation).not.toHaveBeenCalled(); + expect(response.status).toEqual(400); + }); + + it('updates the location and returns it', async () => { + const spec: LocationInput = { + type: 'url', + target: 'https://example.com/new', + }; + + locationService.updateLocation.mockResolvedValue({ + id: 'foo', + ...spec, + entityRef: 'location:default/generated-foo', + }); + + const response = await request(app).put('/locations/foo').send(spec); + + expect(locationService.updateLocation).toHaveBeenCalledTimes(1); + expect(locationService.updateLocation).toHaveBeenCalledWith('foo', spec, { + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id: 'foo', + ...spec, + entityRef: 'location:default/generated-foo', + }); + }); + }); + 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', + entityRef: 'location:default/generated-foo', }; locationService.getLocationByEntity.mockResolvedValueOnce(location); @@ -1132,6 +1236,7 @@ describe('createRouter readonly disabled', () => { id: 'foo', target: 'example.com', type: 'url', + entityRef: 'location:default/generated-foo', }); }); }); @@ -1367,6 +1472,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 +1533,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 +1550,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 +1568,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 +1583,7 @@ describe('createRouter readonly and raw json enabled', () => { id: 'foo', target: 'example.com', type: 'url', + entityRef: 'location:default/generated-foo', }); }); }); @@ -1492,7 +1612,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 +1632,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 +1656,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 +1674,7 @@ describe('createRouter readonly and raw json enabled', () => { id: 'foo', target: 'example.com', type: 'url', + entityRef: 'location:default/generated-foo', }); }); }); @@ -1610,26 +1740,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 +1777,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 +1833,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 +1856,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(),