diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 0b838eb4c2..3fc70e25ae 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -295,4 +295,67 @@ describe('CatalogClient', () => { await client.getLocationById('42'); }); }); + + describe('validateEntity', () => { + it('returns valid false when validation fails', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + return res( + ctx.status(400), + ctx.json({ + errors: [ + { + message: 'Missing name', + }, + ], + }), + ); + }), + ); + + expect( + await client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: '', + }, + }, + 'http://example.com', + ), + ).toMatchObject({ + valid: false, + errors: [ + { + message: 'Missing name', + }, + ], + }); + }); + + it('returns valid true when validation fails', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + return res(ctx.status(200), ctx.text('')); + }), + ); + + expect( + await client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'good', + }, + }, + 'http://example.com', + ), + ).toMatchObject({ + valid: true, + errors: undefined, + }); + }); + }); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 8ddc3eb36d..bbc9b0b62d 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -21,7 +21,7 @@ import { stringifyEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError } from '@backstage/errors'; +import { ResponseError, SerializedError } from '@backstage/errors'; import crossFetch from 'cross-fetch'; import { CATALOG_FILTER_EXISTS, @@ -36,6 +36,7 @@ import { Location, GetEntityFacetsRequest, GetEntityFacetsResponse, + ValidateEntityResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; import { FetchApi } from './types/fetch'; @@ -353,6 +354,41 @@ export class CatalogClient implements CatalogApi { ); } + /** + * {@inheritdoc CatalogApi.validateEntity} + */ + async validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise { + const response = await this.fetchApi.fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/validate-entity`, + { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify({ entity, location }), + }, + ); + + if (response.ok) { + return { + valid: true, + errors: undefined, + }; + } + + const { errors } = (await response.json()) as { errors: SerializedError[] }; + + return { + valid: false, + errors, + }; + } + // // Private methods // diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 389b82c081..d66e2dfaf9 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -15,6 +15,7 @@ */ import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import { SerializedError } from '@backstage/errors'; /** * This symbol can be used in place of a value when passed to filters in e.g. @@ -269,6 +270,16 @@ export type AddLocationResponse = { exists?: boolean; }; +/** + * The response type for {@link CatalogClient.validateEntity} + * + * @public + */ +export type ValidateEntityResponse = { + valid: boolean; + errors?: SerializedError[]; +}; + /** * A client for interacting with the Backstage software catalog through its API. * @@ -390,4 +401,16 @@ export interface CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + + /** + * Validate entity and its location. + * + * @param entity - Entity to validate + * @param location - URL location of the entity + */ + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; }