diff --git a/.changeset/large-books-deny.md b/.changeset/large-books-deny.md new file mode 100644 index 0000000000..c88d391639 --- /dev/null +++ b/.changeset/large-books-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Adding `validateEntity` method that calls `/validate-entity` endpoint. diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 964a3c1018..0ad77ce39c 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -5,6 +5,7 @@ ```ts import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; +import { SerializedError } from '@backstage/errors'; // @public export type AddLocationRequest = { @@ -65,6 +66,11 @@ export interface CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; } // @public @@ -122,6 +128,11 @@ export class CatalogClient implements CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; } // @public @@ -196,4 +207,14 @@ type Location_2 = { target: string; }; export { Location_2 as Location }; + +// @public +export type ValidateEntityResponse = + | { + valid: true; + } + | { + valid: false; + errors: SerializedError[]; + }; ``` diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 0b838eb4c2..6c9c68efa2 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -295,4 +295,87 @@ 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, + }); + }); + + it('throws unexpected error', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => { + return res(ctx.status(500), ctx.json({})); + }), + ); + + await expect(() => + client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'good', + }, + }, + 'http://example.com', + ), + ).rejects.toThrow(/Request failed with 500 Error/); + }); + }); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 8ddc3eb36d..f2b4186bfb 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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,44 @@ 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, + }; + } + + if (response.status !== 400) { + throw await ResponseError.fromResponse(response); + } + + const { errors = [] } = await response.json(); + + 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..f483c3454c 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,15 @@ export type AddLocationResponse = { exists?: boolean; }; +/** + * The response type for {@link CatalogClient.validateEntity} + * + * @public + */ +export type ValidateEntityResponse = + | { valid: true } + | { valid: false; errors: SerializedError[] }; + /** * A client for interacting with the Backstage software catalog through its API. * @@ -390,4 +400,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; } diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 8bf4e34da2..86f7b18ab0 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -27,5 +27,6 @@ export type { Location, GetEntityFacetsRequest, GetEntityFacetsResponse, + ValidateEntityResponse, } from './api'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 587b433ca1..e4d9304892 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -35,6 +35,7 @@ describe('CatalogIdentityClient', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const tokenManager: jest.Mocked = { getToken: jest.fn(), diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 1750a55be7..5fc809906f 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -68,6 +68,7 @@ describe('createRouter', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 9b354b5f38..74fcd4e501 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -59,6 +59,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; apis = TestApiRegistry.from([catalogApiRef, catalog]); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index d8b7ede2c2..9cca6ef9be 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -92,6 +92,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; wrapper = ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index db6711a0a5..c79f975789 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -119,6 +119,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; Wrapper = ({ children }) => ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 74d7423eea..e76b1001c6 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -38,6 +38,7 @@ describe('useEntityStore', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index e26d6c5d6a..30b9f47d99 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -100,6 +100,7 @@ describe('CatalogImportClient', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; let catalogImportClient: CatalogImportClient; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index f3dc1f2144..5ad7e27c4b 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -46,6 +46,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const errorApi: jest.Mocked = { diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index a79d02a2a0..399112fac6 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -32,6 +32,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index c30e845112..c84363f5c6 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 4488a4d0d9..a4769e8f4f 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index 7ef6c799df..676a3cff3c 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -37,6 +37,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const fossaApi: jest.Mocked = { getFindingSummary: jest.fn(), diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 6dd14afcb4..8077e2ddb9 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -53,6 +53,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; if (entity) { mock.getEntityByRef.mockReturnValue(entity);