From 8e8d1afe9cfa49d03b0c0b4309bd48cc7642a6ca Mon Sep 17 00:00:00 2001 From: Bret Hubbard Date: Thu, 10 Feb 2022 22:55:10 -0700 Subject: [PATCH] catalog-backend: add validate-entity endpoint Signed-off-by: Bret Hubbard --- .../src/service/CatalogBuilder.ts | 1 + .../src/service/createRouter.test.ts | 87 ++++++++++++++++++- .../src/service/createRouter.ts | 25 +++++- plugins/catalog-backend/src/service/util.ts | 21 +++++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 99d7f0e41a..075fcd6a5d 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -438,6 +438,7 @@ export class CatalogBuilder { entitiesCatalog, locationAnalyzer, locationService, + orchestrator, refreshService, logger, config, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 6def04a11c..06a5ba74e0 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -18,7 +18,11 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; import type { Location } from '@backstage/catalog-client'; -import type { Entity } from '@backstage/catalog-model'; +import { + ANNOTATION_ORIGIN_LOCATION, + ANNOTATION_SOURCE_LOCATION, + Entity, +} from '@backstage/catalog-model'; import express from 'express'; import request from 'supertest'; import { EntitiesCatalog } from '../catalog/types'; @@ -31,10 +35,12 @@ import { createPermissionRule, } from '@backstage/plugin-permission-node'; import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; +import { CatalogProcessingOrchestrator } from '../processing/types'; describe('createRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; let locationService: jest.Mocked; + let orchestrator: jest.Mocked; let app: express.Express; let refreshService: RefreshService; @@ -52,9 +58,11 @@ describe('createRouter readonly disabled', () => { deleteLocation: jest.fn(), }; refreshService = { refresh: jest.fn() }; + orchestrator = { process: jest.fn() }; const router = await createRouter({ entitiesCatalog, locationService, + orchestrator, logger: getVoidLogger(), refreshService, config: new ConfigReader(undefined), @@ -386,6 +394,83 @@ describe('createRouter readonly disabled', () => { expect(response.status).toEqual(204); }); }); + + describe('POST /validate-entity', () => { + describe('valid entity', () => { + it('returns 200', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: {}, + completedEntity: entity, + deferredEntities: [], + relations: [], + errors: [], + }); + + const response = await request(app) + .post('/validate-entity') + .send(entity); + + expect(response.status).toEqual(200); + expect(orchestrator.process).toHaveBeenCalledTimes(1); + expect(orchestrator.process).toHaveBeenCalledWith({ + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'n', + annotations: { + [ANNOTATION_SOURCE_LOCATION]: 'validate:entity', + [ANNOTATION_ORIGIN_LOCATION]: 'validate:entity', + }, + }, + }, + }); + }); + }); + + describe('invalid entity', () => { + it('returns 422', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'invalid*name' }, + }; + + orchestrator.process.mockResolvedValueOnce({ + ok: false, + errors: [new Error('Invalid entity name')], + }); + + const response = await request(app) + .post('/validate-entity') + .send(entity); + + expect(response.status).toEqual(422); + expect(response.body).toEqual(['Invalid entity name']); + expect(orchestrator.process).toHaveBeenCalledTimes(1); + expect(orchestrator.process).toHaveBeenCalledWith({ + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'invalid*name', + annotations: { + [ANNOTATION_SOURCE_LOCATION]: 'validate:entity', + [ANNOTATION_ORIGIN_LOCATION]: 'validate:entity', + }, + }, + }, + }); + }); + }); + }); }); describe('createRouter readonly enabled', () => { diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index a35309035f..221b5413c2 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -15,7 +15,7 @@ */ import { errorHandler } from '@backstage/backend-common'; -import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; import express from 'express'; @@ -34,10 +34,13 @@ import { disallowReadonlyMode, locationInput, validateRequestBody, + requireRequestBody, + setRequiredEntityLocationAnnotations, } from './util'; -import { RefreshOptions, LocationService, RefreshService } from './types'; import { z } from 'zod'; import { parseEntityFacetParams } from './request/parseEntityFacetParams'; +import { RefreshOptions, LocationService, RefreshService } from './types'; +import { CatalogProcessingOrchestrator } from '../processing/types'; /** * Options used by {@link createRouter}. @@ -48,6 +51,7 @@ export interface RouterOptions { entitiesCatalog?: EntitiesCatalog; locationAnalyzer?: LocationAnalyzer; locationService: LocationService; + orchestrator?: CatalogProcessingOrchestrator; refreshService?: RefreshService; logger: Logger; config: Config; @@ -66,12 +70,12 @@ export async function createRouter( entitiesCatalog, locationAnalyzer, locationService, + orchestrator, refreshService, config, logger, permissionIntegrationRouter, } = options; - const router = Router(); router.use(express.json()); @@ -228,6 +232,21 @@ export async function createRouter( }); } + if (orchestrator) { + router.post('/validate-entity', async (req, res) => { + const input = (await requireRequestBody(req)) as Entity; + const entity = setRequiredEntityLocationAnnotations(input); + const processingResult = await orchestrator.process({ + entity, + }); + if (!processingResult.ok) + return res + .status(422) + .json(processingResult.errors.map(e => e.message)); + return res.status(200).send(); + }); + } + router.use(errorHandler()); return router; } diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index 9a914aadcb..15ffdaa013 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +import { + Entity, + ANNOTATION_SOURCE_LOCATION, + ANNOTATION_ORIGIN_LOCATION, +} from '@backstage/catalog-model'; import { InputError, NotAllowedError } from '@backstage/errors'; import { Request } from 'express'; import lodash from 'lodash'; @@ -48,6 +53,22 @@ export const locationInput = z }) .strict(); // no unknown keys; +export function setRequiredEntityLocationAnnotations(entity: Entity): Entity { + const clonedEntity = lodash.cloneDeep(entity); + lodash.set( + clonedEntity, + `metadata.annotations.['${ANNOTATION_SOURCE_LOCATION}']`, + 'validate:entity', + ); + lodash.set( + clonedEntity, + `metadata.annotations.['${ANNOTATION_ORIGIN_LOCATION}']`, + 'validate:entity', + ); + + return clonedEntity; +} + export async function validateRequestBody( req: Request, schema: z.Schema,