PlatformEngr® Backstage plugins course

Module 01 · The backend plugin · 45 min · You are: Platform team

A backend plugin with an API

In this module

  • You learn: How createBackendPlugin and registerInit work, how httpRouter mounts your routes, how every request is authenticated, and how one plugin calls another.
  • You do: Generate two packages, write the types, router and plugin, then call /checks and /evaluate with curl.
  • You end with: The backend serves /api/readiness. It lists five checks and scores checkout at 80 (it has no runbook link). Merged in your portal repo.

The backend is where the rules live. A scorecard must be the same for every viewer, use data the caller is allowed to see, and fail with clear HTTP errors. Backstage gives you all of that if you ask for the right services.

Open this module in the interactive course to track progress.

Concepts

createBackendPlugin
A plugin has an id and a registerInit with deps (services) and init. init runs once at startup. Building plugins.
httpRouter
Mounts your Express router at /api/<pluginId>. httpRouter service.
Auth by default
Every route needs credentials: a user, another plugin or a configured token. httpAuth.credentials(req) says who called. httpAuth service.
Calling the catalog
catalogServiceRef is the catalog client as a service. Pass the caller's credentials and the catalog applies its own permissions.
Errors
Throw NotFoundError or InputError from @backstage/errors; the backend turns them into 404 and 400 with a JSON body.

Files to know

Open them in your editor or on GitHub; the bullets say what to look at.

Steps

1. Generate the packages

The Backstage CLI generates packages with the right role, scripts and lint config. A backend plugin also gets added to the backend for you.

cd ~/work/portal && yarn new --select plugin-common-library --option pluginId=readiness
yarn new --select backend-plugin --option pluginId=readiness

You now have plugins/readiness-common and plugins/readiness-backend. yarn new also added a backend.add line at the end of packages/backend/src/index.ts:

grep -n readiness packages/backend/src/index.ts

2. Remove the example code

The backend template comes with a to-do list example. You write your own.

cd ~/work/portal/plugins/readiness-backend && rm -r src/services src/router.test.ts src/plugin.test.ts
cd ~/work/portal && yarn --cwd plugins/readiness-common add @backstage/catalog-model@^1.10.2
cd ~/work/portal && yarn --cwd plugins/readiness-backend add @backstage/catalog-model@^1.10.2 @backstage/errors@^1.3.2 @backstage/plugin-catalog-node@^2.2.6 @internal/backstage-plugin-readiness-common@workspace:^

3. Write the shared types

Types in -common are the contract between the backend and the frontend.

Create ~/work/portal/plugins/readiness-common/src/types.ts

import type { Entity } from '@backstage/catalog-model';

/** What one check says about one entity. @public */
export type CheckOutcome = { passed: boolean; message?: string };

/** One rule an entity must pass to be production ready. @public */
export interface ReadinessCheck {
  id: string;
  title: string;
  evaluate(entity: Entity): CheckOutcome | Promise<CheckOutcome>;
}

/** The outcome of one check, as stored and sent to the frontend. @public */
export type CheckResult = CheckOutcome & { id: string; title: string };

/** Every check for one entity, and the share that passed (0 to 100). @public */
export type Scorecard = {
  entityRef: string;
  score: number;
  checks: CheckResult[];
  evaluatedAt: string;
};

Replace the whole file ~/work/portal/plugins/readiness-common/src/index.ts

/**
 * Types and permissions shared by the readiness frontend and backend.
 *
 * @packageDocumentation
 */
export * from './types';

4. Copy the checks

The five checks are plain TypeScript. Copy them, then open the file: each check reads the entity and returns passed or a message.

cp ~/work/backstage-workshop/portal/plugins/readiness-backend/src/checks.ts ~/work/portal/plugins/readiness-backend/src/checks.ts

5. Write the router and the plugin

The router has two routes. POST /evaluate reads the entity from the catalog as the caller, so nobody can score an entity they cannot see.

Replace the whole file ~/work/portal/plugins/readiness-backend/src/router.ts

import { HttpAuthService } from '@backstage/backend-plugin-api';
import { InputError, NotFoundError } from '@backstage/errors';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
import express from 'express';
import Router from 'express-promise-router';
import { z } from 'zod/v3';
import { scoreEntity } from './checks';

export async function createRouter(options: {
  httpAuth: HttpAuthService;
  catalog: CatalogService;
  checks: ReadinessCheck[];
}): Promise<express.Router> {
  const { httpAuth, catalog, checks } = options;
  const router = Router();
  router.use(express.json());

  router.get('/checks', async (_req, res) => {
    res.json({ items: checks.map(({ id, title }) => ({ id, title })) });
  });

  const evaluateSchema = z.object({ entityRef: z.string() });

  // Scores one entity, read from the catalog as whoever called us.
  router.post('/evaluate', async (req, res) => {
    const parsed = evaluateSchema.safeParse(req.body);
    if (!parsed.success) {
      throw new InputError(parsed.error.toString());
    }
    const { entityRef } = parsed.data;
    const credentials = await httpAuth.credentials(req);
    const entity = await catalog.getEntityByRef(entityRef, { credentials });
    if (!entity) {
      throw new NotFoundError(`No entity ${entityRef} in the catalog`);
    }
    res.json(await scoreEntity(entity, checks));
  });

  return router;
}

Replace the whole file ~/work/portal/plugins/readiness-backend/src/plugin.ts
The deps are everything this plugin can touch.

import {
  coreServices,
  createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { defaultChecks } from './checks';
import { createRouter } from './router';

/**
 * Production-readiness scorecards for catalog components.
 *
 * @public
 */
export const readinessPlugin = createBackendPlugin({
  pluginId: 'readiness',
  register(env) {
    env.registerInit({
      deps: {
        httpAuth: coreServices.httpAuth,
        httpRouter: coreServices.httpRouter,
        catalog: catalogServiceRef,
      },
      async init({ httpAuth, httpRouter, catalog }) {
        httpRouter.use(
          await createRouter({ httpAuth, catalog, checks: defaultChecks }),
        );
      },
    });
  },
});

Add to ~/work/portal/packages/backend/src/index.ts before backend.add(import('@internal/backstage-plugin-readiness-backend'));

// Production-readiness scorecards (plugin course) and the modules that add checks
cd ~/work/portal && yarn tsc

6. Call the API

Stop the portal (Ctrl+C) and start it again: a new package needs a fresh start. The startup log lists readiness under Plugin initialization started.

Without a token. Expect a 401 with Missing credentials:

curl -s localhost:7007/api/readiness/checks

With the token from .env. Expect five checks:

cd ~/work/portal && set -a && . ./.env && set +a
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'

Score checkout. Expect 80 and one failing check, has-runbook:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/readiness/evaluate -d '{"entityRef":"component:default/checkout"}' | jq '{score, failing: [.checks[] | select(.passed | not) | .id]}'

An entity that does not exist. Expect a 404 with No entity component:default/nope in the catalog:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/readiness/evaluate -d '{"entityRef":"component:default/nope"}'

7. Merge it

cd ~/work/portal

Propose the change as a pull request. Put it on its own branch and push it:

git switch -c readiness-backend && git add -A && git commit -qm "Readiness: backend plugin with live scores"
git push -qu origin HEAD

Open the pull request. It prints a link:

gh pr create --fill

Open that link. Files changed shows exactly your diff. Read it as a reviewer would: generated files, lockfile and your code.

Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:

gh pr merge --squash --delete-branch

Bring your copy up to date:

git switch -q main && git pull -q

Simplified for the course

Scores are computed on every request and not stored. Module 02 adds a database and a schedule.

Break it on purpose: Lose the backend.add line

A colleague resolves a merge conflict in packages/backend/src/index.ts and keeps the other side. The readiness line is gone. The backend starts without errors.

You practise: telling "plugin not loaded" from "plugin broken", from the status code and the startup log.

1. Break it

Delete from ~/work/portal/packages/backend/src/index.ts
The lost line.

- backend.add(import('@internal/backstage-plugin-readiness-backend'));

Save and wait for the backend to restart. Then call the API:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -w '%{http_code}\n' localhost:7007/api/readiness/checks

2. What you see

Expected: five checks. You get: 404 with an empty body. Nothing in the log is red.

3. Fix it yourself

Prove the plugin is not loaded, then load it again. You are done when /checks lists five checks.

Hint 1

A loaded plugin with no matching route answers with a JSON error. An empty 404 means no plugin owns /api/readiness at all.

Hint 2

Search the startup log for Plugin initialization started: it lists every plugin id the backend loaded.

The fix

Add to ~/work/portal/packages/backend/src/index.ts after // Production-readiness scorecards (plugin course) and the modules that add checks

backend.add(import('@internal/backstage-plugin-readiness-backend'));
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'
After you fixed it: the five questions
What failed?
The backend never loaded the readiness plugin, so nothing served /api/readiness.
How was it detected?
An empty 404, and readiness missing from the Plugin initialization started log line.
Who responds?
The platform team.
How is it corrected?
Put the backend.add line back.
How is it prevented?
A smoke test after every deploy calls one route per plugin and expects 200 or 401, never 404.

Quiz

  1. Why does /evaluate pass the caller's credentials to the catalog?
    • So the catalog can apply its own permissions to what the caller may see
    • Because the catalog refuses every request that has no credentials attached
    • So the catalog can log which plugin made the call for billing
  2. Where does httpRouter mount the readiness routes?
    • At the root of port 7007, next to every other plugin's routes
    • Under /api/readiness, from the plugin id
    • Under whatever path app-config.yaml lists for the plugin
  3. A route throws new NotFoundError(...). What does the caller get?
    • A 500 with a stack trace, because the error was not caught in the route
    • A 200 with null, because Express catches errors by default
    • A 404 with a JSON body that carries the message
Answers
  1. So the catalog can apply its own permissions to what the caller may see Calling as the user means your plugin never shows an entity the user could not open themselves.
  2. Under /api/readiness, from the plugin id Each plugin gets /api/<pluginId>; the id is the only thing that decides it.
  3. A 404 with a JSON body that carries the message The backend maps the error classes from @backstage/errors to HTTP status codes.

Field note

I keep the router thin: parse, authorise, call one function, return. When a route grows an if about business rules, it moves into a class that tests can call without HTTP.

Report a problem with this module