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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user