Merge pull request #31021 from backstage/rugvip/backend-template

cli: update backend plugin template with new service pattern
This commit is contained in:
Patrik Oldsberg
2025-08-29 14:34:23 +02:00
committed by GitHub
11 changed files with 223 additions and 143 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated the backend plugin template to use a new pattern for the `TodoListService` that reduces boilerplate.
+2
View File
@@ -52,6 +52,7 @@ import { version as authBackend } from '../../../../plugins/auth-backend/package
import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json';
import { version as catalogNode } from '../../../../plugins/catalog-node/package.json';
import { version as theme } from '../../../../packages/theme/package.json';
import { version as types } from '../../../../packages/types/package.json';
import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json';
export const packageVersions: Record<string, string> = {
@@ -71,6 +72,7 @@ export const packageVersions: Record<string, string> = {
'@backstage/frontend-test-utils': frontendTestUtils,
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/types': types,
'@backstage/plugin-scaffolder-node': scaffolderNode,
'@backstage/plugin-scaffolder-node-test-utils': scaffolderNodeTestUtils,
'@backstage/plugin-auth-backend': authBackend,
@@ -25,6 +25,7 @@
"@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}",
"@backstage/catalog-client": "{{versionQuery '@backstage/catalog-client'}}",
"@backstage/errors": "{{versionQuery '@backstage/errors'}}",
"@backstage/types": "{{versionQuery '@backstage/types'}}",
"@backstage/plugin-catalog-node": "{{versionQuery '@backstage/plugin-catalog-node'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
@@ -2,9 +2,16 @@ import {
mockCredentials,
startTestBackend,
} from '@backstage/backend-test-utils';
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { todoListServiceRef } from './services/TodoListService';
import { {{pluginVar}} } from './plugin';
import request from 'supertest';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import {
ConflictError,
AuthenticationError,
NotAllowedError,
} from '@backstage/errors';
// TEMPLATE NOTE:
// Plugin tests are integration tests for your plugin, ensuring that all pieces
@@ -82,4 +89,41 @@ describe('plugin', () => {
createdAt: expect.any(String),
});
});
it('should forward errors from the TodoListService', async () => {
const { server } = await startTestBackend({
features: [
{{pluginVar}},
createServiceFactory({
service: todoListServiceRef,
deps: {},
factory: () => ({
createTodo: jest.fn().mockRejectedValue(new ConflictError()),
listTodos: jest.fn().mockRejectedValue(new AuthenticationError()),
getTodo: jest.fn().mockRejectedValue(new NotAllowedError()),
}),
})
],
});
const createRes = await request(server)
.post('/api/{{pluginId}}/todos')
.send({ title: 'My Todo', entityRef: 'component:default/my-component' });
expect(createRes.status).toBe(409);
expect(createRes.body).toMatchObject({
error: { name: 'ConflictError' },
});
const listRes = await request(server).get('/api/{{pluginId}}/todos');
expect(listRes.status).toBe(401);
expect(listRes.body).toMatchObject({
error: { name: 'AuthenticationError' },
});
const getRes = await request(server).get('/api/{{pluginId}}/todos/123');
expect(getRes.status).toBe(403);
expect(getRes.body).toMatchObject({
error: { name: 'NotAllowedError' },
});
});
});
@@ -3,8 +3,7 @@ import {
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { createTodoListService } from './services/TodoListService';
import { todoListServiceRef } from './services/TodoListService';
/**
* {{pluginVar}} backend plugin
@@ -16,21 +15,15 @@ export const {{pluginVar}} = createBackendPlugin({
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
catalog: catalogServiceRef,
todoList: todoListServiceRef,
},
async init({ logger, httpAuth, httpRouter, catalog }) {
const todoListService = await createTodoListService({
logger,
catalog,
});
async init({ httpAuth, httpRouter, todoList }) {
httpRouter.use(
await createRouter({
httpAuth,
todoListService,
todoList,
}),
);
},
@@ -7,7 +7,7 @@ import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { TodoListService } from './services/TodoListService/types';
import { todoListServiceRef } from './services/TodoListService';
const mockTodoItem = {
title: 'Do the thing',
@@ -20,17 +20,17 @@ const mockTodoItem = {
// Testing the router directly allows you to write a unit test that mocks the provided options.
describe('createRouter', () => {
let app: express.Express;
let todoListService: jest.Mocked<TodoListService>;
let todoList: jest.Mocked<typeof todoListServiceRef.T>;
beforeEach(async () => {
todoListService = {
todoList = {
createTodo: jest.fn(),
listTodos: jest.fn(),
getTodo: jest.fn(),
};
const router = await createRouter({
httpAuth: mockServices.httpAuth(),
todoListService,
todoList,
});
app = express();
app.use(router);
@@ -38,7 +38,7 @@ describe('createRouter', () => {
});
it('should create a TODO', async () => {
todoListService.createTodo.mockResolvedValue(mockTodoItem);
todoList.createTodo.mockResolvedValue(mockTodoItem);
const response = await request(app).post('/todos').send({
title: 'Do the thing',
@@ -49,7 +49,7 @@ describe('createRouter', () => {
});
it('should not allow unauthenticated requests to create a TODO', async () => {
todoListService.createTodo.mockResolvedValue(mockTodoItem);
todoList.createTodo.mockResolvedValue(mockTodoItem);
// TEMPLATE NOTE:
// The HttpAuth mock service considers all requests to be authenticated as a
@@ -3,14 +3,14 @@ import { InputError } from '@backstage/errors';
import { z } from 'zod';
import express from 'express';
import Router from 'express-promise-router';
import { TodoListService } from './services/TodoListService/types';
import { todoListServiceRef } from './services/TodoListService';
export async function createRouter({
httpAuth,
todoListService,
todoList,
}: {
httpAuth: HttpAuthService;
todoListService: TodoListService;
todoList: typeof todoListServiceRef.T;
}): Promise<express.Router> {
const router = Router();
router.use(express.json());
@@ -32,7 +32,7 @@ export async function createRouter({
throw new InputError(parsed.error.toString());
}
const result = await todoListService.createTodo(parsed.data, {
const result = await todoList.createTodo(parsed.data, {
credentials: await httpAuth.credentials(req, { allow: ['user'] }),
});
@@ -40,11 +40,11 @@ export async function createRouter({
});
router.get('/todos', async (_req, res) => {
res.json(await todoListService.listTodos());
res.json(await todoList.listTodos());
});
router.get('/todos/:id', async (req, res) => {
res.json(await todoListService.getTodo({ id: req.params.id }));
res.json(await todoList.getTodo({ id: req.params.id }));
});
return router;
@@ -0,0 +1,155 @@
/*
* Copyright 2025 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 crypto from 'node:crypto';
import {
coreServices,
createServiceFactory,
createServiceRef,
LoggerService,
} from '@backstage/backend-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import {
BackstageCredentials,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
import { Expand } from '@backstage/types';
export interface TodoItem {
title: string;
id: string;
createdBy: string;
createdAt: string;
}
// TEMPLATE NOTE:
// This is a simple in-memory todo list store. It is recommended to use a
// database to store data in a real application. See the database service
// documentation for more information on how to do this:
// https://backstage.io/docs/backend-system/core-services/database
export class TodoListService {
readonly #logger: LoggerService;
readonly #catalog: typeof catalogServiceRef.T;
readonly #storedTodos = new Array<TodoItem>();
static create(options: {
logger: LoggerService;
catalog: typeof catalogServiceRef.T;
}) {
return new TodoListService(options.logger, options.catalog);
}
private constructor(
logger: LoggerService,
catalog: typeof catalogServiceRef.T,
) {
this.#logger = logger;
this.#catalog = catalog;
}
async createTodo(
input: {
title: string;
entityRef?: string;
},
options: {
credentials: BackstageCredentials<BackstageUserPrincipal>;
},
): Promise<TodoItem> {
let title = input.title;
// TEMPLATE NOTE:
// A common pattern for Backstage plugins is to pass an entity reference
// from the frontend to then fetch the entire entity from the catalog in the
// backend plugin.
if (input.entityRef) {
// TEMPLATE NOTE:
// Cross-plugin communication uses service-to-service authentication. The
// `AuthService` lets you generate a token that is valid for communication
// with the target plugin only. You must also provide credentials for the
// identity that you are making the request on behalf of.
//
// If you want to make a request using the plugin backend's own identity,
// you can access it via the `auth.getOwnServiceCredentials()` method.
// Beware that this bypasses any user permission checks.
const entity = await this.#catalog.getEntityByRef(
input.entityRef,
options,
);
if (!entity) {
throw new NotFoundError(`No entity found for ref '${input.entityRef}'`);
}
// TEMPLATE NOTE:
// Here you could read any form of data from the entity. A common use case
// is to read the value of a custom annotation for your plugin. You can
// read more about how to add custom annotations here:
// https://backstage.io/docs/features/software-catalog/extending-the-model#adding-a-new-annotation
//
// In this example we just use the entity title to decorate the todo item.
const entityDisplay = entity.metadata.title ?? input.entityRef;
title = `[${entityDisplay}] ${input.title}`;
}
const id = crypto.randomUUID();
const createdBy = options.credentials.principal.userEntityRef;
const newTodo = {
title,
id,
createdBy,
createdAt: new Date().toISOString(),
};
this.#storedTodos.push(newTodo);
// TEMPLATE NOTE:
// The second argument of the logger methods can be used to pass
// structured metadata. You can read more about the logger service here:
// https://backstage.io/docs/backend-system/core-services/logger
this.#logger.info('Created new todo item', { id, title, createdBy });
return newTodo;
}
async listTodos(): Promise<{ items: TodoItem[] }> {
return { items: Array.from(this.#storedTodos) };
}
async getTodo(request: { id: string }): Promise<TodoItem> {
const todo = this.#storedTodos.find(item => item.id === request.id);
if (!todo) {
throw new NotFoundError(`No todo found with id '${request.id}'`);
}
return todo;
}
}
export const todoListServiceRef = createServiceRef<Expand<TodoListService>>({
id: 'todo.list',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {
logger: coreServices.logger,
catalog: catalogServiceRef,
},
async factory(deps) {
return TodoListService.create(deps);
},
}),
});
@@ -1,92 +0,0 @@
import { LoggerService } from '@backstage/backend-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import crypto from 'node:crypto';
import { TodoItem, TodoListService } from './types';
// TEMPLATE NOTE:
// This is a simple in-memory todo list store. It is recommended to use a
// database to store data in a real application. See the database service
// documentation for more information on how to do this:
// https://backstage.io/docs/backend-system/core-services/database
export async function createTodoListService({
logger,
catalog,
}: {
logger: LoggerService;
catalog: typeof catalogServiceRef.T;
}): Promise<TodoListService> {
logger.info('Initializing TodoListService');
const storedTodos = new Array<TodoItem>();
return {
async createTodo(input, options) {
let title = input.title;
// TEMPLATE NOTE:
// A common pattern for Backstage plugins is to pass an entity reference
// from the frontend to then fetch the entire entity from the catalog in the
// backend plugin.
if (input.entityRef) {
// TEMPLATE NOTE:
// Cross-plugin communication uses service-to-service authentication. The
// `AuthService` lets you generate a token that is valid for communication
// with the target plugin only. You must also provide credentials for the
// identity that you are making the request on behalf of.
//
// If you want to make a request using the plugin backend's own identity,
// you can access it via the `auth.getOwnServiceCredentials()` method.
// Beware that this bypasses any user permission checks.
const entity = await catalog.getEntityByRef(input.entityRef, options);
if (!entity) {
throw new NotFoundError(
`No entity found for ref '${input.entityRef}'`,
);
}
// TEMPLATE NOTE:
// Here you could read any form of data from the entity. A common use case
// is to read the value of a custom annotation for your plugin. You can
// read more about how to add custom annotations here:
// https://backstage.io/docs/features/software-catalog/extending-the-model#adding-a-new-annotation
//
// In this example we just use the entity title to decorate the todo item.
const entityDisplay = entity.metadata.title ?? input.entityRef;
title = `[${entityDisplay}] ${input.title}`;
}
const id = crypto.randomUUID();
const createdBy = options.credentials.principal.userEntityRef;
const newTodo = {
title,
id,
createdBy,
createdAt: new Date().toISOString(),
};
storedTodos.push(newTodo);
// TEMPLATE NOTE:
// The second argument of the logger methods can be used to pass
// structured metadata. You can read more about the logger service here:
// https://backstage.io/docs/backend-system/core-services/logger
logger.info('Created new todo item', { id, title, createdBy });
return newTodo;
},
async listTodos() {
return { items: Array.from(storedTodos) };
},
async getTodo(request: { id: string }) {
const todo = storedTodos.find(item => item.id === request.id);
if (!todo) {
throw new NotFoundError(`No todo found with id '${request.id}'`);
}
return todo;
},
};
}
@@ -1 +0,0 @@
export { createTodoListService } from './createTodoListService';
@@ -1,27 +0,0 @@
import {
BackstageCredentials,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
export interface TodoItem {
title: string;
id: string;
createdBy: string;
createdAt: string;
}
export interface TodoListService {
createTodo(
input: {
title: string;
entityRef?: string;
},
options: {
credentials: BackstageCredentials<BackstageUserPrincipal>;
},
): Promise<TodoItem>;
listTodos(): Promise<{ items: TodoItem[] }>;
getTodo(request: { id: string }): Promise<TodoItem>;
}