Merge pull request #15918 from backstage/freben/doctwo

Building Backend Plugins and Modules
This commit is contained in:
Fredrik Adelöw
2023-01-25 10:10:50 +01:00
committed by GitHub
2 changed files with 235 additions and 6 deletions
@@ -9,4 +9,237 @@ description: Building backend plugins and modules using the new backend system
> NOTE: If you have an existing backend and/or backend plugins that are not yet
> using the new backend system, see [migrating](./08-migrating.md).
# Overview
## Overview
Backend [plugins](../architecture/04-plugins.md) and
[modules](../architecture/06-modules.md), sometimes collectively referred to as
backend _features_, are the building blocks that adopters add to their
[backends](../architecture/02-backends.md).
## Plugins
A basic backend plugin might look as follows:
```ts
import {
createBackendPlugin,
coreServices,
} from '@backstage/backend-plugin-api';
import { createExampleRouter } from './router';
export const examplePlugin = createBackendPlugin({
id: 'example',
register(env) {
env.registerInit({
deps: {
// Declare dependencies to services that you want to consume
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
},
async init({
// Requested service instances get injected as per above
logger,
httpRouter,
}) {
// Perform your initialization and access the services as needed
const example = createExampleRouter(logger);
logger.info('Hello from example plugin');
httpRouter.use(example);
},
});
},
});
```
When you depend on `plugin` scoped services, you'll receive an instance of them
that's specific to your plugin. In the example above, the logger might tag
messages with your plugin ID, and the HTTP router might prefix API routes with
your plugin ID, depending on the implementation used.
See [the article on naming patterns](../architecture/07-naming-patterns.md) for
details on how to best choose names/IDs for plugins and related backend system
items.
## Modules
Backend modules are used to extend [plugins](../architecture/04-plugins.md) with
additional features or change existing behavior. They must always be installed
in the same backend instance as the plugin that they extend, and may only extend
a single plugin. Modules interact with their target plugin using the [extension
points](./05-extension-points.md) registered by the plugin, while also being
able to depend on the [services](../architecture/03-services.md) of that plugin.
That last point is worth reiterating: injected `plugin` scoped services will be
the exact
same ones as the target plugin will receive later, i.e. they will be scoped
using the target `pluginId` of the module.
A module depends on the extension points exported by the target plugin's library
package, for example `@backstage/plugin-catalog-node`, and does not directly
declare a dependency on the plugin package itself. This is to avoid a direct
dependency and potentially cause duplicate installations of the plugin package,
while duplicate installations of library packages should always be supported.
The following is an example of how to create a module that adds a new processor
using the `catalogProcessingExtensionPoint`:
```ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node';
import { MyCustomProcessor } from './MyCustomProcessor';
export const catalogModuleExampleCustomProcessor = createBackendModule({
moduleId: 'exampleCustomProcessor',
pluginId: 'catalog',
register(env) {
env.registerInit({
deps: {
catalog: catalogProcessingExtensionPoint,
logger: coreServices.logger,
},
async init({ catalog }) {
catalog.addProcessor(new MyCustomProcessor(logger));
},
});
},
});
```
See [the article on naming patterns](../architecture/07-naming-patterns.md) for
details on how to best choose names/IDs for modules and related backend system
items.
Notice that we're placing the extension point we want to interact with in the
`deps` option, while also depending on the logger service at the same time. When
initializing modules we can depend on both extension points and services
interchangeably. You can also depend on multiple extension points at once, in
case the implementation of the module requires it.
It is typically best to keep modules slim and to each only add a single new
feature. It is often the case that it is better to create two separate modules
rather than one that provides both features. The one limitation here is that
modules can not interact with each other and need to be self contained.
### HTTP Handlers
Since modules have access to the same services as the plugin they extend, they
are also able to register their own HTTP handlers. For more information about
the HTTP service, see [core services](../core-services/01-index.md). When
registering HTTP handlers, it is important to try to avoid any future conflict
with the plugin itself, or other modules. A recommended naming pattern is to
register the handlers under the `/modules/<module-id>` path, where `<module-id>`
is the kebab-case ID of the module, for example
`/modules/example-custom-processor/v1/validators`. In a standard backend setup
the full path would then be
`<backendUrl>/api/catalog/modules/example-custom-processor/v1/validators`.
### Database Access
The same applies for modules that perform their own migrations and interact with
the database. They will run on the same logical database instance as the target
plugin, so care must be taken to choose table names that do not risk colliding
with those of the plugin. A recommended naming pattern is `<package
name>__<table name>`, for example the `@backstage/backend-tasks` package creates
tables named `backstage_backend_tasks__<table>`. If you use the default [`Knex`
migration facilities](https://knexjs.org/guide/migrations.html), you will also
want to make sure that it uses similarly prefixed migration state tables for its
internal bookkeeping needs, so they do not collide with the main ones used by
the plugin itself. You can do this as follows:
```ts
await knex.migrate.latest({
directory: migrationsDir,
tableName: 'backstage_backend_tasks__knex_migrations',
});
```
## Customization
There are several ways of configuring and customizing plugins and modules.
### Extension Points
Whenever you want to allow modules to configure your plugin dynamically, for
example in the way that the catalog backend lets catalog modules inject
additional entity providers, you can use the extension points mechanism. This is
described in detail with code examples in [the extension points architecture
article](../architecture/05-extension-points.md).
### Configuration
Your plugin or module can leverage the app configuration to configure its own
internal behavior. You do this by adding a dependency on `coreServices.config`
and reading from that. This pattern is a good fit especially for customization
that needs to be different across environments.
```ts
import { coreServices } from '@backstage/backend-plugin-api';
export const examplePlugin = createBackendPlugin({
id: 'example',
register(env) {
env.registerInit({
deps: { config: coreServices.config },
async init({ config }) {
// Here you can read from the current config as you see fit, e.g.:
const value = config.getOptionalString('example.value');
},
});
},
});
```
Before adding custom configuration options, make sure to read [the configuration
docs](../../conf/index.md), in particular the section on [defining configuration
for your own plugins](../../conf/defining.md) which explains how to establish a
configuration schema for your specific plugin.
### Options
You'll have noted that the return values from `createBackendPlugin` and
`createBackendModule` are actually factory functions. These can be made to
accept options that shall be passed in at initialization time.
This pattern can be a good fit for fairly simple, static configuration values.
```ts
export interface ExampleOptions {
silent?: boolean;
}
export const examplePlugin = createBackendPlugin(
(options?: ExampleOptions) => ({
id: 'example',
register(env) {
env.registerInit({
deps: {
// Omitted dependencies but they remain the same as above
},
async init(
{
/* ... */
},
) {
// Here you can access the given options and act accordingly, e.g.:
if (!options?.silent) {
// ...
}
},
});
},
}),
);
```
The return type from `createBackendPlugin` and `createBackendModule` will mimic
this, resulting in a factory function that accepts an optional options object.
You can also make it required to pass in options, by removing the optionality
(the question mark on the options) above.
```ts
backend.add(examplePlugin({ silent: true }));
```
Use this pattern sparingly. There is a big convenience benefit in allowing
people to easily install backend plugins without having to always pass in a
large number of options, and these options cannot easily be made dynamic based
on the environment etc.
@@ -107,11 +107,7 @@ The base setup for such a test could look as follows:
```ts
// MyDatabaseClass.test.ts
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import {
MyDatabaseClass,
applyDatabaseMigrations,
type FooTableRow,
} from './MyDatabaseClass';
import { MyDatabaseClass, type FooTableRow } from './MyDatabaseClass';
describe('MyDatabaseClass', () => {
// Change this to the set of constants that you actually actively intend to