diff --git a/.changeset/swift-trees-rest.md b/.changeset/swift-trees-rest.md new file mode 100644 index 0000000000..2e76195997 --- /dev/null +++ b/.changeset/swift-trees-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added `/validate-entity` endpoint 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..8eafd8bfa3 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_LOCATION, + ANNOTATION_ORIGIN_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,117 @@ 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, location: 'url:validate-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_LOCATION]: 'url:validate-entity', + [ANNOTATION_ORIGIN_LOCATION]: 'url:validate-entity', + }, + }, + }, + }); + }); + }); + + describe('invalid entity', () => { + it('returns 400', 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, location: 'url:validate-entity' }); + + expect(response.status).toEqual(400); + expect(response.body.errors.length).toEqual(1); + expect(response.body.errors[0].message).toEqual('Invalid entity name'); + expect(orchestrator.process).toHaveBeenCalledTimes(1); + expect(orchestrator.process).toHaveBeenCalledWith({ + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'invalid*name', + annotations: { + [ANNOTATION_LOCATION]: 'url:validate-entity', + [ANNOTATION_ORIGIN_LOCATION]: 'url:validate-entity', + }, + }, + }, + }); + }); + }); + + describe('no location', () => { + it('returns 400', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + + const response = await request(app) + .post('/validate-entity') + .send({ entity, location: null }); + + expect(response.status).toEqual(400); + expect(response.body.errors.length).toEqual(1); + expect(response.body.errors[0].message).toContain('Malformed request:'); + expect(orchestrator.process).toHaveBeenCalledTimes(0); + }); + }); + + describe('no entity', () => { + it('returns 400', async () => { + const response = await request(app) + .post('/validate-entity') + .send({ entity: null, location: 'url:entity' }); + + expect(response.status).toEqual(400); + expect(response.body.errors.length).toEqual(1); + expect(response.body.errors[0].message).toContain( + ' must be object - type: object', + ); + }); + }); + }); }); describe('createRouter readonly enabled', () => { diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index a35309035f..639893dcc1 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -15,9 +15,15 @@ */ import { errorHandler } from '@backstage/backend-common'; -import { stringifyEntityRef } from '@backstage/catalog-model'; +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + Entity, + parseLocationRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { NotFoundError } from '@backstage/errors'; +import { NotFoundError, serializeError } from '@backstage/errors'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -35,9 +41,11 @@ import { locationInput, validateRequestBody, } 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'; +import { validateEntityEnvelope } from '../processing/util'; /** * Options used by {@link createRouter}. @@ -48,6 +56,7 @@ export interface RouterOptions { entitiesCatalog?: EntitiesCatalog; locationAnalyzer?: LocationAnalyzer; locationService: LocationService; + orchestrator?: CatalogProcessingOrchestrator; refreshService?: RefreshService; logger: Logger; config: Config; @@ -66,12 +75,12 @@ export async function createRouter( entitiesCatalog, locationAnalyzer, locationService, + orchestrator, refreshService, config, logger, permissionIntegrationRouter, } = options; - const router = Router(); router.use(express.json()); @@ -228,6 +237,52 @@ export async function createRouter( }); } + if (orchestrator) { + router.post('/validate-entity', async (req, res) => { + const bodySchema = z.object({ + entity: z.unknown(), + location: z.string(), + }); + + let body: z.infer; + let entity: Entity; + let location: { type: string; target: string }; + try { + body = await validateRequestBody(req, bodySchema); + entity = validateEntityEnvelope(body.entity); + location = parseLocationRef(body.location); + if (location.type !== 'url') + throw new TypeError( + `Invalid location ref ${body.location}, only 'url:' is supported, e.g. url:https://host/path`, + ); + } catch (err) { + return res.status(400).json({ + errors: [serializeError(err)], + }); + } + + const processingResult = await orchestrator.process({ + entity: { + ...entity, + metadata: { + ...entity.metadata, + annotations: { + [ANNOTATION_LOCATION]: body.location, + [ANNOTATION_ORIGIN_LOCATION]: body.location, + ...entity.metadata.annotations, + }, + }, + }, + }); + + if (!processingResult.ok) + res.status(400).json({ + errors: processingResult.errors.map(e => serializeError(e)), + }); + return res.status(200).end(); + }); + } + router.use(errorHandler()); return router; }