From e1adce4bd3c5dbcc7640448fd6d220d586c1e9a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Aug 2025 10:54:10 +0200 Subject: [PATCH] cli: update backend plugin template with new service pattern Signed-off-by: Patrik Oldsberg --- .changeset/puny-books-fetch.md | 5 + packages/cli/src/lib/version.ts | 2 + .../templates/backend-plugin/package.json.hbs | 1 + .../backend-plugin/src/plugin.test.ts.hbs | 44 +++++ .../backend-plugin/src/plugin.ts.hbs | 15 +- .../backend-plugin/src/router.test.ts | 12 +- .../templates/backend-plugin/src/router.ts | 12 +- .../src/services/TodoListService.ts | 155 ++++++++++++++++++ .../TodoListService/createTodoListService.ts | 92 ----------- .../src/services/TodoListService/index.ts | 1 - .../src/services/TodoListService/types.ts | 27 --- 11 files changed, 223 insertions(+), 143 deletions(-) create mode 100644 .changeset/puny-books-fetch.md create mode 100644 packages/cli/templates/backend-plugin/src/services/TodoListService.ts delete mode 100644 packages/cli/templates/backend-plugin/src/services/TodoListService/createTodoListService.ts delete mode 100644 packages/cli/templates/backend-plugin/src/services/TodoListService/index.ts delete mode 100644 packages/cli/templates/backend-plugin/src/services/TodoListService/types.ts diff --git a/.changeset/puny-books-fetch.md b/.changeset/puny-books-fetch.md new file mode 100644 index 0000000000..641285869f --- /dev/null +++ b/.changeset/puny-books-fetch.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated the backend plugin template to use a new pattern for the `TodoListService` that reduces boilerplate. diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index 696728795a..5a2a8c7838 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -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 = { @@ -71,6 +72,7 @@ export const packageVersions: Record = { '@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, diff --git a/packages/cli/templates/backend-plugin/package.json.hbs b/packages/cli/templates/backend-plugin/package.json.hbs index d8fcb9a9d6..acc26480c9 100644 --- a/packages/cli/templates/backend-plugin/package.json.hbs +++ b/packages/cli/templates/backend-plugin/package.json.hbs @@ -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'}}", diff --git a/packages/cli/templates/backend-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/backend-plugin/src/plugin.test.ts.hbs index e105c6ebd2..45a2312289 100644 --- a/packages/cli/templates/backend-plugin/src/plugin.test.ts.hbs +++ b/packages/cli/templates/backend-plugin/src/plugin.test.ts.hbs @@ -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' }, + }); + }); }); diff --git a/packages/cli/templates/backend-plugin/src/plugin.ts.hbs b/packages/cli/templates/backend-plugin/src/plugin.ts.hbs index 4becc31057..e5205fabcb 100644 --- a/packages/cli/templates/backend-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/backend-plugin/src/plugin.ts.hbs @@ -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, }), ); }, diff --git a/packages/cli/templates/backend-plugin/src/router.test.ts b/packages/cli/templates/backend-plugin/src/router.test.ts index 86c91aab8e..a374beca8b 100644 --- a/packages/cli/templates/backend-plugin/src/router.test.ts +++ b/packages/cli/templates/backend-plugin/src/router.test.ts @@ -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; + let todoList: jest.Mocked; 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 diff --git a/packages/cli/templates/backend-plugin/src/router.ts b/packages/cli/templates/backend-plugin/src/router.ts index 4c2ca49b67..fba8a24cb2 100644 --- a/packages/cli/templates/backend-plugin/src/router.ts +++ b/packages/cli/templates/backend-plugin/src/router.ts @@ -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 { 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; diff --git a/packages/cli/templates/backend-plugin/src/services/TodoListService.ts b/packages/cli/templates/backend-plugin/src/services/TodoListService.ts new file mode 100644 index 0000000000..e1b2d3c6de --- /dev/null +++ b/packages/cli/templates/backend-plugin/src/services/TodoListService.ts @@ -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(); + + 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; + }, + ): Promise { + 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 { + 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>({ + id: 'todo.list', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + catalog: catalogServiceRef, + }, + async factory(deps) { + return TodoListService.create(deps); + }, + }), +}); diff --git a/packages/cli/templates/backend-plugin/src/services/TodoListService/createTodoListService.ts b/packages/cli/templates/backend-plugin/src/services/TodoListService/createTodoListService.ts deleted file mode 100644 index 79bf95121a..0000000000 --- a/packages/cli/templates/backend-plugin/src/services/TodoListService/createTodoListService.ts +++ /dev/null @@ -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 { - logger.info('Initializing TodoListService'); - - const storedTodos = new Array(); - - 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; - }, - }; -} diff --git a/packages/cli/templates/backend-plugin/src/services/TodoListService/index.ts b/packages/cli/templates/backend-plugin/src/services/TodoListService/index.ts deleted file mode 100644 index 1bc52001a8..0000000000 --- a/packages/cli/templates/backend-plugin/src/services/TodoListService/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createTodoListService } from './createTodoListService'; diff --git a/packages/cli/templates/backend-plugin/src/services/TodoListService/types.ts b/packages/cli/templates/backend-plugin/src/services/TodoListService/types.ts deleted file mode 100644 index af895981a0..0000000000 --- a/packages/cli/templates/backend-plugin/src/services/TodoListService/types.ts +++ /dev/null @@ -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; - }, - ): Promise; - - listTodos(): Promise<{ items: TodoItem[] }>; - - getTodo(request: { id: string }): Promise; -}