How to Create a Backstage.io Custom Entity Relationship: A Complete Guide
While working with Backstage.io, you might come across a requirement to create a custom entity relationship. Though the official documentation provides some insights, you may find it too high level. This post offers a practical, step-by-step walkthrough of how to create a custom entity relationship.
Understanding Entity Relationships
To start, we must understand what types of relationships we wish to create. Relationships in Backstage.io can be one of two types:
- Incoming: These relationships point from other entities to the current entity.
- Outgoing: These relationships point from the current entity to other entities.
It’s crucial to understand that these relationships are bidirectional, like a two-way street.
Consider this example:
supportOf
hasSupport
In this example, supportOf
signifies that the current entity supports another entity. In contrast, hasSupport
indicates that the current entity is supported by another entity.
Modifying Entity Metadata
To add these relationships to your entities, you’ll need to modify the entities’ metadata. For instance, if we’re defining a support role, we can add a “support” property to our entities’ metadata. Here’s an example:
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: business-team
title: Business Team
description: Doing business.
spec:
type: team
profile:
displayName: Business Team
parent: serious-business-team
children: []
members:
- user:bossyperson
- user:loudestperson
- user:plantogetbacktoyouperson
- user:budgetperson
- user:thatonepersonwhobreakseverything
- user:coderrob
support:
- user:tedlasso
In this case, the support
property lists the entities that support the business-team
.
Creating a Custom Relationship Processor
To manage these relationships more effectively, we’ll create a custom relationship processor. This processor will automate the creation of these relationships and reduce code duplication.
To ensure type safety, we’ll define an EntityContext
type that we’ll use with the parseEntityRef
function provided by @backstage/catalog-model
.
export type EntityContext = {
defaultKind?: string;
defaultNamespace: string;
};
export const DefaultEntityContext: Readonly<EntityContext> = {
defaultNamespace: DEFAULT_NAMESPACE,
};
export enum OrgEntityKind {
USER = 'User',
GROUP = 'Group',
}
export type RelationAssignment = {
incoming: string;
outgoing: string;
};
export const RELATION_SUPPORT_OF = 'supportOf';
export const RELATION_HAS_SUPPORT = 'hasSupport';
This EntityContext
type defines the default kind and namespace for the entities in the relationship.
NOTE: When I refer to “kind” it’s in reference to property defined on the
Entity
definition to identify what the entity represents. Examples beingComponent
,Resource
,Group
, or any other kind of things. :)
Now, we can define a base class for our custom relationship processors:
import {
CatalogProcessor,
CatalogProcessorEmit,
processingResult,
} from '@backstage/plugin-catalog-node';
import {
CompoundEntityRef,
DEFAULT_NAMESPACE,
parseEntityRef,
} from '@backstage/catalog-model';
import { EntityContext } from './types';
export abstract class RelationshipBaseProcessor implements CatalogProcessor {
public abstract getProcessorName(): string;
protected createContext(namespace: string, kind?: string): EntityContext {
const defaultNamespace = namespace || DEFAULT_NAMESPACE;
return !kind
? { defaultNamespace }
: {
defaultKind: kind,
defaultNamespace,
};
}
protected createRelationships(
emit: CatalogProcessorEmit,
source: CompoundEntityRef,
targets: string | string[] | undefined,
context: EntityContext,
assignment: RelationAssignment,
): void {
if (!targets?.length) {
return;
}
[targets].flat().forEach(target => {
const targetRef = parseEntityRef(target, context);
if (!targetRef) {
return;
}
// Outgoing relationship ... from "me" to "you"
emit(
processingResult.relation({
source,
type: assignment.outgoing,
target: { ...targetRef },
}),
);
// Incoming relationship ... from "you" to "me"
emit(
processingResult.relation({
source: { ...targetRef },
type: assignment.incoming,
target: source,
}),
);
});
}
}
This abstract class simplifies the process of creating both incoming and outgoing relationships.
Implementing a Custom Relationship Processor
Next, we’ll implement our custom relationship processor. We’ll extend the RelationshipBaseProcessor
class, overriding the getProcessorName
method and implementing the postProcessEntity
method.
export class SupportProcessor extends RelationshipBaseProcessor {
public getProcessorName(): string {
return "NeverGonnaGiveYouUpProcessor"
}
public async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
/* anticipation building */
}
}
Our postProcessEntity
method will be responsible for inspecting the current entity and creating the appropriate relationships based on its spec
property.
We can simplify this process by creating a relationMapping
object. This object maps property names to relationship types, allowing us to easily create the correct relationships based on the entity’s properties.
const relationMapping: Readonly<{ [key: string]: RelationAssignment }> = {
support: {
incoming: RELATION_SUPPORT_OF,
outgoing: RELATION_HAS_SUPPORT,
},
};
Now we can use this mapping in our postProcessEntity
method:
export class SupportProcessor extends RelationshipBaseProcessor {
public getProcessorName(): string {
return "NeverGonnaGiveYouUpProcessor";
}
public async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
// Not a 'Group' kind? Move along.
if (entity?.kind !== OrgEntityKind.GROUP) {
return entity;
}
const source = getCompoundEntityRef(entity);
Object.keys(relationMapping)
.filter(
prop =>
!!entity.spec && // Ensure that spec is defined
prop in entity.spec && // Ensure spec has expected property
Array.isArray(entity.spec[prop]), // Ensure property is an array
)
.forEach(prop =>
this.createRelationships(
emit,
source,
entity.spec?.[prop] as string[],
this.createContext(source.namespace, OrgEntityKind.USER),
{
outgoing: relationMapping[prop].outgoing,
incoming: relationMapping[prop].incoming,
},
),
);
return entity;
}
}
Finally, we need to integrate our custom processor into Backstage’s catalog processing pipeline. To do this, add it to the catalog builder in your catalog.ts
file:
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = CatalogBuilder.create(env);
builder.addProcessor(new SupportProcessor())
const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
}
With these steps,
our custom processor is now part of Backstage’s processing pipeline. If everything is set up correctly, you should be able to see the new relationships in the entities’ definitions when you navigate to their detail pages.
For example, if you add a support
property to a Group
entity’s spec
, you’ll see the following relationships in its definition:
relations:
. . .
- type: hasSupport
targetRef: user:default/tedlasso
target:
kind: user
namespace: default
name: tedlasso
And that’s it! You’ve successfully created a custom relationship in Backstage. Happy hacking!