diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 550a79c45c..b2861ea5c9 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -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; diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 2208bd66ec..e735a9eae8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -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 }, - }, ], }); }, diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts index 08be91243f..ce2296982f 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts @@ -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 }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts index 98df29b2b3..9b3e2b4d96 100644 --- a/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts +++ b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts @@ -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); diff --git a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts index f997a78707..a54b0cde54 100644 --- a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts +++ b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts @@ -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); diff --git a/packages/catalog-model/src/model/modelActions/addKind.ts b/packages/catalog-model/src/model/modelActions/addKind.ts index 00ca3a8802..2cdc3bf87a 100644 --- a/packages/catalog-model/src/model/modelActions/addKind.ts +++ b/packages/catalog-model/src/model/modelActions/addKind.ts @@ -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; } diff --git a/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts b/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts new file mode 100644 index 0000000000..a48557bd6f --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts @@ -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'); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addKindVersion.ts b/packages/catalog-model/src/model/modelActions/addKindVersion.ts new file mode 100644 index 0000000000..e8f64a7224 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKindVersion.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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; +} diff --git a/packages/catalog-model/src/model/modelActions/index.ts b/packages/catalog-model/src/model/modelActions/index.ts index ca2a6fd539..bb340a561b 100644 --- a/packages/catalog-model/src/model/modelActions/index.ts +++ b/packages/catalog-model/src/model/modelActions/index.ts @@ -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';