catalog-backend: add validate-entity endpoint

Signed-off-by: Bret Hubbard <hubbard.bret@gmail.com>
This commit is contained in:
Bret Hubbard
2022-02-10 22:55:10 -07:00
parent 1a41fa97df
commit 8e8d1afe9c
4 changed files with 130 additions and 4 deletions
@@ -438,6 +438,7 @@ export class CatalogBuilder {
entitiesCatalog,
locationAnalyzer,
locationService,
orchestrator,
refreshService,
logger,
config,
@@ -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<EntitiesCatalog>;
let locationService: jest.Mocked<LocationService>;
let orchestrator: jest.Mocked<CatalogProcessingOrchestrator>;
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', () => {
@@ -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;
}
@@ -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<T>(
req: Request,
schema: z.Schema<T>,