Merge pull request #31021 from backstage/rugvip/backend-template
cli: update backend plugin template with new service pattern
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Updated the backend plugin template to use a new pattern for the `TodoListService` that reduces boilerplate.
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
});
|
||||
-92
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user