Module 01 · The backend plugin · 45 min · You are: Platform team
A backend plugin with an API
In this module
- You learn: How
createBackendPluginandregisterInitwork, howhttpRoutermounts 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
/checksand/evaluatewithcurl. - You end with: The backend serves
/api/readiness. It lists five checks and scorescheckoutat 80 (it has no runbook link). Merged in yourportalrepo.
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
registerInitwithdeps(services) andinit.initruns 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
catalogServiceRefis the catalog client as a service. Pass the caller's credentials and the catalog applies its own permissions.- Errors
- Throw
NotFoundErrororInputErrorfrom@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.
portal/plugins/readiness-common/src/types.ts(reference, your repo): The data shapes, shared with the frontend.portal/plugins/readiness-backend/src/checks.ts(reference, your repo): The five default checks and the scoring.
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
readinessmissing from the Plugin initialization started log line. - Who responds?
- The platform team.
- How is it corrected?
- Put the
backend.addline back. - How is it prevented?
- A smoke test after every deploy calls one route per plugin and expects 200 or 401, never 404.
Quiz
- Why does
/evaluatepass 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
- Where does
httpRoutermount 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.yamllists for the plugin
- 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
- 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.
- Under
/api/readiness, from the plugin id Each plugin gets/api/<pluginId>; the id is the only thing that decides it. - A 404 with a JSON body that carries the message The backend maps the error classes from
@backstage/errorsto 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.