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:
@@ -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:^",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user