From e5fcfcb2cb7ecd54be07e2c469add19e77953aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 29 Mar 2026 22:16:59 +0200 Subject: [PATCH 01/13] Add catalog model layers with JSON Schema based kind declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/catalog-backend-model-processor.md | 5 + .changeset/catalog-model-layers.md | 5 + .changeset/catalog-node-model-registry.md | 5 + .../scaffolder-backend-model-registration.md | 5 + .../scaffolder-common-template-model.md | 5 + packages/catalog-model/package.json | 6 +- packages/catalog-model/report-alpha.api.md | 329 ++++++++ packages/catalog-model/src/alpha.ts | 34 +- .../src/kinds/ApiEntityV1alpha1.ts | 45 ++ .../src/kinds/ComponentEntityV1alpha1.ts | 73 ++ .../src/kinds/DomainEntityV1alpha1.ts | 45 ++ .../src/kinds/GroupEntityV1alpha1.ts | 53 ++ .../src/kinds/LocationEntityV1alpha1.ts | 30 + .../src/kinds/ResourceEntityV1alpha1.ts | 55 ++ .../src/kinds/SystemEntityV1alpha1.ts | 47 +- .../src/kinds/UserEntityV1alpha1.ts | 39 + .../catalog-model/src/kinds/annotations.ts | 59 ++ packages/catalog-model/src/kinds/index.ts | 17 +- packages/catalog-model/src/kinds/relations.ts | 97 +++ .../src/model/compileCatalogModel.test.ts | 757 ++++++++++++++++++ .../src/model/compileCatalogModel.ts | 702 ++++++++++++++++ .../src/model/createCatalogModelLayer.ts | 53 ++ .../model/createCatalogModelLayerBuilder.ts | 264 ++++++ .../src/model/defaultCatalogEntityModel.ts | 49 ++ packages/catalog-model/src/model/index.ts | 31 + .../src/model/jsonSchema/getAjv.ts | 37 + .../src/model/jsonSchema/index.ts | 17 + .../model/jsonSchema/mergeJsonSchemas.test.ts | 159 ++++ .../src/model/jsonSchema/mergeJsonSchemas.ts | 69 ++ .../src/model/jsonSchema/reduceKindSchema.ts | 69 ++ .../src/model/jsonSchema/util.test.ts | 85 ++ .../src/model/jsonSchema/util.ts | 61 ++ .../validateKindRootSchemaSemantics.test.ts | 159 ++++ .../validateKindRootSchemaSemantics.ts | 152 ++++ .../jsonSchema/validateMetaSchema.test.ts | 50 ++ .../model/jsonSchema/validateMetaSchema.ts | 58 ++ .../catalog-model/src/model/jsonSchema/zod.ts | 32 + .../model/modelActions/addAnnotation.test.ts | 81 ++ .../src/model/modelActions/addAnnotation.ts | 79 ++ .../src/model/modelActions/addKind.test.ts | 153 ++++ .../src/model/modelActions/addKind.ts | 200 +++++ .../src/model/modelActions/addLabel.test.ts | 81 ++ .../src/model/modelActions/addLabel.ts | 79 ++ .../modelActions/addRelationPair.test.ts | 156 ++++ .../src/model/modelActions/addRelationPair.ts | 110 +++ .../src/model/modelActions/addTag.test.ts | 55 ++ .../src/model/modelActions/addTag.ts | 55 ++ .../src/model/modelActions/index.ts | 37 + .../modelActions/removeAnnotation.test.ts | 32 + .../model/modelActions/removeAnnotation.ts | 36 + .../src/model/modelActions/removeKind.test.ts | 32 + .../src/model/modelActions/removeKind.ts | 36 + .../model/modelActions/removeLabel.test.ts | 32 + .../src/model/modelActions/removeLabel.ts | 36 + .../src/model/modelActions/removeTag.test.ts | 32 + .../src/model/modelActions/removeTag.ts | 36 + .../modelActions/updateAnnotation.test.ts | 78 ++ .../model/modelActions/updateAnnotation.ts | 79 ++ .../src/model/modelActions/updateKind.test.ts | 58 ++ .../src/model/modelActions/updateKind.ts | 152 ++++ .../model/modelActions/updateLabel.test.ts | 49 ++ .../src/model/modelActions/updateLabel.ts | 79 ++ .../modelActions/updateRelationPair.test.ts | 70 ++ .../model/modelActions/updateRelationPair.ts | 116 +++ .../src/model/modelActions/updateTag.test.ts | 38 + .../src/model/modelActions/updateTag.ts | 55 ++ .../operations/declareAnnotation.test.ts | 80 ++ .../src/model/operations/declareAnnotation.ts | 80 ++ .../src/model/operations/declareKind.test.ts | 84 ++ .../src/model/operations/declareKind.ts | 71 ++ .../operations/declareKindVersion.test.ts | 102 +++ .../model/operations/declareKindVersion.ts | 129 +++ .../src/model/operations/declareLabel.test.ts | 80 ++ .../src/model/operations/declareLabel.ts | 80 ++ .../model/operations/declareRelation.test.ts | 90 +++ .../src/model/operations/declareRelation.ts | 84 ++ .../src/model/operations/declareTag.test.ts | 68 ++ .../src/model/operations/declareTag.ts | 66 ++ .../src/model/operations/index.ts | 69 ++ .../model/operations/removeAnnotation.test.ts | 51 ++ .../src/model/operations/removeAnnotation.ts | 52 ++ .../src/model/operations/removeKind.test.ts | 51 ++ .../src/model/operations/removeKind.ts | 49 ++ .../src/model/operations/removeLabel.test.ts | 51 ++ .../src/model/operations/removeLabel.ts | 49 ++ .../src/model/operations/removeTag.test.ts | 51 ++ .../src/model/operations/removeTag.ts | 49 ++ .../model/operations/updateAnnotation.test.ts | 75 ++ .../src/model/operations/updateAnnotation.ts | 81 ++ .../src/model/operations/updateKind.test.ts | 77 ++ .../src/model/operations/updateKind.ts | 67 ++ .../operations/updateKindVersion.test.ts | 107 +++ .../src/model/operations/updateKindVersion.ts | 137 ++++ .../src/model/operations/updateLabel.test.ts | 73 ++ .../src/model/operations/updateLabel.ts | 81 ++ .../model/operations/updateRelation.test.ts | 89 ++ .../src/model/operations/updateRelation.ts | 85 ++ .../src/model/operations/updateTag.test.ts | 73 ++ .../src/model/operations/updateTag.ts | 64 ++ .../src/model/operations/util.test.ts | 100 +++ .../src/model/operations/util.ts | 162 ++++ .../src/model/sources/CatalogModelSources.ts | 50 ++ .../model/sources/StaticCatalogModelSource.ts | 42 + .../catalog-model/src/model/sources/index.ts | 22 + .../catalog-model/src/model/sources/types.ts | 67 ++ packages/catalog-model/src/model/types.ts | 210 +++++ plugins/catalog-backend/dev/index.ts | 5 + plugins/catalog-backend/package.json | 4 + .../catalog-backend/src/model/ModelHolder.ts | 87 ++ .../src/processors/ModelProcessor.test.ts | 487 +++++++++++ .../src/processors/ModelProcessor.ts | 169 ++++ .../src/processors/SchemaValidator.test.ts | 96 +++ .../src/processors/SchemaValidator.ts | 105 +++ .../src/service/CatalogBuilder.ts | 12 +- .../src/service/CatalogPlugin.ts | 42 +- plugins/catalog-node/package.json | 7 +- plugins/catalog-node/report-alpha.api.md | 9 + plugins/catalog-node/src/alpha.ts | 1 + plugins/catalog-node/src/extensions.ts | 13 +- .../src/provideStaticCatalogModel.ts | 63 ++ plugins/catalog-react/report-alpha.api.md | 2 +- plugins/catalog-react/report.api.md | 2 +- plugins/catalog/report-alpha.api.md | 2 +- plugins/catalog/report.api.md | 2 +- plugins/scaffolder-backend/dev/index.ts | 22 + plugins/scaffolder-common/report-alpha.api.md | 4 + plugins/scaffolder-common/src/alpha.ts | 1 + plugins/scaffolder-common/src/catalogModel.ts | 56 ++ plugins/scaffolder/report.api.md | 12 +- yarn.lock | 13 + 130 files changed, 10224 insertions(+), 30 deletions(-) create mode 100644 .changeset/catalog-backend-model-processor.md create mode 100644 .changeset/catalog-model-layers.md create mode 100644 .changeset/catalog-node-model-registry.md create mode 100644 .changeset/scaffolder-backend-model-registration.md create mode 100644 .changeset/scaffolder-common-template-model.md create mode 100644 packages/catalog-model/src/kinds/annotations.ts create mode 100644 packages/catalog-model/src/model/compileCatalogModel.test.ts create mode 100644 packages/catalog-model/src/model/compileCatalogModel.ts create mode 100644 packages/catalog-model/src/model/createCatalogModelLayer.ts create mode 100644 packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts create mode 100644 packages/catalog-model/src/model/defaultCatalogEntityModel.ts create mode 100644 packages/catalog-model/src/model/index.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/getAjv.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/index.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.test.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/reduceKindSchema.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/util.test.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/util.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.test.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/validateMetaSchema.test.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/validateMetaSchema.ts create mode 100644 packages/catalog-model/src/model/jsonSchema/zod.ts create mode 100644 packages/catalog-model/src/model/modelActions/addAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addAnnotation.ts create mode 100644 packages/catalog-model/src/model/modelActions/addKind.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addKind.ts create mode 100644 packages/catalog-model/src/model/modelActions/addLabel.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addLabel.ts create mode 100644 packages/catalog-model/src/model/modelActions/addRelationPair.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addRelationPair.ts create mode 100644 packages/catalog-model/src/model/modelActions/addTag.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addTag.ts create mode 100644 packages/catalog-model/src/model/modelActions/index.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeAnnotation.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeKind.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeKind.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeLabel.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeLabel.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeTag.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/removeTag.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateAnnotation.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateKind.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateKind.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateLabel.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateLabel.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateRelationPair.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateRelationPair.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateTag.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/updateTag.ts create mode 100644 packages/catalog-model/src/model/operations/declareAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareAnnotation.ts create mode 100644 packages/catalog-model/src/model/operations/declareKind.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareKind.ts create mode 100644 packages/catalog-model/src/model/operations/declareKindVersion.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareKindVersion.ts create mode 100644 packages/catalog-model/src/model/operations/declareLabel.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareLabel.ts create mode 100644 packages/catalog-model/src/model/operations/declareRelation.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareRelation.ts create mode 100644 packages/catalog-model/src/model/operations/declareTag.test.ts create mode 100644 packages/catalog-model/src/model/operations/declareTag.ts create mode 100644 packages/catalog-model/src/model/operations/index.ts create mode 100644 packages/catalog-model/src/model/operations/removeAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/operations/removeAnnotation.ts create mode 100644 packages/catalog-model/src/model/operations/removeKind.test.ts create mode 100644 packages/catalog-model/src/model/operations/removeKind.ts create mode 100644 packages/catalog-model/src/model/operations/removeLabel.test.ts create mode 100644 packages/catalog-model/src/model/operations/removeLabel.ts create mode 100644 packages/catalog-model/src/model/operations/removeTag.test.ts create mode 100644 packages/catalog-model/src/model/operations/removeTag.ts create mode 100644 packages/catalog-model/src/model/operations/updateAnnotation.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateAnnotation.ts create mode 100644 packages/catalog-model/src/model/operations/updateKind.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateKind.ts create mode 100644 packages/catalog-model/src/model/operations/updateKindVersion.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateKindVersion.ts create mode 100644 packages/catalog-model/src/model/operations/updateLabel.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateLabel.ts create mode 100644 packages/catalog-model/src/model/operations/updateRelation.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateRelation.ts create mode 100644 packages/catalog-model/src/model/operations/updateTag.test.ts create mode 100644 packages/catalog-model/src/model/operations/updateTag.ts create mode 100644 packages/catalog-model/src/model/operations/util.test.ts create mode 100644 packages/catalog-model/src/model/operations/util.ts create mode 100644 packages/catalog-model/src/model/sources/CatalogModelSources.ts create mode 100644 packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts create mode 100644 packages/catalog-model/src/model/sources/index.ts create mode 100644 packages/catalog-model/src/model/sources/types.ts create mode 100644 packages/catalog-model/src/model/types.ts create mode 100644 plugins/catalog-backend/src/model/ModelHolder.ts create mode 100644 plugins/catalog-backend/src/processors/ModelProcessor.test.ts create mode 100644 plugins/catalog-backend/src/processors/ModelProcessor.ts create mode 100644 plugins/catalog-backend/src/processors/SchemaValidator.test.ts create mode 100644 plugins/catalog-backend/src/processors/SchemaValidator.ts create mode 100644 plugins/catalog-node/src/provideStaticCatalogModel.ts create mode 100644 plugins/scaffolder-backend/dev/index.ts create mode 100644 plugins/scaffolder-common/src/catalogModel.ts diff --git a/.changeset/catalog-backend-model-processor.md b/.changeset/catalog-backend-model-processor.md new file mode 100644 index 0000000000..f32bb6e261 --- /dev/null +++ b/.changeset/catalog-backend-model-processor.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added `ModelProcessor` that validates catalog entities against the compiled catalog model schemas, and integrated it into the `CatalogBuilder` and `CatalogPlugin`. diff --git a/.changeset/catalog-model-layers.md b/.changeset/catalog-model-layers.md new file mode 100644 index 0000000000..ddb0943043 --- /dev/null +++ b/.changeset/catalog-model-layers.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Added a new catalog model layer system that allows plugins to declare and extend catalog entity kinds, annotations, labels, tags, and relations using JSON Schema. The new `createCatalogModelLayer` API provides a builder for composing model definitions, and a `compileCatalogModel` function validates and merges them into a unified model. Built-in entity kinds now include model layer definitions. diff --git a/.changeset/catalog-node-model-registry.md b/.changeset/catalog-node-model-registry.md new file mode 100644 index 0000000000..299614f77a --- /dev/null +++ b/.changeset/catalog-node-model-registry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +Added `provideStaticCatalogModel` that helps provide a static catalog model at startup. diff --git a/.changeset/scaffolder-backend-model-registration.md b/.changeset/scaffolder-backend-model-registration.md new file mode 100644 index 0000000000..57e5b71889 --- /dev/null +++ b/.changeset/scaffolder-backend-model-registration.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Integrated the Template kind model layer with the catalog model registry service. diff --git a/.changeset/scaffolder-common-template-model.md b/.changeset/scaffolder-common-template-model.md new file mode 100644 index 0000000000..1ffa9e4935 --- /dev/null +++ b/.changeset/scaffolder-common-template-model.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': minor +--- + +Added a Template kind model layer with JSON Schema definition, serving as an example of how plugins can declare their own catalog entity kinds. diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 72a09713d2..cd16dacc84 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -49,9 +49,13 @@ }, "dependencies": { "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/types": "workspace:^", "ajv": "^8.10.0", - "lodash": "^4.17.21" + "ajv-errors": "^3.0.0", + "lodash": "^4.17.21", + "zod": "^3.25.76", + "zod-validation-error": "^4.0.2" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 5b962a7403..7102ff69f4 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -4,6 +4,8 @@ ```ts import { Entity } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/types'; +import { JsonValue } from '@backstage/types'; import { SerializedError } from '@backstage/errors'; // @alpha @@ -11,6 +13,333 @@ export interface AlphaEntity extends Entity { status?: EntityStatus; } +// @alpha +export type AsyncCatalogModelSourceGenerator = AsyncGenerator< + { + layers: CatalogModelLayer[]; + }, + void, + void +>; + +// @alpha +export interface CatalogModel { + getKind(options: { + kind: string; + apiVersion: string; + spec?: { + type?: string; + }; + }): CatalogModelKind | undefined; + getRelations(options: { kind: string }): CatalogModelRelation[] | undefined; +} + +// @alpha +export interface CatalogModelAnnotationDefinition { + description: string; + name: string; + schema?: { + jsonSchema: JsonObject; + }; + title?: string; +} + +// @alpha +export interface CatalogModelKind { + apiVersions: string[]; + jsonSchema: JsonObject; + names: { + kind: string; + singular: string; + plural: string; + }; + relationFields: Array<{ + path: string; + relation: string; + defaultKind?: string; + defaultNamespace?: 'inherit' | 'default'; + allowedKinds?: string[]; + }>; +} + +// @alpha +export interface CatalogModelKindDefinition { + description: string; + group: string; + names: { + kind: string; + singular: string; + plural: string; + }; + versions?: CatalogModelKindVersionDefinition[]; +} + +// @alpha (undocumented) +export interface CatalogModelKindRelationFieldDefinition { + allowedKinds?: string[]; + defaultKind?: string; + defaultNamespace?: 'default' | 'inherit'; + relation: string; + selector: { + path: string; + }; +} + +// @alpha +export interface CatalogModelKindRootSchema extends JsonObject { + // (undocumented) + $ref?: never; + // (undocumented) + [key: string]: JsonValue | undefined; + // (undocumented) + allOf?: never; + // (undocumented) + anyOf?: never; + // (undocumented) + else?: never; + // (undocumented) + if?: never; + // (undocumented) + not?: never; + // (undocumented) + oneOf?: never; + // (undocumented) + properties?: + | undefined + | { + kind?: never; + apiVersion?: never; + metadata?: never; + $ref?: never; + [key: string]: + | undefined + | { + allOf?: never; + anyOf?: never; + oneOf?: never; + if?: never; + then?: never; + else?: never; + not?: never; + $ref?: never; + [key: string]: JsonValue | undefined; + }; + }; + // (undocumented) + then?: never; + // (undocumented) + type: 'object'; +} + +// @alpha +export interface CatalogModelKindVersionDefinition { + description?: string; + name: string | string[]; + relationFields?: CatalogModelKindRelationFieldDefinition[]; + // (undocumented) + schema: { + jsonSchema: JsonObject; + }; + specType?: string | string[]; +} + +// @alpha +export interface CatalogModelLabelDefinition { + description: string; + name: string; + schema?: { + jsonSchema: JsonObject; + }; + title?: string; +} + +// @alpha +export interface CatalogModelLayer { + // (undocumented) + readonly $$type: '@backstage/CatalogModelLayer'; + readonly layerId: string; +} + +// @alpha +export interface CatalogModelLayerBuilder { + addAnnotation(annotation: CatalogModelAnnotationDefinition): void; + addKind(kind: CatalogModelKindDefinition): void; + addLabel(label: CatalogModelLabelDefinition): void; + addRelationPair(relation: CatalogModelRelationPairDefinition): void; + addTag(tag: CatalogModelTagDefinition): void; + import(layer: CatalogModelLayer): void; + removeAnnotation(annotation: CatalogModelRemoveAnnotationDefinition): void; + removeKind(kind: CatalogModelRemoveKindDefinition): void; + removeLabel(label: CatalogModelRemoveLabelDefinition): void; + removeTag(tag: CatalogModelRemoveTagDefinition): void; + updateAnnotation(annotation: CatalogModelUpdateAnnotationDefinition): void; + updateKind(kind: CatalogModelUpdateKindDefinition): void; + updateLabel(label: CatalogModelUpdateLabelDefinition): void; + updateRelationPair(relation: CatalogModelUpdateRelationPairDefinition): void; + updateTag(tag: CatalogModelUpdateTagDefinition): void; +} + +// @alpha +export interface CatalogModelRelation { + description: string; + forward: { + type: string; + title: string; + }; + fromKind: string[]; + reverse: { + type: string; + title: string; + }; + toKind: string[]; +} + +// @alpha +export interface CatalogModelRelationPairDefinition { + description: string; + forward: { + type: string; + title: string; + }; + fromKind: string | string[]; + reverse: { + type: string; + title: string; + }; + toKind: string | string[]; +} + +// @alpha +export interface CatalogModelRemoveAnnotationDefinition { + name: string; +} + +// @alpha +export interface CatalogModelRemoveKindDefinition { + kind: string; +} + +// @alpha +export interface CatalogModelRemoveLabelDefinition { + name: string; +} + +// @alpha +export interface CatalogModelRemoveTagDefinition { + name: string; +} + +// @alpha +export interface CatalogModelSource { + read( + options?: CatalogModelSourceReadOptions, + ): AsyncCatalogModelSourceGenerator; +} + +// @alpha +export interface CatalogModelSourceReadOptions { + // (undocumented) + signal?: AbortSignal; +} + +// @alpha +export class CatalogModelSources { + static default(): CatalogModelSource; + static static(layers: CatalogModelLayer[]): CatalogModelSource; +} + +// @alpha +export interface CatalogModelTagDefinition { + description: string; + name: string; + title?: string; +} + +// @alpha +export interface CatalogModelUpdateAnnotationDefinition { + description?: string; + name: string; + schema?: { + jsonSchema: JsonObject; + }; + title?: string; +} + +// @alpha +export interface CatalogModelUpdateKindDefinition { + description?: string; + names: { + kind: string; + singular?: string; + plural?: string; + }; + versions?: CatalogModelUpdateKindVersionDefinition[]; +} + +// @alpha +export interface CatalogModelUpdateKindVersionDefinition { + description?: string; + name: string | string[]; + relationFields?: CatalogModelKindRelationFieldDefinition[]; + schema?: { + jsonSchema: JsonObject; + }; + specType?: string | string[]; +} + +// @alpha +export interface CatalogModelUpdateLabelDefinition { + description?: string; + name: string; + schema?: { + jsonSchema: JsonObject; + }; + title?: string; +} + +// @alpha +export interface CatalogModelUpdateRelationPairDefinition { + description?: string; + forward: { + type: string; + title?: string; + }; + fromKind: string | string[]; + reverse: { + type?: string; + title?: string; + }; + toKind: string | string[]; +} + +// @alpha +export interface CatalogModelUpdateTagDefinition { + description?: string; + name: string; + title?: string; +} + +// @alpha +export function compileCatalogModel( + inputs: Iterable, +): CatalogModel; + +// @alpha +export function createCatalogModelLayer(options: { + layerId: string; + builder: (model: CatalogModelLayerBuilder) => void; +}): CatalogModelLayer; + +// @alpha +export function createCatalogModelLayerBuilder(options: { + layerId: string; +}): CatalogModelLayerBuilder & { + build(): CatalogModelLayer; +}; + +// @alpha (undocumented) +export const defaultCatalogEntityModel: CatalogModelLayer; + // @alpha export type EntityStatus = { items?: EntityStatusItem[]; diff --git a/packages/catalog-model/src/alpha.ts b/packages/catalog-model/src/alpha.ts index 1d29c97464..c924fc1b06 100644 --- a/packages/catalog-model/src/alpha.ts +++ b/packages/catalog-model/src/alpha.ts @@ -14,9 +14,41 @@ * limitations under the License. */ +import { createCatalogModelLayer } from './model/createCatalogModelLayer'; +import { apiEntityModel } from './kinds/ApiEntityV1alpha1'; +import { componentEntityModel } from './kinds/ComponentEntityV1alpha1'; +import { domainEntityModel } from './kinds/DomainEntityV1alpha1'; +import { groupEntityModel } from './kinds/GroupEntityV1alpha1'; +import { locationEntityModel } from './kinds/LocationEntityV1alpha1'; +import { resourceEntityModel } from './kinds/ResourceEntityV1alpha1'; +import { systemEntityModel } from './kinds/SystemEntityV1alpha1'; +import { userEntityModel } from './kinds/UserEntityV1alpha1'; +import { wellKnownAnnotationsModel } from './kinds/annotations'; +import { wellKnownRelationsModel } from './kinds/relations'; + +export type { AlphaEntity } from './entity/AlphaEntity'; export type { EntityStatus, EntityStatusItem, EntityStatusLevel, } from './entity/EntityStatus'; -export type { AlphaEntity } from './entity/AlphaEntity'; +export * from './model'; + +/** + * @alpha + */ +export const defaultCatalogEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/default-entity-model', + builder: model => { + model.import(apiEntityModel); + model.import(componentEntityModel); + model.import(domainEntityModel); + model.import(groupEntityModel); + model.import(locationEntityModel); + model.import(resourceEntityModel); + model.import(systemEntityModel); + model.import(userEntityModel); + model.import(wellKnownRelationsModel); + model.import(wellKnownAnnotationsModel); + }, +}); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index d733b03c8c..d29f0d57a4 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/API.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -46,3 +47,47 @@ export interface ApiEntityV1alpha1 extends Entity { */ export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the API kind. + * + * @alpha + */ +export const apiEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-api', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'API', + singular: 'api', + plural: 'apis', + }, + description: + 'An API describes an interface that can be exposed by a component.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 9a68d26cac..0df197cf38 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Component.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -50,3 +51,75 @@ export interface ComponentEntityV1alpha1 extends Entity { */ export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the Component kind. + * + * @alpha + */ +export const componentEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-component', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'Component', + singular: 'component', + plural: 'components', + }, + description: + 'A Component describes a software component, usually with a distinct deployable or linkable artifact.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.subcomponentOf' }, + relation: 'partOf', + defaultKind: 'Component', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.providesApis' }, + relation: 'providesApi', + defaultKind: 'API', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.consumesApis' }, + relation: 'consumesApi', + defaultKind: 'API', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.dependsOn' }, + relation: 'dependsOn', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.dependencyOf' }, + relation: 'dependencyOf', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts index 765329abb4..5b223ca419 100644 --- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -44,3 +45,47 @@ export interface DomainEntityV1alpha1 extends Entity { */ export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the Domain kind. + * + * @alpha + */ +export const domainEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-domain', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'Domain', + singular: 'domain', + plural: 'domains', + }, + description: + 'A Domain groups a collection of systems that share terminology, domain models, business purpose, or documentation.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.subdomainOf' }, + relation: 'partOf', + defaultKind: 'Domain', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 8d88817dbe..ac5832f05b 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Group.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -45,3 +46,55 @@ export interface GroupEntityV1alpha1 extends Entity { */ export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the Group kind. + * + * @alpha + */ +export const groupEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-group', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'Group', + singular: 'group', + plural: 'groups', + }, + description: + 'A Group describes an organizational entity, such as a team, a business unit, or a loose collection of people.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.parent' }, + relation: 'childOf', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group'], + }, + { + selector: { path: 'spec.children' }, + relation: 'parentOf', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group'], + }, + { + selector: { path: 'spec.members' }, + relation: 'hasMember', + defaultKind: 'User', + defaultNamespace: 'inherit', + allowedKinds: ['User'], + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 93a218aa2d..3ddcfcb17d 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Location.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -41,3 +42,32 @@ export interface LocationEntityV1alpha1 extends Entity { */ export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the Location kind. + * + * @alpha + */ +export const locationEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-location', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'Location', + singular: 'location', + plural: 'locations', + }, + description: + 'A Location is a marker that references other places to look for catalog data.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index a1d49dd0a6..16c1697616 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -46,3 +47,57 @@ export interface ResourceEntityV1alpha1 extends Entity { */ export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the Resource kind. + * + * @alpha + */ +export const resourceEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-resource', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'Resource', + singular: 'resource', + plural: 'resources', + }, + description: + 'A Resource describes the infrastructure a system needs to operate, like databases, topics, or buckets.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.dependsOn' }, + relation: 'dependsOn', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.dependencyOf' }, + relation: 'dependencyOf', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts index 37ecee8f6c..cfd700e3ae 100644 --- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts @@ -14,9 +14,10 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import { ajvCompiledJsonSchemaValidator } from './util'; import schema from '../schema/kinds/System.v1alpha1.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; /** * Backstage catalog System kind Entity. Systems group Components, Resources and APIs together. @@ -44,3 +45,47 @@ export interface SystemEntityV1alpha1 extends Entity { */ export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the System kind. + * + * @alpha + */ +export const systemEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-system', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'System', + singular: 'system', + plural: 'systems', + }, + description: + 'A System is a collection of resources and components that exposes one or several APIs.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.domain' }, + relation: 'partOf', + defaultKind: 'Domain', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index 55c9b176ea..8da2525a06 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/User.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -43,3 +44,41 @@ export interface UserEntityV1alpha1 extends Entity { */ export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(schema); + +/** + * Extends the catalog model with the User kind. + * + * @alpha + */ +export const userEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-user', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'User', + singular: 'user', + plural: 'users', + }, + description: + 'A User describes a person, such as an employee or a contractor.', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + relationFields: [ + { + selector: { path: 'spec.memberOf' }, + relation: 'memberOf', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group'], + }, + ], + schema: { + jsonSchema: schema, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/annotations.ts b/packages/catalog-model/src/kinds/annotations.ts new file mode 100644 index 0000000000..37b201e65d --- /dev/null +++ b/packages/catalog-model/src/kinds/annotations.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 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 { createCatalogModelLayer } from '../model/createCatalogModelLayer'; +import { ANNOTATION_EDIT_URL, ANNOTATION_VIEW_URL } from '../entity'; +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + ANNOTATION_SOURCE_LOCATION, +} from '../location'; + +export const wellKnownAnnotationsModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/well-known-annotations', + builder: model => { + model.addAnnotation({ + name: ANNOTATION_LOCATION, + description: + 'The location reference that the catalog uses to manage and update the entity.', + }); + model.addAnnotation({ + name: ANNOTATION_ORIGIN_LOCATION, + description: + 'The original location reference that first discovered the entity.', + }); + model.addAnnotation({ + name: ANNOTATION_SOURCE_LOCATION, + description: + 'The location reference of the source data for the entity, e.g. a file in a repository.', + }); + model.addAnnotation({ + name: 'backstage.io/orphan', + description: + 'Set to "true" when the entity is not referenced by any location and is scheduled for deletion.', + }); + model.addAnnotation({ + name: ANNOTATION_VIEW_URL, + description: + 'A URL to view the entity in an external tool, e.g. a source code repository.', + }); + model.addAnnotation({ + name: ANNOTATION_EDIT_URL, + description: + 'A URL to edit the entity in an external tool, e.g. a source code repository.', + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 7211c25a8d..674e4eef30 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -39,7 +39,22 @@ export type { LocationEntityV1alpha1 as LocationEntity, LocationEntityV1alpha1, } from './LocationEntityV1alpha1'; -export * from './relations'; +export { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CHILD_OF, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_MEMBER, + RELATION_HAS_PART, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PARENT_OF, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from './relations'; export { resourceEntityV1alpha1Validator } from './ResourceEntityV1alpha1'; export type { ResourceEntityV1alpha1 as ResourceEntity, diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts index 69f9aae1c4..25856b6eb5 100644 --- a/packages/catalog-model/src/kinds/relations.ts +++ b/packages/catalog-model/src/kinds/relations.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; + /* Naming rules for relations in priority order: @@ -136,3 +138,98 @@ export const RELATION_PART_OF = 'partOf'; * @public */ export const RELATION_HAS_PART = 'hasPart'; + +/** + * Extends the catalog model with the well-known Backstage relation pairs. + */ +export const wellKnownRelationsModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/well-known-relations', + builder: model => { + model.addRelationPair({ + fromKind: [ + 'API', + 'Component', + 'Domain', + 'Group', + 'Location', + 'Resource', + 'System', + 'User', + ], + toKind: ['Group', 'User'], + description: + 'An ownership relation where the owner is usually an organizational entity (user or group), and the other entity can be anything.', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }); + + model.addRelationPair({ + fromKind: 'Component', + toKind: 'API', + description: + 'A relation from a component to an API it provides for consumption by others.', + forward: { type: 'providesApi', title: 'provides API' }, + reverse: { type: 'apiProvidedBy', title: 'API provided by' }, + }); + + model.addRelationPair({ + fromKind: 'Component', + toKind: 'API', + description: 'A relation from a component to an API it consumes.', + forward: { type: 'consumesApi', title: 'consumes API' }, + reverse: { type: 'apiConsumedBy', title: 'API consumed by' }, + }); + + model.addRelationPair({ + fromKind: ['Component', 'Resource'], + toKind: ['Component', 'Resource'], + description: + 'A dependency relation expressing that an entity needs another entity to function.', + forward: { type: 'dependsOn', title: 'depends on' }, + reverse: { type: 'dependencyOf', title: 'dependency of' }, + }); + + model.addRelationPair({ + fromKind: 'Group', + toKind: 'Group', + description: + 'A parent/child relation to build up a tree, used for example to describe the organizational structure between groups.', + forward: { type: 'parentOf', title: 'parent of' }, + reverse: { type: 'childOf', title: 'child of' }, + }); + + model.addRelationPair({ + fromKind: 'User', + toKind: 'Group', + description: 'A membership relation, typically for users in a group.', + forward: { type: 'memberOf', title: 'member of' }, + reverse: { type: 'hasMember', title: 'has member' }, + }); + + model.addRelationPair({ + fromKind: ['Component', 'API', 'Resource'], + toKind: ['Component', 'System'], + description: + 'A part/whole relation where a component, API, or resource belongs to a system or a component is a subcomponent of another.', + forward: { type: 'partOf', title: 'part of' }, + reverse: { type: 'hasPart', title: 'has part' }, + }); + + model.addRelationPair({ + fromKind: 'System', + toKind: 'Domain', + description: 'A part/whole relation where a system belongs to a domain.', + forward: { type: 'partOf', title: 'part of' }, + reverse: { type: 'hasPart', title: 'has part' }, + }); + + model.addRelationPair({ + fromKind: 'Domain', + toKind: 'Domain', + description: + 'A part/whole relation where a domain is a subdomain of another domain.', + forward: { type: 'partOf', title: 'part of' }, + reverse: { type: 'hasPart', title: 'has part' }, + }); + }, +}); diff --git a/packages/catalog-model/src/model/compileCatalogModel.test.ts b/packages/catalog-model/src/model/compileCatalogModel.test.ts new file mode 100644 index 0000000000..b2a228650c --- /dev/null +++ b/packages/catalog-model/src/model/compileCatalogModel.test.ts @@ -0,0 +1,757 @@ +/* + * Copyright 2026 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 Ajv from 'ajv'; +import { createCatalogModelLayer } from './createCatalogModelLayer'; +import { compileCatalogModel } from './compileCatalogModel'; + +const layer = createCatalogModelLayer({ + layerId: 'Test', + builder: model => { + model.addKind({ + group: 'example.com', + names: { kind: 'Widget', singular: 'widget', plural: 'widgets' }, + description: 'A test widget kind', + versions: [ + { + name: 'v1alpha1', + schema: { + jsonSchema: { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['size'], + properties: { + size: { type: 'number' }, + }, + }, + }, + }, + }, + }, + ], + }); + }, +}); + +function compileAndValidate(entity: unknown): boolean { + const model = compileCatalogModel([layer]); + const kind = model.getKind({ + kind: 'Widget', + apiVersion: 'example.com/v1alpha1', + }); + if (!kind) { + throw new Error('Kind not found'); + } + const ajv = new Ajv({ allowUnionTypes: true, allErrors: true }); + const validate = ajv.compile(kind.jsonSchema); + return validate(entity) as boolean; +} + +describe('compileCatalogModel', () => { + it('should validate a complete entity successfully', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1alpha1', + kind: 'Widget', + metadata: { name: 'my-widget' }, + spec: { size: 42 }, + }), + ).toBe(true); + }); + + it('should fail when metadata.name is missing', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1alpha1', + kind: 'Widget', + metadata: {}, + spec: { size: 42 }, + }), + ).toBe(false); + }); + + it('should fail when a required spec field is missing', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1alpha1', + kind: 'Widget', + metadata: { name: 'my-widget' }, + spec: {}, + }), + ).toBe(false); + }); + + it('should fail when a spec field has the wrong type', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1alpha1', + kind: 'Widget', + metadata: { name: 'my-widget' }, + spec: { size: 'large' }, + }), + ).toBe(false); + }); + + it('should fail when kind does not match', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1alpha1', + kind: 'Other', + metadata: { name: 'my-widget' }, + spec: { size: 42 }, + }), + ).toBe(false); + }); + + it('should fail when apiVersion does not match', () => { + expect( + compileAndValidate({ + apiVersion: 'example.com/v1beta1', + kind: 'Widget', + metadata: { name: 'my-widget' }, + spec: { size: 42 }, + }), + ).toBe(false); + }); + + it('should return undefined for an unknown kind', () => { + const model = compileCatalogModel([layer]); + expect( + model.getKind({ kind: 'Unknown', apiVersion: 'example.com/v1alpha1' }), + ).toBeUndefined(); + }); +}); + +describe('compileCatalogModel specType', () => { + const specTypeLayer = createCatalogModelLayer({ + layerId: 'SpecType', + builder: model => { + model.addKind({ + group: 'example.com', + names: { + kind: 'Component', + singular: 'component', + plural: 'components', + }, + description: 'A component', + versions: [ + { + name: 'v1alpha1', + schema: { + jsonSchema: { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['lifecycle'], + properties: { + lifecycle: { type: 'string' }, + }, + }, + }, + }, + }, + }, + { + name: 'v1alpha1', + specType: 'service', + description: 'A service component', + schema: { + jsonSchema: { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['lifecycle', 'port'], + properties: { + lifecycle: { type: 'string' }, + port: { type: 'number' }, + }, + }, + }, + }, + }, + }, + ], + }); + }, + }); + + it('should return the default version when no spec type is given', () => { + const model = compileCatalogModel([specTypeLayer]); + const kind = model.getKind({ + kind: 'Component', + apiVersion: 'example.com/v1alpha1', + }); + expect(kind).toBeDefined(); + // The default version requires lifecycle but not port + const specSchema = (kind!.jsonSchema as any).properties.spec; + expect(specSchema.required).toEqual(['lifecycle']); + expect(specSchema.properties.port).toBeUndefined(); + }); + + it('should return the typed version when a matching spec type is given', () => { + const model = compileCatalogModel([specTypeLayer]); + const kind = model.getKind({ + kind: 'Component', + apiVersion: 'example.com/v1alpha1', + spec: { type: 'service' }, + }); + expect(kind).toBeDefined(); + // The service version requires both lifecycle and port + const specSchema = (kind!.jsonSchema as any).properties.spec; + expect(specSchema.required).toEqual(['lifecycle', 'port']); + expect(specSchema.properties.port).toEqual({ type: 'number' }); + }); + + it('should fall back to the default version for an unknown spec type', () => { + const model = compileCatalogModel([specTypeLayer]); + const kind = model.getKind({ + kind: 'Component', + apiVersion: 'example.com/v1alpha1', + spec: { type: 'unknown-type' }, + }); + expect(kind).toBeDefined(); + // Falls back to the default version + const specSchema = (kind!.jsonSchema as any).properties.spec; + expect(specSchema.required).toEqual(['lifecycle']); + expect(specSchema.properties.port).toBeUndefined(); + }); +}); + +describe('compileCatalogModel integration', () => { + it('should support the full add/update/remove lifecycle', () => { + // Step 1: Add one of everything + const base = createCatalogModelLayer({ + layerId: 'Base', + builder: model => { + model.addKind({ + group: 'example.com', + names: { kind: 'Widget', singular: 'widget', plural: 'widgets' }, + description: 'A widget', + versions: [ + { + name: 'v1alpha1', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + ], + schema: { + jsonSchema: { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['size'], + properties: { + size: { type: 'number' }, + }, + }, + }, + }, + }, + }, + ], + }); + + model.addRelationPair({ + fromKind: 'Widget', + toKind: ['Group', 'User'], + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }); + + model.addAnnotation({ + name: 'example.com/docs-url', + title: 'Docs URL', + description: 'Link to docs', + schema: { jsonSchema: { type: 'string', minLength: 1 } }, + }); + + model.addLabel({ + name: 'example.com/tier', + title: 'Tier', + description: 'Service tier', + schema: { jsonSchema: { type: 'string', enum: ['gold', 'silver'] } }, + }); + + model.addTag({ + name: 'production', + title: 'Production', + description: 'Production-ready', + }); + }, + }); + + // Verify base state + const model1 = compileCatalogModel([base]); + + const kind1 = model1.getKind({ + kind: 'Widget', + apiVersion: 'example.com/v1alpha1', + }); + expect(kind1).toEqual({ + apiVersions: ['example.com/v1alpha1'], + names: { kind: 'Widget', singular: 'widget', plural: 'widgets' }, + relationFields: [ + { + path: 'spec.owner', + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + ], + jsonSchema: { + type: 'object', + required: ['spec', 'apiVersion', 'kind', 'metadata'], + additionalProperties: false, + properties: { + apiVersion: { const: 'example.com/v1alpha1' }, + kind: { const: 'Widget' }, + metadata: { + type: 'object', + required: ['name'], + additionalProperties: true, + properties: { + uid: { + type: 'string', + description: 'A globally unique ID for the entity.', + minLength: 1, + }, + etag: { + type: 'string', + description: + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + minLength: 1, + }, + name: { + type: 'string', + description: + 'The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.', + minLength: 1, + }, + namespace: { + type: 'string', + description: 'The namespace that the entity belongs to.', + default: 'default', + minLength: 1, + }, + title: { + type: 'string', + description: + 'A display name of the entity, to be presented in user interfaces instead of the name property, when available.', + minLength: 1, + }, + description: { + type: 'string', + description: + 'A short (typically relatively few words, on one line) description of the entity.', + }, + annotations: { + type: 'object', + description: + 'Key/value pairs of non-identifying auxiliary information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: { + 'example.com/docs-url': { type: 'string', minLength: 1 }, + }, + }, + labels: { + type: 'object', + description: + 'Key/value pairs of identifying information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: { + 'example.com/tier': { + type: 'string', + enum: ['gold', 'silver'], + }, + }, + }, + tags: { + type: 'array', + description: + 'A list of single-valued strings, to for example classify catalog entities in various ways.', + items: { type: 'string', minLength: 1 }, + }, + links: { + type: 'array', + description: + 'A list of external hyperlinks related to the entity.', + items: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1 }, + icon: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }, + spec: { + type: 'object', + required: ['size'], + properties: { + size: { type: 'number' }, + }, + }, + }, + }, + }); + + expect(model1.getRelations({ kind: 'Widget' })).toEqual([ + { + fromKind: ['Widget'], + toKind: ['Group', 'User'], + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }, + ]); + + // Step 2: Update everything + const updates = createCatalogModelLayer({ + layerId: 'Updates', + builder: model => { + model.updateKind({ + names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' }, + description: 'An updated widget', + versions: [ + { + name: 'v1alpha1', + schema: { + jsonSchema: { + type: 'object', + properties: { + spec: { + type: 'object', + properties: { + color: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + }); + + model.updateAnnotation({ + name: 'example.com/docs-url', + title: 'Documentation URL', + description: 'Updated link to docs', + }); + + model.updateLabel({ + name: 'example.com/tier', + description: 'Updated tier', + }); + + model.updateTag({ + name: 'production', + description: 'Updated production tag', + }); + }, + }); + + const model2 = compileCatalogModel([base, updates]); + + const kind2 = model2.getKind({ + kind: 'Widget', + apiVersion: 'example.com/v1alpha1', + }); + expect(kind2).toEqual({ + apiVersions: ['example.com/v1alpha1'], + names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' }, + relationFields: [ + { + path: 'spec.owner', + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + ], + jsonSchema: { + type: 'object', + required: ['spec', 'apiVersion', 'kind', 'metadata'], + additionalProperties: false, + properties: { + apiVersion: { const: 'example.com/v1alpha1' }, + kind: { const: 'Widget' }, + metadata: { + type: 'object', + required: ['name'], + additionalProperties: true, + properties: { + uid: { + type: 'string', + description: 'A globally unique ID for the entity.', + minLength: 1, + }, + etag: { + type: 'string', + description: + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + minLength: 1, + }, + name: { + type: 'string', + description: + 'The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.', + minLength: 1, + }, + namespace: { + type: 'string', + description: 'The namespace that the entity belongs to.', + default: 'default', + minLength: 1, + }, + title: { + type: 'string', + description: + 'A display name of the entity, to be presented in user interfaces instead of the name property, when available.', + minLength: 1, + }, + description: { + type: 'string', + description: + 'A short (typically relatively few words, on one line) description of the entity.', + }, + annotations: { + type: 'object', + description: + 'Key/value pairs of non-identifying auxiliary information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: { + 'example.com/docs-url': { type: 'string', minLength: 1 }, + }, + }, + labels: { + type: 'object', + description: + 'Key/value pairs of identifying information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: { + 'example.com/tier': { + type: 'string', + enum: ['gold', 'silver'], + }, + }, + }, + tags: { + type: 'array', + description: + 'A list of single-valued strings, to for example classify catalog entities in various ways.', + items: { type: 'string', minLength: 1 }, + }, + links: { + type: 'array', + description: + 'A list of external hyperlinks related to the entity.', + items: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1 }, + icon: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }, + spec: { + type: 'object', + required: ['size'], + properties: { + size: { type: 'number' }, + color: { type: 'string' }, + }, + }, + }, + }, + }); + + expect(model2.getRelations({ kind: 'Widget' })).toEqual([ + { + fromKind: ['Widget'], + toKind: ['Group', 'User'], + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }, + ]); + + // Step 3: Remove things + const removals = createCatalogModelLayer({ + layerId: 'Removals', + builder: model => { + model.removeAnnotation({ name: 'example.com/docs-url' }); + model.removeLabel({ name: 'example.com/tier' }); + model.removeTag({ name: 'production' }); + }, + }); + + const model3 = compileCatalogModel([base, updates, removals]); + + const kind3 = model3.getKind({ + kind: 'Widget', + apiVersion: 'example.com/v1alpha1', + }); + expect(kind3).toEqual({ + apiVersions: ['example.com/v1alpha1'], + names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' }, + relationFields: [ + { + path: 'spec.owner', + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + ], + jsonSchema: { + type: 'object', + required: ['spec', 'apiVersion', 'kind', 'metadata'], + additionalProperties: false, + properties: { + apiVersion: { const: 'example.com/v1alpha1' }, + kind: { const: 'Widget' }, + metadata: { + type: 'object', + required: ['name'], + additionalProperties: true, + properties: { + uid: { + type: 'string', + description: 'A globally unique ID for the entity.', + minLength: 1, + }, + etag: { + type: 'string', + description: + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + minLength: 1, + }, + name: { + type: 'string', + description: + 'The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.', + minLength: 1, + }, + namespace: { + type: 'string', + description: 'The namespace that the entity belongs to.', + default: 'default', + minLength: 1, + }, + title: { + type: 'string', + description: + 'A display name of the entity, to be presented in user interfaces instead of the name property, when available.', + minLength: 1, + }, + description: { + type: 'string', + description: + 'A short (typically relatively few words, on one line) description of the entity.', + }, + annotations: { + type: 'object', + description: + 'Key/value pairs of non-identifying auxiliary information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: {}, + }, + labels: { + type: 'object', + description: + 'Key/value pairs of identifying information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: {}, + }, + tags: { + type: 'array', + description: + 'A list of single-valued strings, to for example classify catalog entities in various ways.', + items: { type: 'string', minLength: 1 }, + }, + links: { + type: 'array', + description: + 'A list of external hyperlinks related to the entity.', + items: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1 }, + icon: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }, + spec: { + type: 'object', + required: ['size'], + properties: { + size: { type: 'number' }, + color: { type: 'string' }, + }, + }, + }, + }, + }); + + // Step 4: Remove the kind entirely + const kindRemoval = createCatalogModelLayer({ + layerId: 'KindRemoval', + builder: model => { + model.removeKind({ kind: 'Widget' }); + }, + }); + + const model4 = compileCatalogModel([base, updates, removals, kindRemoval]); + + expect( + model4.getKind({ + kind: 'Widget', + apiVersion: 'example.com/v1alpha1', + }), + ).toBeUndefined(); + expect(model4.getRelations({ kind: 'Widget' })).toBeUndefined(); + }); +}); diff --git a/packages/catalog-model/src/model/compileCatalogModel.ts b/packages/catalog-model/src/model/compileCatalogModel.ts new file mode 100644 index 0000000000..fb3b337612 --- /dev/null +++ b/packages/catalog-model/src/model/compileCatalogModel.ts @@ -0,0 +1,702 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; +import lodash from 'lodash'; +import { mergeJsonSchemas } from './jsonSchema/mergeJsonSchemas'; +import { CatalogModelOp } from './operations'; +import { ops } from './operations/util'; +import { OpDeclareAnnotationV1 } from './operations/declareAnnotation'; +import { OpDeclareKindV1 } from './operations/declareKind'; +import { OpDeclareKindVersionV1 } from './operations/declareKindVersion'; +import { OpDeclareLabelV1 } from './operations/declareLabel'; +import { OpDeclareRelationV1 } from './operations/declareRelation'; +import { OpDeclareTagV1 } from './operations/declareTag'; +import { OpRemoveAnnotationV1 } from './operations/removeAnnotation'; +import { OpRemoveKindV1 } from './operations/removeKind'; +import { OpRemoveLabelV1 } from './operations/removeLabel'; +import { OpRemoveTagV1 } from './operations/removeTag'; +import { OpUpdateAnnotationV1 } from './operations/updateAnnotation'; +import { OpUpdateKindV1 } from './operations/updateKind'; +import { OpUpdateKindVersionV1 } from './operations/updateKindVersion'; +import { OpUpdateLabelV1 } from './operations/updateLabel'; +import { OpUpdateRelationV1 } from './operations/updateRelation'; +import { OpUpdateTagV1 } from './operations/updateTag'; +import { + CatalogModel, + CatalogModelLayer, + CatalogModelKind, + CatalogModelRelation, + OpaqueCatalogModelLayer, +} from './types'; + +// #region Internal types +interface KindState { + group: string; + singular: string; + plural: string; + description: string; + versions: Map; +} + +interface VersionState { + name: string; + apiVersion: string; + specTypes: Map; +} + +interface SpecTypeState { + description?: string; + relationFields?: OpDeclareKindVersionV1['properties']['relationFields']; + jsonSchema: JsonObject; +} + +interface RelationState { + fromKinds: Set; + toKinds: Set; + description: string; + forward: { type: string; title: string }; + reverse: { type: string; title: string }; +} + +interface AnnotationState { + title?: string; + description: string; + schema?: { jsonSchema: JsonObject }; +} + +interface LabelState { + title?: string; + description: string; + schema?: { jsonSchema: JsonObject }; +} + +interface TagState { + title?: string; + description: string; +} + +// #endregion + +// #region Op sorting + +/** + * Sorts ops so that declarations come before updates, while preserving the + * relative order of ops with the same priority (stable sort). + */ +function sortOps(input: CatalogModelOp[]): CatalogModelOp[] { + return lodash.sortBy(input, op => ops[op.op].order); +} + +// #endregion + +// #region Op application + +function applyDeclareKind( + kinds: Map, + op: OpDeclareKindV1, +): void { + if (kinds.has(op.kind)) { + throw new InputError(`Kind "${op.kind}" is declared more than once`); + } + kinds.set(op.kind, { + group: op.group, + singular: op.properties.singular, + plural: op.properties.plural, + description: op.properties.description, + versions: new Map(), + }); +} + +function applyDeclareKindVersion( + kinds: Map, + op: OpDeclareKindVersionV1, +): void { + const kind = kinds.get(op.kind); + if (!kind) { + throw new InputError( + `Cannot declare version "${op.name}" for unknown kind "${op.kind}"`, + ); + } + + let version = kind.versions.get(op.name); + if (!version) { + version = { + name: op.name, + apiVersion: `${kind.group}/${op.name}`, + specTypes: new Map(), + }; + kind.versions.set(op.name, version); + } + + if (version.specTypes.has(op.specType)) { + const label = op.specType + ? `spec type "${op.specType}"` + : 'default spec type'; + throw new InputError( + `Version "${op.name}" of kind "${op.kind}" already has ${label} declared`, + ); + } + + version.specTypes.set(op.specType, { + description: op.properties.description, + relationFields: op.properties.relationFields, + jsonSchema: op.properties.schema.jsonSchema as JsonObject, + }); +} + +function applyDeclareRelation( + relations: Map, + op: OpDeclareRelationV1, +): void { + const existing = relations.get(op.type); + if (existing) { + existing.fromKinds.add(op.fromKind); + existing.toKinds.add(op.toKind); + } else { + relations.set(op.type, { + fromKinds: new Set([op.fromKind]), + toKinds: new Set([op.toKind]), + description: op.properties.description, + forward: { + type: op.type, + title: op.properties.title, + }, + reverse: { + type: op.properties.reverseType, + title: op.properties.title, + }, + }); + } +} + +function applyUpdateKind( + kinds: Map, + op: OpUpdateKindV1, +): void { + const kind = kinds.get(op.kind); + if (!kind) { + throw new InputError(`Cannot update unknown kind "${op.kind}"`); + } + if (op.properties.singular !== undefined) { + kind.singular = op.properties.singular; + } + if (op.properties.plural !== undefined) { + kind.plural = op.properties.plural; + } + if (op.properties.description !== undefined) { + kind.description = op.properties.description; + } +} + +function applyUpdateKindVersion( + kinds: Map, + op: OpUpdateKindVersionV1, +): void { + const kind = kinds.get(op.kind); + if (!kind) { + throw new InputError( + `Cannot update version "${op.name}" for unknown kind "${op.kind}"`, + ); + } + + const version = kind.versions.get(op.name); + if (!version) { + throw new InputError( + `Cannot update unknown version "${op.name}" of kind "${op.kind}"`, + ); + } + + const specType = version.specTypes.get(op.specType); + if (!specType) { + const label = op.specType + ? `spec type "${op.specType}"` + : 'default spec type'; + throw new InputError( + `Cannot update undeclared ${label} on version "${op.name}" of kind "${op.kind}"`, + ); + } + + if (op.properties.description !== undefined) { + specType.description = op.properties.description; + } + if (op.properties.relationFields !== undefined) { + specType.relationFields = op.properties.relationFields; + } + if (op.properties.schema !== undefined) { + specType.jsonSchema = mergeJsonSchemas( + specType.jsonSchema, + op.properties.schema.jsonSchema as JsonObject, + ); + } +} + +function applyUpdateRelation( + relations: Map, + op: OpUpdateRelationV1, +): void { + const relation = relations.get(op.type); + if (!relation) { + throw new InputError(`Cannot update undeclared relation "${op.type}"`); + } + relation.fromKinds.add(op.fromKind); + relation.toKinds.add(op.toKind); + if (op.properties.reverseType !== undefined) { + relation.reverse.type = op.properties.reverseType; + } + if (op.properties.title !== undefined) { + relation.forward.title = op.properties.title; + relation.reverse.title = op.properties.title; + } + if (op.properties.description !== undefined) { + relation.description = op.properties.description; + } +} + +function applyRemoveKind( + kinds: Map, + op: OpRemoveKindV1, +): void { + if (!kinds.has(op.kind)) { + throw new InputError(`Cannot remove unknown kind "${op.kind}"`); + } + kinds.delete(op.kind); +} + +function applyDeclareAnnotation( + annotations: Map, + op: OpDeclareAnnotationV1, +): void { + if (annotations.has(op.name)) { + throw new InputError(`Annotation "${op.name}" is declared more than once`); + } + annotations.set(op.name, { + title: op.properties.title, + description: op.properties.description, + schema: op.properties.schema as AnnotationState['schema'], + }); +} + +function applyDeclareLabel( + labels: Map, + op: OpDeclareLabelV1, +): void { + if (labels.has(op.name)) { + throw new InputError(`Label "${op.name}" is declared more than once`); + } + labels.set(op.name, { + title: op.properties.title, + description: op.properties.description, + schema: op.properties.schema as LabelState['schema'], + }); +} + +function applyDeclareTag( + tags: Map, + op: OpDeclareTagV1, +): void { + if (tags.has(op.name)) { + throw new InputError(`Tag "${op.name}" is declared more than once`); + } + tags.set(op.name, { + title: op.properties.title, + description: op.properties.description, + }); +} + +function applyUpdateAnnotation( + annotations: Map, + op: OpUpdateAnnotationV1, +): void { + const annotation = annotations.get(op.name); + if (!annotation) { + throw new InputError(`Cannot update undeclared annotation "${op.name}"`); + } + if (op.properties.title !== undefined) { + annotation.title = op.properties.title; + } + if (op.properties.description !== undefined) { + annotation.description = op.properties.description; + } + if (op.properties.schema !== undefined) { + annotation.schema = op.properties.schema as AnnotationState['schema']; + } +} + +function applyUpdateLabel( + labels: Map, + op: OpUpdateLabelV1, +): void { + const label = labels.get(op.name); + if (!label) { + throw new InputError(`Cannot update undeclared label "${op.name}"`); + } + if (op.properties.title !== undefined) { + label.title = op.properties.title; + } + if (op.properties.description !== undefined) { + label.description = op.properties.description; + } + if (op.properties.schema !== undefined) { + label.schema = op.properties.schema as LabelState['schema']; + } +} + +function applyUpdateTag(tags: Map, op: OpUpdateTagV1): void { + const tag = tags.get(op.name); + if (!tag) { + throw new InputError(`Cannot update undeclared tag "${op.name}"`); + } + if (op.properties.title !== undefined) { + tag.title = op.properties.title; + } + if (op.properties.description !== undefined) { + tag.description = op.properties.description; + } +} + +function applyRemoveAnnotation( + annotations: Map, + op: OpRemoveAnnotationV1, +): void { + if (!annotations.has(op.name)) { + throw new InputError(`Cannot remove unknown annotation "${op.name}"`); + } + annotations.delete(op.name); +} + +function applyRemoveLabel( + labels: Map, + op: OpRemoveLabelV1, +): void { + if (!labels.has(op.name)) { + throw new InputError(`Cannot remove unknown label "${op.name}"`); + } + labels.delete(op.name); +} + +function applyRemoveTag(tags: Map, op: OpRemoveTagV1): void { + if (!tags.has(op.name)) { + throw new InputError(`Cannot remove unknown tag "${op.name}"`); + } + tags.delete(op.name); +} + +function buildFullSchema(options: { + kind: string; + apiVersion: string; + kindSchema: JsonObject; + annotations: Map; + labels: Map; + tags: Map; +}): JsonObject { + const annotationProperties: JsonObject = {}; + for (const [name, state] of options.annotations) { + annotationProperties[name] = state.schema?.jsonSchema ?? { type: 'string' }; + } + + const labelProperties: JsonObject = {}; + for (const [name, state] of options.labels) { + labelProperties[name] = state.schema?.jsonSchema ?? { type: 'string' }; + } + + const metadataSchema: JsonObject = { + type: 'object', + required: ['name'], + additionalProperties: true, + properties: { + uid: { + type: 'string', + description: 'A globally unique ID for the entity.', + minLength: 1, + }, + etag: { + type: 'string', + description: + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + minLength: 1, + }, + name: { + type: 'string', + description: + 'The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.', + minLength: 1, + }, + namespace: { + type: 'string', + description: 'The namespace that the entity belongs to.', + default: 'default', + minLength: 1, + }, + title: { + type: 'string', + description: + 'A display name of the entity, to be presented in user interfaces instead of the name property, when available.', + minLength: 1, + }, + description: { + type: 'string', + description: + 'A short (typically relatively few words, on one line) description of the entity.', + }, + annotations: { + type: 'object', + description: + 'Key/value pairs of non-identifying auxiliary information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: annotationProperties, + }, + labels: { + type: 'object', + description: + 'Key/value pairs of identifying information attached to the entity.', + additionalProperties: { type: 'string' }, + properties: labelProperties, + }, + tags: { + type: 'array', + description: + 'A list of single-valued strings, to for example classify catalog entities in various ways.', + items: { type: 'string', minLength: 1 }, + }, + links: { + type: 'array', + description: 'A list of external hyperlinks related to the entity.', + items: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1 }, + icon: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }; + + const kindRequired = Array.isArray(options.kindSchema.required) + ? (options.kindSchema.required as string[]) + : []; + + const generatedSchema: JsonObject = { + type: 'object', + required: [...new Set([...kindRequired, 'apiVersion', 'kind', 'metadata'])], + additionalProperties: false, + properties: { + apiVersion: { const: options.apiVersion }, + kind: { const: options.kind }, + metadata: metadataSchema, + }, + }; + + // The kind schema is the base, and the generated schema (apiVersion, kind, + // metadata) takes priority in case of overlap — though they should not + // overlap in practice. + return mergeJsonSchemas(options.kindSchema, generatedSchema); +} + +// #region Main compilation + +/** + * Compiles a set of catalog model layers into a single unified + * catalog model. + * + * @alpha + * @param inputs - The layers to compile. + * @returns The compiled catalog model. + */ +export function compileCatalogModel( + inputs: Iterable, +): CatalogModel { + // Collect all ops from all inputs + let allOps: CatalogModelOp[] = []; + for (const input of inputs) { + const internal = OpaqueCatalogModelLayer.toInternal(input); + allOps = allOps.concat(internal.ops); + } + + const sortedOps = sortOps(allOps); + + // Apply ops in order + const annotations = new Map(); + const labels = new Map(); + const tags = new Map(); + const kinds = new Map(); + const relations = new Map(); + + for (const op of sortedOps) { + switch (op.op) { + case 'declareAnnotation.v1': + applyDeclareAnnotation(annotations, op); + break; + case 'declareLabel.v1': + applyDeclareLabel(labels, op); + break; + case 'declareTag.v1': + applyDeclareTag(tags, op); + break; + case 'declareKind.v1': + applyDeclareKind(kinds, op); + break; + case 'declareKindVersion.v1': + applyDeclareKindVersion(kinds, op); + break; + case 'declareRelation.v1': + applyDeclareRelation(relations, op); + break; + case 'updateAnnotation.v1': + applyUpdateAnnotation(annotations, op); + break; + case 'updateLabel.v1': + applyUpdateLabel(labels, op); + break; + case 'updateTag.v1': + applyUpdateTag(tags, op); + break; + case 'updateKind.v1': + applyUpdateKind(kinds, op); + break; + case 'updateKindVersion.v1': + applyUpdateKindVersion(kinds, op); + break; + case 'updateRelation.v1': + applyUpdateRelation(relations, op); + break; + case 'removeAnnotation.v1': + applyRemoveAnnotation(annotations, op); + break; + case 'removeLabel.v1': + applyRemoveLabel(labels, op); + break; + case 'removeTag.v1': + applyRemoveTag(tags, op); + break; + case 'removeKind.v1': + applyRemoveKind(kinds, op); + break; + default: + throw new InputError(`Unknown op type "${(op as CatalogModelOp).op}"`); + } + } + + // Precompute the CatalogModelKind output for each kind/version/specType + // combination, so getKind can just look it up. + // Key structure: "Kind\0apiVersion\0specType" (specType may be empty) + const compiledKinds = new Map(); + for (const [kindName, kindState] of kinds) { + for (const version of kindState.versions.values()) { + for (const [specType, specificKind] of version.specTypes) { + const key = `${kindName}\0${version.apiVersion}\0${specType ?? ''}`; + compiledKinds.set(key, { + apiVersions: [version.apiVersion], + names: { + kind: kindName, + singular: kindState.singular, + plural: kindState.plural, + }, + relationFields: (specificKind.relationFields ?? []).map(f => ({ + path: f.selector.path, + relation: f.relation, + defaultKind: f.defaultKind, + defaultNamespace: f.defaultNamespace, + allowedKinds: f.allowedKinds, + })), + jsonSchema: buildFullSchema({ + kind: kindName, + apiVersion: version.apiVersion, + kindSchema: specificKind.jsonSchema, + annotations, + labels, + tags, + }), + }); + } + } + } + + // Precompute the relations per kind, so getRelations can just look them up. + const compiledRelations = new Map(); + for (const kindName of kinds.keys()) { + compiledRelations.set( + kindName, + [...relations.values()] + .filter(r => r.fromKinds.has(kindName)) + .map(r => { + // Look up the reverse relation entry to get its actual title + const reverseEntry = relations.get(r.reverse.type); + return { + fromKind: [...r.fromKinds], + toKind: [...r.toKinds], + description: r.description, + forward: r.forward, + reverse: { + type: r.reverse.type, + title: reverseEntry?.forward.title ?? r.reverse.title, + }, + }; + }), + ); + } + + return { + getKind(options) { + const type = options.spec?.type; + + if (!kinds.has(options.kind)) { + return undefined; + } + + const kindState = kinds.get(options.kind)!; + const version = [...kindState.versions.values()].find( + v => v.apiVersion === options.apiVersion, + ); + if (!version) { + throw new TypeError( + `Kind "${options.kind}" exists, but has no version matching apiVersion "${options.apiVersion}"`, + ); + } + + const key = `${options.kind}\0${version.apiVersion}\0${type ?? ''}`; + const result = compiledKinds.get(key); + if (result) { + return result; + } + + // Fall back to the default (undefined) spec type + if (type !== undefined) { + const fallback = compiledKinds.get( + `${options.kind}\0${version.apiVersion}\0`, + ); + if (fallback) { + return fallback; + } + } + + throw new TypeError( + `Kind "${options.kind}" version "${version.name}" exists, but has no matching spec type`, + ); + }, + + getRelations(options) { + return compiledRelations.get(options.kind); + }, + }; +} + +// #endregion diff --git a/packages/catalog-model/src/model/createCatalogModelLayer.ts b/packages/catalog-model/src/model/createCatalogModelLayer.ts new file mode 100644 index 0000000000..27c97254f5 --- /dev/null +++ b/packages/catalog-model/src/model/createCatalogModelLayer.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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 { + CatalogModelLayerBuilder, + createCatalogModelLayerBuilder, +} from './createCatalogModelLayerBuilder'; +import { CatalogModelLayer } from './types'; + +/** + * Creates a catalog model layer using a builder pattern. + * + * @alpha + * @remarks + * + * Plugins can create such catalog model layers to declare various + * contributions to the overall catalog model, and registering them with the + * catalog which then forms a complete picture out of them. + */ +export function createCatalogModelLayer(options: { + /** + * The unique ID of the model layer. + * + * @remarks + * @example `example.com/MyCustomKind` + * + * This identifier is used for purposes of deduplication and tracking. It is + * expected to be stable and descriptive. Prefer prefixing the ID with a + * matching domain name. The backstage.io domain name is reserved for use by + * the Backstage project itself. + */ + layerId: string; + builder: (model: CatalogModelLayerBuilder) => void; +}): CatalogModelLayer { + const b = createCatalogModelLayerBuilder({ + layerId: options.layerId, + }); + options.builder(b); + return b.build(); +} diff --git a/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts new file mode 100644 index 0000000000..98df29b2b3 --- /dev/null +++ b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts @@ -0,0 +1,264 @@ +/* + * Copyright 2026 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 { + type CatalogModelAnnotationDefinition, + opsFromCatalogModelAnnotation, +} from './modelActions/addAnnotation'; +import { + type CatalogModelKindDefinition, + opsFromCatalogModelKind, +} from './modelActions/addKind'; +import { + type CatalogModelLabelDefinition, + opsFromCatalogModelLabel, +} from './modelActions/addLabel'; +import { + type CatalogModelRelationPairDefinition, + opsFromCatalogModelRelationPair, +} from './modelActions/addRelationPair'; +import { + type CatalogModelRemoveAnnotationDefinition, + opsFromCatalogModelRemoveAnnotation, +} from './modelActions/removeAnnotation'; +import { + type CatalogModelRemoveKindDefinition, + opsFromCatalogModelRemoveKind, +} from './modelActions/removeKind'; +import { + type CatalogModelRemoveLabelDefinition, + opsFromCatalogModelRemoveLabel, +} from './modelActions/removeLabel'; +import { + type CatalogModelRemoveTagDefinition, + opsFromCatalogModelRemoveTag, +} from './modelActions/removeTag'; +import { + type CatalogModelTagDefinition, + opsFromCatalogModelTag, +} from './modelActions/addTag'; +import { + type CatalogModelUpdateAnnotationDefinition, + opsFromCatalogModelUpdateAnnotation, +} from './modelActions/updateAnnotation'; +import { + CatalogModelUpdateKindDefinition, + opsFromCatalogModelUpdateKind, +} from './modelActions/updateKind'; +import { + type CatalogModelUpdateLabelDefinition, + opsFromCatalogModelUpdateLabel, +} from './modelActions/updateLabel'; +import { + CatalogModelUpdateRelationPairDefinition, + opsFromCatalogModelUpdateRelationPair, +} from './modelActions/updateRelationPair'; +import { + type CatalogModelUpdateTagDefinition, + opsFromCatalogModelUpdateTag, +} from './modelActions/updateTag'; +import { CatalogModelOp } from './operations'; +import { CatalogModelLayer, OpaqueCatalogModelLayer } from './types'; + +/** + * A builder for catalog model layers. + * + * @alpha + * + * Plugins can use this builder to declare various contributions to the overall + * catalog model, and registering the outcome with the catalog which then forms + * a complete picture out of them. + */ +export interface CatalogModelLayerBuilder { + /** + * Adds a new kind to the model. + */ + addKind(kind: CatalogModelKindDefinition): void; + /** + * Updates an existing kind in the model. + */ + updateKind(kind: CatalogModelUpdateKindDefinition): void; + /** + * Removes a kind entirely from the model. + */ + removeKind(kind: CatalogModelRemoveKindDefinition): void; + + /** + * Adds a new relation pair to the model. + */ + addRelationPair(relation: CatalogModelRelationPairDefinition): void; + /** + * Updates an existing relation pair in the model. + */ + updateRelationPair(relation: CatalogModelUpdateRelationPairDefinition): void; + + /** + * Adds a new annotation to the model. + */ + addAnnotation(annotation: CatalogModelAnnotationDefinition): void; + /** + * Updates an existing annotation in the model. + */ + updateAnnotation(annotation: CatalogModelUpdateAnnotationDefinition): void; + /** + * Removes an annotation from the model. + */ + removeAnnotation(annotation: CatalogModelRemoveAnnotationDefinition): void; + + /** + * Adds a new label to the model. + */ + addLabel(label: CatalogModelLabelDefinition): void; + /** + * Updates an existing label in the model. + */ + updateLabel(label: CatalogModelUpdateLabelDefinition): void; + /** + * Removes a label from the model. + */ + removeLabel(label: CatalogModelRemoveLabelDefinition): void; + + /** + * Adds a new tag to the model. + */ + addTag(tag: CatalogModelTagDefinition): void; + /** + * Updates an existing tag in the model. + */ + updateTag(tag: CatalogModelUpdateTagDefinition): void; + /** + * Removes a tag from the model. + */ + removeTag(tag: CatalogModelRemoveTagDefinition): void; + + /** + * Imports all operations from another catalog model layer into this one. + */ + import(layer: CatalogModelLayer): void; +} + +/** + * The default implementation of the catalog model layer builder. + */ +export class DefaultCatalogModelLayerBuilder + implements CatalogModelLayerBuilder +{ + readonly #layerId: string; + readonly #ops: CatalogModelOp[]; + + constructor(options: { layerId: string }) { + this.#layerId = options.layerId; + this.#ops = []; + } + + addKind(kind: CatalogModelKindDefinition): void { + const ops = opsFromCatalogModelKind(kind); + this.#ops.push(...ops); + } + + updateKind(kind: CatalogModelUpdateKindDefinition): void { + const ops = opsFromCatalogModelUpdateKind(kind); + this.#ops.push(...ops); + } + + removeKind(kind: CatalogModelRemoveKindDefinition): void { + const ops = opsFromCatalogModelRemoveKind(kind); + this.#ops.push(...ops); + } + + addRelationPair(relation: CatalogModelRelationPairDefinition): void { + const ops = opsFromCatalogModelRelationPair(relation); + this.#ops.push(...ops); + } + + updateRelationPair(relation: CatalogModelUpdateRelationPairDefinition): void { + const ops = opsFromCatalogModelUpdateRelationPair(relation); + this.#ops.push(...ops); + } + + addAnnotation(annotation: CatalogModelAnnotationDefinition): void { + const ops = opsFromCatalogModelAnnotation(annotation); + this.#ops.push(...ops); + } + + updateAnnotation(annotation: CatalogModelUpdateAnnotationDefinition): void { + const ops = opsFromCatalogModelUpdateAnnotation(annotation); + this.#ops.push(...ops); + } + + removeAnnotation(annotation: CatalogModelRemoveAnnotationDefinition): void { + const ops = opsFromCatalogModelRemoveAnnotation(annotation); + this.#ops.push(...ops); + } + + addLabel(label: CatalogModelLabelDefinition): void { + const ops = opsFromCatalogModelLabel(label); + this.#ops.push(...ops); + } + + updateLabel(label: CatalogModelUpdateLabelDefinition): void { + const ops = opsFromCatalogModelUpdateLabel(label); + this.#ops.push(...ops); + } + + removeLabel(label: CatalogModelRemoveLabelDefinition): void { + const ops = opsFromCatalogModelRemoveLabel(label); + this.#ops.push(...ops); + } + + addTag(tag: CatalogModelTagDefinition): void { + const ops = opsFromCatalogModelTag(tag); + this.#ops.push(...ops); + } + + updateTag(tag: CatalogModelUpdateTagDefinition): void { + const ops = opsFromCatalogModelUpdateTag(tag); + this.#ops.push(...ops); + } + + removeTag(tag: CatalogModelRemoveTagDefinition): void { + const ops = opsFromCatalogModelRemoveTag(tag); + this.#ops.push(...ops); + } + + import(layer: CatalogModelLayer): void { + const internal = OpaqueCatalogModelLayer.toInternal(layer); + this.#ops.push(...internal.ops); + } + + build(): CatalogModelLayer { + return OpaqueCatalogModelLayer.createInstance('v1', { + layerId: this.#layerId, + ops: this.#ops.slice(), + }); + } +} + +/** + * Creates a builder for a catalog model layer. + * + * @alpha + * @remarks + * + * Plugins can use the resulting builder to declare various contributions to the + * overall catalog model, and registering it with the catalog which then forms a + * complete picture out of them. + */ +export function createCatalogModelLayerBuilder(options: { + layerId: string; +}): CatalogModelLayerBuilder & { build(): CatalogModelLayer } { + return new DefaultCatalogModelLayerBuilder(options); +} diff --git a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts new file mode 100644 index 0000000000..f997a78707 --- /dev/null +++ b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 { apiEntityModel } from '../kinds/ApiEntityV1alpha1'; +import { componentEntityModel } from '../kinds/ComponentEntityV1alpha1'; +import { domainEntityModel } from '../kinds/DomainEntityV1alpha1'; +import { groupEntityModel } from '../kinds/GroupEntityV1alpha1'; +import { locationEntityModel } from '../kinds/LocationEntityV1alpha1'; +import { resourceEntityModel } from '../kinds/ResourceEntityV1alpha1'; +import { systemEntityModel } from '../kinds/SystemEntityV1alpha1'; +import { userEntityModel } from '../kinds/UserEntityV1alpha1'; +import { wellKnownAnnotationsModel } from '../kinds/annotations'; +import { wellKnownRelationsModel } from '../kinds/relations'; +import { createCatalogModelLayer } from './createCatalogModelLayer'; + +/** + * The default catalog entity model, containing all built-in Backstage entity + * kinds, relations, and annotations. + * + * @alpha + */ +export const defaultCatalogEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/default-entity-model', + builder: model => { + model.import(apiEntityModel); + model.import(componentEntityModel); + model.import(domainEntityModel); + model.import(groupEntityModel); + model.import(locationEntityModel); + model.import(resourceEntityModel); + model.import(systemEntityModel); + model.import(userEntityModel); + model.import(wellKnownRelationsModel); + model.import(wellKnownAnnotationsModel); + }, +}); diff --git a/packages/catalog-model/src/model/index.ts b/packages/catalog-model/src/model/index.ts new file mode 100644 index 0000000000..20c7b0a97c --- /dev/null +++ b/packages/catalog-model/src/model/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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. + */ + +export { compileCatalogModel } from './compileCatalogModel'; +export { createCatalogModelLayer } from './createCatalogModelLayer'; +export { + createCatalogModelLayerBuilder, + type CatalogModelLayerBuilder, +} from './createCatalogModelLayerBuilder'; +export * from './jsonSchema'; +export * from './modelActions'; +export * from './sources'; +export type { + CatalogModel, + CatalogModelLayer, + CatalogModelKind, + CatalogModelRelation, +} from './types'; diff --git a/packages/catalog-model/src/model/jsonSchema/getAjv.ts b/packages/catalog-model/src/model/jsonSchema/getAjv.ts new file mode 100644 index 0000000000..8114548c2c --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/getAjv.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 Ajv from 'ajv'; +import ajvErrors from 'ajv-errors'; + +/** + * Gets a singleton instance of Ajv. + */ +export const getAjv = (() => { + let instance: Ajv | undefined = undefined; + + return () => { + if (!instance) { + instance = new Ajv({ + allowUnionTypes: true, + allErrors: true, + validateSchema: true, + }); + ajvErrors(instance); + } + return instance; + }; +})(); diff --git a/packages/catalog-model/src/model/jsonSchema/index.ts b/packages/catalog-model/src/model/jsonSchema/index.ts new file mode 100644 index 0000000000..d67cfa9410 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 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. + */ + +export { type CatalogModelKindRootSchema } from './validateKindRootSchemaSemantics'; diff --git a/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.test.ts b/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.test.ts new file mode 100644 index 0000000000..9047258ae1 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2026 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 { mergeJsonSchemas } from './mergeJsonSchemas'; + +describe('mergeJsonSchemas', () => { + it('should merge scalar properties from source into target', () => { + const result = mergeJsonSchemas( + { type: 'object', description: 'old' }, + { description: 'new', title: 'My Schema' }, + ); + + expect(result).toEqual({ + type: 'object', + description: 'new', + title: 'My Schema', + }); + }); + + it('should deep merge nested objects', () => { + const result = mergeJsonSchemas( + { + type: 'object', + properties: { + spec: { type: 'object', properties: { owner: { type: 'string' } } }, + }, + }, + { + properties: { + spec: { properties: { name: { type: 'string' } } }, + }, + }, + ); + + expect(result).toEqual({ + type: 'object', + properties: { + spec: { + type: 'object', + properties: { + owner: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }); + }); + + it('should delete properties when source value is null', () => { + const result = mergeJsonSchemas( + { type: 'object', description: 'remove me', title: 'keep' }, + { description: null }, + ); + + expect(result).toEqual({ type: 'object', title: 'keep' }); + }); + + it('should delete nested properties when source value is null', () => { + const result = mergeJsonSchemas( + { + type: 'object', + properties: { + spec: { type: 'object', description: 'gone' }, + status: { type: 'object' }, + }, + }, + { properties: { spec: { description: null } } }, + ); + + expect(result).toEqual({ + type: 'object', + properties: { + spec: { type: 'object' }, + status: { type: 'object' }, + }, + }); + }); + + it('should fully replace when source has a different type', () => { + const result = mergeJsonSchemas( + { type: 'object', properties: { spec: { type: 'object' } } }, + { type: 'string', minLength: 1 }, + ); + + expect(result).toEqual({ type: 'string', minLength: 1 }); + }); + + it('should not fully replace when types match', () => { + const result = mergeJsonSchemas( + { type: 'object', description: 'old' }, + { type: 'object', title: 'added' }, + ); + + expect(result).toEqual({ + type: 'object', + description: 'old', + title: 'added', + }); + }); + + it('should replace arrays rather than merging them', () => { + const result = mergeJsonSchemas( + { type: 'object', required: ['a', 'b'] }, + { required: ['c'] }, + ); + + expect(result).toEqual({ type: 'object', required: ['c'] }); + }); + + it('should not mutate target or source', () => { + const target = { + type: 'object', + properties: { spec: { type: 'object', description: 'old' } }, + }; + const source = { + properties: { spec: { description: 'new' } }, + }; + + const targetCopy = JSON.parse(JSON.stringify(target)); + const sourceCopy = JSON.parse(JSON.stringify(source)); + + mergeJsonSchemas(target, source); + + expect(target).toEqual(targetCopy); + expect(source).toEqual(sourceCopy); + }); + + it('should handle source adding entirely new nested objects', () => { + const result = mergeJsonSchemas( + { type: 'object' }, + { properties: { spec: { type: 'object' } } }, + ); + + expect(result).toEqual({ + type: 'object', + properties: { spec: { type: 'object' } }, + }); + }); + + it('should handle an empty source as a no-op', () => { + const target = { type: 'object', description: 'keep' }; + const result = mergeJsonSchemas(target, {}); + + expect(result).toEqual({ type: 'object', description: 'keep' }); + }); +}); diff --git a/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.ts b/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.ts new file mode 100644 index 0000000000..4c55fbc305 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/mergeJsonSchemas.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import { isJsonObject } from './util'; + +/** + * Merges two JSON schemas into a single schema. + * + * @alpha + * @remarks + * + * This function deep merges two JSON schemas into a new, single schema. Both + * `source` and `target` are left unchanged. + * + * Properties from the `source` schema will override properties from the + * `target` schema. The `target` schema is assumed to be a pre-validated fully + * valid JSON Schema. The `source` schema is similar, but with one addition - + * object fields can have the special value `null` which leads to a deletion of + * the corresponding property in `target` if it existed. + * + * If a property `type` is defined in the `source` schema and different from the + * one in the `target` schema, a full replacement happens at that point. But you + * can also just define just for example `description` and similar; those just + * get merged into the existing definition if any. + * + * @param target - The schema to merge into (left unchanged). + * @param source - The schema to merge from (left unchanged). + * @returns The merged schema. + */ +export function mergeJsonSchemas( + target: JsonObject, + source: JsonObject, +): JsonObject { + // If the type field differs, the source schema fully replaces target + if ('type' in source && source.type !== target.type) { + return { ...source }; + } + + const result: JsonObject = { ...target }; + + for (const [key, value] of Object.entries(source)) { + if (value === null) { + // null means delete the property + delete result[key]; + } else if (isJsonObject(value) && isJsonObject(result[key])) { + // Both sides are objects — recurse + result[key] = mergeJsonSchemas(result[key], value); + } else { + // Scalar or array — source overrides target + result[key] = value; + } + } + + return result; +} diff --git a/packages/catalog-model/src/model/jsonSchema/reduceKindSchema.ts b/packages/catalog-model/src/model/jsonSchema/reduceKindSchema.ts new file mode 100644 index 0000000000..bac2cd5e16 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/reduceKindSchema.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import { z } from 'zod/v3'; +import { CatalogModelKindRootSchema } from './validateKindRootSchemaSemantics'; + +/** + * The expected shape of the standard full kind schemas, which use an allOf + * with a $ref to Entity and a second element with the kind-specific schema. + */ +const fullKindSchema = z + .object({ + allOf: z.tuple([ + z.object({ $ref: z.literal('Entity') }), + z + .object({ + type: z.literal('object'), + properties: z + .object({ + apiVersion: z.record(z.string(), z.unknown()).optional(), + kind: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(), + }) + .passthrough(), + ]), + }) + .passthrough(); + +/** + * Reduces a full kind JSON schema to the form expected by the catalog model + * layer system. The full schemas use an allOf with a $ref to Entity and + * kind/apiVersion constraints that are not needed in the reduced form. + * + * If the schema does not match the expected shape, it is returned unchanged. + */ +export function reduceKindSchema( + schema: JsonObject, +): CatalogModelKindRootSchema { + const parsed = fullKindSchema.safeParse(schema); + if (!parsed.success) { + return schema as CatalogModelKindRootSchema; + } + + const { allOf, ...rest } = parsed.data; + const { type, properties: allProperties, ...kindRest } = allOf[1]; + const { apiVersion, kind, ...properties } = allProperties; + + return { + ...rest, + type, + ...kindRest, + properties, + } as CatalogModelKindRootSchema; +} diff --git a/packages/catalog-model/src/model/jsonSchema/util.test.ts b/packages/catalog-model/src/model/jsonSchema/util.test.ts new file mode 100644 index 0000000000..d773648106 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/util.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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 { isJsonObject, isJsonObjectDeep } from './util'; + +describe('isJsonObject', () => { + it('should return true for a valid JSON object', () => { + expect(isJsonObject({ foo: 'bar' })).toBe(true); + }); + + it('should return false for a non-object', () => { + expect(isJsonObject('foo')).toBe(false); + }); + + it('should return false for an array', () => { + expect(isJsonObject(['foo', 'bar'])).toBe(false); + }); +}); + +describe('isJsonObjectDeep', () => { + it('should return true for an empty object', () => { + expect(isJsonObjectDeep({})).toBe(true); + }); + + it('should return true for an object with JSON primitive values', () => { + expect(isJsonObjectDeep({ s: 'hello', n: 42, b: true, nil: null })).toBe( + true, + ); + }); + + it('should return true for nested objects and arrays', () => { + expect( + isJsonObjectDeep({ + nested: { deep: { value: 'ok' } }, + list: [1, 'two', { three: 3 }], + }), + ).toBe(true); + }); + + it('should return false for non-objects', () => { + expect(isJsonObjectDeep('string')).toBe(false); + expect(isJsonObjectDeep(42)).toBe(false); + expect(isJsonObjectDeep(null)).toBe(false); + expect(isJsonObjectDeep(undefined)).toBe(false); + expect(isJsonObjectDeep([1, 2])).toBe(false); + }); + + it('should return false when a nested value is a function', () => { + expect(isJsonObjectDeep({ fn: () => {} })).toBe(false); + }); + + it('should return false when a deeply nested value is invalid', () => { + expect(isJsonObjectDeep({ a: { b: { c: undefined } } })).toBe(false); + }); + + it('should return false when an array contains invalid values', () => { + expect(isJsonObjectDeep({ list: [1, Symbol('bad')] })).toBe(false); + }); + + it('should return false for circular references without crashing', () => { + const obj: Record = { a: 1 }; + obj.self = obj; + expect(isJsonObjectDeep(obj)).toBe(false); + }); + + it('should return false for deeply nested circular references', () => { + const inner: Record = { x: 1 }; + const obj = { a: { b: inner } }; + inner.loop = obj; + expect(isJsonObjectDeep(obj)).toBe(false); + }); +}); diff --git a/packages/catalog-model/src/model/jsonSchema/util.ts b/packages/catalog-model/src/model/jsonSchema/util.ts new file mode 100644 index 0000000000..7caf0f9b83 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/util.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; + +/** + * Asserts that the value is a JSON object shallowly. + */ +export function isJsonObject(value?: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Asserts that the value is a JSON object recorsively containing only JSON safe + * values and no circular references. + */ +export function isJsonObjectDeep(value: unknown): value is JsonObject { + if (!isJsonObject(value)) { + return false; + } + const seen = new Set(); + return Object.values(value).every(v => isJsonValueDeep(v, seen)); +} + +function isJsonValueDeep(value: unknown, seen: Set): boolean { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return true; + } + if (typeof value !== 'object') { + return false; + } + if (seen.has(value)) { + return false; + } + seen.add(value); + if (Array.isArray(value)) { + return value.every(v => isJsonValueDeep(v, seen)); + } + if (isJsonObject(value)) { + return Object.values(value).every(v => isJsonValueDeep(v, seen)); + } + return false; +} diff --git a/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.test.ts b/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.test.ts new file mode 100644 index 0000000000..c278cb18d6 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2026 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 { + CatalogModelKindRootSchema, + validateKindRootSchemaSemantics, +} from './validateKindRootSchemaSemantics'; + +describe('validateKindRootSchemaSemantics', () => { + it('should accept a valid schema with custom properties', () => { + expect(() => + validateKindRootSchemaSemantics({ + type: 'object', + properties: { + spec: { type: 'object' }, + status: { type: 'object' }, + }, + }), + ).not.toThrow(); + }); + + it('should reject non-object input', () => { + expect(() => validateKindRootSchemaSemantics(undefined)).toThrow( + 'Schema must be an object', + ); + expect(() => validateKindRootSchemaSemantics(null)).toThrow( + 'Schema must be an object', + ); + expect(() => validateKindRootSchemaSemantics('string')).toThrow( + 'Schema must be an object', + ); + expect(() => validateKindRootSchemaSemantics(123)).toThrow( + 'Schema must be an object', + ); + expect(() => validateKindRootSchemaSemantics([])).toThrow( + 'Schema must be an object', + ); + }); + + it('should reject schemas that use structural keywords in the root', () => { + for (const keyword of [ + 'allOf', + 'oneOf', + 'anyOf', + 'if', + 'else', + 'then', + 'not', + '$ref', + ]) { + expect(() => + validateKindRootSchemaSemantics({ + properties: { spec: { type: 'object' } }, + [keyword]: [], + }), + ).toThrow(`Schema must not use "${keyword}" keyword in the root`); + } + }); + + it('should accept schemas that do not use structural keywords in the root', () => { + expect(() => + validateKindRootSchemaSemantics({ + type: 'object', + properties: { spec: { type: 'object' } }, + }), + ).not.toThrow(); + }); + + it('should reject schemas without a properties field', () => { + expect(() => validateKindRootSchemaSemantics({ type: 'object' })).toThrow( + 'Schema must have a "properties" field that is an object', + ); + }); + + it('should reject schemas where properties is not an object', () => { + expect(() => + validateKindRootSchemaSemantics({ properties: 'not-an-object' }), + ).toThrow('Schema must have a "properties" field that is an object'); + }); + + it('should reject schemas that declare reserved root fields', () => { + for (const field of ['kind', 'apiVersion', 'metadata']) { + expect(() => + validateKindRootSchemaSemantics({ + type: 'object', + properties: { [field]: { type: 'string' } }, + }), + ).toThrow(`reserved root field "${field}"`); + } + }); + + it('should reject schemas where a root field schema is not an object', () => { + expect(() => + validateKindRootSchemaSemantics({ + type: 'object', + properties: { spec: 'not-an-object' }, + }), + ).toThrow('Schema for root field "spec" must be an object'); + }); + + it('should reject schemas that use structural keywords in root field schemas', () => { + for (const keyword of [ + 'allOf', + 'oneOf', + 'anyOf', + 'if', + 'else', + 'then', + 'not', + '$ref', + ]) { + expect(() => + validateKindRootSchemaSemantics({ + type: 'object', + properties: { + spec: { type: 'object', [keyword]: [] }, + }, + }), + ).toThrow( + `Schema for root field "spec" must not use "${keyword}" keyword`, + ); + } + }); + + it('should accept arbitrary extra fields at all levels via the type', () => { + // This test is a compile-time check that CatalogModelKindRootSchema + // allows unknown properties at every level, not just the forbidden ones. + const schema: CatalogModelKindRootSchema = { + type: 'object', + foo: 'bar', + properties: { + spec: { + type: 'object', + foo: 'bar', + properties: { + owner: { + type: 'string', + foo: 'bar', + }, + }, + }, + }, + }; + expect(schema).toBeDefined(); + }); +}); diff --git a/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.ts b/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.ts new file mode 100644 index 0000000000..ed8d090093 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/validateKindRootSchemaSemantics.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { isJsonObject, isJsonObjectDeep } from './util'; + +const FORBIDDEN_SCHEMA_ROOT_FIELDS = [ + 'kind', + 'apiVersion', + 'metadata', + '$ref', +] as const; +const FORBIDDEN_SCHEMA_STRUCTURAL_FIELDS = [ + 'allOf', + 'oneOf', + 'anyOf', + 'if', + 'else', + 'then', + 'not', + '$ref', +] as const; + +/** + * When declaring the JSON schema model for a kind, this is the type that you + * should abide by. + * + * @alpha + * @remarks + * + * It forbids some patterns that would make the schema hard or impossible to + * inspect / merge properly. + */ +export interface CatalogModelKindRootSchema extends JsonObject { + type: 'object'; + + // NOTE: These should match the FORBIDDEN_SCHEMA_STRUCTURAL_FIELDS list above + allOf?: never; + anyOf?: never; + oneOf?: never; + if?: never; + then?: never; + else?: never; + not?: never; + $ref?: never; + + properties?: + | undefined + | { + // NOTE: These should match the FORBIDDEN_SCHEMA_ROOT_FIELDS list above + kind?: never; + apiVersion?: never; + metadata?: never; + $ref?: never; + + [key: string]: + | undefined + | { + // NOTE: These should match the FORBIDDEN_SCHEMA_STRUCTURAL_FIELDS list above + allOf?: never; + anyOf?: never; + oneOf?: never; + if?: never; + then?: never; + else?: never; + not?: never; + $ref?: never; + + [key: string]: JsonValue | undefined; + }; + }; + + [key: string]: JsonValue | undefined; +} + +/** + * Validates that the semantics of a schema describing a kind follows semantic + * rules. + * + * @remarks + * + * It's expected that the input is a valid JSON Schema, as in having been + * validated by `validateMetaSchema` already. + * + * The schema must not try to explicitly declare certain readonly root fields, + * and neither the root nor the top level keys of fields may use certain + * structural keywords that makes the schema hard or impossible to inspect / + * merge properly. + * + * @param schema - The schema to validate. + */ +export function validateKindRootSchemaSemantics( + schema: unknown, +): schema is CatalogModelKindRootSchema { + if (!isJsonObjectDeep(schema)) { + throw new InputError('Schema must be an object'); + } + + for (const field of FORBIDDEN_SCHEMA_STRUCTURAL_FIELDS) { + if (field in schema) { + throw new InputError( + `Schema must not use "${field}" keyword in the root`, + ); + } + } + + const rootProperties = schema.properties; + if (!isJsonObject(rootProperties)) { + throw new InputError( + 'Schema must have a "properties" field that is an object', + ); + } + + for (const field of FORBIDDEN_SCHEMA_ROOT_FIELDS) { + if (field in rootProperties) { + throw new InputError( + `Schema must not try to declare the reserved root field "${field}"`, + ); + } + } + + for (const [fieldName, fieldSchema] of Object.entries(rootProperties)) { + if (!isJsonObject(fieldSchema)) { + throw new InputError( + `Schema for root field "${fieldName}" must be an object`, + ); + } + for (const field of FORBIDDEN_SCHEMA_STRUCTURAL_FIELDS) { + if (field in fieldSchema) { + throw new InputError( + `Schema for root field "${fieldName}" must not use "${field}" keyword`, + ); + } + } + } + + return true; +} diff --git a/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.test.ts b/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.test.ts new file mode 100644 index 0000000000..cde83001a6 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 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 { validateMetaSchema } from './validateMetaSchema'; + +describe('validateMetaSchema', () => { + it('should validate a valid schema', () => { + const simpleSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + expect(validateMetaSchema(simpleSchema)).toEqual(true); + const complexSchema = require('../../schema/kinds/Component.v1alpha1.schema.json'); + expect(validateMetaSchema(complexSchema)).toEqual(true); + }); + + it('should throw an error for an invalid schema', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'what-is-this' }, + }, + }; + expect(() => validateMetaSchema(schema)).toThrow('Invalid JSON schema'); + }); + + it('should gracefully handle completely wrong input', () => { + expect(() => validateMetaSchema(undefined)).toThrow('Invalid JSON schema'); + expect(() => validateMetaSchema(null)).toThrow('Invalid JSON schema'); + expect(() => validateMetaSchema(true)).toThrow('Invalid JSON schema'); + expect(() => validateMetaSchema(false)).toThrow('Invalid JSON schema'); + expect(() => validateMetaSchema(123)).toThrow('Invalid JSON schema'); + expect(() => validateMetaSchema('string')).toThrow('Invalid JSON schema'); + }); +}); diff --git a/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.ts b/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.ts new file mode 100644 index 0000000000..f0063149a8 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/validateMetaSchema.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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 { type SchemaObject } from 'ajv'; +import { isJsonObject } from './util'; +import { InputError } from '@backstage/errors'; +import { getAjv } from './getAjv'; + +/** + * Validates that the schema is valid according to known JSON Schema meta + * schemas - does not perform any semantic validation beyond that. + */ +export function validateMetaSchema(schema: unknown): schema is SchemaObject { + if (!isJsonObject(schema) || typeof schema.then === 'function') { + throw new InputError('Invalid JSON schema: must be an object'); + } + + const ajv = getAjv(); + try { + const result = ajv.validateSchema(schema); + if (typeof result !== 'boolean') { + // Should not happen; the function is expected to return a promise only + // for async schemas, but since that's not enforced at the typescript + // level we add the extra check just to be sure + throw new InputError('Expected synchronous validation result'); + } + } catch (error) { + throw new InputError(`Invalid JSON schema: ${error}`); + } + + // The full set of errors is typically too complex to report; just pick the + // first one. + const error = ajv.errors?.[0]; + if (error) { + let message = error.message; + if (Array.isArray(error?.params?.allowedValues)) { + message += `, expected one of: ${error.params.allowedValues.join(', ')}`; + } + throw new InputError( + `Invalid JSON schema, error at path ${error.instancePath}: ${message}`, + ); + } + + return true; +} diff --git a/packages/catalog-model/src/model/jsonSchema/zod.ts b/packages/catalog-model/src/model/jsonSchema/zod.ts new file mode 100644 index 0000000000..c4f7ff3e78 --- /dev/null +++ b/packages/catalog-model/src/model/jsonSchema/zod.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import { z } from 'zod/v3'; +import { isJsonObjectDeep } from './util'; +import { validateMetaSchema } from './validateMetaSchema'; + +export const jsonObjectSchema = z + .record(z.string(), z.unknown()) + .refine((x): x is JsonObject => isJsonObjectDeep(x), { + message: 'Invalid JSON schema', + }); + +export const jsonSchemaSchema = z + .record(z.string(), z.unknown()) + .refine((x): x is JsonObject => validateMetaSchema(x), { + message: 'Invalid JSON schema', + }); diff --git a/packages/catalog-model/src/model/modelActions/addAnnotation.test.ts b/packages/catalog-model/src/model/modelActions/addAnnotation.test.ts new file mode 100644 index 0000000000..d7e299bbb0 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addAnnotation.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelAnnotation } from './addAnnotation'; + +describe('opsFromCatalogModelAnnotation', () => { + it('should produce an op for a simple annotation', () => { + const ops = opsFromCatalogModelAnnotation({ + name: 'backstage.io/techdocs-ref', + description: 'A reference to the TechDocs source for this entity.', + }); + + expect(ops).toEqual([ + { + op: 'declareAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source for this entity.', + }, + }, + ]); + }); + + it('should produce an op with title and schema', () => { + const ops = opsFromCatalogModelAnnotation({ + name: 'backstage.io/view-url', + title: 'View URL', + description: 'A URL to view the entity in an external system.', + schema: { + jsonSchema: { + type: 'string', + format: 'uri', + }, + }, + }); + + expect(ops).toEqual([ + { + op: 'declareAnnotation.v1', + name: 'backstage.io/view-url', + properties: { + title: 'View URL', + description: 'A URL to view the entity in an external system.', + schema: { + jsonSchema: { + type: 'string', + format: 'uri', + }, + }, + }, + }, + ]); + }); + + it('should reject a schema with a non-string type', () => { + expect(() => + opsFromCatalogModelAnnotation({ + name: 'example.com/count', + description: 'A count.', + schema: { + jsonSchema: { + type: 'number', + }, + }, + }), + ).toThrow(/only string values are supported/); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addAnnotation.ts b/packages/catalog-model/src/model/modelActions/addAnnotation.ts new file mode 100644 index 0000000000..b71aac4cf0 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addAnnotation.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createDeclareAnnotationOp } from '../operations/declareAnnotation'; +import { JsonObject } from '@backstage/types'; + +/** + * The definition of a catalog model annotation. + * + * @alpha + */ +export interface CatalogModelAnnotationDefinition { + /** + * The name of the annotation, e.g. "backstage.io/techdocs-ref". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the annotation. + */ + description: string; + + /** + * The JSON schema that values of this annotation must conform to. + * + * @remarks + * + * If not provided, the annotation is assumed to be a simple string with no + * particular schema. + */ + schema?: { + jsonSchema: JsonObject; + }; +} + +export function opsFromCatalogModelAnnotation( + annotation: CatalogModelAnnotationDefinition, +): CatalogModelOp[] { + if (annotation.schema?.jsonSchema) { + validateMetaSchema(annotation.schema.jsonSchema); + if (annotation.schema.jsonSchema.type !== 'string') { + throw new InputError( + `Annotation "${annotation.name}" schema must have "type": "string" at the root, only string values are supported`, + ); + } + } + return [ + createDeclareAnnotationOp({ + name: annotation.name, + properties: { + title: annotation.title, + description: annotation.description, + schema: annotation.schema, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/modelActions/addKind.test.ts b/packages/catalog-model/src/model/modelActions/addKind.test.ts new file mode 100644 index 0000000000..150cb0b278 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKind.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelKind } from './addKind'; + +describe('opsFromCatalogModelKind', () => { + it('should produce ops for a complete kind with a single version', () => { + const ops = opsFromCatalogModelKind({ + group: 'backstage.io', + names: { + kind: 'Component', + singular: 'component', + plural: 'components', + }, + description: 'A software component', + versions: [ + { + name: 'v1alpha1', + specType: 'service', + description: 'A backend service', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['User', 'Group'], + }, + ], + schema: { + jsonSchema: { + type: 'object', + properties: { + spec: { + type: 'object', + properties: { + owner: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + }); + + expect(ops).toEqual([ + { + op: 'declareKind.v1', + kind: 'Component', + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + }, + { + op: 'declareKindVersion.v1', + kind: 'Component', + name: 'v1alpha1', + specType: 'service', + properties: { + description: 'A backend service', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['User', 'Group'], + }, + ], + schema: { + jsonSchema: { + type: 'object', + properties: { + spec: { + type: 'object', + properties: { + owner: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + ]); + }); + + it('should reject a jsonSchema that is invalid in the JSON Schema sense', () => { + expect(() => + opsFromCatalogModelKind({ + group: 'backstage.io', + names: { kind: 'Bad', singular: 'bad', plural: 'bads' }, + description: 'Bad kind', + versions: [ + { + name: 'v1alpha1', + schema: { + jsonSchema: { + type: 'object', + properties: { + spec: { + type: 'not-a-real-type' as any, + }, + }, + }, + }, + }, + ], + }), + ).toThrow(/Invalid JSON schema/); + }); + + it('should reject a jsonSchema that violates semantic rules', () => { + expect(() => + opsFromCatalogModelKind({ + group: 'backstage.io', + names: { kind: 'Bad', singular: 'bad', plural: 'bads' }, + description: 'Bad kind', + versions: [ + { + name: 'v1alpha1', + schema: { + jsonSchema: { + type: 'object', + allOf: [{ properties: { spec: { type: 'object' } } }], + properties: { + spec: { type: 'object' }, + }, + } as any, + }, + }, + ], + }), + ).toThrow(/allOf/); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addKind.ts b/packages/catalog-model/src/model/modelActions/addKind.ts new file mode 100644 index 0000000000..00ca3a8802 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKind.ts @@ -0,0 +1,200 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import { reduceKindSchema } from '../jsonSchema/reduceKindSchema'; +import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createDeclareKindOp } from '../operations/declareKind'; +import { createDeclareKindVersionOp } from '../operations/declareKindVersion'; + +/** + * The definition of a catalog model kind, roughly resembling a JSON Schema. + * + * @alpha + */ +export interface CatalogModelKindDefinition { + /** + * The apiVersion group of the kind, e.g. "backstage.io". + */ + group: string; + + /** + * The names used for this kind. + */ + names: { + /** + * The name of the kind with proper casing, e.g. "Component". + */ + kind: string; + + /** + * The singular form of the kind name, e.g. "component". + */ + singular: string; + + /** + * The plural form of the kind name, e.g. "components". + */ + plural: string; + }; + + /** + * A short description of the kind. + * + * @remarks + * + * For kinds that have wide applicability over for example several different + * spec types, this description should be a generic one and the types + * themselves can be more precise. + */ + description: string; + + /** + * Declare one or more versions of the kind's actual schema shape. + */ + versions?: CatalogModelKindVersionDefinition[]; +} + +/** + * The definition of one or more specific versions of a catalog model kind. + * + * @alpha + */ +export interface CatalogModelKindVersionDefinition { + /** + * The specific version name or names, e.g. "v1alpha1" or + * ["v1alpha1", "v1beta1"]. The kind group and the version name form the + * full apiVersion, e.g. "backstage.io/v1alpha1". + */ + name: string | string[]; + + /** + * The spec type or types that this version applies to. + * + * @remarks + * + * This can be used to make kinds whose spec effectively are discriminated + * unions. If you don't specify this, the schema will apply to a spec that + * has no type given at all, or to those where the type is not among the set + * of any other known declared spec types. + * + * TODO: Should this be more like `matcher: { [path: string]: string }`, or + * even a full JSON Schema that can be used in an "if"? + */ + specType?: string | string[]; + + /** + * A short description of this particular version (and type, where applicable). + */ + description?: string; + + /** + * The fields that shall be used to generate relations, if any. + * + * TODO: Should this be not an array, to be more easily mergeable? Or should + * we just have a custom merge strategy for them + */ + relationFields?: CatalogModelKindRelationFieldDefinition[]; + + schema: { + jsonSchema: JsonObject; + }; +} + +/** + * @alpha + */ +export interface CatalogModelKindRelationFieldDefinition { + /** + * What field that shall be used to generate relations. + * + * @remarks + * + * The field value is expected to be a string or string array at runtime. + */ + selector: { path: string }; + /** + * The relation type that this field generates, e.g. "ownedBy". + */ + relation: string; + /** + * If the given shorthand ref did not have a kind, use this kind as the + * default. If no default kind is specified, the ref must contain a kind. + */ + defaultKind?: string; + /** + * If the given shorthand ref did not have a namespace, either inherit the + * namespace of the entity itself, or choose the default namespace. If no + * default namespace is specified, the namespace of the entity itself is used. + */ + defaultNamespace?: 'default' | 'inherit'; + /** + * Only allow relations to be specified to the given kinds. This list must + * include the default kind, if any. If no allowed kinds are specified, all + * kinds are. + */ + allowedKinds?: string[]; +} + +export function opsFromCatalogModelKind( + kind: CatalogModelKindDefinition, +): CatalogModelOp[] { + const ops: CatalogModelOp[] = []; + + ops.push( + createDeclareKindOp({ + kind: kind.names.kind, + group: kind.group, + properties: { + singular: kind.names.singular, + plural: kind.names.plural, + description: kind.description, + }, + }), + ); + + for (const version of kind.versions ?? []) { + const jsonSchema = reduceKindSchema(version.schema.jsonSchema); + validateMetaSchema(jsonSchema); + validateKindRootSchemaSemantics(jsonSchema); + const names = Array.isArray(version.name) ? version.name : [version.name]; + for (const name of names) { + const specTypes = version.specType + ? [version.specType].flat() + : [undefined]; + for (const specType of specTypes) { + ops.push( + createDeclareKindVersionOp({ + kind: kind.names.kind, + name, + specType: specType, + properties: { + description: version.description, + relationFields: version.relationFields, + schema: { + jsonSchema: jsonSchema as any, + }, + }, + }), + ); + } + } + } + + return ops; +} diff --git a/packages/catalog-model/src/model/modelActions/addLabel.test.ts b/packages/catalog-model/src/model/modelActions/addLabel.test.ts new file mode 100644 index 0000000000..2a92ec5401 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addLabel.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelLabel } from './addLabel'; + +describe('opsFromCatalogModelLabel', () => { + it('should produce an op for a simple label', () => { + const ops = opsFromCatalogModelLabel({ + name: 'backstage.io/source-location', + description: 'The source location of the entity.', + }); + + expect(ops).toEqual([ + { + op: 'declareLabel.v1', + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity.', + }, + }, + ]); + }); + + it('should produce an op with title and schema', () => { + const ops = opsFromCatalogModelLabel({ + name: 'backstage.io/environment', + title: 'Environment', + description: 'The deployment environment of the entity.', + schema: { + jsonSchema: { + type: 'string', + enum: ['production', 'staging', 'development'], + }, + }, + }); + + expect(ops).toEqual([ + { + op: 'declareLabel.v1', + name: 'backstage.io/environment', + properties: { + title: 'Environment', + description: 'The deployment environment of the entity.', + schema: { + jsonSchema: { + type: 'string', + enum: ['production', 'staging', 'development'], + }, + }, + }, + }, + ]); + }); + + it('should reject a schema with a non-string type', () => { + expect(() => + opsFromCatalogModelLabel({ + name: 'example.com/count', + description: 'A count.', + schema: { + jsonSchema: { + type: 'number', + }, + }, + }), + ).toThrow(/only string values are supported/); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addLabel.ts b/packages/catalog-model/src/model/modelActions/addLabel.ts new file mode 100644 index 0000000000..5eed34b3ce --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addLabel.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createDeclareLabelOp } from '../operations/declareLabel'; +import { JsonObject } from '@backstage/types'; + +/** + * The definition of a catalog model label. + * + * @alpha + */ +export interface CatalogModelLabelDefinition { + /** + * The name of the label, e.g. "backstage.io/source-location". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the label. + */ + description: string; + + /** + * The JSON schema that values of this label must conform to. + * + * @remarks + * + * If not provided, the label is assumed to be a simple string with no + * particular schema. + */ + schema?: { + jsonSchema: JsonObject; + }; +} + +export function opsFromCatalogModelLabel( + label: CatalogModelLabelDefinition, +): CatalogModelOp[] { + if (label.schema?.jsonSchema) { + validateMetaSchema(label.schema.jsonSchema); + if (label.schema.jsonSchema.type !== 'string') { + throw new InputError( + `Label "${label.name}" schema must have "type": "string" at the root, only string values are supported`, + ); + } + } + return [ + createDeclareLabelOp({ + name: label.name, + properties: { + title: label.title, + description: label.description, + schema: label.schema, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/modelActions/addRelationPair.test.ts b/packages/catalog-model/src/model/modelActions/addRelationPair.test.ts new file mode 100644 index 0000000000..ace12d9f81 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addRelationPair.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelRelationPair } from './addRelationPair'; + +describe('opsFromCatalogModelRelationPair', () => { + it('should produce ops for a single fromKind/toKind', () => { + const ops = opsFromCatalogModelRelationPair({ + fromKind: 'Component', + toKind: 'Group', + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }); + + expect(ops).toEqual([ + { + op: 'declareRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Group', + type: 'ownerOf', + toKind: 'Component', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + description: 'Ownership', + }, + }, + ]); + }); + + it('should produce ops for array fromKind/toKind', () => { + const ops = opsFromCatalogModelRelationPair({ + fromKind: ['Component', 'Resource'], + toKind: ['Group', 'User'], + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }); + + expect(ops).toHaveLength(8); + expect(ops).toEqual([ + { + op: 'declareRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Group', + type: 'ownerOf', + toKind: 'Component', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'User', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'User', + type: 'ownerOf', + toKind: 'Component', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Resource', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Group', + type: 'ownerOf', + toKind: 'Resource', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'Resource', + type: 'ownedBy', + toKind: 'User', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'Ownership', + }, + }, + { + op: 'declareRelation.v1', + fromKind: 'User', + type: 'ownerOf', + toKind: 'Resource', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + description: 'Ownership', + }, + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addRelationPair.ts b/packages/catalog-model/src/model/modelActions/addRelationPair.ts new file mode 100644 index 0000000000..1481c9a3c4 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addRelationPair.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createDeclareRelationOp } from '../operations/declareRelation'; + +/** + * The definition of a catalog model relation. + * + * @alpha + */ +export interface CatalogModelRelationPairDefinition { + /** + * The kind(s) that this relation originates from, e.g. "Component" or + * ["Component", "Resource"]. + */ + fromKind: string | string[]; + + /** + * The kind(s) that this relation points to, e.g. "Group" or + * ["Group", "User"]. + */ + toKind: string | string[]; + + /** + * A human-readable description of the relation. + */ + description: string; + + /** + * The names for the forward direction (from the current entity toward + * the one being referenced). + */ + forward: { + /** + * The technical type of the relation, e.g. "ownedBy" + */ + type: string; + /** + * A human-readable title for the relation type, e.g. "owned by". + */ + title: string; + }; + + /** + * The names for the reverse direction (from the one being referenced + * toward the current entity). + */ + reverse: { + /** + * The technical type of the relation, e.g. "ownerOf" + */ + type: string; + /** + * A human-readable title for the relation type, e.g. "owner of". + */ + title: string; + }; +} + +export function opsFromCatalogModelRelationPair( + relationPair: CatalogModelRelationPairDefinition, +): CatalogModelOp[] { + const ops: CatalogModelOp[] = []; + + // Duplicate across kinds, and both directions + for (const firstKind of [relationPair.fromKind].flat()) { + for (const secondKind of [relationPair.toKind].flat()) { + ops.push( + createDeclareRelationOp({ + fromKind: firstKind, + type: relationPair.forward.type, + toKind: secondKind, + properties: { + reverseType: relationPair.reverse.type, + title: relationPair.forward.title, + description: relationPair.description, + }, + }), + ); + ops.push( + createDeclareRelationOp({ + fromKind: secondKind, + type: relationPair.reverse.type, + toKind: firstKind, + properties: { + reverseType: relationPair.forward.type, + title: relationPair.reverse.title, + description: relationPair.description, + }, + }), + ); + } + } + + return ops; +} diff --git a/packages/catalog-model/src/model/modelActions/addTag.test.ts b/packages/catalog-model/src/model/modelActions/addTag.test.ts new file mode 100644 index 0000000000..a3c137479e --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addTag.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelTag } from './addTag'; + +describe('opsFromCatalogModelTag', () => { + it('should produce an op for a simple tag', () => { + const ops = opsFromCatalogModelTag({ + name: 'java', + description: 'Indicates that the entity is related to Java.', + }); + + expect(ops).toEqual([ + { + op: 'declareTag.v1', + name: 'java', + properties: { + description: 'Indicates that the entity is related to Java.', + }, + }, + ]); + }); + + it('should produce an op with a title', () => { + const ops = opsFromCatalogModelTag({ + name: 'production-ready', + title: 'Production Ready', + description: 'Indicates that the entity is ready for production use.', + }); + + expect(ops).toEqual([ + { + op: 'declareTag.v1', + name: 'production-ready', + properties: { + title: 'Production Ready', + description: 'Indicates that the entity is ready for production use.', + }, + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addTag.ts b/packages/catalog-model/src/model/modelActions/addTag.ts new file mode 100644 index 0000000000..b2da172fc5 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addTag.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createDeclareTagOp } from '../operations/declareTag'; + +/** + * The definition of a catalog model tag. + * + * @alpha + */ +export interface CatalogModelTagDefinition { + /** + * The name of the tag, e.g. "java". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the tag. + */ + description: string; +} + +export function opsFromCatalogModelTag( + tag: CatalogModelTagDefinition, +): CatalogModelOp[] { + return [ + createDeclareTagOp({ + name: tag.name, + properties: { + title: tag.title, + description: tag.description, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/modelActions/index.ts b/packages/catalog-model/src/model/modelActions/index.ts new file mode 100644 index 0000000000..ca2a6fd539 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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. + */ + +export { type CatalogModelAnnotationDefinition } from './addAnnotation'; +export { + type CatalogModelKindDefinition, + type CatalogModelKindRelationFieldDefinition, + type CatalogModelKindVersionDefinition, +} from './addKind'; +export { type CatalogModelLabelDefinition } from './addLabel'; +export { type CatalogModelRelationPairDefinition } from './addRelationPair'; +export { type CatalogModelRemoveAnnotationDefinition } from './removeAnnotation'; +export { type CatalogModelRemoveKindDefinition } from './removeKind'; +export { type CatalogModelRemoveLabelDefinition } from './removeLabel'; +export { type CatalogModelRemoveTagDefinition } from './removeTag'; +export { type CatalogModelTagDefinition } from './addTag'; +export { type CatalogModelUpdateAnnotationDefinition } from './updateAnnotation'; +export { + type CatalogModelUpdateKindDefinition, + type CatalogModelUpdateKindVersionDefinition, +} from './updateKind'; +export { type CatalogModelUpdateLabelDefinition } from './updateLabel'; +export { type CatalogModelUpdateRelationPairDefinition } from './updateRelationPair'; +export { type CatalogModelUpdateTagDefinition } from './updateTag'; diff --git a/packages/catalog-model/src/model/modelActions/removeAnnotation.test.ts b/packages/catalog-model/src/model/modelActions/removeAnnotation.test.ts new file mode 100644 index 0000000000..421838b55d --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeAnnotation.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelRemoveAnnotation } from './removeAnnotation'; + +describe('opsFromCatalogModelRemoveAnnotation', () => { + it('should produce an op for removing an annotation', () => { + const ops = opsFromCatalogModelRemoveAnnotation({ + name: 'backstage.io/techdocs-ref', + }); + + expect(ops).toEqual([ + { + op: 'removeAnnotation.v1', + name: 'backstage.io/techdocs-ref', + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/removeAnnotation.ts b/packages/catalog-model/src/model/modelActions/removeAnnotation.ts new file mode 100644 index 0000000000..ed1cd52636 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeAnnotation.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createRemoveAnnotationOp } from '../operations/removeAnnotation'; + +/** + * The definition of an annotation removal from the catalog model. + * + * @alpha + */ +export interface CatalogModelRemoveAnnotationDefinition { + /** + * The name of the annotation to remove, e.g. "backstage.io/techdocs-ref". + */ + name: string; +} + +export function opsFromCatalogModelRemoveAnnotation( + definition: CatalogModelRemoveAnnotationDefinition, +): CatalogModelOp[] { + return [createRemoveAnnotationOp({ name: definition.name })]; +} diff --git a/packages/catalog-model/src/model/modelActions/removeKind.test.ts b/packages/catalog-model/src/model/modelActions/removeKind.test.ts new file mode 100644 index 0000000000..c1c8d85b07 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeKind.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelRemoveKind } from './removeKind'; + +describe('opsFromCatalogModelRemoveKind', () => { + it('should produce an op for removing a kind', () => { + const ops = opsFromCatalogModelRemoveKind({ + kind: 'Component', + }); + + expect(ops).toEqual([ + { + op: 'removeKind.v1', + kind: 'Component', + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/removeKind.ts b/packages/catalog-model/src/model/modelActions/removeKind.ts new file mode 100644 index 0000000000..910176215a --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeKind.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createRemoveKindOp } from '../operations/removeKind'; + +/** + * The definition of a kind removal from the catalog model. + * + * @alpha + */ +export interface CatalogModelRemoveKindDefinition { + /** + * The kind to remove, e.g. "Component". + */ + kind: string; +} + +export function opsFromCatalogModelRemoveKind( + definition: CatalogModelRemoveKindDefinition, +): CatalogModelOp[] { + return [createRemoveKindOp({ kind: definition.kind })]; +} diff --git a/packages/catalog-model/src/model/modelActions/removeLabel.test.ts b/packages/catalog-model/src/model/modelActions/removeLabel.test.ts new file mode 100644 index 0000000000..7917024afa --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeLabel.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelRemoveLabel } from './removeLabel'; + +describe('opsFromCatalogModelRemoveLabel', () => { + it('should produce an op for removing a label', () => { + const ops = opsFromCatalogModelRemoveLabel({ + name: 'backstage.io/environment', + }); + + expect(ops).toEqual([ + { + op: 'removeLabel.v1', + name: 'backstage.io/environment', + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/removeLabel.ts b/packages/catalog-model/src/model/modelActions/removeLabel.ts new file mode 100644 index 0000000000..0377f0cfa7 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeLabel.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createRemoveLabelOp } from '../operations/removeLabel'; + +/** + * The definition of a label removal from the catalog model. + * + * @alpha + */ +export interface CatalogModelRemoveLabelDefinition { + /** + * The name of the label to remove, e.g. "backstage.io/source-location". + */ + name: string; +} + +export function opsFromCatalogModelRemoveLabel( + definition: CatalogModelRemoveLabelDefinition, +): CatalogModelOp[] { + return [createRemoveLabelOp({ name: definition.name })]; +} diff --git a/packages/catalog-model/src/model/modelActions/removeTag.test.ts b/packages/catalog-model/src/model/modelActions/removeTag.test.ts new file mode 100644 index 0000000000..310eeb2e8d --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeTag.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelRemoveTag } from './removeTag'; + +describe('opsFromCatalogModelRemoveTag', () => { + it('should produce an op for removing a tag', () => { + const ops = opsFromCatalogModelRemoveTag({ + name: 'java', + }); + + expect(ops).toEqual([ + { + op: 'removeTag.v1', + name: 'java', + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/removeTag.ts b/packages/catalog-model/src/model/modelActions/removeTag.ts new file mode 100644 index 0000000000..956034c506 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/removeTag.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createRemoveTagOp } from '../operations/removeTag'; + +/** + * The definition of a tag removal from the catalog model. + * + * @alpha + */ +export interface CatalogModelRemoveTagDefinition { + /** + * The name of the tag to remove, e.g. "java". + */ + name: string; +} + +export function opsFromCatalogModelRemoveTag( + definition: CatalogModelRemoveTagDefinition, +): CatalogModelOp[] { + return [createRemoveTagOp({ name: definition.name })]; +} diff --git a/packages/catalog-model/src/model/modelActions/updateAnnotation.test.ts b/packages/catalog-model/src/model/modelActions/updateAnnotation.test.ts new file mode 100644 index 0000000000..d6fc245074 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateAnnotation.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelUpdateAnnotation } from './updateAnnotation'; + +describe('opsFromCatalogModelUpdateAnnotation', () => { + it('should produce an op for a basic annotation update', () => { + const ops = opsFromCatalogModelUpdateAnnotation({ + name: 'backstage.io/techdocs-ref', + title: 'TechDocs Ref', + description: 'Updated', + }); + + expect(ops).toEqual([ + { + op: 'updateAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + title: 'TechDocs Ref', + description: 'Updated', + }, + }, + ]); + }); + + it('should produce an op with schema', () => { + const ops = opsFromCatalogModelUpdateAnnotation({ + name: 'backstage.io/view-url', + schema: { + jsonSchema: { + type: 'string', + format: 'uri', + }, + }, + }); + + expect(ops).toEqual([ + { + op: 'updateAnnotation.v1', + name: 'backstage.io/view-url', + properties: { + schema: { + jsonSchema: { + type: 'string', + format: 'uri', + }, + }, + }, + }, + ]); + }); + + it('should reject a schema with a non-string type', () => { + expect(() => + opsFromCatalogModelUpdateAnnotation({ + name: 'example.com/count', + schema: { + jsonSchema: { + type: 'number', + }, + }, + }), + ).toThrow(/only string values are supported/); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/updateAnnotation.ts b/packages/catalog-model/src/model/modelActions/updateAnnotation.ts new file mode 100644 index 0000000000..1732014f0b --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateAnnotation.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createUpdateAnnotationOp } from '../operations/updateAnnotation'; +import { JsonObject } from '@backstage/types'; + +/** + * The definition of updates to a catalog model annotation. + * + * @alpha + */ +export interface CatalogModelUpdateAnnotationDefinition { + /** + * The name of the annotation, e.g. "backstage.io/techdocs-ref". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the annotation. + */ + description?: string; + + /** + * The JSON schema that values of this annotation must conform to. + * + * @remarks + * + * If not provided, the annotation is assumed to be a simple string with no + * particular schema. + */ + schema?: { + jsonSchema: JsonObject; + }; +} + +export function opsFromCatalogModelUpdateAnnotation( + annotation: CatalogModelUpdateAnnotationDefinition, +): CatalogModelOp[] { + if (annotation.schema) { + validateMetaSchema(annotation.schema.jsonSchema); + if (annotation.schema.jsonSchema.type !== 'string') { + throw new InputError( + `Annotation "${annotation.name}" schema must have "type": "string" at the root, only string values are supported`, + ); + } + } + return [ + createUpdateAnnotationOp({ + name: annotation.name, + properties: { + title: annotation.title, + description: annotation.description, + schema: annotation.schema, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/modelActions/updateKind.test.ts b/packages/catalog-model/src/model/modelActions/updateKind.test.ts new file mode 100644 index 0000000000..0aee201530 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateKind.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelUpdateKind } from './updateKind'; + +describe('opsFromCatalogModelUpdateKind', () => { + it('should produce an updateKind op when names are updated', () => { + const ops = opsFromCatalogModelUpdateKind({ + names: { kind: 'Component', singular: 'comp' }, + }); + + expect(ops).toEqual([ + { + op: 'updateKind.v1', + kind: 'Component', + properties: { + singular: 'comp', + }, + }, + ]); + }); + + it('should produce an updateKindVersion op for versions without names changes', () => { + const ops = opsFromCatalogModelUpdateKind({ + names: { kind: 'Component' }, + versions: [ + { + name: 'v1alpha1', + schema: { jsonSchema: { type: 'object' } }, + }, + ], + }); + + expect(ops).toEqual([ + { + op: 'updateKindVersion.v1', + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { jsonSchema: { type: 'object' } }, + }, + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/updateKind.ts b/packages/catalog-model/src/model/modelActions/updateKind.ts new file mode 100644 index 0000000000..802a650961 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateKind.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import { reduceKindSchema } from '../jsonSchema/reduceKindSchema'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createUpdateKindOp } from '../operations/updateKind'; +import { createUpdateKindVersionOp } from '../operations/updateKindVersion'; +import { CatalogModelKindRelationFieldDefinition } from './addKind'; + +/** + * The definition of updates to a catalog model kind. + * + * @alpha + */ +export interface CatalogModelUpdateKindDefinition { + /** + * The names used for this kind. + */ + names: { + /** + * The name of the kind with proper casing, e.g. "Component". + */ + kind: string; + + /** + * The singular form of the kind name, e.g. "component". Specify this if you + * want to override the default value. + */ + singular?: string; + + /** + * The plural form of the kind name, e.g. "components". Specify this if you + * want to override the default value. + */ + plural?: string; + }; + + /** + * A short description of the kind. Specify this if you want to override the + * default value. + */ + description?: string; + + /** + * Update one or more versions of the kind's actual schema shape. + */ + versions?: CatalogModelUpdateKindVersionDefinition[]; +} + +/** + * The definition of updates to a specific version of a catalog model kind. + * + * @alpha + */ +export interface CatalogModelUpdateKindVersionDefinition { + /** + * The specific version name or names to update, e.g. "v1alpha1" or + * ["v1alpha1", "v1beta1"]. + */ + name: string | string[]; + + /** + * The spec type or types that this version update applies to. + */ + specType?: string | string[]; + + /** + * A short description of this particular version (and type, where + * applicable). Specify this if you want to override the default value. + */ + description?: string; + + /** + * The fields that shall be used to generate relations, if any. Specify this + * if you want to override the default value. + */ + relationFields?: CatalogModelKindRelationFieldDefinition[]; + + /** + * The JSON schema to deep merge with the existing schema for this version. + */ + schema?: { + jsonSchema: JsonObject; + }; +} + +export function opsFromCatalogModelUpdateKind( + kind: CatalogModelUpdateKindDefinition, +): CatalogModelOp[] { + const ops: CatalogModelOp[] = []; + + if (kind.names.singular || kind.names.plural || kind.description) { + ops.push( + createUpdateKindOp({ + kind: kind.names.kind, + properties: { + singular: kind.names.singular, + plural: kind.names.plural, + description: kind.description, + }, + }), + ); + } + + for (const version of kind.versions ?? []) { + const jsonSchema = version.schema + ? reduceKindSchema(version.schema.jsonSchema) + : undefined; + if (jsonSchema) { + validateMetaSchema(jsonSchema); + } + const names = Array.isArray(version.name) ? version.name : [version.name]; + for (const name of names) { + const specTypes = version.specType?.length + ? [version.specType].flat() + : [undefined]; + for (const specType of specTypes) { + ops.push( + createUpdateKindVersionOp({ + kind: kind.names.kind, + name, + specType: specType, + properties: { + description: version.description, + relationFields: version.relationFields, + schema: jsonSchema + ? { jsonSchema: jsonSchema as any } + : undefined, + }, + }), + ); + } + } + } + + return ops; +} diff --git a/packages/catalog-model/src/model/modelActions/updateLabel.test.ts b/packages/catalog-model/src/model/modelActions/updateLabel.test.ts new file mode 100644 index 0000000000..7f03805e27 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateLabel.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelUpdateLabel } from './updateLabel'; + +describe('opsFromCatalogModelUpdateLabel', () => { + it('should produce an op for a basic label update', () => { + const ops = opsFromCatalogModelUpdateLabel({ + name: 'backstage.io/environment', + title: 'Environment', + }); + + expect(ops).toEqual([ + { + op: 'updateLabel.v1', + name: 'backstage.io/environment', + properties: { + title: 'Environment', + }, + }, + ]); + }); + + it('should reject a schema with a non-string type', () => { + expect(() => + opsFromCatalogModelUpdateLabel({ + name: 'example.com/count', + schema: { + jsonSchema: { + type: 'number', + }, + }, + }), + ).toThrow(/only string values are supported/); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/updateLabel.ts b/packages/catalog-model/src/model/modelActions/updateLabel.ts new file mode 100644 index 0000000000..bbcb27cf20 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateLabel.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { validateMetaSchema } from '../jsonSchema/validateMetaSchema'; +import { CatalogModelOp } from '../operations'; +import { createUpdateLabelOp } from '../operations/updateLabel'; +import { JsonObject } from '@backstage/types'; + +/** + * The definition of updates to a catalog model label. + * + * @alpha + */ +export interface CatalogModelUpdateLabelDefinition { + /** + * The name of the label, e.g. "backstage.io/source-location". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the label. + */ + description?: string; + + /** + * The JSON schema that values of this label must conform to. + * + * @remarks + * + * If not provided, the label is assumed to be a simple string with no + * particular schema. + */ + schema?: { + jsonSchema: JsonObject; + }; +} + +export function opsFromCatalogModelUpdateLabel( + label: CatalogModelUpdateLabelDefinition, +): CatalogModelOp[] { + if (label.schema) { + validateMetaSchema(label.schema.jsonSchema); + if (label.schema.jsonSchema.type !== 'string') { + throw new InputError( + `Label "${label.name}" schema must have "type": "string" at the root, only string values are supported`, + ); + } + } + return [ + createUpdateLabelOp({ + name: label.name, + properties: { + title: label.title, + description: label.description, + schema: label.schema, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/modelActions/updateRelationPair.test.ts b/packages/catalog-model/src/model/modelActions/updateRelationPair.test.ts new file mode 100644 index 0000000000..464cdc6f54 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateRelationPair.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelUpdateRelationPair } from './updateRelationPair'; + +describe('opsFromCatalogModelUpdateRelationPair', () => { + it('should produce ops for forward and reverse with a reverse type', () => { + const ops = opsFromCatalogModelUpdateRelationPair({ + fromKind: 'Component', + toKind: 'Group', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }); + + expect(ops).toEqual([ + { + op: 'updateRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + }, + }, + { + op: 'updateRelation.v1', + fromKind: 'Group', + type: 'ownerOf', + toKind: 'Component', + properties: { + reverseType: 'ownedBy', + title: 'owner of', + }, + }, + ]); + }); + + it('should produce only the forward op when reverse type is not set', () => { + const ops = opsFromCatalogModelUpdateRelationPair({ + fromKind: 'Component', + toKind: 'Group', + forward: { type: 'ownedBy' }, + reverse: {}, + }); + + expect(ops).toEqual([ + { + op: 'updateRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: {}, + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/updateRelationPair.ts b/packages/catalog-model/src/model/modelActions/updateRelationPair.ts new file mode 100644 index 0000000000..04ad73a715 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateRelationPair.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createUpdateRelationOp } from '../operations/updateRelation'; + +/** + * The definition of a catalog model relation. + * + * @alpha + */ +export interface CatalogModelUpdateRelationPairDefinition { + /** + * The kind(s) that this relation originates from, e.g. "Component" or + * ["Component", "Resource"]. + */ + fromKind: string | string[]; + + /** + * The kind(s) that this relation points to, e.g. "Group" or + * ["Group", "User"]. + */ + toKind: string | string[]; + + /** + * A human-readable description of the relation. Specify this if you want + * to override the default value. + */ + description?: string; + + /** + * The names for the forward direction (from the current entity toward the one + * being referenced). + */ + forward: { + /** + * The technical type of the relation, e.g. "ownedBy" + */ + type: string; + /** + * A human-readable title for the relation type, e.g. "owned by". + * Specify this if you want to override the default value. + */ + title?: string; + }; + + /** + * The names for the reverse direction (from the one being referenced toward + * the current entity). + */ + reverse: { + /** + * The technical type of the relation, e.g. "ownerOf". Specify this if you + * want to override the default value. + */ + type?: string; + /** + * A human-readable title for the relation type, e.g. "owner of". + * Specify this if you want to override the default value. + */ + title?: string; + }; +} + +export function opsFromCatalogModelUpdateRelationPair( + relationPair: CatalogModelUpdateRelationPairDefinition, +): CatalogModelOp[] { + const ops: CatalogModelOp[] = []; + + // Duplicate across kinds, and both directions + for (const firstKind of [relationPair.fromKind].flat()) { + for (const secondKind of [relationPair.toKind].flat()) { + ops.push( + createUpdateRelationOp({ + fromKind: firstKind, + type: relationPair.forward.type, + toKind: secondKind, + properties: { + reverseType: relationPair.reverse.type, + title: relationPair.forward.title, + description: relationPair.description, + }, + }), + ); + if (relationPair.reverse.type) { + ops.push( + createUpdateRelationOp({ + fromKind: secondKind, + type: relationPair.reverse.type, + toKind: firstKind, + properties: { + reverseType: relationPair.forward.type, + title: relationPair.reverse.title, + description: relationPair.description, + }, + }), + ); + } + } + } + + return ops; +} diff --git a/packages/catalog-model/src/model/modelActions/updateTag.test.ts b/packages/catalog-model/src/model/modelActions/updateTag.test.ts new file mode 100644 index 0000000000..a128ce4081 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateTag.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2026 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 { opsFromCatalogModelUpdateTag } from './updateTag'; + +describe('opsFromCatalogModelUpdateTag', () => { + it('should produce an op for a basic tag update', () => { + const ops = opsFromCatalogModelUpdateTag({ + name: 'java', + title: 'Java', + description: 'Java tag', + }); + + expect(ops).toEqual([ + { + op: 'updateTag.v1', + name: 'java', + properties: { + title: 'Java', + description: 'Java tag', + }, + }, + ]); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/updateTag.ts b/packages/catalog-model/src/model/modelActions/updateTag.ts new file mode 100644 index 0000000000..5fcb0e2f20 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/updateTag.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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 { CatalogModelOp } from '../operations'; +import { createUpdateTagOp } from '../operations/updateTag'; + +/** + * The definition of updates to a catalog model tag. + * + * @alpha + */ +export interface CatalogModelUpdateTagDefinition { + /** + * The name of the tag, e.g. "java". + */ + name: string; + + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title?: string; + + /** + * A human-readable description of the tag. + */ + description?: string; +} + +export function opsFromCatalogModelUpdateTag( + tag: CatalogModelUpdateTagDefinition, +): CatalogModelOp[] { + return [ + createUpdateTagOp({ + name: tag.name, + properties: { + title: tag.title, + description: tag.description, + }, + }), + ]; +} diff --git a/packages/catalog-model/src/model/operations/declareAnnotation.test.ts b/packages/catalog-model/src/model/operations/declareAnnotation.test.ts new file mode 100644 index 0000000000..7757ce86ae --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareAnnotation.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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 { createDeclareAnnotationOp } from './declareAnnotation'; + +describe('createDeclareAnnotationOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + + expect(result).toEqual({ + op: 'declareAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should accept missing optional schema field', () => { + const result = createDeclareAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + }, + }); + + expect(result).toEqual({ + op: 'declareAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + }, + }); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareAnnotationOp({ + name: 123, + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareAnnotation.ts b/packages/catalog-model/src/model/operations/declareAnnotation.ts new file mode 100644 index 0000000000..f3888bb83e --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareAnnotation.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +/** + * Declare the existence of a well-known annotation and its properties. + */ +export const opDeclareAnnotationV1Schema = z.strictObject({ + op: z.literal('declareAnnotation.v1'), + /** + * The name of the annotation, e.g. "backstage.io/techdocs-ref". + */ + name: z.string(), + + /** + * The properties that apply to this annotation. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the annotation. + */ + description: z.string(), + /** + * The JSON schema that values of this annotation must conform to. + * + * @remarks + * + * If not provided, the annotation is assumed to be a simple string with no + * particular schema. + */ + schema: z + .strictObject({ + jsonSchema: jsonSchemaSchema, + }) + .optional(), + }), +}); + +/** {@inheritDoc opDeclareAnnotationV1Schema} */ +export type OpDeclareAnnotationV1 = z.infer; + +/** + * Creates a validated {@link OpDeclareAnnotationV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareAnnotationV1}. + */ +export function createDeclareAnnotationOp( + input: Omit & { op?: never }, +): OpDeclareAnnotationV1 { + return opDeclareAnnotationV1Schema.parse({ + ...input, + op: 'declareAnnotation.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/declareKind.test.ts b/packages/catalog-model/src/model/operations/declareKind.test.ts new file mode 100644 index 0000000000..11a8246811 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareKind.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2026 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 { createDeclareKindOp } from './declareKind'; + +describe('createDeclareKindOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareKindOp({ + kind: 'Component', + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + }); + + expect(result).toEqual({ + op: 'declareKind.v1', + kind: 'Component', + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareKindOp({ + kind: 'Component', + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createDeclareKindOp({ + kind: 'Component', + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + }, + } as any), + ).toThrow(/description/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareKindOp({ + kind: 123, + group: 'backstage.io', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + } as any), + ).toThrow(/kind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareKind.ts b/packages/catalog-model/src/model/operations/declareKind.ts new file mode 100644 index 0000000000..33bf5bb206 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareKind.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Make a declaration about the properties of a certain kind. + */ +export const opDeclareKindV1Schema = z.strictObject({ + op: z.literal('declareKind.v1'), + + /** + * The kind to declare, e.g. "Component". + */ + kind: z.string(), + /** + * The apiVersion group of the kind, e.g. "backstage.io". + */ + group: z.string(), + + /** + * Properties that apply for this kind + */ + properties: z.strictObject({ + /** + * The singular form of the human-readable kind, e.g. "component". + */ + singular: z.string(), + /** + * The plural form of the human-readable kind, e.g. "components". + */ + plural: z.string(), + /** + * Short description of the kind. + */ + description: z.string(), + }), +}); + +/** {@inheritDoc opDeclareKindV1Schema} */ +export type OpDeclareKindV1 = z.infer; + +/** + * Creates a validated {@link OpDeclareKindV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareKindV1}. + */ +export function createDeclareKindOp( + input: Omit & { op?: never }, +): OpDeclareKindV1 { + return opDeclareKindV1Schema.parse({ ...input, op: 'declareKind.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/declareKindVersion.test.ts b/packages/catalog-model/src/model/operations/declareKindVersion.test.ts new file mode 100644 index 0000000000..5bfa4d82ca --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareKindVersion.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2026 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 { createDeclareKindVersionOp } from './declareKindVersion'; + +describe('createDeclareKindVersionOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { + jsonSchema: { type: 'object' }, + }, + }, + }); + + expect(result).toEqual({ + op: 'declareKindVersion.v1', + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { + jsonSchema: { type: 'object' }, + }, + }, + }); + }); + + it('should accept optional fields', () => { + const result = createDeclareKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + specType: 'service', + properties: { + description: 'A service component', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: { type: 'object' }, + }, + }, + }); + + expect(result.op).toBe('declareKindVersion.v1'); + expect(result.specType).toBe('service'); + expect(result.properties.description).toBe('A service component'); + expect(result.properties.relationFields).toHaveLength(1); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { jsonSchema: {} }, + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createDeclareKindVersionOp({ + kind: 'Component', + } as any), + ).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareKindVersionOp({ + kind: 123, + name: 'v1alpha1', + properties: { + schema: { jsonSchema: {} }, + }, + } as any), + ).toThrow(/kind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareKindVersion.ts b/packages/catalog-model/src/model/operations/declareKindVersion.ts new file mode 100644 index 0000000000..f1da0f6651 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareKindVersion.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +const relationFieldSchema = z.strictObject({ + /** + * What field that shall be used to generate relations. + * + * @remarks + * + * The field value is expected to be a string or string array at runtime. + */ + selector: z.strictObject({ + /** + * A dot separated path on the common catalog form, e.g. `spec.owner`. + */ + path: z.string(), + }), + /** + * The relation type that this field generates, e.g. "ownedBy". + */ + relation: z.string(), + /** + * If the given shorthand ref did not have a kind, use this kind as the + * default. If no default kind is specified, the ref must contain a kind. + */ + defaultKind: z.string().optional(), + /** + * If the given shorthand ref did not have a namespace, either inherit the + * namespace of the entity itself, or choose the default namespace. + */ + defaultNamespace: z.enum(['default', 'inherit']).optional(), + /** + * Only allow relations to be specified to the given kinds. This list must + * include the default kind, if any. If no allowed kinds are specified, + * all kinds are allowed. + */ + allowedKinds: z.array(z.string()).optional(), +}); + +/** + * Make a declaration about the version of a certain kind. + */ +export const opDeclareKindVersionV1Schema = z.strictObject({ + op: z.literal('declareKindVersion.v1'), + + /** + * The kind that this version belongs to. + */ + kind: z.string(), + /** + * The specific version name, e.g. "v1alpha1". This and the kind group form + * the full apiVersion. + */ + name: z.string(), + /** + * The spec type that this version applies to, if any. + * + * @remarks + * + * This can be used to make kinds whose spec effectively are discriminated + * unions. If you don't specify this, the schema will apply to a spec that has + * no type given at all, or to those where the type is not among the set of + * any other known declared spec types. + */ + specType: z.string().optional(), + + /** + * The properties that apply to this version. + */ + properties: z.strictObject({ + /** + * A short description of this particular version (and type, where applicable). + */ + description: z.string().optional(), + + /** + * The fields that shall be used to generate relations, if any. + */ + relationFields: z.array(relationFieldSchema).optional(), + + /** + * The JSON schema of the version. + */ + schema: z.strictObject({ + jsonSchema: jsonSchemaSchema, + }), + }), +}); + +/** {@inheritDoc opDeclareKindVersionV1Schema} */ +export type OpDeclareKindVersionV1 = z.infer< + typeof opDeclareKindVersionV1Schema +>; + +/** + * Creates a validated {@link OpDeclareKindVersionV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareKindVersionV1}. + */ +export function createDeclareKindVersionOp( + input: Omit & { op?: never }, +): OpDeclareKindVersionV1 { + return opDeclareKindVersionV1Schema.parse({ + ...input, + op: 'declareKindVersion.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/declareLabel.test.ts b/packages/catalog-model/src/model/operations/declareLabel.test.ts new file mode 100644 index 0000000000..3f34a72701 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareLabel.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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 { createDeclareLabelOp } from './declareLabel'; + +describe('createDeclareLabelOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareLabelOp({ + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + + expect(result).toEqual({ + op: 'declareLabel.v1', + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareLabelOp({ + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + schema: { jsonSchema: { type: 'string' } }, + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should accept missing optional schema field', () => { + const result = createDeclareLabelOp({ + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + }, + }); + + expect(result).toEqual({ + op: 'declareLabel.v1', + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + }, + }); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareLabelOp({ + name: 123, + properties: { + description: 'The source location of the entity', + schema: { jsonSchema: { type: 'string' } }, + }, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareLabel.ts b/packages/catalog-model/src/model/operations/declareLabel.ts new file mode 100644 index 0000000000..7a98805d02 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareLabel.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +/** + * Declare the existence of a well-known label and its properties. + */ +export const opDeclareLabelV1Schema = z.strictObject({ + op: z.literal('declareLabel.v1'), + /** + * The name of the label, e.g. "backstage.io/source-location". + */ + name: z.string(), + + /** + * The properties that apply to this label. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the label. + */ + description: z.string(), + /** + * The JSON schema that values of this label must conform to. + * + * @remarks + * + * If not provided, the label is assumed to be a simple string with no + * particular schema. + */ + schema: z + .strictObject({ + jsonSchema: jsonSchemaSchema, + }) + .optional(), + }), +}); + +/** {@inheritDoc opDeclareLabelV1Schema} */ +export type OpDeclareLabelV1 = z.infer; + +/** + * Creates a validated {@link OpDeclareLabelV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareLabelV1}. + */ +export function createDeclareLabelOp( + input: Omit & { op?: never }, +): OpDeclareLabelV1 { + return opDeclareLabelV1Schema.parse({ + ...input, + op: 'declareLabel.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/declareRelation.test.ts b/packages/catalog-model/src/model/operations/declareRelation.test.ts new file mode 100644 index 0000000000..d94914198f --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareRelation.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2026 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 { createDeclareRelationOp } from './declareRelation'; + +describe('createDeclareRelationOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'The owner of the component', + }, + }); + + expect(result).toEqual({ + op: 'declareRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'The owner of the component', + }, + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + singular: 'owner', + plural: 'owners', + description: 'The owner', + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createDeclareRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + }, + } as any), + ).toThrow(/title/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareRelationOp({ + fromKind: 123, + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + singular: 'owner', + plural: 'owners', + description: 'The owner', + }, + } as any), + ).toThrow(/fromKind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareRelation.ts b/packages/catalog-model/src/model/operations/declareRelation.ts new file mode 100644 index 0000000000..02b1555244 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareRelation.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Make a declaration about the properties of a certain relation type between a + * given pair of kinds. + * + * @remarks + * + * Note that this is NOT the same as declaring that a certain field is a + * relation type - it declares that IF a relation was generated between two + * kinds for any reason, then these properties apply to it. + */ +export const opDeclareRelationV1Schema = z.strictObject({ + op: z.literal('declareRelation.v1'), + /** + * The kind that this relation originates from, e.g. "Component". + */ + fromKind: z.string(), + /** + * The technical type of the relation, e.g. "ownedBy". + */ + type: z.string(), + /** + * The kind that this relation points to, e.g. "Group". + */ + toKind: z.string(), + + /** + * The properties that apply to this relation. + */ + properties: z.strictObject({ + /** + * The technical type of the reverse relation, e.g. "ownerOf". + */ + reverseType: z.string(), + /** + * A human-readable title for the relation type, e.g. "owned by". + */ + title: z.string(), + /** + * A human-readable description of the relation. + */ + description: z.string(), + }), +}); + +/** {@inheritDoc opDeclareRelationV1Schema} */ +export type OpDeclareRelationV1 = z.infer; + +/** + * Creates a validated {@link OpDeclareRelationV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareRelationV1}. + */ +export function createDeclareRelationOp( + input: Omit & { op?: never }, +): OpDeclareRelationV1 { + return opDeclareRelationV1Schema.parse({ + ...input, + op: 'declareRelation.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/declareTag.test.ts b/packages/catalog-model/src/model/operations/declareTag.test.ts new file mode 100644 index 0000000000..8e520a476f --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareTag.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2026 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 { createDeclareTagOp } from './declareTag'; + +describe('createDeclareTagOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createDeclareTagOp({ + name: 'java', + properties: { + description: 'Indicates a Java-based component', + }, + }); + + expect(result).toEqual({ + op: 'declareTag.v1', + name: 'java', + properties: { + description: 'Indicates a Java-based component', + }, + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createDeclareTagOp({ + name: 'java', + properties: { + description: 'Indicates a Java-based component', + }, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createDeclareTagOp({ + name: 'java', + properties: {}, + } as any), + ).toThrow(/description/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createDeclareTagOp({ + name: 123, + properties: { + description: 'Indicates a Java-based component', + }, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/declareTag.ts b/packages/catalog-model/src/model/operations/declareTag.ts new file mode 100644 index 0000000000..97a9a2e1c9 --- /dev/null +++ b/packages/catalog-model/src/model/operations/declareTag.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Declare the existence of a well-known tag and its properties. + */ +export const opDeclareTagV1Schema = z.strictObject({ + op: z.literal('declareTag.v1'), + /** + * The name of the tag, e.g. "java". + */ + name: z.string(), + + /** + * The properties that apply to this tag. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the tag. + */ + description: z.string(), + }), +}); + +/** {@inheritDoc opDeclareTagV1Schema} */ +export type OpDeclareTagV1 = z.infer; + +/** + * Creates a validated {@link OpDeclareTagV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpDeclareTagV1}. + */ +export function createDeclareTagOp( + input: Omit & { op?: never }, +): OpDeclareTagV1 { + return opDeclareTagV1Schema.parse({ + ...input, + op: 'declareTag.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/index.ts b/packages/catalog-model/src/model/operations/index.ts new file mode 100644 index 0000000000..d5e33b14bd --- /dev/null +++ b/packages/catalog-model/src/model/operations/index.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 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 { OpDeclareAnnotationV1 } from './declareAnnotation'; +import { OpDeclareKindV1 } from './declareKind'; +import { OpDeclareKindVersionV1 } from './declareKindVersion'; +import { OpDeclareLabelV1 } from './declareLabel'; +import { OpDeclareRelationV1 } from './declareRelation'; +import { OpDeclareTagV1 } from './declareTag'; +import { OpRemoveAnnotationV1 } from './removeAnnotation'; +import { OpRemoveKindV1 } from './removeKind'; +import { OpRemoveLabelV1 } from './removeLabel'; +import { OpRemoveTagV1 } from './removeTag'; +import { OpUpdateAnnotationV1 } from './updateAnnotation'; +import { OpUpdateKindV1 } from './updateKind'; +import { OpUpdateKindVersionV1 } from './updateKindVersion'; +import { OpUpdateLabelV1 } from './updateLabel'; +import { OpUpdateRelationV1 } from './updateRelation'; +import { OpUpdateTagV1 } from './updateTag'; + +export type { + OpDeclareAnnotationV1, + OpDeclareKindV1, + OpDeclareKindVersionV1, + OpDeclareLabelV1, + OpDeclareRelationV1, + OpDeclareTagV1, + OpRemoveAnnotationV1, + OpRemoveKindV1, + OpRemoveLabelV1, + OpRemoveTagV1, + OpUpdateAnnotationV1, + OpUpdateKindV1, + OpUpdateKindVersionV1, + OpUpdateLabelV1, + OpUpdateRelationV1, + OpUpdateTagV1, +}; + +export type CatalogModelOp = + | OpDeclareAnnotationV1 + | OpDeclareKindV1 + | OpDeclareKindVersionV1 + | OpDeclareLabelV1 + | OpDeclareRelationV1 + | OpDeclareTagV1 + | OpRemoveAnnotationV1 + | OpRemoveKindV1 + | OpRemoveLabelV1 + | OpRemoveTagV1 + | OpUpdateAnnotationV1 + | OpUpdateKindV1 + | OpUpdateKindVersionV1 + | OpUpdateLabelV1 + | OpUpdateRelationV1 + | OpUpdateTagV1; diff --git a/packages/catalog-model/src/model/operations/removeAnnotation.test.ts b/packages/catalog-model/src/model/operations/removeAnnotation.test.ts new file mode 100644 index 0000000000..a003368b5a --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeAnnotation.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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 { createRemoveAnnotationOp } from './removeAnnotation'; + +describe('createRemoveAnnotationOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createRemoveAnnotationOp({ + name: 'backstage.io/techdocs-ref', + }); + + expect(result).toEqual({ + op: 'removeAnnotation.v1', + name: 'backstage.io/techdocs-ref', + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createRemoveAnnotationOp({ + name: 'backstage.io/techdocs-ref', + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => createRemoveAnnotationOp({} as any)).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createRemoveAnnotationOp({ + name: 123, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/removeAnnotation.ts b/packages/catalog-model/src/model/operations/removeAnnotation.ts new file mode 100644 index 0000000000..1dd44d4f65 --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeAnnotation.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Remove an annotation from the model. + */ +export const opRemoveAnnotationV1Schema = z.strictObject({ + op: z.literal('removeAnnotation.v1'), + + /** + * The name of the annotation to remove, e.g. "backstage.io/techdocs-ref". + */ + name: z.string(), +}); + +/** {@inheritDoc opRemoveAnnotationV1Schema} */ +export type OpRemoveAnnotationV1 = z.infer; + +/** + * Creates a validated {@link OpRemoveAnnotationV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpRemoveAnnotationV1}. + */ +export function createRemoveAnnotationOp( + input: Omit & { op?: never }, +): OpRemoveAnnotationV1 { + return opRemoveAnnotationV1Schema.parse({ + ...input, + op: 'removeAnnotation.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/removeKind.test.ts b/packages/catalog-model/src/model/operations/removeKind.test.ts new file mode 100644 index 0000000000..b49a15b1d2 --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeKind.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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 { createRemoveKindOp } from './removeKind'; + +describe('createRemoveKindOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createRemoveKindOp({ + kind: 'Component', + }); + + expect(result).toEqual({ + op: 'removeKind.v1', + kind: 'Component', + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createRemoveKindOp({ + kind: 'Component', + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => createRemoveKindOp({} as any)).toThrow(/kind/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createRemoveKindOp({ + kind: 123, + } as any), + ).toThrow(/kind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/removeKind.ts b/packages/catalog-model/src/model/operations/removeKind.ts new file mode 100644 index 0000000000..965ecea0f6 --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeKind.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Remove a kind entirely from the model. + */ +export const opRemoveKindV1Schema = z.strictObject({ + op: z.literal('removeKind.v1'), + + /** + * The kind to remove, e.g. "Component". + */ + kind: z.string(), +}); + +/** {@inheritDoc opRemoveKindV1Schema} */ +export type OpRemoveKindV1 = z.infer; + +/** + * Creates a validated {@link OpRemoveKindV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpRemoveKindV1}. + */ +export function createRemoveKindOp( + input: Omit & { op?: never }, +): OpRemoveKindV1 { + return opRemoveKindV1Schema.parse({ ...input, op: 'removeKind.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/removeLabel.test.ts b/packages/catalog-model/src/model/operations/removeLabel.test.ts new file mode 100644 index 0000000000..c08979a9e2 --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeLabel.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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 { createRemoveLabelOp } from './removeLabel'; + +describe('createRemoveLabelOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createRemoveLabelOp({ + name: 'backstage.io/environment', + }); + + expect(result).toEqual({ + op: 'removeLabel.v1', + name: 'backstage.io/environment', + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createRemoveLabelOp({ + name: 'backstage.io/environment', + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => createRemoveLabelOp({} as any)).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createRemoveLabelOp({ + name: 123, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/removeLabel.ts b/packages/catalog-model/src/model/operations/removeLabel.ts new file mode 100644 index 0000000000..69cbd61b8d --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeLabel.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Remove a label from the model. + */ +export const opRemoveLabelV1Schema = z.strictObject({ + op: z.literal('removeLabel.v1'), + + /** + * The name of the label to remove, e.g. "backstage.io/source-location". + */ + name: z.string(), +}); + +/** {@inheritDoc opRemoveLabelV1Schema} */ +export type OpRemoveLabelV1 = z.infer; + +/** + * Creates a validated {@link OpRemoveLabelV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpRemoveLabelV1}. + */ +export function createRemoveLabelOp( + input: Omit & { op?: never }, +): OpRemoveLabelV1 { + return opRemoveLabelV1Schema.parse({ ...input, op: 'removeLabel.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/removeTag.test.ts b/packages/catalog-model/src/model/operations/removeTag.test.ts new file mode 100644 index 0000000000..0dbc807305 --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeTag.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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 { createRemoveTagOp } from './removeTag'; + +describe('createRemoveTagOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createRemoveTagOp({ + name: 'java', + }); + + expect(result).toEqual({ + op: 'removeTag.v1', + name: 'java', + }); + }); + + it('should reject unknown fields', () => { + expect(() => + createRemoveTagOp({ + name: 'java', + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => createRemoveTagOp({} as any)).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createRemoveTagOp({ + name: 123, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/removeTag.ts b/packages/catalog-model/src/model/operations/removeTag.ts new file mode 100644 index 0000000000..755dd370ba --- /dev/null +++ b/packages/catalog-model/src/model/operations/removeTag.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Remove a tag from the model. + */ +export const opRemoveTagV1Schema = z.strictObject({ + op: z.literal('removeTag.v1'), + + /** + * The name of the tag to remove, e.g. "java". + */ + name: z.string(), +}); + +/** {@inheritDoc opRemoveTagV1Schema} */ +export type OpRemoveTagV1 = z.infer; + +/** + * Creates a validated {@link OpRemoveTagV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpRemoveTagV1}. + */ +export function createRemoveTagOp( + input: Omit & { op?: never }, +): OpRemoveTagV1 { + return opRemoveTagV1Schema.parse({ ...input, op: 'removeTag.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/updateAnnotation.test.ts b/packages/catalog-model/src/model/operations/updateAnnotation.test.ts new file mode 100644 index 0000000000..4140b5bf26 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateAnnotation.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 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 { createUpdateAnnotationOp } from './updateAnnotation'; + +describe('createUpdateAnnotationOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: { + title: 'TechDocs Ref', + description: 'Updated description', + }, + }); + + expect(result).toEqual({ + op: 'updateAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + title: 'TechDocs Ref', + description: 'Updated description', + }, + }); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: {}, + }); + + expect(result.op).toBe('updateAnnotation.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateAnnotationOp({ + name: 'backstage.io/techdocs-ref', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateAnnotationOp({ + properties: {}, + } as any), + ).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateAnnotationOp({ + name: 123, + properties: {}, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateAnnotation.ts b/packages/catalog-model/src/model/operations/updateAnnotation.ts new file mode 100644 index 0000000000..8cd87667d3 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateAnnotation.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +/** + * Update the properties of an existing annotation. + */ +export const opUpdateAnnotationV1Schema = z.strictObject({ + op: z.literal('updateAnnotation.v1'), + + /** + * The name of the annotation, e.g. "backstage.io/techdocs-ref". + */ + name: z.string(), + + /** + * The properties that apply to this annotation. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the annotation. + */ + description: z.string().optional(), + /** + * The JSON schema that values of this annotation must conform to. + * + * @remarks + * + * If not provided, the annotation is assumed to be a simple string with no + * particular schema. + */ + schema: z + .strictObject({ + jsonSchema: jsonSchemaSchema, + }) + .optional(), + }), +}); + +/** {@inheritDoc opUpdateAnnotationV1Schema} */ +export type OpUpdateAnnotationV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateAnnotationV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateAnnotationV1}. + */ +export function createUpdateAnnotationOp( + input: Omit & { op?: never }, +): OpUpdateAnnotationV1 { + return opUpdateAnnotationV1Schema.parse({ + ...input, + op: 'updateAnnotation.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/updateKind.test.ts b/packages/catalog-model/src/model/operations/updateKind.test.ts new file mode 100644 index 0000000000..71e37d236e --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateKind.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2026 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 { createUpdateKindOp } from './updateKind'; + +describe('createUpdateKindOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateKindOp({ + kind: 'Component', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + }); + + expect(result).toEqual({ + op: 'updateKind.v1', + kind: 'Component', + properties: { + singular: 'component', + plural: 'components', + description: 'A software component', + }, + }); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateKindOp({ + kind: 'Component', + properties: {}, + }); + + expect(result.op).toBe('updateKind.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateKindOp({ + kind: 'Component', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateKindOp({ + properties: {}, + } as any), + ).toThrow(/kind/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateKindOp({ + kind: 123, + properties: {}, + } as any), + ).toThrow(/kind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateKind.ts b/packages/catalog-model/src/model/operations/updateKind.ts new file mode 100644 index 0000000000..3c2808ad0d --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateKind.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Make a declaration about the properties of a certain kind. + */ +export const opUpdateKindV1Schema = z.strictObject({ + op: z.literal('updateKind.v1'), + + /** + * The kind to update, e.g. "Component". + */ + kind: z.string(), + + /** + * Updated properties that apply for this kind + */ + properties: z.strictObject({ + /** + * The singular form of the human-readable kind, e.g. "component". + */ + singular: z.string().optional(), + /** + * The plural form of the human-readable kind, e.g. "components". + */ + plural: z.string().optional(), + /** + * Short description of the kind. + */ + description: z.string().optional(), + }), +}); + +/** {@inheritDoc opUpdateKindV1Schema} */ +export type OpUpdateKindV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateKindV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateKindV1}. + */ +export function createUpdateKindOp( + input: Omit & { op?: never }, +): OpUpdateKindV1 { + return opUpdateKindV1Schema.parse({ ...input, op: 'updateKind.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/updateKindVersion.test.ts b/packages/catalog-model/src/model/operations/updateKindVersion.test.ts new file mode 100644 index 0000000000..9dbbdd268a --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateKindVersion.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2026 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 { createUpdateKindVersionOp } from './updateKindVersion'; + +describe('createUpdateKindVersionOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { + jsonSchema: { type: 'object' }, + }, + }, + }); + + expect(result).toEqual({ + op: 'updateKindVersion.v1', + kind: 'Component', + name: 'v1alpha1', + properties: { + schema: { + jsonSchema: { type: 'object' }, + }, + }, + }); + }); + + it('should accept optional fields', () => { + const result = createUpdateKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + specType: 'service', + properties: { + description: 'A service component', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + }, + ], + }, + }); + + expect(result.op).toBe('updateKindVersion.v1'); + expect(result.specType).toBe('service'); + expect(result.properties.description).toBe('A service component'); + expect(result.properties.relationFields).toHaveLength(1); + expect(result.properties.schema).toBeUndefined(); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + properties: {}, + }); + + expect(result.op).toBe('updateKindVersion.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateKindVersionOp({ + kind: 'Component', + name: 'v1alpha1', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateKindVersionOp({ + kind: 'Component', + } as any), + ).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateKindVersionOp({ + kind: 123, + name: 'v1alpha1', + properties: {}, + } as any), + ).toThrow(/kind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateKindVersion.ts b/packages/catalog-model/src/model/operations/updateKindVersion.ts new file mode 100644 index 0000000000..395e0e8aa6 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateKindVersion.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +const relationFieldSchema = z.strictObject({ + /** + * What field that shall be used to generate relations. + * + * @remarks + * + * The field value is expected to be a string or string array at runtime. + */ + selector: z.strictObject({ + /** + * A dot separated path on the common catalog form, e.g. `spec.owner`. + */ + path: z.string(), + }), + /** + * The relation type that this field generates, e.g. "ownedBy". + */ + relation: z.string(), + /** + * If the given shorthand ref did not have a kind, use this kind as the + * default. If no default kind is specified, the ref must contain a kind. + */ + defaultKind: z.string().optional(), + /** + * If the given shorthand ref did not have a namespace, either inherit the + * namespace of the entity itself, or choose the default namespace. + */ + defaultNamespace: z.enum(['default', 'inherit']).optional(), + /** + * Only allow relations to be specified to the given kinds. This list must + * include the default kind, if any. If no allowed kinds are specified, + * all kinds are allowed. + */ + allowedKinds: z.array(z.string()).optional(), +}); + +/** + * Make an update to a pre-existing version of a certain kind. + */ +export const opUpdateKindVersionV1Schema = z.strictObject({ + op: z.literal('updateKindVersion.v1'), + + /** + * The kind that this version belongs to. + */ + kind: z.string(), + /** + * The specific version name, e.g. "v1alpha1". This and the kind group form + * the full apiVersion. + */ + name: z.string(), + /** + * The spec type that this version applies to, if any. + * + * @remarks + * + * This can be used to make kinds whose spec effectively are discriminated + * unions. If you don't specify this, the schema will apply to a spec that has + * no type given at all, or to those where the type is not among the set of + * any other known declared spec types. + */ + specType: z.string().optional(), + + /** + * The properties that apply to this version update. + */ + properties: z.strictObject({ + /** + * A short description of this particular version (and type, where + * applicable). Specify this if you want to override the default value. + */ + description: z.string().optional(), + + /** + * The fields that shall be used to generate relations, if any. Specify this + * if you want to override the default value. + */ + relationFields: z.array(relationFieldSchema).optional(), + + /** + * The JSON schema of the version. Specify this if you want to override the + * default value. + * + * @remarks + * + * This schema gets deep merged with the default one for this version. It + * can therefore be used for both amending and changing existing fields. + */ + schema: z + .strictObject({ + jsonSchema: jsonSchemaSchema, + }) + .optional(), + }), +}); + +/** {@inheritDoc opUpdateKindVersionV1Schema} */ +export type OpUpdateKindVersionV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateKindVersionV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateKindVersionV1}. + */ +export function createUpdateKindVersionOp( + input: Omit & { op?: never }, +): OpUpdateKindVersionV1 { + return opUpdateKindVersionV1Schema.parse({ + ...input, + op: 'updateKindVersion.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/updateLabel.test.ts b/packages/catalog-model/src/model/operations/updateLabel.test.ts new file mode 100644 index 0000000000..53b7333794 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateLabel.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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 { createUpdateLabelOp } from './updateLabel'; + +describe('createUpdateLabelOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateLabelOp({ + name: 'backstage.io/environment', + properties: { + title: 'Environment', + }, + }); + + expect(result).toEqual({ + op: 'updateLabel.v1', + name: 'backstage.io/environment', + properties: { + title: 'Environment', + }, + }); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateLabelOp({ + name: 'backstage.io/environment', + properties: {}, + }); + + expect(result.op).toBe('updateLabel.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateLabelOp({ + name: 'backstage.io/environment', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateLabelOp({ + properties: {}, + } as any), + ).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateLabelOp({ + name: 123, + properties: {}, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateLabel.ts b/packages/catalog-model/src/model/operations/updateLabel.ts new file mode 100644 index 0000000000..7470fe42b1 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateLabel.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; +import { jsonSchemaSchema } from '../jsonSchema/zod'; + +/** + * Update the properties of an existing label. + */ +export const opUpdateLabelV1Schema = z.strictObject({ + op: z.literal('updateLabel.v1'), + + /** + * The name of the label, e.g. "backstage.io/source-location". + */ + name: z.string(), + + /** + * The properties that apply to this label. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the label. + */ + description: z.string().optional(), + /** + * The JSON schema that values of this label must conform to. + * + * @remarks + * + * If not provided, the label is assumed to be a simple string with no + * particular schema. + */ + schema: z + .strictObject({ + jsonSchema: jsonSchemaSchema, + }) + .optional(), + }), +}); + +/** {@inheritDoc opUpdateLabelV1Schema} */ +export type OpUpdateLabelV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateLabelV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateLabelV1}. + */ +export function createUpdateLabelOp( + input: Omit & { op?: never }, +): OpUpdateLabelV1 { + return opUpdateLabelV1Schema.parse({ + ...input, + op: 'updateLabel.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/updateRelation.test.ts b/packages/catalog-model/src/model/operations/updateRelation.test.ts new file mode 100644 index 0000000000..83a9095d80 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateRelation.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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 { createUpdateRelationOp } from './updateRelation'; + +describe('createUpdateRelationOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'The owner of the component', + }, + }); + + expect(result).toEqual({ + op: 'updateRelation.v1', + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: { + reverseType: 'ownerOf', + title: 'owned by', + description: 'The owner of the component', + }, + }); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: {}, + }); + + expect(result.op).toBe('updateRelation.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateRelationOp({ + fromKind: 'Component', + type: 'ownedBy', + toKind: 'Group', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateRelationOp({ + fromKind: 'Component', + toKind: 'Group', + properties: {}, + } as any), + ).toThrow(/type/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateRelationOp({ + fromKind: 123, + type: 'ownedBy', + toKind: 'Group', + properties: {}, + } as any), + ).toThrow(/fromKind/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateRelation.ts b/packages/catalog-model/src/model/operations/updateRelation.ts new file mode 100644 index 0000000000..342baaa2c0 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateRelation.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Update the properties of a certain relation type between a given pair of + * kinds. + * + * @remarks + * + * Note that this is NOT the same as updating the properties of a certain field + * that is a relation type - it updates the properties of a relation that was + * generated between two kinds for any reason. + */ +export const opUpdateRelationV1Schema = z.strictObject({ + op: z.literal('updateRelation.v1'), + + /** + * The kind that this relation originates from, e.g. "Component". + */ + fromKind: z.string(), + /** + * The technical type of the relation, e.g. "ownedBy". + */ + type: z.string(), + /** + * The kind that this relation points to, e.g. "Group". + */ + toKind: z.string(), + + /** + * The properties that apply to this relation. + */ + properties: z.strictObject({ + /** + * The technical type of the reverse relation, e.g. "ownerOf". + */ + reverseType: z.string().optional(), + /** + * A human-readable title for the relation type, e.g. "owned by". + */ + title: z.string().optional(), + /** + * A human-readable description of the relation. + */ + description: z.string().optional(), + }), +}); + +/** {@inheritDoc opUpdateRelationV1Schema} */ +export type OpUpdateRelationV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateRelationV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateRelationV1}. + */ +export function createUpdateRelationOp( + input: Omit & { op?: never }, +): OpUpdateRelationV1 { + return opUpdateRelationV1Schema.parse({ + ...input, + op: 'updateRelation.v1', + }); +} diff --git a/packages/catalog-model/src/model/operations/updateTag.test.ts b/packages/catalog-model/src/model/operations/updateTag.test.ts new file mode 100644 index 0000000000..5e04de8efc --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateTag.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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 { createUpdateTagOp } from './updateTag'; + +describe('createUpdateTagOp', () => { + it('should create a valid op with the op field filled in', () => { + const result = createUpdateTagOp({ + name: 'java', + properties: { + title: 'Java', + }, + }); + + expect(result).toEqual({ + op: 'updateTag.v1', + name: 'java', + properties: { + title: 'Java', + }, + }); + }); + + it('should accept all-optional properties', () => { + const result = createUpdateTagOp({ + name: 'java', + properties: {}, + }); + + expect(result.op).toBe('updateTag.v1'); + expect(result.properties).toEqual({}); + }); + + it('should reject unknown fields', () => { + expect(() => + createUpdateTagOp({ + name: 'java', + properties: {}, + extra: 'should be rejected', + } as any), + ).toThrow(/extra/); + }); + + it('should throw on missing required fields', () => { + expect(() => + createUpdateTagOp({ + properties: {}, + } as any), + ).toThrow(/name/); + }); + + it('should throw on wrong field types', () => { + expect(() => + createUpdateTagOp({ + name: 123, + properties: {}, + } as any), + ).toThrow(/name/); + }); +}); diff --git a/packages/catalog-model/src/model/operations/updateTag.ts b/packages/catalog-model/src/model/operations/updateTag.ts new file mode 100644 index 0000000000..5370262ee1 --- /dev/null +++ b/packages/catalog-model/src/model/operations/updateTag.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2026 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 { z } from 'zod/v3'; + +/** + * Update the properties of an existing tag. + */ +export const opUpdateTagV1Schema = z.strictObject({ + op: z.literal('updateTag.v1'), + + /** + * The name of the tag, e.g. "java". + */ + name: z.string(), + + /** + * The properties that apply to this tag. + */ + properties: z.strictObject({ + /** + * A human-readable title that can be used for display purposes instead of + * the technical name. + */ + title: z.string().optional(), + /** + * A human-readable description of the tag. + */ + description: z.string().optional(), + }), +}); + +/** {@inheritDoc opUpdateTagV1Schema} */ +export type OpUpdateTagV1 = z.infer; + +/** + * Creates a validated {@link OpUpdateTagV1} operation instance. + * + * @remarks + * + * The `op` field is filled in automatically. The input is verified against the + * schema before returning, ensuring that the resulting op is reliably valid. + * + * @param input - All fields of the op except `op` itself. + * @returns A fully validated {@link OpUpdateTagV1}. + */ +export function createUpdateTagOp( + input: Omit & { op?: never }, +): OpUpdateTagV1 { + return opUpdateTagV1Schema.parse({ ...input, op: 'updateTag.v1' }); +} diff --git a/packages/catalog-model/src/model/operations/util.test.ts b/packages/catalog-model/src/model/operations/util.test.ts new file mode 100644 index 0000000000..67c00d651d --- /dev/null +++ b/packages/catalog-model/src/model/operations/util.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2026 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 { parseOp } from './util'; + +describe('parseOp', () => { + it('should parse a valid declareAnnotation op', () => { + const result = parseOp({ + op: 'declareAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + + expect(result).toEqual({ + op: { + op: 'declareAnnotation.v1', + name: 'backstage.io/techdocs-ref', + properties: { + description: 'A reference to the TechDocs source', + schema: { jsonSchema: { type: 'string' } }, + }, + }, + order: 0, + }); + }); + + it('should parse a valid declareLabel op', () => { + const result = parseOp({ + op: 'declareLabel.v1', + name: 'backstage.io/source-location', + properties: { + description: 'The source location of the entity', + schema: { jsonSchema: { type: 'string' } }, + }, + }); + + expect(result.op.op).toBe('declareLabel.v1'); + expect(result.order).toBe(1); + }); + + it('should parse a valid declareTag op', () => { + const result = parseOp({ + op: 'declareTag.v1', + name: 'java', + properties: { + description: 'Indicates a Java-based component', + }, + }); + + expect(result.op.op).toBe('declareTag.v1'); + expect(result.order).toBe(2); + }); + + it('should throw on non-object input', () => { + expect(() => parseOp('not an object')).toThrow( + 'Invalid op: expected a JSON object', + ); + expect(() => parseOp(null)).toThrow('Invalid op: expected a JSON object'); + expect(() => parseOp(42)).toThrow('Invalid op: expected a JSON object'); + }); + + it('should throw on missing op field', () => { + expect(() => parseOp({ name: 'test' })).toThrow('Unknown op type'); + }); + + it('should throw on unknown op type', () => { + expect(() => parseOp({ op: 'nonExistent.v1' })).toThrow( + 'Unknown op nonExistent.v1', + ); + }); + + it('should throw on invalid op data', () => { + expect(() => + parseOp({ + op: 'declareAnnotation.v1', + name: 123, + properties: { + description: 'test', + schema: { jsonSchema: { type: 'string' } }, + }, + }), + ).toThrow('Invalid op declareAnnotation.v1'); + }); +}); diff --git a/packages/catalog-model/src/model/operations/util.ts b/packages/catalog-model/src/model/operations/util.ts new file mode 100644 index 0000000000..0f6ab7ea58 --- /dev/null +++ b/packages/catalog-model/src/model/operations/util.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { isJsonObject } from '../jsonSchema/util'; +import { opDeclareAnnotationV1Schema } from './declareAnnotation'; +import { opDeclareKindV1Schema } from './declareKind'; +import { opDeclareKindVersionV1Schema } from './declareKindVersion'; +import { opDeclareLabelV1Schema } from './declareLabel'; +import { opDeclareRelationV1Schema } from './declareRelation'; +import { opDeclareTagV1Schema } from './declareTag'; +import { CatalogModelOp } from './index'; +import { opRemoveAnnotationV1Schema } from './removeAnnotation'; +import { opRemoveKindV1Schema } from './removeKind'; +import { opRemoveLabelV1Schema } from './removeLabel'; +import { opRemoveTagV1Schema } from './removeTag'; +import { opUpdateAnnotationV1Schema } from './updateAnnotation'; +import { opUpdateKindV1Schema } from './updateKind'; +import { opUpdateKindVersionV1Schema } from './updateKindVersion'; +import { opUpdateLabelV1Schema } from './updateLabel'; +import { opUpdateRelationV1Schema } from './updateRelation'; +import { opUpdateTagV1Schema } from './updateTag'; + +/** + * Descriptor for a catalog model operation, mapping it to its parser. + */ +export interface CatalogModelOpDescriptor { + op: T['op']; + order: number; + parse: (data: unknown) => T; +} + +/** + * A mapping from each operation's `op` string to its descriptor, containing + * the `op` literal and a `parse` function that validates unknown data into the + * corresponding operation type. + */ +export const ops: { + [K in CatalogModelOp['op']]: CatalogModelOpDescriptor< + Extract + >; +} = { + 'declareAnnotation.v1': { + op: 'declareAnnotation.v1', + order: 0, + parse: data => opDeclareAnnotationV1Schema.parse(data), + }, + 'declareLabel.v1': { + op: 'declareLabel.v1', + order: 1, + parse: data => opDeclareLabelV1Schema.parse(data), + }, + 'declareTag.v1': { + op: 'declareTag.v1', + order: 2, + parse: data => opDeclareTagV1Schema.parse(data), + }, + 'declareKind.v1': { + op: 'declareKind.v1', + order: 3, + parse: data => opDeclareKindV1Schema.parse(data), + }, + 'declareKindVersion.v1': { + op: 'declareKindVersion.v1', + order: 4, + parse: data => opDeclareKindVersionV1Schema.parse(data), + }, + 'declareRelation.v1': { + op: 'declareRelation.v1', + order: 5, + parse: data => opDeclareRelationV1Schema.parse(data), + }, + 'updateAnnotation.v1': { + op: 'updateAnnotation.v1', + order: 6, + parse: data => opUpdateAnnotationV1Schema.parse(data), + }, + 'updateLabel.v1': { + op: 'updateLabel.v1', + order: 7, + parse: data => opUpdateLabelV1Schema.parse(data), + }, + 'updateTag.v1': { + op: 'updateTag.v1', + order: 8, + parse: data => opUpdateTagV1Schema.parse(data), + }, + 'updateKind.v1': { + op: 'updateKind.v1', + order: 9, + parse: data => opUpdateKindV1Schema.parse(data), + }, + 'updateKindVersion.v1': { + op: 'updateKindVersion.v1', + order: 10, + parse: data => opUpdateKindVersionV1Schema.parse(data), + }, + 'updateRelation.v1': { + op: 'updateRelation.v1', + order: 11, + parse: data => opUpdateRelationV1Schema.parse(data), + }, + 'removeAnnotation.v1': { + op: 'removeAnnotation.v1', + order: 12, + parse: data => opRemoveAnnotationV1Schema.parse(data), + }, + 'removeLabel.v1': { + op: 'removeLabel.v1', + order: 13, + parse: data => opRemoveLabelV1Schema.parse(data), + }, + 'removeTag.v1': { + op: 'removeTag.v1', + order: 14, + parse: data => opRemoveTagV1Schema.parse(data), + }, + 'removeKind.v1': { + op: 'removeKind.v1', + order: 15, + parse: data => opRemoveKindV1Schema.parse(data), + }, +}; + +/** + * Parses and validates a catalog model operation, as received for example from + * a REST endpoint. + */ +export function parseOp(data: unknown): { op: CatalogModelOp; order: number } { + if (!isJsonObject(data)) { + throw new InputError('Invalid op: expected a JSON object'); + } + + const opOp = data.op; + if (typeof opOp !== 'string') { + throw new InputError(`Unknown op type ${opOp}`); + } + + const op = ops[opOp as keyof typeof ops]; + if (!op) { + throw new InputError(`Unknown op ${opOp}`); + } + + try { + return { op: op.parse(data), order: op.order }; + } catch (error) { + throw new InputError(`Invalid op ${opOp}: ${error}`); + } +} diff --git a/packages/catalog-model/src/model/sources/CatalogModelSources.ts b/packages/catalog-model/src/model/sources/CatalogModelSources.ts new file mode 100644 index 0000000000..9ef035c336 --- /dev/null +++ b/packages/catalog-model/src/model/sources/CatalogModelSources.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 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 { NotImplementedError } from '@backstage/errors'; +import { defaultCatalogEntityModel } from '../defaultCatalogEntityModel'; +import { StaticCatalogModelSource } from './StaticCatalogModelSource'; +import { CatalogModelSource } from './types'; +import { CatalogModelLayer } from '../types'; +import uniqBy from 'lodash/uniqBy'; + +/** + * A helper for creating common catalog model sources. + * + * @alpha + */ +export class CatalogModelSources { + /** + * Provides the default catalog model. + */ + static default(): CatalogModelSource { + return CatalogModelSources.static([]); + } + + /** + * Provides a static catalog model on top of the default one (which is + * included automatically). + */ + static static(layers: CatalogModelLayer[]): CatalogModelSource { + return new StaticCatalogModelSource( + uniqBy([...layers, defaultCatalogEntityModel], 'layerId'), + ); + } + + private constructor() { + throw new NotImplementedError(); + } +} diff --git a/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts b/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts new file mode 100644 index 0000000000..0b20a130a4 --- /dev/null +++ b/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2026 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 { CatalogModelLayer } from '../types'; +import { + AsyncCatalogModelSourceGenerator, + CatalogModelSource, + CatalogModelSourceReadOptions, +} from './types'; + +/** + * A static implementation of {@link CatalogModelSource}, which yields a fixed + * set of layers once. + * + * @internal + */ +export class StaticCatalogModelSource implements CatalogModelSource { + readonly #layers: CatalogModelLayer[]; + + constructor(layers: CatalogModelLayer[]) { + this.#layers = layers; + } + + async *read( + _options?: CatalogModelSourceReadOptions, + ): AsyncCatalogModelSourceGenerator { + yield { layers: this.#layers }; + } +} diff --git a/packages/catalog-model/src/model/sources/index.ts b/packages/catalog-model/src/model/sources/index.ts new file mode 100644 index 0000000000..d43cc7d806 --- /dev/null +++ b/packages/catalog-model/src/model/sources/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 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. + */ + +export { CatalogModelSources } from './CatalogModelSources'; +export type { + AsyncCatalogModelSourceGenerator, + CatalogModelSource, + CatalogModelSourceReadOptions, +} from './types'; diff --git a/packages/catalog-model/src/model/sources/types.ts b/packages/catalog-model/src/model/sources/types.ts new file mode 100644 index 0000000000..43e5dcdf61 --- /dev/null +++ b/packages/catalog-model/src/model/sources/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2023 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 { CatalogModelLayer } from '../types'; + +/** + * Options for {@link CatalogModelSource#read}. + * + * @alpha + */ +export interface CatalogModelSourceReadOptions { + signal?: AbortSignal; +} + +/** + * The generator returned by {@link CatalogModelSource#read}. + * + * @alpha + */ +export type AsyncCatalogModelSourceGenerator = AsyncGenerator< + { layers: CatalogModelLayer[] }, + void, + void +>; + +/** + * A source of catalog model layers. + * + * @remarks + * + * It is recommended to implement the `read` method as an async generator. + * + * @example + * + * ```ts + * class MyCatalogModelSource implements CatalogModelSource { + * async *read() { + * yield { + * layers: [defaultCatalogEntityModel] + * }; + * } + * } + * ``` + * + * @alpha + */ +export interface CatalogModelSource { + /** + * Retuns a stream of layers as epxressed by this particular source. + */ + read( + options?: CatalogModelSourceReadOptions, + ): AsyncCatalogModelSourceGenerator; +} diff --git a/packages/catalog-model/src/model/types.ts b/packages/catalog-model/src/model/types.ts new file mode 100644 index 0000000000..1a2e857046 --- /dev/null +++ b/packages/catalog-model/src/model/types.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2026 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 { OpaqueType } from '@internal/opaque'; +import { CatalogModelOp } from './operations'; +import { JsonObject } from '@backstage/types'; + +// #region CatalogModelLayer + +/** + * An opaque type that represents a set of catalog model layers. + * + * @alpha + */ +export interface CatalogModelLayer { + readonly $$type: '@backstage/CatalogModelLayer'; + /** + * The unique ID of the model layer. + * + * @remarks + * @example `catalog.backstage.io/kind-api` + * + * This identifier is used for purposes of deduplication and tracking. It is + * expected to be stable and descriptive. Prefer prefixing the ID with a + * matching domain name. The backstage.io domain name is reserved for use by + * the Backstage project itself. + */ + readonly layerId: string; +} + +/** + * The opaque type that represents a catalog model layer. + * + * @internal + * @remarks + * + * Model layers are essentially an array of operations. Several such model + * layers are merged together to form a final outcome. + */ +export const OpaqueCatalogModelLayer = OpaqueType.create<{ + public: CatalogModelLayer; + versions: { + readonly version: 'v1'; + readonly layerId: string; + readonly ops: Array; + }; +}>({ + type: '@backstage/CatalogModelLayer', + versions: ['v1'], +}); + +// #endregion + +// #region CatalogModel + +/** + * A compiled catalog model. + * + * @alpha + */ +export interface CatalogModel { + /** + * Look up a kind in the model. + * + * @returns The kind if found, or `undefined` if no matching kind exists. + * @throws TypeError if the kind exists in the model, but not for this apiVersion or type. + */ + getKind(options: { + kind: string; + apiVersion: string; + spec?: { type?: string }; + }): CatalogModelKind | undefined; + + /** + * Look up all relations that originate from a given kind. + * + * @param kind - The kind name, e.g. "Component". + * @returns The relations originating from the kind, or `undefined` if the + * kind is not known. + */ + getRelations(options: { kind: string }): CatalogModelRelation[] | undefined; +} + +// #endregion + +// #region CatalogModelKind + +/** + * A compiled catalog model kind. + * + * @alpha + */ +export interface CatalogModelKind { + /** + * The API version(s) of the kind that this schema applies to, e.g. + * "backstage.io/v1alpha1". + */ + apiVersions: string[]; + + /** + * The names used for this kind. + */ + names: { + /** + * The name of the kind with proper casing, e.g. "Component". + */ + kind: string; + + /** + * The singular form of the kind name, e.g. "component". + */ + singular: string; + + /** + * The plural form of the kind name, e.g. "components". + */ + plural: string; + }; + + /** + * The relation fields declared for this kind, with full dot-separated paths + * into the entity (e.g. "spec.owner"). + */ + relationFields: Array<{ + /** + * The full dot-separated path to the field in the entity, e.g. "spec.owner". + */ + path: string; + /** + * The relation type that this field generates, e.g. "ownedBy". + */ + relation: string; + /** + * The default kind for parsing shorthand entity refs. + */ + defaultKind?: string; + /** + * The default namespace for parsing shorthand entity refs. + */ + defaultNamespace?: 'inherit' | 'default'; + /** + * The kinds that are allowed as targets for this relation field. + */ + allowedKinds?: string[]; + }>; + + /** + * The JSON schema of the kind. + * + * @remarks + * + * This can be used for validation of entities. Note that it is up to the + * caller to ensure that the kind and apiVersion match what you are validating + * against. + */ + jsonSchema: JsonObject; +} + +// #endregion + +// #region CatalogModelRelation + +/** + * A compiled catalog model relation. + * + * @alpha + */ +export interface CatalogModelRelation { + /** + * The kinds that this relation can originate from. + */ + fromKind: string[]; + /** + * The kinds that this relation can point to. + */ + toKind: string[]; + /** + * A human-readable description of the relation. + */ + description: string; + /** + * The forward direction of this relation. + */ + forward: { + type: string; + title: string; + }; + /** + * The reverse direction of this relation. + */ + reverse: { + type: string; + title: string; + }; +} + +// #endregion diff --git a/plugins/catalog-backend/dev/index.ts b/plugins/catalog-backend/dev/index.ts index dc287d46cc..2ac0bbebcc 100644 --- a/plugins/catalog-backend/dev/index.ts +++ b/plugins/catalog-backend/dev/index.ts @@ -15,7 +15,12 @@ */ import { createBackend } from '@backstage/backend-defaults'; +import { provideStaticCatalogModel } from '@backstage/plugin-catalog-node/alpha'; +import { templateModelLayer } from '@backstage/plugin-scaffolder-common/alpha'; const backend = createBackend(); backend.add(import('../src')); +backend.add(import('@backstage/plugin-catalog-backend-module-logs')); +backend.add(provideStaticCatalogModel({ layers: [templateModelLayer] })); + backend.start(); diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 2759545070..7d85c4261b 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -79,6 +79,8 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@opentelemetry/api": "^1.9.0", + "ajv": "^8.10.0", + "ajv-errors": "^3.0.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "express": "^4.22.0", @@ -102,7 +104,9 @@ "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/plugin-catalog-backend-module-logs": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/repo-tools": "workspace:^", "@types/core-js": "^2.5.4", "@types/express": "^4.17.6", diff --git a/plugins/catalog-backend/src/model/ModelHolder.ts b/plugins/catalog-backend/src/model/ModelHolder.ts new file mode 100644 index 0000000000..fc71fc29c0 --- /dev/null +++ b/plugins/catalog-backend/src/model/ModelHolder.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2026 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 { LifecycleService, LoggerService } from '@backstage/backend-plugin-api'; +import { + CatalogModel, + CatalogModelSource, + compileCatalogModel, +} from '@backstage/catalog-model/alpha'; + +/** + * Wraps the concern of maintaining a compiled catalog model based on sources. + * + * @internal + */ +export class ModelHolder { + #model: CatalogModel; + + static modelPassthroughForTest(model: CatalogModel): ModelHolder { + return new ModelHolder(model); + } + + static async create(options: { + sources: CatalogModelSource[]; + logger: LoggerService; + lifecycle: LifecycleService; + }): Promise { + const { sources, logger, lifecycle } = options; + + const shutdownController = new AbortController(); + lifecycle.addShutdownHook(() => shutdownController.abort()); + + logger.info(`Reading ${sources.length} catalog model sources`); + let readyCount = 0; + + const logInterval = setInterval(() => { + const remaining = sources.length - readyCount; + logger.warn( + `Waiting for ${remaining}/${sources.length} catalog model sources to be ready`, + ); + }, 3000); + + // TODO(freben): Obviopusly this needs to be extended to support dynamic + // model source events during the lifetime of the plugin. + try { + const layers = await Promise.all( + sources.map(source => + source + .read({ signal: shutdownController.signal }) + .next() + .then(result => { + readyCount += 1; + const ls = result.value?.layers ?? []; + for (const layer of ls) { + logger.info(`Loaded catalog model layer: ${layer.layerId}`); + } + return ls; + }), + ), + ); + return new ModelHolder(compileCatalogModel(layers.flat())); + } finally { + clearInterval(logInterval); + } + } + + get model(): CatalogModel { + return this.#model; + } + + private constructor(model: CatalogModel) { + this.#model = model; + } +} diff --git a/plugins/catalog-backend/src/processors/ModelProcessor.test.ts b/plugins/catalog-backend/src/processors/ModelProcessor.test.ts new file mode 100644 index 0000000000..a6de247c5f --- /dev/null +++ b/plugins/catalog-backend/src/processors/ModelProcessor.test.ts @@ -0,0 +1,487 @@ +/* + * Copyright 2026 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 } from '@backstage/catalog-model'; +import { + CatalogModel, + CatalogModelKind, + CatalogModelRelation, +} from '@backstage/catalog-model/alpha'; +import { ModelProcessor } from './ModelProcessor'; +import { ModelHolder } from '../model/ModelHolder'; + +const componentKind: CatalogModelKind = { + apiVersions: ['backstage.io/v1alpha1'], + names: { kind: 'Component', singular: 'component', plural: 'components' }, + relationFields: [ + { + path: 'spec.owner', + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + path: 'spec.dependsOn', + relation: 'dependsOn', + defaultKind: 'Component', + defaultNamespace: 'default', + allowedKinds: ['Component', 'Resource'], + }, + ], + jsonSchema: { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['type', 'lifecycle', 'owner'], + properties: { + type: { type: 'string', minLength: 1 }, + lifecycle: { type: 'string', minLength: 1 }, + owner: { type: 'string', minLength: 1 }, + }, + }, + }, + }, +}; + +const ownedByRelation: CatalogModelRelation = { + fromKind: ['Component'], + toKind: ['Group', 'User'], + description: 'Ownership', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, +}; + +const dependsOnRelation: CatalogModelRelation = { + fromKind: ['Component'], + toKind: ['Component', 'Resource'], + description: 'Dependency', + forward: { type: 'dependsOn', title: 'depends on' }, + reverse: { type: 'dependencyOf', title: 'dependency of' }, +}; + +function createModel(overrides?: { + getKind?: CatalogModel['getKind']; + getRelations?: CatalogModel['getRelations']; +}): ModelHolder { + return ModelHolder.modelPassthroughForTest({ + getKind: overrides?.getKind ?? (() => componentKind), + getRelations: + overrides?.getRelations ?? (() => [ownedByRelation, dependsOnRelation]), + }); +} + +function createEntity(spec?: Entity['spec']): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'my-component', namespace: 'default' }, + spec: spec ?? { + type: 'service', + lifecycle: 'production', + owner: 'group:default/my-team', + }, + }; +} + +const location = { type: 'url', target: 'https://example.com' }; + +describe('ModelProcessor', () => { + describe('preProcessEntity', () => { + it('returns the entity unchanged when the kind is not found', async () => { + const processor = new ModelProcessor( + createModel({ getKind: () => undefined }), + ); + const entity = createEntity(); + const result = await processor.preProcessEntity(entity); + expect(result).toBe(entity); + }); + + it('sorts array relation fields in-place', async () => { + const processor = new ModelProcessor(createModel()); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'group:default/my-team', + dependsOn: ['component:c', 'component:a', 'component:b'], + }); + + const result = await processor.preProcessEntity(entity); + + expect((result.spec as any).dependsOn).toEqual([ + 'component:a', + 'component:b', + 'component:c', + ]); + }); + + it('does not modify scalar relation fields', async () => { + const processor = new ModelProcessor(createModel()); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'group:default/my-team', + }); + + const result = await processor.preProcessEntity(entity); + + expect((result.spec as any).owner).toBe('group:default/my-team'); + }); + + it('handles entities with no spec gracefully', async () => { + const processor = new ModelProcessor(createModel()); + const entity = createEntity(); + delete (entity as any).spec; + + const result = await processor.preProcessEntity(entity); + + expect(result).toBe(entity); + }); + }); + + describe('validateEntityKind', () => { + it('returns false when the kind is not found', async () => { + const processor = new ModelProcessor( + createModel({ getKind: () => undefined }), + ); + const entity = createEntity(); + + expect(await processor.validateEntityKind(entity)).toBe(false); + }); + + it('returns true when the entity is valid', async () => { + const processor = new ModelProcessor(createModel()); + const entity = createEntity(); + + expect(await processor.validateEntityKind(entity)).toBe(true); + }); + + it('throws when the entity fails schema validation', async () => { + const processor = new ModelProcessor(createModel()); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + // missing required "owner" + }); + + await expect(processor.validateEntityKind(entity)).rejects.toThrow( + /Validation of Component entity failed/, + ); + }); + }); + + describe('postProcessEntity', () => { + it('returns the entity unchanged when the kind is not found', async () => { + const processor = new ModelProcessor( + createModel({ getKind: () => undefined }), + ); + const emit = jest.fn(); + const entity = createEntity(); + + const result = await processor.postProcessEntity(entity, location, emit); + + expect(result).toBe(entity); + expect(emit).not.toHaveBeenCalled(); + }); + + it('emits only forward relations when relation declarations are not found', async () => { + const processor = new ModelProcessor( + createModel({ getRelations: () => undefined }), + ); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'my-team', + }); + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: expect.objectContaining({ type: 'ownedBy' }), + }), + ); + const reverseEmits = emit.mock.calls.filter( + ([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownerOf', + ); + expect(reverseEmits).toHaveLength(0); + }); + + it('emits forward and reverse relations for a scalar relation field', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'my-team', + }); + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: { + source: { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'my-team' }, + }, + }), + ); + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'my-team' }, + type: 'ownerOf', + target: { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + }, + }), + ); + }); + + it('emits forward and reverse relations for each element in an array relation field', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'my-team', + dependsOn: [ + 'component:default/service-a', + 'component:default/service-b', + ], + }); + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: { + source: { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + type: 'dependsOn', + target: { + kind: 'component', + namespace: 'default', + name: 'service-a', + }, + }, + }), + ); + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: { + source: { + kind: 'component', + namespace: 'default', + name: 'service-a', + }, + type: 'dependencyOf', + target: { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + }, + }), + ); + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: { + source: { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + type: 'dependsOn', + target: { + kind: 'component', + namespace: 'default', + name: 'service-b', + }, + }, + }), + ); + }); + + it('skips both forward and reverse when target kind is not in allowedKinds', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'api:default/some-api', + }); + + await processor.postProcessEntity(entity, location, emit); + + const ownedByEmits = emit.mock.calls.filter( + ([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownedBy', + ); + expect(ownedByEmits).toHaveLength(0); + const ownerOfEmits = emit.mock.calls.filter( + ([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownerOf', + ); + expect(ownerOfEmits).toHaveLength(0); + }); + + it('allows any target kind when allowedKinds is not set', async () => { + const noAllowedKindsKind: CatalogModelKind = { + ...componentKind, + relationFields: [ + { + path: 'spec.owner', + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + }, + ], + }; + const processor = new ModelProcessor( + createModel({ getKind: () => noAllowedKindsKind }), + ); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'api:default/some-api', + }); + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: expect.objectContaining({ type: 'ownedBy' }), + }), + ); + }); + + it('uses defaultNamespace inherit to pick the entity namespace', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'my-team', + }); + entity.metadata.namespace = 'custom-ns'; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: expect.objectContaining({ + type: 'ownedBy', + target: { kind: 'Group', namespace: 'custom-ns', name: 'my-team' }, + }), + }), + ); + }); + + it('uses default namespace when defaultNamespace is not inherit', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity({ + type: 'service', + lifecycle: 'production', + owner: 'my-team', + dependsOn: ['service-a'], + }); + entity.metadata.namespace = 'custom-ns'; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: expect.objectContaining({ + type: 'dependsOn', + target: { + kind: 'Component', + namespace: 'default', + name: 'service-a', + }, + }), + }), + ); + }); + + it('handles entities with no spec gracefully', async () => { + const processor = new ModelProcessor(createModel()); + const emit = jest.fn(); + const entity = createEntity(); + delete (entity as any).spec; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).not.toHaveBeenCalled(); + }); + + it('handles nested relation fields via dot paths', async () => { + const nestedKind: CatalogModelKind = { + ...componentKind, + relationFields: [ + { + path: 'spec.nested.maintainer', + relation: 'ownedBy', + defaultKind: 'User', + defaultNamespace: 'default', + }, + ], + }; + const processor = new ModelProcessor( + createModel({ getKind: () => nestedKind }), + ); + const emit = jest.fn(); + const entity = createEntity({ + nested: { maintainer: 'jane' }, + }); + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'relation', + relation: expect.objectContaining({ + type: 'ownedBy', + target: { kind: 'User', namespace: 'default', name: 'jane' }, + }), + }), + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/processors/ModelProcessor.ts b/plugins/catalog-backend/src/processors/ModelProcessor.ts new file mode 100644 index 0000000000..b98aa44284 --- /dev/null +++ b/plugins/catalog-backend/src/processors/ModelProcessor.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2026 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 { + DEFAULT_NAMESPACE, + Entity, + getCompoundEntityRef, + parseEntityRef, +} from '@backstage/catalog-model'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; +import { + CatalogProcessor, + CatalogProcessorEmit, + processingResult, +} from '@backstage/plugin-catalog-node'; +import lodash from 'lodash'; +import { ModelHolder } from '../model/ModelHolder'; +import { SchemaValidator } from './SchemaValidator'; + +export class ModelProcessor implements CatalogProcessor { + readonly #modelHolder: ModelHolder; + readonly #schemaValidator = new SchemaValidator(); + + constructor(modelHolder: ModelHolder) { + this.#modelHolder = modelHolder; + } + + getProcessorName(): string { + return 'ModelProcessor'; + } + + /** + * For all fields in the entity that the model says are relations: if it's an + * array of strings, sort that array. Since relations are unordered, this cuts + * down on unnecessary processing and stitching for sources that don't have a + * stable order for its output. + */ + async preProcessEntity(entity: Entity): Promise { + const kind = this.#modelHolder.model.getKind(entity); + if (kind) { + for (const fieldModel of kind.relationFields) { + const value = lodash.get(entity, fieldModel.path); + if (Array.isArray(value) && value.every(v => typeof v === 'string')) { + value.sort(); + } + } + } + + return entity; + } + + /** + * If the model knows how to handle this entity, validate it against its + * schema and then return true. Otherwise return false. + */ + async validateEntityKind(entity: Entity): Promise { + const kind = this.#modelHolder.model.getKind(entity); + if (!kind) { + return false; + } + + const errors = this.#schemaValidator.validate(kind.jsonSchema, entity); + if (errors.length) { + throw new TypeError( + `Validation of ${entity.kind} entity failed: ${errors.join('; ')}`, + ); + } + + return true; + } + + /** + * For all fields in the entity that the model says are relations: if the + * field is a string or an array of strings, emit both the forward and reverse + * relations that the model says apply for it. + */ + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + const kind = this.#modelHolder.model.getKind(entity); + if (!kind) { + return entity; + } + + const modelRelations = + this.#modelHolder.model.getRelations({ kind: entity.kind }) ?? []; + const selfRef = getCompoundEntityRef(entity); + const selfNamespace = entity.metadata.namespace ?? DEFAULT_NAMESPACE; + + for (const fieldModel of kind.relationFields) { + const fieldValue = lodash.get(entity, fieldModel.path); + if (!fieldValue) { + continue; + } + + const shorthandRefs = ( + Array.isArray(fieldValue) ? fieldValue : [fieldValue] + ).filter((x): x is string => x && typeof x === 'string'); + + for (const shorthandRef of shorthandRefs) { + const targetRef = parseEntityRef(shorthandRef, { + defaultKind: fieldModel.defaultKind, + defaultNamespace: + fieldModel.defaultNamespace === 'inherit' + ? selfNamespace + : DEFAULT_NAMESPACE, + }); + + const targetKind = targetRef.kind.toLocaleLowerCase('en-US'); + + if ( + fieldModel.allowedKinds && + !fieldModel.allowedKinds.some( + k => k.toLocaleLowerCase('en-US') === targetKind, + ) + ) { + // TODO: Make this more visible. We should probably not use logging, + // but if we added admonition support on entities, this would be a + // good time to emit one. + continue; + } + + // Emit the forward relation + emit( + processingResult.relation({ + source: selfRef, + type: fieldModel.relation, + target: targetRef, + }), + ); + + // Emit the reverse relation if the model knows about it + const selfKind = entity.kind.toLocaleLowerCase('en-US'); + const relation = modelRelations.find( + r => + r.forward.type === fieldModel.relation && + r.fromKind.some(k => k.toLocaleLowerCase('en-US') === selfKind) && + r.toKind.some(k => k.toLocaleLowerCase('en-US') === targetKind), + ); + if (relation) { + emit( + processingResult.relation({ + source: targetRef, + type: relation.reverse.type, + target: selfRef, + }), + ); + } + } + } + + return entity; + } +} diff --git a/plugins/catalog-backend/src/processors/SchemaValidator.test.ts b/plugins/catalog-backend/src/processors/SchemaValidator.test.ts new file mode 100644 index 0000000000..b44308d460 --- /dev/null +++ b/plugins/catalog-backend/src/processors/SchemaValidator.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2026 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 { SchemaValidator } from './SchemaValidator'; + +const schema = { + type: 'object', + required: ['spec'], + properties: { + spec: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1 }, + count: { type: 'number' }, + }, + }, + }, +}; + +describe('SchemaValidator', () => { + it('returns no errors for valid data', () => { + const validator = new SchemaValidator(); + const errors = validator.validate(schema, { + spec: { name: 'foo' }, + }); + expect(errors).toEqual([]); + }); + + it('returns errors for missing required field', () => { + const validator = new SchemaValidator(); + const errors = validator.validate(schema, { spec: {} }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.includes('name'))).toBe(true); + }); + + it('returns errors for wrong type', () => { + const validator = new SchemaValidator(); + const errors = validator.validate(schema, { + spec: { name: 'foo', count: 'not-a-number' }, + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.includes('count') || e.includes('number'))).toBe( + true, + ); + }); + + it('returns errors when spec is missing entirely', () => { + const validator = new SchemaValidator(); + const errors = validator.validate(schema, {}); + expect(errors.length).toBeGreaterThan(0); + }); + + it('caches compiled validators across calls', () => { + const validator = new SchemaValidator(); + validator.validate(schema, { spec: { name: 'a' } }); + validator.validate(schema, { spec: { name: 'b' } }); + // No way to directly observe caching, but verifying that repeated + // calls with the same schema object work correctly + expect(validator.validate(schema, { spec: { name: 'c' } })).toEqual([]); + }); + + it('expires cached validators after the TTL', () => { + jest.useFakeTimers(); + try { + const validator = new SchemaValidator({ ttlMs: 1000 }); + + // First call compiles and caches + expect(validator.validate(schema, { spec: { name: 'a' } })).toEqual([]); + + // Advance past TTL + jest.advanceTimersByTime(1500); + + // Should still work (recompiles from expired cache) + expect(validator.validate(schema, { spec: { name: 'b' } })).toEqual([]); + expect(validator.validate(schema, { spec: {} }).length).toBeGreaterThan( + 0, + ); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/plugins/catalog-backend/src/processors/SchemaValidator.ts b/plugins/catalog-backend/src/processors/SchemaValidator.ts new file mode 100644 index 0000000000..c1b01c5d5f --- /dev/null +++ b/plugins/catalog-backend/src/processors/SchemaValidator.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2026 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 { JsonObject } from '@backstage/types'; +import Ajv, { ValidateFunction } from 'ajv'; +import ajvErrors from 'ajv-errors'; + +class ExpiryMap extends Map { + readonly #ttlMs: number; + readonly #timestamps = new Map(); + + constructor(ttlMs: number) { + super(); + this.#ttlMs = ttlMs; + } + + set(key: K, value: V) { + this.#timestamps.set(key, Date.now()); + return super.set(key, value); + } + + get(key: K) { + const timestamp = this.#timestamps.get(key); + if (timestamp !== undefined && Date.now() - timestamp > this.#ttlMs) { + this.delete(key); + return undefined; + } + return super.get(key); + } + + delete(key: K) { + this.#timestamps.delete(key); + return super.delete(key); + } + + clear() { + this.#timestamps.clear(); + return super.clear(); + } +} + +/** + * A helper that lazily compiles and caches AJV validators for JSON schemas, + * with a time-based expiry to avoid holding on to stale entries indefinitely. + */ +export class SchemaValidator { + readonly #ajv: Ajv; + readonly #cache: ExpiryMap; + + constructor(options?: { ttlMs?: number }) { + this.#cache = new ExpiryMap(options?.ttlMs ?? 60 * 60 * 1000); // 1 hour + this.#ajv = new Ajv({ + allowUnionTypes: true, + allErrors: true, + validateSchema: true, + }); + ajvErrors(this.#ajv); + } + + /** + * Validates the given data against the provided JSON schema. Returns an + * array of human-readable error strings, or an empty array if valid. + */ + validate(schema: JsonObject, data: unknown): string[] { + const validator = this.#getOrCompile(schema); + const valid = validator(data); + if (valid) { + return []; + } + return (validator.errors ?? []).map( + e => + `${e.instancePath || ''} ${e.message}${ + e.params + ? ` - ${Object.entries(e.params) + .map(([key, val]) => `${key}: ${val}`) + .join(', ')}` + : '' + }`, + ); + } + + #getOrCompile(schema: JsonObject): ValidateFunction { + const cached = this.#cache.get(schema); + if (cached) { + return cached; + } + + const validator = this.#ajv.compile(schema); + this.#cache.set(schema, validator); + return validator; + } +} diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 9710f6d00c..881c29db3f 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -106,9 +106,12 @@ import { filterAndSortProcessors, filterProviders } from './util'; import { GenericScmEventRefreshProvider } from '../providers/GenericScmEventRefreshProvider'; import { readScmEventHandlingConfig } from '../util/readScmEventHandlingConfig'; import { MetricsService } from '@backstage/backend-plugin-api/alpha'; +import { ModelProcessor } from '../processors/ModelProcessor'; +import { ModelHolder } from '../model/ModelHolder'; export type CatalogEnvironment = { logger: LoggerService; + modelHolder?: ModelHolder; database: DatabaseService; config: RootConfigService; reader: UrlReaderService; @@ -629,9 +632,11 @@ export class CatalogBuilder { ]; const builtinKindsEntityProcessor = new BuiltinKindsEntityProcessor(); - // If the user adds a processor named 'BuiltinKindsEntityProcessor', - // skip inclusion of the catalog-backend version. + // If the user adds a processor named 'BuiltinKindsEntityProcessor', skip + // inclusion of the catalog-backend version. Same if there's a model + // registered - then we are using the new model flow. if ( + !this.env.modelHolder && !this.processors.some( processor => processor.getProcessorName() === @@ -640,6 +645,9 @@ export class CatalogBuilder { ) { processors.push(builtinKindsEntityProcessor); } + if (this.env.modelHolder) { + processors.push(new ModelProcessor(this.env.modelHolder)); + } const disableDefaultProcessors = config.getOptionalBoolean( 'catalog.disableDefaultProcessors', diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 76fdf4eec5..84074689c3 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -13,13 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { + actionsRegistryServiceRef, + metricsServiceRef, +} from '@backstage/backend-plugin-api/alpha'; import { Entity, Validators } from '@backstage/catalog-model'; +import { CatalogModelSource } from '@backstage/catalog-model/alpha'; import { ForwardedError } from '@backstage/errors'; import { + catalogAnalysisExtensionPoint, + CatalogLocationsExtensionPoint, + catalogLocationsExtensionPoint, + catalogProcessingExtensionPoint, CatalogProcessor, CatalogProcessorParser, catalogServiceRef, @@ -27,12 +37,6 @@ import { PlaceholderResolver, ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; -import { - catalogAnalysisExtensionPoint, - CatalogLocationsExtensionPoint, - catalogLocationsExtensionPoint, - catalogProcessingExtensionPoint, -} from '@backstage/plugin-catalog-node'; import { CatalogModelExtensionPoint, catalogModelExtensionPoint, @@ -40,13 +44,10 @@ import { } from '@backstage/plugin-catalog-node/alpha'; import { eventsServiceRef } from '@backstage/plugin-events-node'; import { merge } from 'lodash'; -import { CatalogBuilder } from './CatalogBuilder'; -import { - actionsRegistryServiceRef, - metricsServiceRef, -} from '@backstage/backend-plugin-api/alpha'; import { createCatalogActions } from '../actions'; +import { ModelHolder } from '../model/ModelHolder'; import type { EntityProviderEntry } from '../processing/connectEntityProviders'; +import { CatalogBuilder } from './CatalogBuilder'; class CatalogLocationsExtensionPointImpl implements CatalogLocationsExtensionPoint @@ -87,6 +88,16 @@ class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint { get entityDataParser() { return this.#entityDataParser; } + + #modelSources: CatalogModelSource[] = []; + + addModelSource(source: CatalogModelSource): void { + this.#modelSources.push(source); + } + + get modelSources() { + return this.#modelSources; + } } /** @@ -206,8 +217,17 @@ export const catalogPlugin = createBackendPlugin({ catalogScmEvents, metrics, }) { + const modelHolder = modelExtensions.modelSources.length + ? await ModelHolder.create({ + sources: modelExtensions.modelSources, + logger, + lifecycle, + }) + : undefined; + const builder = await CatalogBuilder.create({ config, + modelHolder, reader, permissions, permissionsRegistry, diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index fda1898a62..6f99a978f4 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -69,13 +69,18 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@opentelemetry/api": "^1.9.0", + "express": "^4.22.0", + "express-promise-router": "^4.1.0", "lodash": "^4.17.21", "yaml": "^2.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "msw": "^1.0.0" + "@types/express": "^4.17.6", + "@types/supertest": "^2.0.8", + "msw": "^1.0.0", + "supertest": "^7.0.0" }, "peerDependencies": { "@backstage/backend-test-utils": "workspace:^" diff --git a/plugins/catalog-node/report-alpha.api.md b/plugins/catalog-node/report-alpha.api.md index 5f82e164b7..f0598ece59 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -3,6 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogModelLayer } from '@backstage/catalog-model/alpha'; +import { CatalogModelSource } from '@backstage/catalog-model/alpha'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; import { EntitiesSearchFilter } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; @@ -21,6 +24,7 @@ export const catalogEntityPermissionResourceRef: PermissionResourceRef< // @alpha (undocumented) export interface CatalogModelExtensionPoint { + addModelSource(source: CatalogModelSource): void; setEntityDataParser(parser: CatalogProcessorParser): void; setFieldValidators(validators: Partial): void; } @@ -99,5 +103,10 @@ export interface CatalogScmEventsServiceSubscriber { onEvents: (events: CatalogScmEvent[]) => Promise; } +// @alpha +export function provideStaticCatalogModel(options?: { + layers?: CatalogModelLayer[]; +}): BackendFeature; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts index 9e35abf4d9..f6f8781a0f 100644 --- a/plugins/catalog-node/src/alpha.ts +++ b/plugins/catalog-node/src/alpha.ts @@ -32,3 +32,4 @@ export type { CatalogModelExtensionPoint } from './extensions'; export { catalogModelExtensionPoint } from './extensions'; export * from './scmEvents'; +export { provideStaticCatalogModel } from './provideStaticCatalogModel'; diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index 57150775a4..223a18be05 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -16,12 +16,13 @@ import { createExtensionPoint } from '@backstage/backend-plugin-api'; import { Entity, Validators } from '@backstage/catalog-model'; +import { CatalogModelSource } from '@backstage/catalog-model/alpha'; import { CatalogProcessor, CatalogProcessorParser, EntityProvider, - PlaceholderResolver, LocationAnalyzer, + PlaceholderResolver, ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; @@ -103,10 +104,18 @@ export interface CatalogModelExtensionPoint { setFieldValidators(validators: Partial): void; /** - * Sets the entity data parser which is used to read raw data from locations + * Sets the entity data parser which is used to read raw data from locations. + * * @param parser - Parser which will used to extract entities from raw data */ setEntityDataParser(parser: CatalogProcessorParser): void; + + /** + * Adds a catalog model source to be part of the compiled entity model. + * + * @param source - The model source to add + */ + addModelSource(source: CatalogModelSource): void; } /** diff --git a/plugins/catalog-node/src/provideStaticCatalogModel.ts b/plugins/catalog-node/src/provideStaticCatalogModel.ts new file mode 100644 index 0000000000..4285d03a12 --- /dev/null +++ b/plugins/catalog-node/src/provideStaticCatalogModel.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + CatalogModelLayer, + CatalogModelSources, +} from '@backstage/catalog-model/alpha'; +import { catalogModelExtensionPoint } from './extensions'; + +/** + * Creates a backend module that registers static catalog model layers. + * + * @alpha + * @remarks + * + * This is a convenience function for registering catalog model layers + * without having to manually create a backend module and interact with + * the catalog model extension point. The built-in default catalog entity + * model is always included automatically. + * + * @example + * ```ts + * backend.add( + * provideStaticCatalogModel({ + * layers: [templateModelLayer], + * }), + * ); + * ``` + */ +export function provideStaticCatalogModel(options?: { + layers?: CatalogModelLayer[]; +}) { + return createBackendModule({ + pluginId: 'catalog', + moduleId: 'static-catalog-model', + register(reg) { + reg.registerInit({ + deps: { + model: catalogModelExtensionPoint, + }, + async init({ model }) { + model.addModelSource( + CatalogModelSources.static(options?.layers ?? []), + ); + }, + }); + }, + }); +} diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 8cc0c5a84e..0631e587f4 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -84,10 +84,10 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; - readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index befbd1faf8..bf241251b0 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -206,10 +206,10 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more'; - readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index cac3bd8ff2..5544eb83b7 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -181,8 +181,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'relatedEntitiesCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'systemDiagramCard.title': 'System Diagram'; readonly 'systemDiagramCard.description': 'Use pinch & zoom to move around the diagram.'; - readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on'; readonly 'systemDiagramCard.edgeLabels.partOf': 'part of'; + readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on'; readonly 'systemDiagramCard.edgeLabels.provides': 'provides'; } >; diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 12104ec8ac..e89ceecf15 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -310,8 +310,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'relatedEntitiesCard.emptyHelpLinkTitle': 'Learn how to change this.'; readonly 'systemDiagramCard.title': 'System Diagram'; readonly 'systemDiagramCard.description': 'Use pinch & zoom to move around the diagram.'; - readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on'; readonly 'systemDiagramCard.edgeLabels.partOf': 'part of'; + readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on'; readonly 'systemDiagramCard.edgeLabels.provides': 'provides'; } >; diff --git a/plugins/scaffolder-backend/dev/index.ts b/plugins/scaffolder-backend/dev/index.ts new file mode 100644 index 0000000000..21ec460129 --- /dev/null +++ b/plugins/scaffolder-backend/dev/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 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 { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); +backend.add(import('../src')); + +backend.start(); diff --git a/plugins/scaffolder-common/report-alpha.api.md b/plugins/scaffolder-common/report-alpha.api.md index 0bed459cc8..dadddff352 100644 --- a/plugins/scaffolder-common/report-alpha.api.md +++ b/plugins/scaffolder-common/report-alpha.api.md @@ -4,6 +4,7 @@ ```ts import { BasicPermission } from '@backstage/plugin-permission-common'; +import { CatalogModelLayer } from '@backstage/catalog-model/alpha'; import { ResourcePermission } from '@backstage/plugin-permission-common'; // @alpha @@ -50,6 +51,9 @@ export const taskReadPermission: ResourcePermission<'scaffolder-task'>; // @alpha export const templateManagementPermission: BasicPermission; +// @alpha +export const templateModelLayer: CatalogModelLayer; + // @alpha export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>; diff --git a/plugins/scaffolder-common/src/alpha.ts b/plugins/scaffolder-common/src/alpha.ts index 82585fcf1d..f5cc736504 100644 --- a/plugins/scaffolder-common/src/alpha.ts +++ b/plugins/scaffolder-common/src/alpha.ts @@ -15,3 +15,4 @@ */ export * from './permissions'; +export { templateModelLayer } from './catalogModel'; diff --git a/plugins/scaffolder-common/src/catalogModel.ts b/plugins/scaffolder-common/src/catalogModel.ts new file mode 100644 index 0000000000..da2b0fbc5c --- /dev/null +++ b/plugins/scaffolder-common/src/catalogModel.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2026 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 { createCatalogModelLayer } from '@backstage/catalog-model/alpha'; +import schema from './Template.v1beta3.schema.json'; + +/** + * Extends the catalog model with the Template kind. + * + * @alpha + */ +export const templateModelLayer = createCatalogModelLayer({ + layerId: 'Template', + builder: model => { + model.addKind({ + group: 'scaffolder.backstage.io', + names: { + kind: 'Template', + singular: 'template', + plural: 'templates', + }, + description: 'A template for scaffolding a new component', + versions: [ + { + name: 'v1beta3', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + // TODO: This was inherit since before, but should ownership in general be default instead? + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + ], + schema: { + jsonSchema: schema as any, + }, + }, + ], + }); + }, +}); diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 2101501bff..a0c35e1b5e 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -78,6 +78,7 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2< autoSelect?: boolean | undefined; defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -96,7 +97,6 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; @@ -108,6 +108,7 @@ export const EntityPickerFieldSchema: FieldSchema_2< autoSelect?: boolean | undefined; defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -126,7 +127,6 @@ export const EntityPickerFieldSchema: FieldSchema_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; @@ -255,6 +255,7 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2< autoSelect?: boolean | undefined; defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -273,7 +274,6 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; @@ -285,6 +285,7 @@ export const OwnedEntityPickerFieldSchema: FieldSchema_2< autoSelect?: boolean | undefined; defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -303,7 +304,6 @@ export const OwnedEntityPickerFieldSchema: FieldSchema_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; @@ -318,6 +318,7 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2< string, { defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -336,7 +337,6 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; @@ -346,6 +346,7 @@ export const OwnerPickerFieldSchema: FieldSchema_2< string, { defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; catalogFilter?: | Record< string, @@ -364,7 +365,6 @@ export const OwnerPickerFieldSchema: FieldSchema_2< } >[] | undefined; - allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; } >; diff --git a/yarn.lock b/yarn.lock index 8038c5ee0b..f128e10572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2814,12 +2814,16 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/types": "workspace:^" "@types/json-schema": "npm:^7.0.5" "@types/lodash": "npm:^4.14.151" ajv: "npm:^8.10.0" + ajv-errors: "npm:^3.0.0" lodash: "npm:^4.17.21" yaml: "npm:^2.0.0" + zod: "npm:^3.25.76" + zod-validation-error: "npm:^4.0.2" languageName: unknown linkType: soft @@ -5178,11 +5182,13 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/filter-predicates": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/plugin-catalog-backend-module-logs": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-events-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" + "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/repo-tools": "workspace:^" "@backstage/types": "workspace:^" "@opentelemetry/api": "npm:^1.9.0" @@ -5191,6 +5197,8 @@ __metadata: "@types/git-url-parse": "npm:^9.0.0" "@types/lodash": "npm:^4.14.151" "@types/supertest": "npm:^2.0.8" + ajv: "npm:^8.10.0" + ajv-errors: "npm:^3.0.0" better-sqlite3: "npm:^12.0.0" codeowners-utils: "npm:^1.0.2" core-js: "npm:^3.6.5" @@ -5341,8 +5349,13 @@ __metadata: "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@opentelemetry/api": "npm:^1.9.0" + "@types/express": "npm:^4.17.6" + "@types/supertest": "npm:^2.0.8" + express: "npm:^4.22.0" + express-promise-router: "npm:^4.1.0" lodash: "npm:^4.17.21" msw: "npm:^1.0.0" + supertest: "npm:^7.0.0" yaml: "npm:^2.0.0" peerDependencies: "@backstage/backend-test-utils": "workspace:^" From 31290ca25ffdd25aa773a6d928f68f20b07aecf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 13:27:33 +0200 Subject: [PATCH 02/13] Add summary types and listing methods to CatalogModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce CatalogModelKindSummary, CatalogModelRelationSummary, CatalogModelAnnotationSummary, CatalogModelLabelSummary, and CatalogModelTagSummary as reduced views of the full model data. Add listKinds(), listRelations(), and getMetadata() methods to CatalogModel for retrieving these summaries. Add a new action for fetching a markdown-formatted catalog model description, useful for informing LLMs about the catalog structure. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- packages/catalog-model/report-alpha.api.md | 58 ++++++ .../src/model/compileCatalogModel.test.ts | 3 + .../src/model/compileCatalogModel.ts | 86 +++++++- packages/catalog-model/src/model/index.ts | 7 +- packages/catalog-model/src/model/types.ts | 192 +++++++++++++++++ ...teGetCatalogModelDescriptionAction.test.ts | 136 ++++++++++++ .../createGetCatalogModelDescriptionAction.ts | 197 ++++++++++++++++++ plugins/catalog-backend/src/actions/index.ts | 5 + .../src/processors/ModelProcessor.test.ts | 10 + .../src/service/CatalogPlugin.ts | 1 + 10 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts create mode 100644 plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 7102ff69f4..0150301adc 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -31,7 +31,14 @@ export interface CatalogModel { type?: string; }; }): CatalogModelKind | undefined; + getMetadata(): { + annotations: CatalogModelAnnotationSummary[]; + labels: CatalogModelLabelSummary[]; + tags: CatalogModelTagSummary[]; + }; getRelations(options: { kind: string }): CatalogModelRelation[] | undefined; + listKinds(): CatalogModelKindSummary[]; + listRelations(): CatalogModelRelationSummary[]; } // @alpha @@ -44,9 +51,17 @@ export interface CatalogModelAnnotationDefinition { title?: string; } +// @alpha +export interface CatalogModelAnnotationSummary { + description: string; + name: string; + title?: string; +} + // @alpha export interface CatalogModelKind { apiVersions: string[]; + description: string; jsonSchema: JsonObject; names: { kind: string; @@ -131,6 +146,20 @@ export interface CatalogModelKindRootSchema extends JsonObject { type: 'object'; } +// @alpha +export interface CatalogModelKindSummary { + description: string; + names: { + kind: string; + singular: string; + plural: string; + }; + versions: Array<{ + apiVersion: string; + specType?: string; + }>; +} + // @alpha export interface CatalogModelKindVersionDefinition { description?: string; @@ -153,6 +182,13 @@ export interface CatalogModelLabelDefinition { title?: string; } +// @alpha +export interface CatalogModelLabelSummary { + description: string; + name: string; + title?: string; +} + // @alpha export interface CatalogModelLayer { // (undocumented) @@ -209,6 +245,21 @@ export interface CatalogModelRelationPairDefinition { toKind: string | string[]; } +// @alpha +export interface CatalogModelRelationSummary { + description: string; + forward: { + type: string; + title: string; + }; + fromKind: string[]; + reverse: { + type: string; + title: string; + }; + toKind: string[]; +} + // @alpha export interface CatalogModelRemoveAnnotationDefinition { name: string; @@ -255,6 +306,13 @@ export interface CatalogModelTagDefinition { title?: string; } +// @alpha +export interface CatalogModelTagSummary { + description: string; + name: string; + title?: string; +} + // @alpha export interface CatalogModelUpdateAnnotationDefinition { description?: string; diff --git a/packages/catalog-model/src/model/compileCatalogModel.test.ts b/packages/catalog-model/src/model/compileCatalogModel.test.ts index b2a228650c..9842057830 100644 --- a/packages/catalog-model/src/model/compileCatalogModel.test.ts +++ b/packages/catalog-model/src/model/compileCatalogModel.test.ts @@ -316,6 +316,7 @@ describe('compileCatalogModel integration', () => { apiVersion: 'example.com/v1alpha1', }); expect(kind1).toEqual({ + description: 'A widget', apiVersions: ['example.com/v1alpha1'], names: { kind: 'Widget', singular: 'widget', plural: 'widgets' }, relationFields: [ @@ -490,6 +491,7 @@ describe('compileCatalogModel integration', () => { apiVersion: 'example.com/v1alpha1', }); expect(kind2).toEqual({ + description: 'An updated widget', apiVersions: ['example.com/v1alpha1'], names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' }, relationFields: [ @@ -630,6 +632,7 @@ describe('compileCatalogModel integration', () => { apiVersion: 'example.com/v1alpha1', }); expect(kind3).toEqual({ + description: 'An updated widget', apiVersions: ['example.com/v1alpha1'], names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' }, relationFields: [ diff --git a/packages/catalog-model/src/model/compileCatalogModel.ts b/packages/catalog-model/src/model/compileCatalogModel.ts index fb3b337612..ca32400a10 100644 --- a/packages/catalog-model/src/model/compileCatalogModel.ts +++ b/packages/catalog-model/src/model/compileCatalogModel.ts @@ -38,9 +38,14 @@ import { OpUpdateRelationV1 } from './operations/updateRelation'; import { OpUpdateTagV1 } from './operations/updateTag'; import { CatalogModel, - CatalogModelLayer, + CatalogModelAnnotationSummary, CatalogModelKind, + CatalogModelKindSummary, + CatalogModelLabelSummary, + CatalogModelLayer, CatalogModelRelation, + CatalogModelRelationSummary, + CatalogModelTagSummary, OpaqueCatalogModelLayer, } from './types'; @@ -604,6 +609,7 @@ export function compileCatalogModel( for (const [specType, specificKind] of version.specTypes) { const key = `${kindName}\0${version.apiVersion}\0${specType ?? ''}`; compiledKinds.set(key, { + description: specificKind.description ?? kindState.description, apiVersions: [version.apiVersion], names: { kind: kindName, @@ -654,7 +660,85 @@ export function compileCatalogModel( ); } + // Precompute kind summaries, one per unique kind (not per version/specType) + const kindSummaries: CatalogModelKindSummary[] = [...kinds.entries()].map( + ([kindName, kindState]) => ({ + description: kindState.description, + names: { + kind: kindName, + singular: kindState.singular, + plural: kindState.plural, + }, + versions: [...kindState.versions.values()].flatMap(version => + [...version.specTypes.keys()].map(specType => ({ + apiVersion: version.apiVersion, + ...(specType !== undefined ? { specType } : undefined), + })), + ), + }), + ); + + // Precompute annotation summaries + const annotationSummaries: CatalogModelAnnotationSummary[] = [ + ...annotations.entries(), + ].map(([name, state]) => ({ + name, + ...(state.title !== undefined ? { title: state.title } : undefined), + description: state.description, + })); + + // Precompute label summaries + const labelSummaries: CatalogModelLabelSummary[] = [...labels.entries()].map( + ([name, state]) => ({ + name, + ...(state.title !== undefined ? { title: state.title } : undefined), + description: state.description, + }), + ); + + // Precompute tag summaries + const tagSummaries: CatalogModelTagSummary[] = [...tags.entries()].map( + ([name, state]) => ({ + name, + ...(state.title !== undefined ? { title: state.title } : undefined), + description: state.description, + }), + ); + + // Collect all unique relation summaries + const relationSummaries: CatalogModelRelationSummary[] = [ + ...relations.values(), + ].map(r => { + const reverseEntry = relations.get(r.reverse.type); + return { + fromKind: [...r.fromKinds], + toKind: [...r.toKinds], + description: r.description, + forward: r.forward, + reverse: { + type: r.reverse.type, + title: reverseEntry?.forward.title ?? r.reverse.title, + }, + }; + }); + return { + listKinds() { + return kindSummaries; + }, + + listRelations() { + return relationSummaries; + }, + + getMetadata() { + return { + annotations: annotationSummaries, + labels: labelSummaries, + tags: tagSummaries, + }; + }, + getKind(options) { const type = options.spec?.type; diff --git a/packages/catalog-model/src/model/index.ts b/packages/catalog-model/src/model/index.ts index 20c7b0a97c..ec9233fdab 100644 --- a/packages/catalog-model/src/model/index.ts +++ b/packages/catalog-model/src/model/index.ts @@ -25,7 +25,12 @@ export * from './modelActions'; export * from './sources'; export type { CatalogModel, - CatalogModelLayer, + CatalogModelAnnotationSummary, CatalogModelKind, + CatalogModelKindSummary, + CatalogModelLabelSummary, + CatalogModelLayer, CatalogModelRelation, + CatalogModelRelationSummary, + CatalogModelTagSummary, } from './types'; diff --git a/packages/catalog-model/src/model/types.ts b/packages/catalog-model/src/model/types.ts index 1a2e857046..b4948b9ef9 100644 --- a/packages/catalog-model/src/model/types.ts +++ b/packages/catalog-model/src/model/types.ts @@ -72,6 +72,26 @@ export const OpaqueCatalogModelLayer = OpaqueType.create<{ * @alpha */ export interface CatalogModel { + /** + * Lists all kinds in the model. + */ + listKinds(): CatalogModelKindSummary[]; + + /** + * Lists all relations in the model. + */ + listRelations(): CatalogModelRelationSummary[]; + + /** + * Returns summaries of the shared metadata fields in the model, including + * all declared annotations, labels, and tags. + */ + getMetadata(): { + annotations: CatalogModelAnnotationSummary[]; + labels: CatalogModelLabelSummary[]; + tags: CatalogModelTagSummary[]; + }; + /** * Look up a kind in the model. * @@ -104,6 +124,11 @@ export interface CatalogModel { * @alpha */ export interface CatalogModelKind { + /** + * A human-readable description of the kind. + */ + description: string; + /** * The API version(s) of the kind that this schema applies to, e.g. * "backstage.io/v1alpha1". @@ -171,6 +196,63 @@ export interface CatalogModelKind { // #endregion +// #region CatalogModelKindSummary + +/** + * A summary of a catalog model kind, without version-specific details such as + * schemas and relation fields. + * + * @alpha + */ +export interface CatalogModelKindSummary { + /** + * A human-readable description of the kind. + */ + description: string; + + /** + * The names used for this kind. + */ + names: { + /** + * The name of the kind with proper casing, e.g. "Component". + */ + kind: string; + + /** + * The singular form of the kind name, e.g. "component". + */ + singular: string; + + /** + * The plural form of the kind name, e.g. "components". + */ + plural: string; + }; + + /** + * The available versions and spec types for this kind. + * + * @remarks + * + * Each entry represents a unique apiVersion/specType combination that can be + * passed to {@link CatalogModel.getKind} to retrieve the full kind details. + */ + versions: Array<{ + /** + * The API version, e.g. "backstage.io/v1alpha1". + */ + apiVersion: string; + /** + * The spec type, if any, e.g. "service". Undefined means the default + * (untyped) version. + */ + specType?: string; + }>; +} + +// #endregion + // #region CatalogModelRelation /** @@ -208,3 +290,113 @@ export interface CatalogModelRelation { } // #endregion + +// #region CatalogModelAnnotationSummary + +/** + * A summary of a catalog model annotation. + * + * @alpha + */ +export interface CatalogModelAnnotationSummary { + /** + * The annotation key, e.g. "backstage.io/managed-by-location". + */ + name: string; + /** + * A short human-readable title for the annotation. + */ + title?: string; + /** + * A human-readable description of the annotation. + */ + description: string; +} + +// #endregion + +// #region CatalogModelLabelSummary + +/** + * A summary of a catalog model label. + * + * @alpha + */ +export interface CatalogModelLabelSummary { + /** + * The label key, e.g. "backstage.io/orphan". + */ + name: string; + /** + * A short human-readable title for the label. + */ + title?: string; + /** + * A human-readable description of the label. + */ + description: string; +} + +// #endregion + +// #region CatalogModelTagSummary + +/** + * A summary of a catalog model tag. + * + * @alpha + */ +export interface CatalogModelTagSummary { + /** + * The tag value, e.g. "java". + */ + name: string; + /** + * A short human-readable title for the tag. + */ + title?: string; + /** + * A human-readable description of the tag. + */ + description: string; +} + +// #endregion + +// #region CatalogModelRelationSummary + +/** + * A summary of a catalog model relation. + * + * @alpha + */ +export interface CatalogModelRelationSummary { + /** + * The kinds that this relation can originate from. + */ + fromKind: string[]; + /** + * The kinds that this relation can point to. + */ + toKind: string[]; + /** + * A human-readable description of the relation. + */ + description: string; + /** + * The forward direction of this relation. + */ + forward: { + type: string; + title: string; + }; + /** + * The reverse direction of this relation. + */ + reverse: { + type: string; + title: string; + }; +} + +// #endregion diff --git a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts new file mode 100644 index 0000000000..8539ae3d03 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2026 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 { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { CatalogModel } from '@backstage/catalog-model/alpha'; +import { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction'; +import { ModelHolder } from '../model/ModelHolder'; + +const model: CatalogModel = { + listKinds: () => [ + { + description: 'A software component', + names: { kind: 'Component', singular: 'component', plural: 'components' }, + versions: [ + { apiVersion: 'backstage.io/v1alpha1' }, + { apiVersion: 'backstage.io/v1alpha1', specType: 'service' }, + ], + }, + ], + listRelations: () => [ + { + fromKind: ['Component'], + toKind: ['Group', 'User'], + description: 'Ownership relation', + forward: { type: 'ownedBy', title: 'owned by' }, + reverse: { type: 'ownerOf', title: 'owner of' }, + }, + ], + getMetadata: () => ({ + annotations: [ + { + name: 'backstage.io/managed-by-location', + title: 'Managed By Location', + description: 'The location that manages this entity.', + }, + ], + labels: [ + { + name: 'backstage.io/orphan', + description: 'Indicates that this entity is an orphan.', + }, + ], + tags: [ + { + name: 'java', + title: 'Java', + description: 'Relates to the Java programming language.', + }, + ], + }), + getKind: () => undefined, + getRelations: () => undefined, +}; + +describe('createGetCatalogModelDescriptionAction', () => { + it('should return a markdown description of the catalog model', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + createGetCatalogModelDescriptionAction({ + modelHolder: ModelHolder.modelPassthroughForTest(model), + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-catalog-model-description', + input: {}, + }); + + const description = result.output.description as string; + + expect(description).toContain('# Catalog Model'); + expect(description).toContain('## Entity Kinds'); + expect(description).toContain('## Entity Relations'); + expect(description).toContain('## Entity Metadata field'); + + // Kind details + expect(description).toContain('### Entity Kind "Component"'); + expect(description).toContain('A software component'); + expect(description).toContain('apiVersion: "backstage.io/v1alpha1"'); + expect(description).toContain('spec.type: "service"'); + expect(description).toContain('Singular in text: "component"'); + expect(description).toContain('Plural in text: "components"'); + + // Relation details + expect(description).toContain('### Relation "ownedBy": owned by'); + expect(description).toContain('Ownership relation'); + expect(description).toContain('Reverse type: "ownerOf": owner of'); + + // Annotation details + expect(description).toContain( + '### Annotation "backstage.io/managed-by-location": Managed By Location', + ); + expect(description).toContain('The location that manages this entity.'); + + // Label details + expect(description).toContain('### Label "backstage.io/orphan"'); + expect(description).toContain('Indicates that this entity is an orphan.'); + + // Tag details + expect(description).toContain('### Tag "java": Java'); + expect(description).toContain('Relates to the Java programming language.'); + }); + + it('should fall back to the default model when no model holder is provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + createGetCatalogModelDescriptionAction({ + modelHolder: undefined, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-catalog-model-description', + input: {}, + }); + + const description = result.output.description as string; + + expect(description).toContain('# Catalog Model'); + expect(description).toContain('## Entity Kinds'); + expect(description).toContain('### Entity Kind "Component"'); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts new file mode 100644 index 0000000000..fd4df0787b --- /dev/null +++ b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts @@ -0,0 +1,197 @@ +/* + * Copyright 2026 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { + CatalogModel, + CatalogModelAnnotationSummary, + CatalogModelKindSummary, + CatalogModelLabelSummary, + CatalogModelRelationSummary, + CatalogModelTagSummary, + compileCatalogModel, + defaultCatalogEntityModel, +} from '@backstage/catalog-model/alpha'; +import { ModelHolder } from '../model/ModelHolder'; + +/** + * Lets users fetch a markdown formatted description of the catalog model. This + * is useful for informing LLMs how to properly work with it. + */ +export const createGetCatalogModelDescriptionAction = ({ + modelHolder, + actionsRegistry, +}: { + modelHolder: ModelHolder | undefined; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'get-catalog-model-description', + title: 'Get a Catalog Model Description', + description: + 'Returns a markdown formatted description of the current catalog model, including all registered entity kinds, their spec fields, and available relations.', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + schema: { + input: z => z.object({}), + output: z => + z.object({ + description: z + .string() + .describe( + 'Markdown description of the catalog model including entity kinds, spec fields, and relations.', + ), + }), + }, + action: async () => { + return { + output: { + description: describeCatalogModel(modelHolder?.model), + }, + }; + }, + }); +}; + +// Compute the default description once (if needed) and cache it +let defaultModelDescription: string | undefined; + +function describeCatalogModel(model: CatalogModel | undefined): string { + if (!model) { + if (!defaultModelDescription) { + defaultModelDescription = describeModel( + compileCatalogModel([defaultCatalogEntityModel]), + ); + } + return defaultModelDescription; + } + + return describeModel(model); +} + +function describeModel(model: CatalogModel): string { + return `# Catalog Model + +The software catalog contains a few key concepts: + +* Entities + * rich objects with various kinds, with different schemas + * can have relations between each other +* Locations + * registered as type/target pairs + * typically in the form of URLs that the catalog has been tasked with keeping track of + +## Entities + +The catalog contains entities of different kinds. Every entity is an object with +fields "kind", "apiVersion", "metadata", and optionally "spec" and "relations". + +When querying the catalog you use dot path notation to address fields, e.g. "metadata.name". +When querying for entity relationships, prefer using relations over spec fields, e.g. the +special syntax "relations.ownedBy" instead of "spec.owner". + +The unique identifying key for an entity is its so called entity reference (or entity ref +for short), on the form of "kind:namespace/name", e.g. "component:default/my-service". +These are always uniformly lowercased in the "en-US" locale. + +## Entity Metadata field + +The "metadata" object field on all entities has the same static schema. Some common fields +there are: + +* "name": The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair. This value is part of the technical identifier of the entity, and as such it will appear in URLs, database tables, entity references, and similar. It is subject to restrictions regarding what characters are allowed. If you want to use a different, more human readable string with fewer restrictions on it in user interfaces, see the "title" field below. +* "namespace" (default value: "default"): The namespace that the entity belongs to. +* "uid": A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics. +* "etag": An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value. +* "title": A display name of the entity, to be presented in user interfaces instead of the name property, when available. This field is sometimes useful when the name is cumbersome or ends up being perceived as overly technical. The title generally does not have as stringent format requirements on it, so it may contain special characters and be more explanatory. Do keep it very short though, and avoid situations where a title can be confused with the name of another entity, or where two entities share a title. Note that this is only for display purposes, and may be ignored by some parts of the code. Entity references still always make use of the name property, not the title. +* "description": A short (typically relatively few words, on one line) description of the entity. +* "annotations": A map of annotations on the entity. +* "labels": A map of labels on the entity. +* "tags": A list of tags on the entity. +* "links": A list of links on the entity. + +${model.getMetadata().annotations.map(describeAnnotation).join('\n')} +${model.getMetadata().labels.map(describeLabel).join('\n')} +${model.getMetadata().tags.map(describeTag).join('\n')} + +## Entity Kinds + +The model contains the following entity kinds: + +${model.listKinds().map(describeKind).join('\n')} + +## Entity Relations + +The model contains the following entity relations: + +${model.listRelations().map(describeRelation).join('\n')} +`; +} + +function describeKind(kind: CatalogModelKindSummary): string { + return `### Entity Kind "${kind.names.kind}" + +* Singular in text: "${kind.names.singular}" +* Plural in text: "${kind.names.plural}" +* Versions: +${kind.versions + .map( + version => + ` * apiVersion: "${version.apiVersion}"${ + version.specType ? ` (spec.type: "${version.specType}")` : '' + }`, + ) + .join('\n')} + +${kind.description} +`; +} + +function describeAnnotation(annotation: CatalogModelAnnotationSummary): string { + const title = annotation.title ? `: ${annotation.title}` : ''; + return `### Annotation "${annotation.name}"${title} + +${annotation.description} +`; +} + +function describeLabel(label: CatalogModelLabelSummary): string { + const title = label.title ? `: ${label.title}` : ''; + return `### Label "${label.name}"${title} + +${label.description} +`; +} + +function describeTag(tag: CatalogModelTagSummary): string { + const title = tag.title ? `: ${tag.title}` : ''; + return `### Tag "${tag.name}"${title} + +${tag.description} +`; +} + +function describeRelation(relation: CatalogModelRelationSummary): string { + return `### Relation "${relation.forward.type}": ${relation.forward.title} + +* Reverse type: "${relation.reverse.type}": ${relation.reverse.title} + +${relation.description} +`; +} diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index 04587c1a4e..2d6fb58f87 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; +import { ModelHolder } from '../model/ModelHolder'; +import { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction.ts'; import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts'; import { createValidateEntityAction } from './createValidateEntityAction.ts'; import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts'; @@ -24,7 +27,9 @@ import { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAc export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; catalog: CatalogService; + modelHolder: ModelHolder | undefined; }) => { + createGetCatalogModelDescriptionAction(options); createGetCatalogEntityAction(options); createValidateEntityAction(options); createRegisterCatalogEntitiesAction(options); diff --git a/plugins/catalog-backend/src/processors/ModelProcessor.test.ts b/plugins/catalog-backend/src/processors/ModelProcessor.test.ts index a6de247c5f..f246b176df 100644 --- a/plugins/catalog-backend/src/processors/ModelProcessor.test.ts +++ b/plugins/catalog-backend/src/processors/ModelProcessor.test.ts @@ -24,6 +24,7 @@ import { ModelProcessor } from './ModelProcessor'; import { ModelHolder } from '../model/ModelHolder'; const componentKind: CatalogModelKind = { + description: 'A software component', apiVersions: ['backstage.io/v1alpha1'], names: { kind: 'Component', singular: 'component', plural: 'components' }, relationFields: [ @@ -80,6 +81,15 @@ function createModel(overrides?: { getRelations?: CatalogModel['getRelations']; }): ModelHolder { return ModelHolder.modelPassthroughForTest({ + listKinds: () => [ + { + description: componentKind.description, + names: componentKind.names, + versions: componentKind.apiVersions.map(apiVersion => ({ apiVersion })), + }, + ], + listRelations: () => [ownedByRelation, dependsOnRelation], + getMetadata: () => ({ annotations: [], labels: [], tags: [] }), getKind: overrides?.getKind ?? (() => componentKind), getRelations: overrides?.getRelations ?? (() => [ownedByRelation, dependsOnRelation]), diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 84074689c3..84e7fd8cdb 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -287,6 +287,7 @@ export const catalogPlugin = createBackendPlugin({ createCatalogActions({ catalog, actionsRegistry, + modelHolder, }); const scmEventsMessagesCounter = metrics.createCounter<{ From 85ed75ee93e601ab9a3367d1b318b07fb2ca6dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 13:31:13 +0200 Subject: [PATCH 03/13] fixup! Add summary types and listing methods to CatalogModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixup! Add summary types and listing methods to CatalogModel Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../actions/createGetCatalogModelDescriptionAction.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts index 8539ae3d03..22ef1607bc 100644 --- a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts +++ b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.test.ts @@ -79,7 +79,7 @@ describe('createGetCatalogModelDescriptionAction', () => { input: {}, }); - const description = result.output.description as string; + const description = (result as any).output.description as string; expect(description).toContain('# Catalog Model'); expect(description).toContain('## Entity Kinds'); @@ -127,7 +127,7 @@ describe('createGetCatalogModelDescriptionAction', () => { input: {}, }); - const description = result.output.description as string; + const description = (result as any).output.description as string; expect(description).toContain('# Catalog Model'); expect(description).toContain('## Entity Kinds'); From 445aefd4b97ce8b203cab647d954e708d261ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 13:41:22 +0200 Subject: [PATCH 04/13] Address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused deps @backstage/filter-predicates and zod-validation-error from catalog-model - Deduplicate defaultCatalogEntityModel by re-exporting from the single source in model/defaultCatalogEntityModel.ts - Fix typos: "Retuns"/"epxressed", "Obviopusly", "recorsively" - Use domain-prefixed layer ID for scaffolder template model Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- packages/catalog-model/package.json | 4 +-- packages/catalog-model/src/alpha.ts | 32 +------------------ .../src/model/jsonSchema/util.ts | 2 +- .../catalog-model/src/model/sources/types.ts | 2 +- .../catalog-backend/src/model/ModelHolder.ts | 2 +- plugins/scaffolder-common/src/catalogModel.ts | 2 +- yarn.lock | 2 -- 7 files changed, 6 insertions(+), 40 deletions(-) diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index cd16dacc84..b32e54045d 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -49,13 +49,11 @@ }, "dependencies": { "@backstage/errors": "workspace:^", - "@backstage/filter-predicates": "workspace:^", "@backstage/types": "workspace:^", "ajv": "^8.10.0", "ajv-errors": "^3.0.0", "lodash": "^4.17.21", - "zod": "^3.25.76", - "zod-validation-error": "^4.0.2" + "zod": "^3.25.76" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/catalog-model/src/alpha.ts b/packages/catalog-model/src/alpha.ts index c924fc1b06..9381d92329 100644 --- a/packages/catalog-model/src/alpha.ts +++ b/packages/catalog-model/src/alpha.ts @@ -14,18 +14,6 @@ * limitations under the License. */ -import { createCatalogModelLayer } from './model/createCatalogModelLayer'; -import { apiEntityModel } from './kinds/ApiEntityV1alpha1'; -import { componentEntityModel } from './kinds/ComponentEntityV1alpha1'; -import { domainEntityModel } from './kinds/DomainEntityV1alpha1'; -import { groupEntityModel } from './kinds/GroupEntityV1alpha1'; -import { locationEntityModel } from './kinds/LocationEntityV1alpha1'; -import { resourceEntityModel } from './kinds/ResourceEntityV1alpha1'; -import { systemEntityModel } from './kinds/SystemEntityV1alpha1'; -import { userEntityModel } from './kinds/UserEntityV1alpha1'; -import { wellKnownAnnotationsModel } from './kinds/annotations'; -import { wellKnownRelationsModel } from './kinds/relations'; - export type { AlphaEntity } from './entity/AlphaEntity'; export type { EntityStatus, @@ -33,22 +21,4 @@ export type { EntityStatusLevel, } from './entity/EntityStatus'; export * from './model'; - -/** - * @alpha - */ -export const defaultCatalogEntityModel = createCatalogModelLayer({ - layerId: 'catalog.backstage.io/default-entity-model', - builder: model => { - model.import(apiEntityModel); - model.import(componentEntityModel); - model.import(domainEntityModel); - model.import(groupEntityModel); - model.import(locationEntityModel); - model.import(resourceEntityModel); - model.import(systemEntityModel); - model.import(userEntityModel); - model.import(wellKnownRelationsModel); - model.import(wellKnownAnnotationsModel); - }, -}); +export { defaultCatalogEntityModel } from './model/defaultCatalogEntityModel'; diff --git a/packages/catalog-model/src/model/jsonSchema/util.ts b/packages/catalog-model/src/model/jsonSchema/util.ts index 7caf0f9b83..50463f17b2 100644 --- a/packages/catalog-model/src/model/jsonSchema/util.ts +++ b/packages/catalog-model/src/model/jsonSchema/util.ts @@ -24,7 +24,7 @@ export function isJsonObject(value?: unknown): value is JsonObject { } /** - * Asserts that the value is a JSON object recorsively containing only JSON safe + * Asserts that the value is a JSON object recursively containing only JSON safe * values and no circular references. */ export function isJsonObjectDeep(value: unknown): value is JsonObject { diff --git a/packages/catalog-model/src/model/sources/types.ts b/packages/catalog-model/src/model/sources/types.ts index 43e5dcdf61..c5cfbf33c2 100644 --- a/packages/catalog-model/src/model/sources/types.ts +++ b/packages/catalog-model/src/model/sources/types.ts @@ -59,7 +59,7 @@ export type AsyncCatalogModelSourceGenerator = AsyncGenerator< */ export interface CatalogModelSource { /** - * Retuns a stream of layers as epxressed by this particular source. + * Returns a stream of layers as expressed by this particular source. */ read( options?: CatalogModelSourceReadOptions, diff --git a/plugins/catalog-backend/src/model/ModelHolder.ts b/plugins/catalog-backend/src/model/ModelHolder.ts index fc71fc29c0..e12b6fc4a7 100644 --- a/plugins/catalog-backend/src/model/ModelHolder.ts +++ b/plugins/catalog-backend/src/model/ModelHolder.ts @@ -53,7 +53,7 @@ export class ModelHolder { ); }, 3000); - // TODO(freben): Obviopusly this needs to be extended to support dynamic + // TODO(freben): Obviously this needs to be extended to support dynamic // model source events during the lifetime of the plugin. try { const layers = await Promise.all( diff --git a/plugins/scaffolder-common/src/catalogModel.ts b/plugins/scaffolder-common/src/catalogModel.ts index da2b0fbc5c..3428d88f6a 100644 --- a/plugins/scaffolder-common/src/catalogModel.ts +++ b/plugins/scaffolder-common/src/catalogModel.ts @@ -23,7 +23,7 @@ import schema from './Template.v1beta3.schema.json'; * @alpha */ export const templateModelLayer = createCatalogModelLayer({ - layerId: 'Template', + layerId: 'scaffolder.backstage.io/kind-template', builder: model => { model.addKind({ group: 'scaffolder.backstage.io', diff --git a/yarn.lock b/yarn.lock index f128e10572..cb1f7706b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2814,7 +2814,6 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" - "@backstage/filter-predicates": "workspace:^" "@backstage/types": "workspace:^" "@types/json-schema": "npm:^7.0.5" "@types/lodash": "npm:^4.14.151" @@ -2823,7 +2822,6 @@ __metadata: lodash: "npm:^4.17.21" yaml: "npm:^2.0.0" zod: "npm:^3.25.76" - zod-validation-error: "npm:^4.0.2" languageName: unknown linkType: soft From 173ef97b483933477078927cba82f2b3d2644099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 13:59:39 +0200 Subject: [PATCH 05/13] Address second round of PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Close async iterators in ModelHolder after reading first value to prevent resource leaks - Catch exceptions from validateMetaSchema in Zod refine predicate so validation errors flow through Zod's normal issue reporting - Warn on duplicate catalog model layer IDs instead of silently dropping later entries - Replace `as any` with `as JsonObject` for schema import in scaffolder template model layer Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- packages/catalog-model/report-alpha.api.md | 2 +- .../catalog-model/src/model/jsonSchema/zod.ts | 15 +++++++---- .../src/model/sources/CatalogModelSources.ts | 19 ++++++++++--- .../catalog-backend/src/model/ModelHolder.ts | 27 ++++++++++--------- plugins/scaffolder-common/src/catalogModel.ts | 3 ++- 5 files changed, 42 insertions(+), 24 deletions(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 0150301adc..903f0770a6 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -395,7 +395,7 @@ export function createCatalogModelLayerBuilder(options: { build(): CatalogModelLayer; }; -// @alpha (undocumented) +// @alpha export const defaultCatalogEntityModel: CatalogModelLayer; // @alpha diff --git a/packages/catalog-model/src/model/jsonSchema/zod.ts b/packages/catalog-model/src/model/jsonSchema/zod.ts index c4f7ff3e78..ea82263cac 100644 --- a/packages/catalog-model/src/model/jsonSchema/zod.ts +++ b/packages/catalog-model/src/model/jsonSchema/zod.ts @@ -25,8 +25,13 @@ export const jsonObjectSchema = z message: 'Invalid JSON schema', }); -export const jsonSchemaSchema = z - .record(z.string(), z.unknown()) - .refine((x): x is JsonObject => validateMetaSchema(x), { - message: 'Invalid JSON schema', - }); +export const jsonSchemaSchema = z.record(z.string(), z.unknown()).refine( + (x): x is JsonObject => { + try { + return validateMetaSchema(x); + } catch { + return false; + } + }, + { message: 'Invalid JSON schema' }, +); diff --git a/packages/catalog-model/src/model/sources/CatalogModelSources.ts b/packages/catalog-model/src/model/sources/CatalogModelSources.ts index 9ef035c336..6c021be5e4 100644 --- a/packages/catalog-model/src/model/sources/CatalogModelSources.ts +++ b/packages/catalog-model/src/model/sources/CatalogModelSources.ts @@ -19,7 +19,6 @@ import { defaultCatalogEntityModel } from '../defaultCatalogEntityModel'; import { StaticCatalogModelSource } from './StaticCatalogModelSource'; import { CatalogModelSource } from './types'; import { CatalogModelLayer } from '../types'; -import uniqBy from 'lodash/uniqBy'; /** * A helper for creating common catalog model sources. @@ -39,9 +38,21 @@ export class CatalogModelSources { * included automatically). */ static static(layers: CatalogModelLayer[]): CatalogModelSource { - return new StaticCatalogModelSource( - uniqBy([...layers, defaultCatalogEntityModel], 'layerId'), - ); + const allLayers = [...layers, defaultCatalogEntityModel]; + const seen = new Set(); + const deduped: CatalogModelLayer[] = []; + for (const layer of allLayers) { + if (seen.has(layer.layerId)) { + // eslint-disable-next-line no-console + console.warn( + `Duplicate catalog model layer ID "${layer.layerId}" detected; only the first occurrence will be used`, + ); + } else { + seen.add(layer.layerId); + deduped.push(layer); + } + } + return new StaticCatalogModelSource(deduped); } private constructor() { diff --git a/plugins/catalog-backend/src/model/ModelHolder.ts b/plugins/catalog-backend/src/model/ModelHolder.ts index e12b6fc4a7..146ef59f85 100644 --- a/plugins/catalog-backend/src/model/ModelHolder.ts +++ b/plugins/catalog-backend/src/model/ModelHolder.ts @@ -57,19 +57,20 @@ export class ModelHolder { // model source events during the lifetime of the plugin. try { const layers = await Promise.all( - sources.map(source => - source - .read({ signal: shutdownController.signal }) - .next() - .then(result => { - readyCount += 1; - const ls = result.value?.layers ?? []; - for (const layer of ls) { - logger.info(`Loaded catalog model layer: ${layer.layerId}`); - } - return ls; - }), - ), + sources.map(async source => { + const iter = source.read({ signal: shutdownController.signal }); + try { + const result = await iter.next(); + readyCount += 1; + const ls = result.value?.layers ?? []; + for (const layer of ls) { + logger.info(`Loaded catalog model layer: ${layer.layerId}`); + } + return ls; + } finally { + await iter.return(undefined as void); + } + }), ); return new ModelHolder(compileCatalogModel(layers.flat())); } finally { diff --git a/plugins/scaffolder-common/src/catalogModel.ts b/plugins/scaffolder-common/src/catalogModel.ts index 3428d88f6a..4ee69b1dd7 100644 --- a/plugins/scaffolder-common/src/catalogModel.ts +++ b/plugins/scaffolder-common/src/catalogModel.ts @@ -15,6 +15,7 @@ */ import { createCatalogModelLayer } from '@backstage/catalog-model/alpha'; +import { JsonObject } from '@backstage/types'; import schema from './Template.v1beta3.schema.json'; /** @@ -47,7 +48,7 @@ export const templateModelLayer = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema as any, + jsonSchema: schema as JsonObject, }, }, ], From ee80f3f0f9ed3f90b8c510ac5d02b63a28bd77a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 14:07:23 +0200 Subject: [PATCH 06/13] Improve schema validation errors and clean up review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use superRefine in jsonSchemaSchema to preserve the detailed error message from validateMetaSchema instead of swallowing it - Revert CatalogModelSources to silent dedup with uniqBy since user layers intentionally take precedence over the default model - Clean up unnecessary `as void` cast in ModelHolder iterator cleanup Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../catalog-model/src/model/jsonSchema/zod.ts | 15 ++++++++----- .../src/model/sources/CatalogModelSources.ts | 22 +++++-------------- .../catalog-backend/src/model/ModelHolder.ts | 2 +- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/catalog-model/src/model/jsonSchema/zod.ts b/packages/catalog-model/src/model/jsonSchema/zod.ts index ea82263cac..61135f1b04 100644 --- a/packages/catalog-model/src/model/jsonSchema/zod.ts +++ b/packages/catalog-model/src/model/jsonSchema/zod.ts @@ -25,13 +25,16 @@ export const jsonObjectSchema = z message: 'Invalid JSON schema', }); -export const jsonSchemaSchema = z.record(z.string(), z.unknown()).refine( - (x): x is JsonObject => { +export const jsonSchemaSchema = z + .record(z.string(), z.unknown()) + .superRefine((x, ctx): x is JsonObject => { try { return validateMetaSchema(x); - } catch { + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : 'Invalid JSON schema', + }); return false; } - }, - { message: 'Invalid JSON schema' }, -); + }); diff --git a/packages/catalog-model/src/model/sources/CatalogModelSources.ts b/packages/catalog-model/src/model/sources/CatalogModelSources.ts index 6c021be5e4..114e4ae376 100644 --- a/packages/catalog-model/src/model/sources/CatalogModelSources.ts +++ b/packages/catalog-model/src/model/sources/CatalogModelSources.ts @@ -19,6 +19,7 @@ import { defaultCatalogEntityModel } from '../defaultCatalogEntityModel'; import { StaticCatalogModelSource } from './StaticCatalogModelSource'; import { CatalogModelSource } from './types'; import { CatalogModelLayer } from '../types'; +import uniqBy from 'lodash/uniqBy'; /** * A helper for creating common catalog model sources. @@ -35,24 +36,13 @@ export class CatalogModelSources { /** * Provides a static catalog model on top of the default one (which is - * included automatically). + * included automatically). User-provided layers take precedence over the + * default model when layer IDs overlap. */ static static(layers: CatalogModelLayer[]): CatalogModelSource { - const allLayers = [...layers, defaultCatalogEntityModel]; - const seen = new Set(); - const deduped: CatalogModelLayer[] = []; - for (const layer of allLayers) { - if (seen.has(layer.layerId)) { - // eslint-disable-next-line no-console - console.warn( - `Duplicate catalog model layer ID "${layer.layerId}" detected; only the first occurrence will be used`, - ); - } else { - seen.add(layer.layerId); - deduped.push(layer); - } - } - return new StaticCatalogModelSource(deduped); + return new StaticCatalogModelSource( + uniqBy([...layers, defaultCatalogEntityModel], 'layerId'), + ); } private constructor() { diff --git a/plugins/catalog-backend/src/model/ModelHolder.ts b/plugins/catalog-backend/src/model/ModelHolder.ts index 146ef59f85..96b2de46dc 100644 --- a/plugins/catalog-backend/src/model/ModelHolder.ts +++ b/plugins/catalog-backend/src/model/ModelHolder.ts @@ -68,7 +68,7 @@ export class ModelHolder { } return ls; } finally { - await iter.return(undefined as void); + await iter.return(undefined); } }), ); From b91223faf8ea77e674b214462a8d625a8e858d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 14:08:33 +0200 Subject: [PATCH 07/13] Remove unused express and supertest deps from catalog-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These dependencies were added but never imported or used in the package source or tests. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-node/package.json | 7 +------ yarn.lock | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index 6f99a978f4..fda1898a62 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -69,18 +69,13 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@opentelemetry/api": "^1.9.0", - "express": "^4.22.0", - "express-promise-router": "^4.1.0", "lodash": "^4.17.21", "yaml": "^2.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@types/express": "^4.17.6", - "@types/supertest": "^2.0.8", - "msw": "^1.0.0", - "supertest": "^7.0.0" + "msw": "^1.0.0" }, "peerDependencies": { "@backstage/backend-test-utils": "workspace:^" diff --git a/yarn.lock b/yarn.lock index cb1f7706b1..e5902ae09f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5347,13 +5347,8 @@ __metadata: "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@opentelemetry/api": "npm:^1.9.0" - "@types/express": "npm:^4.17.6" - "@types/supertest": "npm:^2.0.8" - express: "npm:^4.22.0" - express-promise-router: "npm:^4.1.0" lodash: "npm:^4.17.21" msw: "npm:^1.0.0" - supertest: "npm:^7.0.0" yaml: "npm:^2.0.0" peerDependencies: "@backstage/backend-test-utils": "workspace:^" From c51c6eb81c22ecdb6b16d5b9da4df5b27a21590e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 14:10:31 +0200 Subject: [PATCH 08/13] Use isError from @backstage/errors instead of instanceof Error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- packages/catalog-model/src/model/jsonSchema/zod.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/catalog-model/src/model/jsonSchema/zod.ts b/packages/catalog-model/src/model/jsonSchema/zod.ts index 61135f1b04..d8ae43142d 100644 --- a/packages/catalog-model/src/model/jsonSchema/zod.ts +++ b/packages/catalog-model/src/model/jsonSchema/zod.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { isError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { z } from 'zod/v3'; import { isJsonObjectDeep } from './util'; @@ -33,7 +34,7 @@ export const jsonSchemaSchema = z } catch (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: error instanceof Error ? error.message : 'Invalid JSON schema', + message: isError(error) ? error.message : 'Invalid JSON schema', }); return false; } From 4114d4b95598a63af079f7bbb720eda805413289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 14:39:44 +0200 Subject: [PATCH 09/13] Clarify that ModelProcessor has no effect on default catalog usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/catalog-backend-model-processor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/catalog-backend-model-processor.md b/.changeset/catalog-backend-model-processor.md index f32bb6e261..eb461dc3ef 100644 --- a/.changeset/catalog-backend-model-processor.md +++ b/.changeset/catalog-backend-model-processor.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': minor --- -Added `ModelProcessor` that validates catalog entities against the compiled catalog model schemas, and integrated it into the `CatalogBuilder` and `CatalogPlugin`. +Added `ModelProcessor` that validates catalog entities against the compiled catalog model schemas, and integrated it into the `CatalogBuilder` and `CatalogPlugin`. This processor is only activated if you explicitly add catalog model sources to your backend; there is no functional change for regular catalog usage. From 12998a965ff0f852aca67b9ee5a4d4017b6b4916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 15:31:46 +0200 Subject: [PATCH 10/13] Change model source generator to yield data array with layer entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change AsyncCatalogModelSourceGenerator from yielding { layers: CatalogModelLayer[] } to { data: Array<{ layer: CatalogModelLayer }> } so that additional contextual data can be attached alongside each layer in the future. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- packages/catalog-model/report-alpha.api.md | 4 +++- .../src/model/sources/StaticCatalogModelSource.ts | 2 +- packages/catalog-model/src/model/sources/types.ts | 4 ++-- plugins/catalog-backend/src/model/ModelHolder.ts | 8 ++++---- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 903f0770a6..c323e4be5c 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -16,7 +16,9 @@ export interface AlphaEntity extends Entity { // @alpha export type AsyncCatalogModelSourceGenerator = AsyncGenerator< { - layers: CatalogModelLayer[]; + data: Array<{ + layer: CatalogModelLayer; + }>; }, void, void diff --git a/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts b/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts index 0b20a130a4..b1f4186b20 100644 --- a/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts +++ b/packages/catalog-model/src/model/sources/StaticCatalogModelSource.ts @@ -37,6 +37,6 @@ export class StaticCatalogModelSource implements CatalogModelSource { async *read( _options?: CatalogModelSourceReadOptions, ): AsyncCatalogModelSourceGenerator { - yield { layers: this.#layers }; + yield { data: this.#layers.map(layer => ({ layer })) }; } } diff --git a/packages/catalog-model/src/model/sources/types.ts b/packages/catalog-model/src/model/sources/types.ts index c5cfbf33c2..6174a4c0e3 100644 --- a/packages/catalog-model/src/model/sources/types.ts +++ b/packages/catalog-model/src/model/sources/types.ts @@ -31,7 +31,7 @@ export interface CatalogModelSourceReadOptions { * @alpha */ export type AsyncCatalogModelSourceGenerator = AsyncGenerator< - { layers: CatalogModelLayer[] }, + { data: Array<{ layer: CatalogModelLayer }> }, void, void >; @@ -49,7 +49,7 @@ export type AsyncCatalogModelSourceGenerator = AsyncGenerator< * class MyCatalogModelSource implements CatalogModelSource { * async *read() { * yield { - * layers: [defaultCatalogEntityModel] + * data: [{ layer: defaultCatalogEntityModel }] * }; * } * } diff --git a/plugins/catalog-backend/src/model/ModelHolder.ts b/plugins/catalog-backend/src/model/ModelHolder.ts index 96b2de46dc..78dc788818 100644 --- a/plugins/catalog-backend/src/model/ModelHolder.ts +++ b/plugins/catalog-backend/src/model/ModelHolder.ts @@ -62,11 +62,11 @@ export class ModelHolder { try { const result = await iter.next(); readyCount += 1; - const ls = result.value?.layers ?? []; - for (const layer of ls) { - logger.info(`Loaded catalog model layer: ${layer.layerId}`); + const entries = result.value?.data ?? []; + for (const entry of entries) { + logger.info(`Loaded catalog model layer: ${entry.layer.layerId}`); } - return ls; + return entries.map(entry => entry.layer); } finally { await iter.return(undefined); } From 49d12d31c1e5ee23290f135010b9dcd33dbe9430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 15:41:08 +0200 Subject: [PATCH 11/13] Rename templateModelLayer to scaffolderCatalogModelLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/scaffolder-common-template-model.md | 2 +- plugins/catalog-backend/dev/index.ts | 6 ++++-- plugins/catalog-node/src/provideStaticCatalogModel.ts | 2 +- plugins/scaffolder-common/report-alpha.api.md | 6 +++--- plugins/scaffolder-common/src/alpha.ts | 2 +- plugins/scaffolder-common/src/catalogModel.ts | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/scaffolder-common-template-model.md b/.changeset/scaffolder-common-template-model.md index 1ffa9e4935..40c76bab3d 100644 --- a/.changeset/scaffolder-common-template-model.md +++ b/.changeset/scaffolder-common-template-model.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-common': minor --- -Added a Template kind model layer with JSON Schema definition, serving as an example of how plugins can declare their own catalog entity kinds. +Added `scaffolderCatalogModelLayer`, a Template kind model layer with JSON Schema definition, serving as an example of how plugins can declare their own catalog entity kinds. diff --git a/plugins/catalog-backend/dev/index.ts b/plugins/catalog-backend/dev/index.ts index 2ac0bbebcc..b948dc2377 100644 --- a/plugins/catalog-backend/dev/index.ts +++ b/plugins/catalog-backend/dev/index.ts @@ -16,11 +16,13 @@ import { createBackend } from '@backstage/backend-defaults'; import { provideStaticCatalogModel } from '@backstage/plugin-catalog-node/alpha'; -import { templateModelLayer } from '@backstage/plugin-scaffolder-common/alpha'; +import { scaffolderCatalogModelLayer } from '@backstage/plugin-scaffolder-common/alpha'; const backend = createBackend(); backend.add(import('../src')); backend.add(import('@backstage/plugin-catalog-backend-module-logs')); -backend.add(provideStaticCatalogModel({ layers: [templateModelLayer] })); +backend.add( + provideStaticCatalogModel({ layers: [scaffolderCatalogModelLayer] }), +); backend.start(); diff --git a/plugins/catalog-node/src/provideStaticCatalogModel.ts b/plugins/catalog-node/src/provideStaticCatalogModel.ts index 4285d03a12..489b9b5532 100644 --- a/plugins/catalog-node/src/provideStaticCatalogModel.ts +++ b/plugins/catalog-node/src/provideStaticCatalogModel.ts @@ -36,7 +36,7 @@ import { catalogModelExtensionPoint } from './extensions'; * ```ts * backend.add( * provideStaticCatalogModel({ - * layers: [templateModelLayer], + * layers: [scaffolderCatalogModelLayer], * }), * ); * ``` diff --git a/plugins/scaffolder-common/report-alpha.api.md b/plugins/scaffolder-common/report-alpha.api.md index dadddff352..c7ea24100f 100644 --- a/plugins/scaffolder-common/report-alpha.api.md +++ b/plugins/scaffolder-common/report-alpha.api.md @@ -22,6 +22,9 @@ export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template'; // @alpha export const scaffolderActionPermissions: ResourcePermission<'scaffolder-action'>[]; +// @alpha +export const scaffolderCatalogModelLayer: CatalogModelLayer; + // @alpha export const scaffolderPermissions: ( | BasicPermission @@ -51,9 +54,6 @@ export const taskReadPermission: ResourcePermission<'scaffolder-task'>; // @alpha export const templateManagementPermission: BasicPermission; -// @alpha -export const templateModelLayer: CatalogModelLayer; - // @alpha export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>; diff --git a/plugins/scaffolder-common/src/alpha.ts b/plugins/scaffolder-common/src/alpha.ts index f5cc736504..3c7d6f1372 100644 --- a/plugins/scaffolder-common/src/alpha.ts +++ b/plugins/scaffolder-common/src/alpha.ts @@ -15,4 +15,4 @@ */ export * from './permissions'; -export { templateModelLayer } from './catalogModel'; +export { scaffolderCatalogModelLayer } from './catalogModel'; diff --git a/plugins/scaffolder-common/src/catalogModel.ts b/plugins/scaffolder-common/src/catalogModel.ts index 4ee69b1dd7..d383451ea5 100644 --- a/plugins/scaffolder-common/src/catalogModel.ts +++ b/plugins/scaffolder-common/src/catalogModel.ts @@ -23,7 +23,7 @@ import schema from './Template.v1beta3.schema.json'; * * @alpha */ -export const templateModelLayer = createCatalogModelLayer({ +export const scaffolderCatalogModelLayer = createCatalogModelLayer({ layerId: 'scaffolder.backstage.io/kind-template', builder: model => { model.addKind({ From ca5425176329fe499f0f3a9b27c83c3b2611ec79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 15:43:01 +0200 Subject: [PATCH 12/13] tweaked action wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/actions/createGetCatalogModelDescriptionAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts index fd4df0787b..b37658e8d9 100644 --- a/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts +++ b/plugins/catalog-backend/src/actions/createGetCatalogModelDescriptionAction.ts @@ -42,7 +42,7 @@ export const createGetCatalogModelDescriptionAction = ({ name: 'get-catalog-model-description', title: 'Get a Catalog Model Description', description: - 'Returns a markdown formatted description of the current catalog model, including all registered entity kinds, their spec fields, and available relations.', + 'Returns a markdown formatted description of the current catalog model, including all registered entity kinds, annotations, labels, tags, and relations.', attributes: { destructive: false, readOnly: true, From 3e291ae5232781340616a47d89a6f45b16015feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Apr 2026 15:44:11 +0200 Subject: [PATCH 13/13] Rename schema imports to jsonSchema and remove stale changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the JSON schema imports in kind definitions from `schema` to `jsonSchema` to enable shorthand property syntax. Also remove the stale scaffolder-backend changeset since that package no longer has changes in this PR. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/scaffolder-backend-model-registration.md | 5 ----- packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts | 6 +++--- packages/catalog-model/src/kinds/UserEntityV1alpha1.ts | 6 +++--- plugins/scaffolder-common/src/catalogModel.ts | 6 +++--- 10 files changed, 27 insertions(+), 32 deletions(-) delete mode 100644 .changeset/scaffolder-backend-model-registration.md diff --git a/.changeset/scaffolder-backend-model-registration.md b/.changeset/scaffolder-backend-model-registration.md deleted file mode 100644 index 57e5b71889..0000000000 --- a/.changeset/scaffolder-backend-model-registration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Integrated the Template kind model layer with the catalog model registry service. diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index d29f0d57a4..565266e219 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/API.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/API.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -46,7 +46,7 @@ export interface ApiEntityV1alpha1 extends Entity { * @public */ export const apiEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the API kind. @@ -84,7 +84,7 @@ export const apiEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 0df197cf38..c88f24b44d 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Component.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/Component.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -50,7 +50,7 @@ export interface ComponentEntityV1alpha1 extends Entity { * @public */ export const componentEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the Component kind. @@ -116,7 +116,7 @@ export const componentEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts index 5b223ca419..da1280f0b2 100644 --- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/Domain.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -44,7 +44,7 @@ export interface DomainEntityV1alpha1 extends Entity { * @public */ export const domainEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the Domain kind. @@ -82,7 +82,7 @@ export const domainEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index ac5832f05b..c3f6c22351 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Group.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/Group.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -45,7 +45,7 @@ export interface GroupEntityV1alpha1 extends Entity { * @public */ export const groupEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the Group kind. @@ -91,7 +91,7 @@ export const groupEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 3ddcfcb17d..0f02419bbf 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Location.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/Location.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -41,7 +41,7 @@ export interface LocationEntityV1alpha1 extends Entity { * @public */ export const locationEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the Location kind. @@ -64,7 +64,7 @@ export const locationEntityModel = createCatalogModelLayer({ { name: ['v1alpha1', 'v1beta1'], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index 16c1697616..3fe873a925 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/Resource.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -46,7 +46,7 @@ export interface ResourceEntityV1alpha1 extends Entity { * @public */ export const resourceEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the Resource kind. @@ -94,7 +94,7 @@ export const resourceEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts index cfd700e3ae..4655033420 100644 --- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/System.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/System.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -44,7 +44,7 @@ export interface SystemEntityV1alpha1 extends Entity { * @public */ export const systemEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the System kind. @@ -82,7 +82,7 @@ export const systemEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index 8da2525a06..f962f75fea 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -16,7 +16,7 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/User.v1alpha1.schema.json'; +import jsonSchema from '../schema/kinds/User.v1alpha1.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -43,7 +43,7 @@ export interface UserEntityV1alpha1 extends Entity { * @public */ export const userEntityV1alpha1Validator = - ajvCompiledJsonSchemaValidator(schema); + ajvCompiledJsonSchemaValidator(jsonSchema); /** * Extends the catalog model with the User kind. @@ -75,7 +75,7 @@ export const userEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema, + jsonSchema, }, }, ], diff --git a/plugins/scaffolder-common/src/catalogModel.ts b/plugins/scaffolder-common/src/catalogModel.ts index d383451ea5..d360e857cb 100644 --- a/plugins/scaffolder-common/src/catalogModel.ts +++ b/plugins/scaffolder-common/src/catalogModel.ts @@ -15,8 +15,8 @@ */ import { createCatalogModelLayer } from '@backstage/catalog-model/alpha'; -import { JsonObject } from '@backstage/types'; -import schema from './Template.v1beta3.schema.json'; +import type { JsonObject } from '@backstage/types'; +import jsonSchema from './Template.v1beta3.schema.json'; /** * Extends the catalog model with the Template kind. @@ -48,7 +48,7 @@ export const scaffolderCatalogModelLayer = createCatalogModelLayer({ }, ], schema: { - jsonSchema: schema as JsonObject, + jsonSchema: jsonSchema as JsonObject, }, }, ],