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] 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:^"