feat(catalog-model): add addKindVersion builder method and move v1alpha2 to own layer

Adds addKindVersion to CatalogModelLayerBuilder so layers can add
versions to an existing kind without re-declaring its metadata.
Moves v1alpha2 API registration into ApiEntityV1alpha2.ts as a
separate model layer using the new method.

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-05-18 14:54:41 +02:00
parent fc407b805e
commit 8dd6dce9ed
9 changed files with 457 additions and 63 deletions
@@ -69,6 +69,12 @@ export interface CatalogModel {
listRelations(): CatalogModelRelationSummary[];
}
// @alpha
export interface CatalogModelAddKindVersionDefinition {
kind: string;
versions: CatalogModelKindVersionDefinition[];
}
// @alpha
export interface CatalogModelAnnotationDefinition {
description: string;
@@ -228,6 +234,7 @@ export interface CatalogModelLayer {
export interface CatalogModelLayerBuilder {
addAnnotation(annotation: CatalogModelAnnotationDefinition): void;
addKind(kind: CatalogModelKindDefinition): void;
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
addLabel(label: CatalogModelLabelDefinition): void;
addRelationPair(relation: CatalogModelRelationPairDefinition): void;
addTag(tag: CatalogModelTagDefinition): void;
@@ -17,8 +17,6 @@
import { createCatalogModelLayer } from '../model/createCatalogModelLayer';
import type { Entity } from '../entity/Entity';
import jsonSchema from '../schema/kinds/API.v1alpha1.schema.json';
import defaultSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.schema.json';
import mcpServerSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.mcp-server.schema.json';
import { ajvCompiledJsonSchemaValidator } from './util';
/**
@@ -50,22 +48,6 @@ export interface ApiEntityV1alpha1 extends Entity {
export const apiEntityV1alpha1Validator =
ajvCompiledJsonSchemaValidator(jsonSchema);
const apiRelationFields = [
{
selector: { path: 'spec.owner' },
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit' as const,
allowedKinds: ['Group', 'User'],
},
{
selector: { path: 'spec.system' },
relation: 'partOf',
defaultKind: 'System',
defaultNamespace: 'inherit' as const,
},
];
/**
* Extends the catalog model with the API kind.
*
@@ -86,22 +68,23 @@ export const apiEntityModel = createCatalogModelLayer({
versions: [
{
name: ['v1alpha1', 'v1beta1'],
relationFields: apiRelationFields,
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 },
},
{
name: 'v1alpha2',
relationFields: apiRelationFields,
schema: { jsonSchema: defaultSchemaV1alpha2 },
},
{
name: 'v1alpha2',
specType: 'mcp-server',
description:
'An MCP (Model Context Protocol) server exposed as an API entity.',
relationFields: apiRelationFields,
schema: { jsonSchema: mcpServerSchemaV1alpha2 },
},
],
});
},
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createCatalogModelLayer } from '../model/createCatalogModelLayer';
import type { Entity } from '../entity/Entity';
import defaultSchema from '../schema/kinds/API.v1alpha2.schema.json';
import mcpServerSchema from '../schema/kinds/API.v1alpha2.mcp-server.schema.json';
@@ -102,3 +103,48 @@ export function isMcpServerApiEntity(
): entity is McpServerApiEntityV1alpha2 {
return entity.spec.type === 'mcp-server';
}
const apiRelationFields = [
{
selector: { path: 'spec.owner' },
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit' as const,
allowedKinds: ['Group', 'User'],
},
{
selector: { path: 'spec.system' },
relation: 'partOf',
defaultKind: 'System',
defaultNamespace: 'inherit' as const,
},
];
/**
* Extends the catalog model with v1alpha2 versions of the API kind.
*
* @alpha
*/
export const apiEntityV1alpha2Model = createCatalogModelLayer({
layerId: 'catalog.backstage.io/kind-api-v1alpha2',
builder: model => {
model.addKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
relationFields: apiRelationFields,
schema: { jsonSchema: defaultSchema },
},
{
name: 'v1alpha2',
specType: 'mcp-server',
description:
'An MCP (Model Context Protocol) server exposed as an API entity.',
relationFields: apiRelationFields,
schema: { jsonSchema: mcpServerSchema },
},
],
});
},
});
@@ -22,6 +22,10 @@ import {
type CatalogModelKindDefinition,
opsFromCatalogModelKind,
} from './modelActions/addKind';
import {
type CatalogModelAddKindVersionDefinition,
opsFromCatalogModelAddKindVersion,
} from './modelActions/addKindVersion';
import {
type CatalogModelLabelDefinition,
opsFromCatalogModelLabel,
@@ -87,6 +91,10 @@ export interface CatalogModelLayerBuilder {
* Adds a new kind to the model.
*/
addKind(kind: CatalogModelKindDefinition): void;
/**
* Adds one or more versions to an already-declared kind.
*/
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
/**
* Updates an existing kind in the model.
*/
@@ -169,6 +177,11 @@ export class DefaultCatalogModelLayerBuilder
this.#ops.push(...ops);
}
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void {
const ops = opsFromCatalogModelAddKindVersion(definition);
this.#ops.push(...ops);
}
updateKind(kind: CatalogModelUpdateKindDefinition): void {
const ops = opsFromCatalogModelUpdateKind(kind);
this.#ops.push(...ops);
@@ -15,6 +15,7 @@
*/
import { apiEntityModel } from '../kinds/ApiEntityV1alpha1';
import { apiEntityV1alpha2Model } from '../kinds/ApiEntityV1alpha2';
import { componentEntityModel } from '../kinds/ComponentEntityV1alpha1';
import { domainEntityModel } from '../kinds/DomainEntityV1alpha1';
import { groupEntityModel } from '../kinds/GroupEntityV1alpha1';
@@ -36,6 +37,7 @@ export const defaultCatalogEntityModel = createCatalogModelLayer({
layerId: 'catalog.backstage.io/default-entity-model',
builder: model => {
model.import(apiEntityModel);
model.import(apiEntityV1alpha2Model);
model.import(componentEntityModel);
model.import(domainEntityModel);
model.import(groupEntityModel);
@@ -15,12 +15,9 @@
*/
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';
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
/**
* The definition of a catalog model kind, roughly resembling a JSON Schema.
@@ -168,33 +165,12 @@ export function opsFromCatalogModelKind(
}),
);
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,
},
},
}),
);
}
}
}
ops.push(
...opsFromCatalogModelAddKindVersion({
kind: kind.names.kind,
versions: kind.versions ?? [],
}),
);
return ops;
}
@@ -0,0 +1,291 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { compileCatalogModel } from '../compileCatalogModel';
import { createCatalogModelLayer } from '../createCatalogModelLayer';
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
describe('opsFromCatalogModelAddKindVersion', () => {
it('produces declareKindVersion ops for a single version', () => {
const ops = opsFromCatalogModelAddKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
description: 'A new version',
relationFields: [
{
selector: { path: 'spec.owner' },
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit',
},
],
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
properties: {
owner: { type: 'string' },
},
},
},
},
},
},
],
});
expect(ops).toEqual([
{
op: 'declareKindVersion.v1',
kind: 'API',
name: 'v1alpha2',
specType: undefined,
properties: {
description: 'A new version',
relationFields: [
{
selector: { path: 'spec.owner' },
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit',
},
],
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
properties: {
owner: { type: 'string' },
},
},
},
},
},
},
},
]);
});
it('produces separate ops for each specType', () => {
const ops = opsFromCatalogModelAddKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
properties: { definition: { type: 'string' } },
},
},
},
},
},
{
name: 'v1alpha2',
specType: 'mcp-server',
description: 'An MCP server',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
properties: { remotes: { type: 'array' } },
},
},
},
},
},
],
});
expect(ops).toHaveLength(2);
expect(ops[0]).toMatchObject({
op: 'declareKindVersion.v1',
name: 'v1alpha2',
specType: undefined,
});
expect(ops[1]).toMatchObject({
op: 'declareKindVersion.v1',
name: 'v1alpha2',
specType: 'mcp-server',
});
});
it('does not produce a declareKind op', () => {
const ops = opsFromCatalogModelAddKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: { type: 'object' },
},
},
},
},
],
});
expect(ops.every(op => op.op === 'declareKindVersion.v1')).toBe(true);
});
it('rejects an invalid JSON schema', () => {
expect(() =>
opsFromCatalogModelAddKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: { type: 'not-a-real-type' as any },
},
},
},
},
],
}),
).toThrow(/Invalid JSON schema/);
});
it('rejects a schema that violates semantic rules', () => {
expect(() =>
opsFromCatalogModelAddKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha2',
schema: {
jsonSchema: {
type: 'object',
allOf: [{ properties: { spec: { type: 'object' } } }],
properties: {
spec: { type: 'object' },
},
} as any,
},
},
],
}),
).toThrow(/allOf/);
});
});
describe('addKindVersion integration with compileCatalogModel', () => {
it('adds a new specType to an existing kind via a separate layer', () => {
const baseLayer = createCatalogModelLayer({
layerId: 'test/base-api',
builder: model => {
model.addKind({
group: 'backstage.io',
names: { kind: 'API', singular: 'api', plural: 'apis' },
description: 'An API',
versions: [
{
name: 'v1alpha1',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
required: ['definition'],
properties: {
definition: { type: 'string' },
},
},
},
},
},
},
],
});
},
});
const extensionLayer = createCatalogModelLayer({
layerId: 'test/api-mcp-extension',
builder: model => {
model.addKindVersion({
kind: 'API',
versions: [
{
name: 'v1alpha1',
specType: 'mcp-server',
description: 'An MCP server API',
schema: {
jsonSchema: {
type: 'object',
properties: {
spec: {
type: 'object',
required: ['remotes'],
properties: {
remotes: { type: 'array', minItems: 1 },
},
},
},
},
},
},
],
});
},
});
const model = compileCatalogModel([baseLayer, extensionLayer]);
const defaultKind = model.getKind({
kind: 'API',
apiVersion: 'backstage.io/v1alpha1',
spec: { type: 'openapi' },
});
const mcpKind = model.getKind({
kind: 'API',
apiVersion: 'backstage.io/v1alpha1',
spec: { type: 'mcp-server' },
});
expect(defaultKind).toBeDefined();
expect(mcpKind).toBeDefined();
const defaultRequired = (defaultKind!.jsonSchema.properties as any).spec
.required as string[];
const mcpRequired = (mcpKind!.jsonSchema.properties as any).spec
.required as string[];
expect(defaultRequired).toContain('definition');
expect(mcpRequired).toContain('remotes');
expect(mcpRequired).not.toContain('definition');
expect(mcpKind!.description).toBe('An MCP server API');
});
});
@@ -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 { reduceKindSchema } from '../jsonSchema/reduceKindSchema';
import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics';
import { validateMetaSchema } from '../jsonSchema/validateMetaSchema';
import { CatalogModelOp } from '../operations';
import { createDeclareKindVersionOp } from '../operations/declareKindVersion';
import type { CatalogModelKindVersionDefinition } from './addKind';
/**
* The definition for adding one or more versions to an already-declared kind.
*
* @alpha
*/
export interface CatalogModelAddKindVersionDefinition {
/**
* The kind to add versions to, e.g. "API".
*/
kind: string;
/**
* The versions to add.
*/
versions: CatalogModelKindVersionDefinition[];
}
export function opsFromCatalogModelAddKindVersion(
definition: CatalogModelAddKindVersionDefinition,
): CatalogModelOp[] {
const ops: CatalogModelOp[] = [];
for (const version of definition.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: definition.kind,
name,
specType: specType,
properties: {
description: version.description,
relationFields: version.relationFields,
schema: {
jsonSchema: jsonSchema as any,
},
},
}),
);
}
}
}
return ops;
}
@@ -20,6 +20,7 @@ export {
type CatalogModelKindRelationFieldDefinition,
type CatalogModelKindVersionDefinition,
} from './addKind';
export { type CatalogModelAddKindVersionDefinition } from './addKindVersion';
export { type CatalogModelLabelDefinition } from './addLabel';
export { type CatalogModelRelationPairDefinition } from './addRelationPair';
export { type CatalogModelRemoveAnnotationDefinition } from './removeAnnotation';