chore: refactor service and backend to use REST

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-01-12 15:39:03 +02:00
parent 13346970b4
commit 1a38100a8e
27 changed files with 407 additions and 359 deletions
+8 -1
View File
@@ -19,7 +19,9 @@ export default async function createPlugin(
return await createRouter({
logger: env.logger,
identity: env.identity,
notificationService: env.notificationService,
tokenManager: env.tokenManager,
database: env.database,
discovery: env.discovery,
});
}
```
@@ -37,3 +39,8 @@ async function main() {
apiRouter.use('/notifications', await notifications(notificationsEnv));
}
```
## Extending Notifications
The notifications can be extended with `NotificationProcessor`. These processors allow to decorate notifications
before they are sent or/and send the notifications to external services.
+22 -4
View File
@@ -4,25 +4,43 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import express from 'express';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { NotificationService } from '@backstage/plugin-notifications-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { TokenManager } from '@backstage/backend-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export type NotificationProcessor = {
decorate?(notification: Notification_2): Promise<Notification_2>;
send?(notification: Notification_2): Promise<void>;
};
// @public
export const notificationsPlugin: () => BackendFeature;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
catalog?: CatalogApi;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: DiscoveryApi;
// (undocumented)
identity: IdentityApi;
// (undocumented)
logger: Logger;
logger: LoggerService;
// (undocumented)
notificationService: NotificationService;
processors?: NotificationProcessor[];
// (undocumented)
tokenManager: TokenManager;
}
// (No @packageDocumentation comment for this package)
@@ -0,0 +1,37 @@
/*
* Copyright 2023 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.
*/
exports.up = async function up(knex) {
await knex.schema.createTable('notifications', table => {
table.uuid('id').primary();
table.string('userRef').notNullable();
table.string('title').notNullable();
table.text('description').notNullable();
table.text('link').notNullable();
table.text('icon').nullable();
table.text('image').nullable();
table.datetime('created').notNullable();
table.datetime('read').nullable();
table.boolean('saved').defaultTo(false).notNullable();
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('notifications');
};
@@ -24,14 +24,20 @@
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-notifications-common": "workspace:^",
"@backstage/plugin-notifications-node": "workspace:^",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^3.0.0",
"node-fetch": "^2.6.7",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -0,0 +1,128 @@
/*
* Copyright 2023 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 {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import {
NotificationGetOptions,
NotificationModifyOptions,
NotificationsStore,
} from './NotificationsStore';
import { Notification } from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-notifications-backend',
'migrations',
);
/** @public */
export class DatabaseNotificationsStore implements NotificationsStore {
private constructor(private readonly db: Knex) {}
static async create({
database,
skipMigrations,
}: {
database: PluginDatabaseManager;
skipMigrations?: boolean;
}): Promise<DatabaseNotificationsStore> {
const client = await database.getClient();
if (!database.migrations?.skip && !skipMigrations) {
await client.migrate.latest({
directory: migrationsDir,
});
}
return new DatabaseNotificationsStore(client);
}
private mapToInteger = (val: string | number | undefined): number => {
return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0;
};
private getNotificationsBaseQuery = (
options: NotificationGetOptions | NotificationModifyOptions,
) => {
const { user_ref, type } = options;
const query = this.db('notifications').where('userRef', user_ref);
if (type === 'unread') {
query.whereNull('read');
} else if (type === 'read') {
query.whereNotNull('read');
} else if (type === 'saved') {
query.where('saved', true);
}
if ('ids' in options && options.ids) {
query.whereIn('id', options.ids);
}
return query;
};
async getNotifications(options: NotificationGetOptions) {
const notificationQuery = this.getNotificationsBaseQuery(options);
const notifications = await notificationQuery.select('*');
return notifications;
}
async saveNotification(notification: Notification) {
await this.db.insert(notification).into('notifications');
}
async getStatus(options: NotificationGetOptions) {
const notificationQuery = this.getNotificationsBaseQuery(options);
const unreadQuery = await notificationQuery
.clone()
.whereNull('read')
.count('id as UNREAD')
.first();
const readQuery = await notificationQuery
.clone()
.whereNotNull('read')
.count('id as READ')
.first();
return {
unread: this.mapToInteger((unreadQuery as any)?.UNREAD),
read: this.mapToInteger((readQuery as any)?.READ),
};
}
async markRead(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ read: new Date() });
}
async markUnread(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ read: null });
}
async markSaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: true });
}
async markUnsaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: false });
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2023 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 {
Notification,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @public */
export type NotificationGetOptions = {
user_ref: string;
type?: NotificationType;
};
/** @public */
export type NotificationModifyOptions = {
ids: string[];
} & NotificationGetOptions;
/** @public */
export interface NotificationsStore {
getNotifications(options: NotificationGetOptions): Promise<Notification[]>;
saveNotification(notification: Notification): Promise<void>;
getStatus(options: NotificationGetOptions): Promise<NotificationStatus>;
markRead(options: NotificationModifyOptions): Promise<void>;
markUnread(options: NotificationModifyOptions): Promise<void>;
markSaved(options: NotificationModifyOptions): Promise<void>;
markUnsaved(options: NotificationModifyOptions): Promise<void>;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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.
*/
export * from './DatabaseNotificationsStore';
export * from './NotificationsStore';
@@ -15,3 +15,4 @@
*/
export * from './service/router';
export * from './plugin';
export type { NotificationProcessor } from './types';
+15 -6
View File
@@ -13,13 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { loggerToWinstonLogger } from '@backstage/backend-common';
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import { notificationService } from '@backstage/plugin-notifications-node';
/**
* Notifications backend plugin
@@ -34,14 +32,25 @@ export const notificationsPlugin = createBackendPlugin({
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
identity: coreServices.identity,
service: notificationService,
database: coreServices.database,
tokenManager: coreServices.tokenManager,
discovery: coreServices.discovery,
},
async init({ httpRouter, logger, identity, service }) {
async init({
httpRouter,
logger,
identity,
database,
tokenManager,
discovery,
}) {
httpRouter.use(
await createRouter({
logger: loggerToWinstonLogger(logger),
logger,
identity,
notificationService: service,
database,
tokenManager,
discovery,
}),
);
},
@@ -13,13 +13,32 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import {
DatabaseManager,
getVoidLogger,
PluginDatabaseManager,
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { NotificationService } from '@backstage/plugin-notifications-node';
import { ConfigReader } from '@backstage/config';
function createDatabase(): PluginDatabaseManager {
return DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
}),
).forPlugin('notifications');
}
describe('createRouter', () => {
let app: express.Express;
@@ -36,17 +55,23 @@ describe('createRouter', () => {
};
},
};
const mockedTokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
authenticate: jest.fn(),
};
const notificationServiceMock: jest.Mocked<Partial<NotificationService>> = {
getStore: jest.fn(),
send: jest.fn(),
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
identity: identityMock,
notificationService: notificationServiceMock as NotificationService,
database: createDatabase(),
tokenManager: mockedTokenManager,
discovery,
});
app = express().use(router);
});
@@ -13,36 +13,125 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorHandler } from '@backstage/backend-common';
import {
errorHandler,
PluginDatabaseManager,
TokenManager,
} from '@backstage/backend-common';
import express, { Request } from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
getBearerTokenFromAuthorizationHeader,
IdentityApi,
} from '@backstage/plugin-auth-node';
import {
DatabaseNotificationsStore,
NotificationGetOptions,
NotificationService,
} from '@backstage/plugin-notifications-node';
import { IdentityApi } from '@backstage/plugin-auth-node';
} from '../database';
import { v4 as uuid } from 'uuid';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import {
Entity,
isGroupEntity,
isUserEntity,
RELATION_HAS_MEMBER,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { NotificationProcessor } from '../types';
import { AuthenticationError } from '@backstage/errors';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
/** @public */
export interface RouterOptions {
logger: Logger;
logger: LoggerService;
identity: IdentityApi;
notificationService: NotificationService;
database: PluginDatabaseManager;
tokenManager: TokenManager;
discovery: DiscoveryApi;
catalog?: CatalogApi;
processors?: NotificationProcessor[];
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, notificationService, identity } = options;
const {
logger,
database,
identity,
discovery,
catalog,
tokenManager,
processors,
} = options;
const store = await notificationService.getStore();
const catalogClient =
catalog ?? new CatalogClient({ discoveryApi: discovery });
const store = await DatabaseNotificationsStore.create({ database });
const getUser = async (req: Request<unknown>) => {
const user = await identity.getIdentity({ request: req });
return user ? user.identity.userEntityRef : 'user:default/guest';
};
const authenticateService = async (req: Request<unknown>) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
if (!token) {
throw new AuthenticationError();
}
await tokenManager.authenticate(token);
};
const getUsersForEntityRef = async (
entityRef: string | string[],
): Promise<string[]> => {
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
const { token } = await tokenManager.getToken();
const entities = await catalogClient.getEntitiesByRefs(
{
entityRefs: refs,
},
{ token },
);
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
if (!entity) {
return [];
}
if (isUserEntity(entity)) {
return [stringifyEntityRef(entity)];
} else if (isGroupEntity(entity) && entity.relations) {
return entity.relations
.filter(
relation =>
relation.type === RELATION_HAS_MEMBER && relation.targetRef,
)
.map(r => r.targetRef);
} else if (!isGroupEntity(entity) && entity.spec?.owner) {
const owner = await catalogClient.getEntityByRef(
entity.spec.owner as string,
{ token },
);
if (owner) {
return mapEntity(owner);
}
}
return [];
};
const users: string[] = [];
for (const entity of entities.items) {
const u = await mapEntity(entity);
users.push(...u);
}
return users;
};
const router = Router();
router.use(express.json());
@@ -114,6 +203,57 @@ export async function createRouter(
res.status(200).send({ ids });
});
router.post('/notifications', async (req, res) => {
const { entityRef, title, description, link } = req.body;
const notifications = [];
let users = [];
try {
await authenticateService(req);
} catch (e) {
logger.error(`Failed to authenticate notification request ${e}`);
res.status(401).send();
return;
}
try {
users = await getUsersForEntityRef(entityRef);
} catch (e) {
logger.error(`Failed to resolve notification receiver ${e}`);
res.status(400).send();
return;
}
const baseNotification = {
id: uuid(),
title,
description,
link,
created: new Date(),
saved: false,
};
for (const user of users) {
let notification = { ...baseNotification, userRef: user };
for (const processor of processors ?? []) {
notification = processor.decorate
? await processor.decorate(notification)
: notification;
}
await store.saveNotification(notification);
for (const processor of processors ?? []) {
if (processor.send) {
processor.send(notification);
}
}
notifications.push(notification);
// TODO: Signal service
}
res.send(notifications);
});
router.use(errorHandler());
return router;
}
@@ -15,9 +15,10 @@
*/
import {
createServiceBuilder,
HostDiscovery,
loadBackendConfig,
PluginDatabaseManager,
PluginEndpointDiscovery,
ServerTokenManager,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
@@ -25,7 +26,11 @@ import { createRouter } from './router';
import Knex from 'knex';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Request } from 'express';
import { NotificationService } from '@backstage/plugin-notifications-node';
import {
CatalogApi,
CatalogRequestOptions,
GetEntitiesByRefsRequest,
} from '@backstage/catalog-client';
export interface ServerOptions {
port: number;
@@ -42,26 +47,34 @@ export async function startStandaloneServer(
const config = await loadBackendConfig({ logger, argv: process.argv });
const db = Knex(config.get('backend.database'));
const tokenManager = ServerTokenManager.fromConfig(config, {
logger,
});
const discovery = HostDiscovery.fromConfig(config);
const dbMock: PluginDatabaseManager = {
async getClient() {
return db;
},
};
const discoveryMock: PluginEndpointDiscovery = {
async getBaseUrl(pluginId: string) {
return `http://localhost:7007/api/${pluginId}`;
const catalogApi = {
async getEntitiesByRefs(
_request: GetEntitiesByRefsRequest,
__options?: CatalogRequestOptions,
) {
return {
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: 'user', namespace: 'default' },
spec: {},
},
],
};
},
async getExternalBaseUrl(pluginId: string) {
return `http://localhost:7007/api/${pluginId}`;
},
};
const notificationService = NotificationService.create({
database: dbMock,
discovery: discoveryMock,
});
} as Partial<CatalogApi> as CatalogApi;
const identityMock: IdentityApi = {
async getIdentity({ request }: { request: Request<unknown> }) {
@@ -80,7 +93,10 @@ export async function startStandaloneServer(
const router = await createRouter({
logger,
identity: identityMock,
notificationService,
database: dbMock,
catalog: catalogApi,
discovery,
tokenManager,
});
let service = createServiceBuilder(module)
@@ -0,0 +1,34 @@
/*
* Copyright 2023 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 { Notification } from '@backstage/plugin-notifications-common';
/** @public */
export type NotificationProcessor = {
/**
* Decorate notification before sending it
*
* @param notification - The notification to decorate
* @returns The same notification or a modified version of it
*/
decorate?(notification: Notification): Promise<Notification>;
/**
* Send notification using this processor.
*
* @param notification - The notification to send
*/
send?(notification: Notification): Promise<void>;
};