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

cli: update backend plugin template
This commit is contained in:
Patrik Oldsberg
2024-10-14 15:29:44 +02:00
committed by GitHub
19 changed files with 460 additions and 97 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
The backend plugin template for the `new` command has been updated to provide more guidance and use a more modern structure.
+6
View File
@@ -163,11 +163,17 @@
"@backstage/backend-common": "^0.25.0",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@backstage/theme": "workspace:^",
"@rspack/core": "^1.0.10",
@@ -92,12 +92,16 @@ describe('backendPlugin factory', () => {
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating index.ts.hbs',
'templating index.ts.hbs',
'templating package.json.hbs',
'templating plugin.ts.hbs',
'templating plugin.test.ts.hbs',
'copying index.ts',
'copying setupTests.ts',
'copying router.test.ts',
'copying router.ts',
'templating plugin.ts.hbs',
'copying router.test.ts',
'copying createTodoListService.ts',
'copying types.ts',
'Installing:',
`moving plugins${sep}test-backend`,
'backend adding dependency',
+6
View File
@@ -34,16 +34,19 @@ leaving any imports in place.
*/
import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json';
import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json';
import { version as catalogClient } from '../../../../packages/catalog-client/package.json';
import { version as cli } from '../../../../packages/cli/package.json';
import { version as config } from '../../../../packages/config/package.json';
import { version as coreAppApi } from '../../../../packages/core-app-api/package.json';
import { version as coreComponents } from '../../../../packages/core-components/package.json';
import { version as corePluginApi } from '../../../../packages/core-plugin-api/package.json';
import { version as devUtils } from '../../../../packages/dev-utils/package.json';
import { version as errors } from '../../../../packages/errors/package.json';
import { version as testUtils } from '../../../../packages/test-utils/package.json';
import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json';
import { version as authBackend } from '../../../../plugins/auth-backend/package.json';
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 backendDefaults } from '../../../../packages/backend-defaults/package.json';
@@ -51,18 +54,21 @@ export const packageVersions: Record<string, string> = {
'@backstage/backend-defaults': backendDefaults,
'@backstage/backend-plugin-api': backendPluginApi,
'@backstage/backend-test-utils': backendTestUtils,
'@backstage/catalog-client': catalogClient,
'@backstage/cli': cli,
'@backstage/config': config,
'@backstage/core-app-api': coreAppApi,
'@backstage/core-components': coreComponents,
'@backstage/core-plugin-api': corePluginApi,
'@backstage/dev-utils': devUtils,
'@backstage/errors': errors,
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/plugin-scaffolder-node': scaffolderNode,
'@backstage/plugin-auth-backend': authBackend,
'@backstage/plugin-auth-backend-module-guest-provider':
authBackendModuleGuestProvider,
'@backstage/plugin-catalog-node': catalogNode,
};
export function findVersion() {
@@ -1,14 +1,28 @@
# {{id}}
Welcome to the {{id}} backend plugin!
This plugin backend was templated using the Backstage CLI. You should replace this text with a description of your plugin backend.
_This plugin was created through the Backstage CLI_
## Installation
## Getting started
This plugin is installed via the `{{name}}` package. To install it to your backend package, run the following command:
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn
start` in the root directory, and then navigating to [/{{id}}/health](http://localhost:7007/api/{{id}}/health).
```bash
# From your root directory
yarn --cwd packages/backend add {{name}}
```
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
Then add the plugin to your backend in `packages/backend/src/index.ts`:
```ts
const backend = createBackend();
// ...
backend.add(import('{{name}}'));
```
## Development
This plugin backend can be started in a standalone mode from directly in this
package with `yarn start`. It is a limited setup that is most convenient when
developing the plugin backend itself.
If you want to run the entire project, including the frontend, run `yarn dev` from the root directory.
@@ -1,9 +0,0 @@
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(import('../src'));
backend.start();
@@ -0,0 +1,60 @@
import { createBackend } from '@backstage/backend-defaults';
import { mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
// TEMPLATE NOTE:
// This is the development setup for your plugin that wires up a
// minimal backend that can use both real and mocked plugins and services.
//
// Start up the backend by running `yarn start` in the package directory.
// Once it's up and running, try out the following requests:
//
// Create a new todo item, standalone or for the sample component:
//
// curl http://localhost:7007/api/{{id}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo"}'
// curl http://localhost:7007/api/{{id}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo", "entityRef": "component:default/sample"}'
//
// List TODOs:
//
// curl http://localhost:7007/api/{{id}}/todos
//
// Explicitly make an unauthenticated request, or with service auth:
//
// curl http://localhost:7007/api/{{id}}/todos -H 'Authorization: Bearer mock-none-token'
// curl http://localhost:7007/api/{{id}}/todos -H 'Authorization: Bearer mock-service-token'
const backend = createBackend();
// TEMPLATE NOTE:
// Mocking the auth and httpAuth service allows you to call your plugin API without
// having to authenticate.
//
// If you want to use real auth, you can install the following instead:
// backend.add(import('@backstage/plugin-auth-backend'));
// backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(mockServices.auth.factory());
backend.add(mockServices.httpAuth.factory());
// TEMPLATE NOTE:
// Rather than using a real catalog you can use a mock with a fixed set of entities.
backend.add(
catalogServiceMock.factory({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'sample',
title: 'Sample Component',
},
spec: {
type: 'service',
},
},
],
}),
);
backend.add(import('../src'));
backend.start();
@@ -30,19 +30,19 @@
"dependencies": {
"@backstage/backend-defaults": "{{versionQuery '@backstage/backend-defaults'}}",
"@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}",
"@backstage/catalog-client": "{{versionQuery '@backstage/catalog-client'}}",
"@backstage/errors": "{{versionQuery '@backstage/errors'}}",
"@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'}}",
"node-fetch": "{{versionQuery 'node-fetch' '2.6.7'}}"
"zod": "{{versionQuery 'zod' '3.22.4'}}"
},
"devDependencies": {
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/plugin-auth-backend": "{{versionQuery '@backstage/plugin-auth-backend'}}",
"@backstage/plugin-auth-backend-module-guest-provider": "{{versionQuery '@backstage/plugin-auth-backend-module-guest-provider'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.12'}}",
"supertest": "{{versionQuery 'supertest' '6.2.4'}}",
"msw": "{{versionQuery 'msw' '2.3.1'}}"
"supertest": "{{versionQuery 'supertest' '6.2.4'}}"
},
"files": [
"dist"
@@ -1,2 +1 @@
export * from './service/router';
export { {{pluginVar}} as default } from './plugin';
@@ -0,0 +1,85 @@
import {
mockCredentials,
startTestBackend,
} from '@backstage/backend-test-utils';
import { {{pluginVar}} } from './plugin';
import request from 'supertest';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
// TEMPLATE NOTE:
// Plugin tests are integration tests for your plugin, ensuring that all pieces
// work together end-to-end. You can still mock injected backend services
// however, just like anyone who installs your plugin might replace the
// services with their own implementations.
describe('plugin', () => {
it('should create and read TODO items', async () => {
const { server } = await startTestBackend({
features: [{{pluginVar}}],
});
await request(server).get('/api/{{id}}/todos').expect(200, {
items: [],
});
const createRes = await request(server)
.post('/api/{{id}}/todos')
.send({ title: 'My Todo' });
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual({
id: expect.any(String),
title: 'My Todo',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: expect.any(String),
});
const createdTodoItem = createRes.body;
await request(server)
.get('/api/{{id}}/todos')
.expect(200, {
items: [createdTodoItem],
});
await request(server)
.get(`/api/{{id}}/todos/${createdTodoItem.id}`)
.expect(200, createdTodoItem);
});
it('should create TODO item with catalog information', async () => {
const { server } = await startTestBackend({
features: [
{{pluginVar}},
catalogServiceMock.factory({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
namespace: 'default',
title: 'My Component',
},
spec: {
type: 'service',
owner: 'me',
},
},
],
}),
],
});
const createRes = await request(server)
.post('/api/{{id}}/todos')
.send({ title: 'My Todo', entityRef: 'component:default/my-component' });
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual({
id: expect.any(String),
title: '[My Component] My Todo',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: expect.any(String),
});
});
});
@@ -2,7 +2,9 @@ import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import { createRouter } from './router';
import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha';
import { createTodoListService } from './services/TodoListService';
/**
* {{pluginVar}} backend plugin
@@ -14,25 +16,25 @@ export const {{pluginVar}} = createBackendPlugin({
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
config: coreServices.rootConfig,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
catalog: catalogServiceRef,
},
async init({
httpRouter,
logger,
config,
}) {
async init({ logger, auth, httpAuth, httpRouter, catalog }) {
const todoListService = await createTodoListService({
logger,
auth,
catalog,
});
httpRouter.use(
await createRouter({
logger,
config,
httpAuth,
todoListService,
}),
);
httpRouter.addAuthPolicy({
path: '/health',
allow: 'unauthenticated',
});
},
});
},
@@ -0,0 +1,67 @@
import {
mockCredentials,
mockErrorHandler,
mockServices,
} from '@backstage/backend-test-utils';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { TodoListService } from './services/TodoListService/types';
const mockTodoItem = {
title: 'Do the thing',
id: '123',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: new Date().toISOString(),
};
// TEMPLATE NOTE:
// 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>;
beforeEach(async () => {
todoListService = {
createTodo: jest.fn(),
listTodos: jest.fn(),
getTodo: jest.fn(),
};
const router = await createRouter({
httpAuth: mockServices.httpAuth(),
todoListService,
});
app = express();
app.use(router);
app.use(mockErrorHandler());
});
it('should create a TODO', async () => {
todoListService.createTodo.mockResolvedValue(mockTodoItem);
const response = await request(app).post('/todos').send({
title: 'Do the thing',
});
expect(response.status).toBe(201);
expect(response.body).toEqual(mockTodoItem);
});
it('should not allow unauthenticated requests to create a TODO', async () => {
todoListService.createTodo.mockResolvedValue(mockTodoItem);
// TEMPLATE NOTE:
// The HttpAuth mock service considers all requests to be authenticated as a
// mock user by default. In order to test other cases we need to explicitly
// pass an authorization header with mock credentials.
const response = await request(app)
.post('/todos')
.set('Authorization', mockCredentials.none.header())
.send({
title: 'Do the thing',
});
expect(response.status).toBe(401);
});
});
@@ -0,0 +1,51 @@
import { HttpAuthService } from '@backstage/backend-plugin-api';
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';
export async function createRouter({
httpAuth,
todoListService,
}: {
httpAuth: HttpAuthService;
todoListService: TodoListService;
}): Promise<express.Router> {
const router = Router();
router.use(express.json());
// TEMPLATE NOTE:
// Zod is a powerful library for data validation and recommended in particular
// for user-defined schemas. In this case we use it for input validation too.
//
// If you want to define a schema for your API we recommend using Backstage's
// OpenAPI tooling: https://backstage.io/docs/next/openapi/01-getting-started
const todoSchema = z.object({
title: z.string(),
entityRef: z.string().optional(),
});
router.post('/todos', async (req, res) => {
const parsed = todoSchema.safeParse(req.body);
if (!parsed.success) {
throw new InputError(parsed.error.toString());
}
const result = await todoListService.createTodo(parsed.data, {
credentials: await httpAuth.credentials(req, { allow: ['user'] }),
});
res.status(201).json(result);
});
router.get('/todos', async (_req, res) => {
res.json(await todoListService.listTodos());
});
router.get('/todos/:id', async (req, res) => {
res.json(await todoListService.getTodo({ id: req.params.id }));
});
return router;
}
@@ -1,30 +0,0 @@
import { mockServices } from '@backstage/backend-test-utils';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
config: mockServices.rootConfig(),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -1,28 +0,0 @@
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
import { LoggerService, RootConfigService } from '@backstage/backend-plugin-api';
import express from 'express';
import Router from 'express-promise-router';
export interface RouterOptions {
logger: LoggerService;
config: RootConfigService;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, config } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.json({ status: 'ok' });
});
const middleware = MiddlewareFactory.create({ logger, config });
router.use(middleware.error());
return router;
}
@@ -0,0 +1,98 @@
import { AuthService, LoggerService } from '@backstage/backend-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha';
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({
auth,
logger,
catalog,
}: {
auth: AuthService;
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 { token } = await auth.getPluginRequestToken({
onBehalfOf: options.credentials,
targetPluginId: 'catalog',
});
const entity = await catalog.getEntityByRef(input.entityRef, {
token,
});
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
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;
},
};
}
@@ -0,0 +1 @@
export { createTodoListService } from './createTodoListService';
@@ -0,0 +1,27 @@
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>;
}
+5
View File
@@ -3913,6 +3913,7 @@ __metadata:
"@backstage/backend-common": ^0.25.0
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli-common": "workspace:^"
"@backstage/cli-node": "workspace:^"
@@ -3925,6 +3926,10 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/eslint-plugin": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/release-manifests": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"