Coderrob brand logo Coderrob

Hi, I'm Rob—programmer, Pluralsight author, software architect, emerging technologist, and lifelong learner.


How to Create a Backstage.io Custom Entity Relationship: A Complete Guide


Thu, 08 Jun 2023

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:

It’s crucial to understand that these relationships are bidirectional, like a two-way street.

Consider this example:

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 being Component, 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!