Merge pull request #33663 from backstage/freben/catalog-model-extensions

Add catalog model layer system with JSON Schema based kind declarations
This commit is contained in:
Fredrik Adelöw
2026-04-14 16:29:01 +02:00
committed by GitHub
131 changed files with 10896 additions and 44 deletions
+3 -1
View File
@@ -51,7 +51,9 @@
"@backstage/errors": "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"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+389
View File
@@ -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,393 @@ export interface AlphaEntity extends Entity {
status?: EntityStatus;
}
// @alpha
export type AsyncCatalogModelSourceGenerator = AsyncGenerator<
{
data: Array<{
layer: CatalogModelLayer;
}>;
},
void,
void
>;
// @alpha
export interface CatalogModel {
getKind(options: {
kind: string;
apiVersion: string;
spec?: {
type?: string;
};
}): CatalogModelKind | undefined;
getMetadata(): {
annotations: CatalogModelAnnotationSummary[];
labels: CatalogModelLabelSummary[];
tags: CatalogModelTagSummary[];
};
getRelations(options: { kind: string }): CatalogModelRelation[] | undefined;
listKinds(): CatalogModelKindSummary[];
listRelations(): CatalogModelRelationSummary[];
}
// @alpha
export interface CatalogModelAnnotationDefinition {
description: string;
name: string;
schema?: {
jsonSchema: JsonObject;
};
title?: string;
}
// @alpha
export interface CatalogModelAnnotationSummary {
description: string;
name: string;
title?: string;
}
// @alpha
export interface CatalogModelKind {
apiVersions: string[];
description: string;
jsonSchema: JsonObject;
names: {
kind: string;
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 CatalogModelKindSummary {
description: string;
names: {
kind: string;
singular: string;
plural: string;
};
versions: Array<{
apiVersion: string;
specType?: string;
}>;
}
// @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 CatalogModelLabelSummary {
description: string;
name: string;
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 CatalogModelRelationSummary {
description: string;
forward: {
type: string;
title: string;
};
fromKind: string[];
reverse: {
type: string;
title: string;
};
toKind: 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 CatalogModelTagSummary {
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<CatalogModelLayer>,
): CatalogModel;
// @alpha
export function createCatalogModelLayer(options: {
layerId: string;
builder: (model: CatalogModelLayerBuilder) => void;
}): CatalogModelLayer;
// @alpha
export function createCatalogModelLayerBuilder(options: {
layerId: string;
}): CatalogModelLayerBuilder & {
build(): CatalogModelLayer;
};
// @alpha
export const defaultCatalogEntityModel: CatalogModelLayer;
// @alpha
export type EntityStatus = {
items?: EntityStatusItem[];
+3 -1
View File
@@ -14,9 +14,11 @@
* limitations under the License.
*/
export type { AlphaEntity } from './entity/AlphaEntity';
export type {
EntityStatus,
EntityStatusItem,
EntityStatusLevel,
} from './entity/EntityStatus';
export type { AlphaEntity } from './entity/AlphaEntity';
export * from './model';
export { defaultCatalogEntityModel } from './model/defaultCatalogEntityModel';
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/API.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -45,4 +46,48 @@ export interface ApiEntityV1alpha1 extends Entity {
* @public
*/
export const apiEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/Component.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -49,4 +50,76 @@ export interface ComponentEntityV1alpha1 extends Entity {
* @public
*/
export const componentEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/Domain.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -43,4 +44,48 @@ export interface DomainEntityV1alpha1 extends Entity {
* @public
*/
export const domainEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/Group.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -44,4 +45,56 @@ export interface GroupEntityV1alpha1 extends Entity {
* @public
*/
export const groupEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/Location.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -40,4 +41,33 @@ export interface LocationEntityV1alpha1 extends Entity {
* @public
*/
export const locationEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/Resource.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -45,4 +46,58 @@ export interface ResourceEntityV1alpha1 extends Entity {
* @public
*/
export const resourceEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCatalogModelLayer } from '../model/createCatalogModelLayer';
import type { Entity } from '../entity/Entity';
import jsonSchema from '../schema/kinds/System.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
import schema from '../schema/kinds/System.v1alpha1.schema.json';
/**
* Backstage catalog System kind Entity. Systems group Components, Resources and APIs together.
@@ -43,4 +44,48 @@ export interface SystemEntityV1alpha1 extends Entity {
* @public
*/
export const systemEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -14,8 +14,9 @@
* 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 jsonSchema from '../schema/kinds/User.v1alpha1.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -42,4 +43,42 @@ export interface UserEntityV1alpha1 extends Entity {
* @public
*/
export const userEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(schema);
ajvCompiledJsonSchemaValidator(jsonSchema);
/**
* 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,
},
},
],
});
},
});
@@ -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.',
});
},
});
+16 -1
View File
@@ -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,
@@ -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' },
});
},
});
@@ -0,0 +1,760 @@
/*
* 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({
description: 'A widget',
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({
description: 'An updated widget',
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({
description: 'An updated widget',
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();
});
});
@@ -0,0 +1,786 @@
/*
* 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,
CatalogModelAnnotationSummary,
CatalogModelKind,
CatalogModelKindSummary,
CatalogModelLabelSummary,
CatalogModelLayer,
CatalogModelRelation,
CatalogModelRelationSummary,
CatalogModelTagSummary,
OpaqueCatalogModelLayer,
} from './types';
// #region Internal types
interface KindState {
group: string;
singular: string;
plural: string;
description: string;
versions: Map<string, VersionState>;
}
interface VersionState {
name: string;
apiVersion: string;
specTypes: Map<string | undefined, SpecTypeState>;
}
interface SpecTypeState {
description?: string;
relationFields?: OpDeclareKindVersionV1['properties']['relationFields'];
jsonSchema: JsonObject;
}
interface RelationState {
fromKinds: Set<string>;
toKinds: Set<string>;
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<string, KindState>,
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<string, KindState>,
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<string, RelationState>,
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<string, KindState>,
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<string, KindState>,
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<string, RelationState>,
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<string, KindState>,
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<string, AnnotationState>,
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<string, LabelState>,
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<string, TagState>,
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<string, AnnotationState>,
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<string, LabelState>,
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<string, TagState>, 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<string, AnnotationState>,
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<string, LabelState>,
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<string, TagState>, 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<string, AnnotationState>;
labels: Map<string, LabelState>;
tags: Map<string, TagState>;
}): 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<CatalogModelLayer>,
): 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<string, AnnotationState>();
const labels = new Map<string, LabelState>();
const tags = new Map<string, TagState>();
const kinds = new Map<string, KindState>();
const relations = new Map<string, RelationState>();
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<string, CatalogModelKind>();
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, {
description: specificKind.description ?? kindState.description,
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<string, CatalogModelRelation[]>();
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,
},
};
}),
);
}
// Precompute kind summaries, one per unique kind (not per version/specType)
const kindSummaries: CatalogModelKindSummary[] = [...kinds.entries()].map(
([kindName, kindState]) => ({
description: kindState.description,
names: {
kind: kindName,
singular: kindState.singular,
plural: kindState.plural,
},
versions: [...kindState.versions.values()].flatMap(version =>
[...version.specTypes.keys()].map(specType => ({
apiVersion: version.apiVersion,
...(specType !== undefined ? { specType } : undefined),
})),
),
}),
);
// Precompute annotation summaries
const annotationSummaries: CatalogModelAnnotationSummary[] = [
...annotations.entries(),
].map(([name, state]) => ({
name,
...(state.title !== undefined ? { title: state.title } : undefined),
description: state.description,
}));
// Precompute label summaries
const labelSummaries: CatalogModelLabelSummary[] = [...labels.entries()].map(
([name, state]) => ({
name,
...(state.title !== undefined ? { title: state.title } : undefined),
description: state.description,
}),
);
// Precompute tag summaries
const tagSummaries: CatalogModelTagSummary[] = [...tags.entries()].map(
([name, state]) => ({
name,
...(state.title !== undefined ? { title: state.title } : undefined),
description: state.description,
}),
);
// Collect all unique relation summaries
const relationSummaries: CatalogModelRelationSummary[] = [
...relations.values(),
].map(r => {
const reverseEntry = relations.get(r.reverse.type);
return {
fromKind: [...r.fromKinds],
toKind: [...r.toKinds],
description: r.description,
forward: r.forward,
reverse: {
type: r.reverse.type,
title: reverseEntry?.forward.title ?? r.reverse.title,
},
};
});
return {
listKinds() {
return kindSummaries;
},
listRelations() {
return relationSummaries;
},
getMetadata() {
return {
annotations: annotationSummaries,
labels: labelSummaries,
tags: tagSummaries,
};
},
getKind(options) {
const type = options.spec?.type;
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
@@ -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();
}
@@ -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);
}
@@ -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);
},
});
+36
View File
@@ -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.
*/
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,
CatalogModelAnnotationSummary,
CatalogModelKind,
CatalogModelKindSummary,
CatalogModelLabelSummary,
CatalogModelLayer,
CatalogModelRelation,
CatalogModelRelationSummary,
CatalogModelTagSummary,
} from './types';
@@ -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;
};
})();
@@ -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';
@@ -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' });
});
});
@@ -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;
}
@@ -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;
}
@@ -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<string, unknown> = { a: 1 };
obj.self = obj;
expect(isJsonObjectDeep(obj)).toBe(false);
});
it('should return false for deeply nested circular references', () => {
const inner: Record<string, unknown> = { x: 1 };
const obj = { a: { b: inner } };
inner.loop = obj;
expect(isJsonObjectDeep(obj)).toBe(false);
});
});
@@ -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 recursively 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<unknown>();
return Object.values(value).every(v => isJsonValueDeep(v, seen));
}
function isJsonValueDeep(value: unknown, seen: Set<unknown>): 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;
}
@@ -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();
});
});
@@ -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;
}
@@ -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');
});
});
@@ -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;
}
@@ -0,0 +1,41 @@
/*
* 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 { isError } from '@backstage/errors';
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())
.superRefine((x, ctx): x is JsonObject => {
try {
return validateMetaSchema(x);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: isError(error) ? error.message : 'Invalid JSON schema',
});
return false;
}
});
@@ -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/);
});
});
@@ -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,
},
}),
];
}
@@ -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/);
});
});
@@ -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;
}
@@ -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/);
});
});
@@ -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,
},
}),
];
}
@@ -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',
},
},
]);
});
});
@@ -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;
}
@@ -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.',
},
},
]);
});
});
@@ -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,
},
}),
];
}
@@ -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';
@@ -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',
},
]);
});
});
@@ -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 })];
}
@@ -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',
},
]);
});
});
@@ -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 })];
}
@@ -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',
},
]);
});
});
@@ -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 })];
}
@@ -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',
},
]);
});
});
@@ -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 })];
}
@@ -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/);
});
});
@@ -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,
},
}),
];
}
@@ -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' } },
},
},
]);
});
});
@@ -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;
}
@@ -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/);
});
});
@@ -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,
},
}),
];
}
@@ -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: {},
},
]);
});
});
@@ -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;
}
@@ -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',
},
},
]);
});
});
@@ -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,
},
}),
];
}
@@ -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/);
});
});
@@ -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<typeof opDeclareAnnotationV1Schema>;
/**
* 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<OpDeclareAnnotationV1, 'op'> & { op?: never },
): OpDeclareAnnotationV1 {
return opDeclareAnnotationV1Schema.parse({
...input,
op: 'declareAnnotation.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opDeclareKindV1Schema>;
/**
* 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<OpDeclareKindV1, 'op'> & { op?: never },
): OpDeclareKindV1 {
return opDeclareKindV1Schema.parse({ ...input, op: 'declareKind.v1' });
}
@@ -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/);
});
});
@@ -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<OpDeclareKindVersionV1, 'op'> & { op?: never },
): OpDeclareKindVersionV1 {
return opDeclareKindVersionV1Schema.parse({
...input,
op: 'declareKindVersion.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opDeclareLabelV1Schema>;
/**
* 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<OpDeclareLabelV1, 'op'> & { op?: never },
): OpDeclareLabelV1 {
return opDeclareLabelV1Schema.parse({
...input,
op: 'declareLabel.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opDeclareRelationV1Schema>;
/**
* 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<OpDeclareRelationV1, 'op'> & { op?: never },
): OpDeclareRelationV1 {
return opDeclareRelationV1Schema.parse({
...input,
op: 'declareRelation.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opDeclareTagV1Schema>;
/**
* 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<OpDeclareTagV1, 'op'> & { op?: never },
): OpDeclareTagV1 {
return opDeclareTagV1Schema.parse({
...input,
op: 'declareTag.v1',
});
}
@@ -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;
@@ -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/);
});
});
@@ -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<typeof opRemoveAnnotationV1Schema>;
/**
* 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<OpRemoveAnnotationV1, 'op'> & { op?: never },
): OpRemoveAnnotationV1 {
return opRemoveAnnotationV1Schema.parse({
...input,
op: 'removeAnnotation.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opRemoveKindV1Schema>;
/**
* 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<OpRemoveKindV1, 'op'> & { op?: never },
): OpRemoveKindV1 {
return opRemoveKindV1Schema.parse({ ...input, op: 'removeKind.v1' });
}
@@ -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/);
});
});
@@ -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<typeof opRemoveLabelV1Schema>;
/**
* 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<OpRemoveLabelV1, 'op'> & { op?: never },
): OpRemoveLabelV1 {
return opRemoveLabelV1Schema.parse({ ...input, op: 'removeLabel.v1' });
}
@@ -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/);
});
});
@@ -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<typeof opRemoveTagV1Schema>;
/**
* 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<OpRemoveTagV1, 'op'> & { op?: never },
): OpRemoveTagV1 {
return opRemoveTagV1Schema.parse({ ...input, op: 'removeTag.v1' });
}
@@ -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/);
});
});
@@ -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<typeof opUpdateAnnotationV1Schema>;
/**
* 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<OpUpdateAnnotationV1, 'op'> & { op?: never },
): OpUpdateAnnotationV1 {
return opUpdateAnnotationV1Schema.parse({
...input,
op: 'updateAnnotation.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opUpdateKindV1Schema>;
/**
* 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<OpUpdateKindV1, 'op'> & { op?: never },
): OpUpdateKindV1 {
return opUpdateKindV1Schema.parse({ ...input, op: 'updateKind.v1' });
}
@@ -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/);
});
});
@@ -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<typeof opUpdateKindVersionV1Schema>;
/**
* 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<OpUpdateKindVersionV1, 'op'> & { op?: never },
): OpUpdateKindVersionV1 {
return opUpdateKindVersionV1Schema.parse({
...input,
op: 'updateKindVersion.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opUpdateLabelV1Schema>;
/**
* 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<OpUpdateLabelV1, 'op'> & { op?: never },
): OpUpdateLabelV1 {
return opUpdateLabelV1Schema.parse({
...input,
op: 'updateLabel.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opUpdateRelationV1Schema>;
/**
* 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<OpUpdateRelationV1, 'op'> & { op?: never },
): OpUpdateRelationV1 {
return opUpdateRelationV1Schema.parse({
...input,
op: 'updateRelation.v1',
});
}
@@ -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/);
});
});
@@ -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<typeof opUpdateTagV1Schema>;
/**
* 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<OpUpdateTagV1, 'op'> & { op?: never },
): OpUpdateTagV1 {
return opUpdateTagV1Schema.parse({ ...input, op: 'updateTag.v1' });
}
@@ -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');
});
});
@@ -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<T extends CatalogModelOp> {
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<CatalogModelOp, { op: K }>
>;
} = {
'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}`);
}
}
@@ -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 { 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). User-provided layers take precedence over the
* default model when layer IDs overlap.
*/
static static(layers: CatalogModelLayer[]): CatalogModelSource {
return new StaticCatalogModelSource(
uniqBy([...layers, defaultCatalogEntityModel], 'layerId'),
);
}
private constructor() {
throw new NotImplementedError();
}
}
@@ -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 { data: this.#layers.map(layer => ({ layer })) };
}
}
@@ -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';
@@ -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<
{ data: Array<{ layer: 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 {
* data: [{ layer: defaultCatalogEntityModel }]
* };
* }
* }
* ```
*
* @alpha
*/
export interface CatalogModelSource {
/**
* Returns a stream of layers as expressed by this particular source.
*/
read(
options?: CatalogModelSourceReadOptions,
): AsyncCatalogModelSourceGenerator;
}

Some files were not shown because too many files have changed in this diff Show More