diff --git a/.changeset/thirty-bags-try.md b/.changeset/thirty-bags-try.md new file mode 100644 index 0000000000..98d6c5620b --- /dev/null +++ b/.changeset/thirty-bags-try.md @@ -0,0 +1,33 @@ +--- +'@backstage/plugin-catalog-backend': minor +'@backstage/plugin-catalog-node': minor +--- + +Adds support for supplying field validators to the new backend's catalog plugin. If you're using entity policies, you should use the new `transformLegacyPolicyToProcessor` function to install them as processors instead. + +```ts +import { + catalogProcessingExtensionPoint, + catalogModelExtensionPoint, +} from '@backstage/plugin-catalog-node/alpha'; +import {myPolicy} from './my-policy'; + +export const catalogModulePolicyProvider = createBackendModule({ + pluginId: 'catalog', + moduleId: 'internal-policy-provider', + register(reg) { + reg.registerInit({ + deps: { + modelExtensions: catalogModelExtensionPoint, + processingExtensions: catalogProcessingExtensionPoint, + }, + async init({ modelExtensions, processingExtensions }) { + modelExtensions.setFieldValidators({ + ... + }); + processingExtensions.addProcessors(transformLegacyPolicyToProcessor(myPolicy)) + }, + }); + }, +}); +``` diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index a38a964d48..92a2fbb280 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -450,6 +450,11 @@ export const processingResult: Readonly<{ // @public @deprecated (undocumented) export type ScmLocationAnalyzer = ScmLocationAnalyzer_2; +// @public +export function transformLegacyPolicyToProcessor( + policy: EntityPolicy, +): CatalogProcessor_2; + // @public (undocumented) export class UrlReaderProcessor implements CatalogProcessor_2 { constructor(options: { reader: UrlReader; logger: Logger }); diff --git a/plugins/catalog-backend/src/modules/core/index.ts b/plugins/catalog-backend/src/modules/core/index.ts index 365def0bfe..e410c3824d 100644 --- a/plugins/catalog-backend/src/modules/core/index.ts +++ b/plugins/catalog-backend/src/modules/core/index.ts @@ -24,3 +24,4 @@ export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderProcessorOptions } from './PlaceholderProcessor'; export { UrlReaderProcessor } from './UrlReaderProcessor'; export { parseEntityYaml } from '../util/parse'; +export { transformLegacyPolicyToProcessor } from './transformLegacyPolicyToProcessor'; diff --git a/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.test.ts b/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.test.ts new file mode 100644 index 0000000000..a38074723c --- /dev/null +++ b/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityPolicy } from '@backstage/catalog-model'; +import { transformLegacyPolicyToProcessor } from './transformLegacyPolicyToProcessor'; +import { clone } from 'lodash'; + +describe('transformLegacyPolicyToProcessor', () => { + const entityToProcess: Entity = { + apiVersion: 'backstage.io/v1alpha', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + it('modifies the entity if the policy modifies the entity', async () => { + const policy: EntityPolicy = { + async enforce(entity) { + entity.kind = 'Group'; + return entity; + }, + }; + const processor = transformLegacyPolicyToProcessor(policy); + const clonedEntity = clone(entityToProcess); + const entity = await processor.preProcessEntity?.( + clonedEntity, + {} as any, + jest.fn(), + {} as any, + {} as any, + ); + expect(entity).toBeTruthy(); + expect(entity?.kind).toBe('Group'); + expect(entity?.apiVersion).toBe('backstage.io/v1alpha'); + expect(entity?.metadata.name).toBe('test'); + }); + + it('does not modify the entity if the policy returns undefined', async () => { + const policy: EntityPolicy = { + async enforce() { + return undefined; + }, + }; + const processor = transformLegacyPolicyToProcessor(policy); + const clonedEntity = clone(entityToProcess); + const entity = await processor.preProcessEntity?.( + clonedEntity, + {} as any, + jest.fn(), + {} as any, + {} as any, + ); + expect(entity).toBeTruthy(); + expect(entity?.kind).toBe('Component'); + expect(entity?.apiVersion).toBe('backstage.io/v1alpha'); + expect(entity?.metadata.name).toBe('test'); + }); + + it('bubbles up processor error', async () => { + const policy: EntityPolicy = { + async enforce() { + throw new TypeError('Invalid value for metadata.name'); + }, + }; + const processor = transformLegacyPolicyToProcessor(policy); + const clonedEntity = clone(entityToProcess); + await expect( + processor.preProcessEntity?.( + clonedEntity, + {} as any, + jest.fn(), + {} as any, + {} as any, + ), + ).rejects.toThrow(/Invalid value for metadata.name/); + }); +}); diff --git a/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.ts b/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.ts new file mode 100644 index 0000000000..40a3e749ab --- /dev/null +++ b/plugins/catalog-backend/src/modules/core/transformLegacyPolicyToProcessor.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityPolicy } from '@backstage/catalog-model'; +import { CatalogProcessor } from '@backstage/plugin-catalog-node'; + +/** + * Transform a given entity policy to an entity processor. + * @param policy - The policy to transform + * @returns A new entity processor that uses the entity policy. + * @public + */ +export function transformLegacyPolicyToProcessor( + policy: EntityPolicy, +): CatalogProcessor { + return { + getProcessorName() { + return policy.constructor.name; + }, + async preProcessEntity(entity) { + // If enforcing the policy fails, throw the policy error. + const result = await policy.enforce(entity); + if (!result) { + return entity; + } + return result; + }, + }; +} diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index bfe8ef5a16..255df84d58 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -17,7 +17,7 @@ import { createBackendPlugin, coreServices, } from '@backstage/backend-plugin-api'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, Validators } from '@backstage/catalog-model'; import { CatalogBuilder, CatalogPermissionRuleInput } from './CatalogBuilder'; import { CatalogAnalysisExtensionPoint, @@ -26,6 +26,8 @@ import { catalogProcessingExtensionPoint, CatalogPermissionExtensionPoint, catalogPermissionExtensionPoint, + CatalogModelExtensionPoint, + catalogModelExtensionPoint, } from '@backstage/plugin-catalog-node/alpha'; import { CatalogProcessor, @@ -34,6 +36,7 @@ import { ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { merge } from 'lodash'; class CatalogProcessingExtensionPointImpl implements CatalogProcessingExtensionPoint @@ -124,6 +127,18 @@ class CatalogPermissionExtensionPointImpl } } +class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint { + #fieldValidators: Partial = {}; + + setFieldValidators(validators: Partial): void { + merge(this.#fieldValidators, validators); + } + + get fieldValidators() { + return this.#fieldValidators; + } +} + /** * Catalog plugin * @alpha @@ -150,6 +165,9 @@ export const catalogPlugin = createBackendPlugin({ permissionExtensions, ); + const modelExtensions = new CatalogModelExtensionPointImpl(); + env.registerExtensionPoint(catalogModelExtensionPoint, modelExtensions); + env.registerInit({ deps: { logger: coreServices.logger, @@ -192,6 +210,7 @@ export const catalogPlugin = createBackendPlugin({ ); builder.addLocationAnalyzers(...analysisExtensions.locationAnalyzers); builder.addPermissionRules(...permissionExtensions.permissionRules); + builder.setFieldFormatValidators(modelExtensions.fieldValidators); const { processingEngine, router } = await builder.build(); diff --git a/plugins/catalog-node/api-report-alpha.md b/plugins/catalog-node/api-report-alpha.md index 5be4b114c0..e43d1bd49f 100644 --- a/plugins/catalog-node/api-report-alpha.md +++ b/plugins/catalog-node/api-report-alpha.md @@ -14,6 +14,7 @@ import { PermissionRuleParams } from '@backstage/plugin-permission-common'; import { PlaceholderResolver } from '@backstage/plugin-catalog-node'; import { ScmLocationAnalyzer } from '@backstage/plugin-catalog-node'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { Validators } from '@backstage/catalog-model'; // @alpha (undocumented) export interface CatalogAnalysisExtensionPoint { @@ -24,6 +25,14 @@ export interface CatalogAnalysisExtensionPoint { // @alpha (undocumented) export const catalogAnalysisExtensionPoint: ExtensionPoint; +// @alpha (undocumented) +export interface CatalogModelExtensionPoint { + setFieldValidators(validators: Partial): void; +} + +// @alpha (undocumented) +export const catalogModelExtensionPoint: ExtensionPoint; + // @alpha (undocumented) export interface CatalogPermissionExtensionPoint { // (undocumented) diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts index 3e958d1df1..91b5bb99b6 100644 --- a/plugins/catalog-node/src/alpha.ts +++ b/plugins/catalog-node/src/alpha.ts @@ -22,3 +22,5 @@ export { catalogAnalysisExtensionPoint } from './extensions'; export type { CatalogPermissionRuleInput } from './extensions'; export type { CatalogPermissionExtensionPoint } from './extensions'; export { catalogPermissionExtensionPoint } from './extensions'; +export type { CatalogModelExtensionPoint } from './extensions'; +export { catalogModelExtensionPoint } from './extensions'; diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index e2ee1b4f4b..763e934176 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -15,7 +15,7 @@ */ import { createExtensionPoint } from '@backstage/backend-plugin-api'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, Validators } from '@backstage/catalog-model'; import { CatalogProcessor, EntitiesSearchFilter, @@ -45,6 +45,18 @@ export interface CatalogProcessingExtensionPoint { ): void; } +/** @alpha */ +export interface CatalogModelExtensionPoint { + /** + * Sets the validator function to use for one or more special fields of an + * entity. This is useful if the default rules for formatting of fields are + * not sufficient. + * + * @param validators - The (subset of) validators to set + */ + setFieldValidators(validators: Partial): void; +} + /** * @alpha */ @@ -68,6 +80,12 @@ export const catalogAnalysisExtensionPoint = id: 'catalog.analysis', }); +/** @alpha */ +export const catalogModelExtensionPoint = + createExtensionPoint({ + id: 'catalog.model', + }); + /** * @alpha */