feat(entity-feedback): implement plugin

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2023-01-19 15:00:48 -05:00
parent 3efd4da23c
commit a3c86a7ed2
69 changed files with 4381 additions and 3 deletions
@@ -0,0 +1,22 @@
/*
* 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.
*/
/**
* Entity Feedback backend plugin
*
* @packageDocumentation
*/
export * from './service/router';
@@ -0,0 +1,33 @@
/*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,310 @@
/*
* 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Knex } from 'knex';
import { DatabaseHandler } from './DatabaseHandler';
describe('DatabaseHandler', () => {
const databases = TestDatabases.create();
async function createDatabaseHandler(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
return {
knex,
dbHandler: await DatabaseHandler.create({ database: knex }),
};
}
describe.each(databases.eachSupportedId())(
'%p',
databaseId => {
let knex: Knex;
let dbHandler: DatabaseHandler;
beforeEach(async () => {
({ knex, dbHandler } = await createDatabaseHandler(databaseId));
}, 30000);
afterEach(async () => {
// Clean up after each test
await knex('ratings').del();
await knex('responses').del();
}, 30000);
it('getAllRatedEntities', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
},
{
entity_ref: 'component:default/library',
user_ref: 'user:default/bar',
rating: 'LIKE',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
},
]);
const entities = await dbHandler.getAllRatedEntities();
expect(entities.length).toEqual(3);
expect(entities).toEqual(
expect.arrayContaining([
'component:default/service',
'component:default/library',
'component:default/website',
]),
);
});
it('getRatingsAggregates', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/test',
rating: 'DISLIKE',
timestamp: '2004-11-20 08:23:54',
},
]);
let ratings = await dbHandler.getRatingsAggregates([
'component:default/service',
'component:default/website',
]);
expect(ratings.length).toEqual(3);
expect(ratings).toEqual(
expect.arrayContaining([
{
entityRef: 'component:default/service',
rating: 'LIKE',
count: 2,
},
{
entityRef: 'component:default/website',
rating: 'DISLIKE',
count: 1,
},
{
entityRef: 'component:default/website',
rating: 'LIKE',
count: 1,
},
]),
);
ratings = await dbHandler.getRatingsAggregates([
'component:default/website',
]);
expect(ratings.length).toEqual(2);
expect(ratings).toEqual(
expect.arrayContaining([
{
entityRef: 'component:default/website',
rating: 'DISLIKE',
count: 1,
},
{
entityRef: 'component:default/website',
rating: 'LIKE',
count: 1,
},
]),
);
});
it('recordRating', async () => {
const newRating = {
userRef: 'user:default/me',
entityRef: 'component:default/service',
rating: 'LIKE',
};
await dbHandler.recordRating(newRating);
const ratings = await knex('ratings').select();
expect(ratings.length).toEqual(1);
expect(ratings[0]).toEqual(
expect.objectContaining({
user_ref: newRating.userRef,
entity_ref: newRating.entityRef,
rating: newRating.rating,
timestamp: expect.anything(),
}),
);
});
it('getRatings', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
]);
const ratings = await dbHandler.getRatings('component:default/service');
expect(ratings.length).toEqual(2);
expect(ratings).toEqual(
expect.arrayContaining([
{ userRef: 'user:default/foo', rating: 'LIKE' },
{ userRef: 'user:default/bar', rating: 'LIKE' },
]),
);
});
it('recordResponse', async () => {
const newResponse = {
userRef: 'user:default/me',
entityRef: 'component:default/service',
response: 'blah',
comments: 'here is some feedback',
consent: false,
};
await dbHandler.recordResponse(newResponse);
const responses = await knex('responses').select();
expect(responses.length).toEqual(1);
expect({
...responses[0],
consent: Boolean(responses[0].consent),
}).toEqual(
expect.objectContaining({
user_ref: newResponse.userRef,
entity_ref: newResponse.entityRef,
response: newResponse.response,
comments: newResponse.comments,
consent: newResponse.consent,
timestamp: expect.anything(),
}),
);
});
it('getResponses', async () => {
await knex('responses').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
response: 'blah',
comments: 'here is some feedback',
consent: true,
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
response: 'cool',
comments: 'no comment',
consent: true,
timestamp: '2004-11-20 08:23:54',
},
]);
const responses = await dbHandler.getResponses(
'component:default/service',
);
expect(responses.length).toEqual(2);
expect(responses).toEqual(
expect.arrayContaining([
{
userRef: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
},
{
userRef: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
},
]),
);
});
},
60000,
);
});
@@ -0,0 +1,161 @@
/*
* 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 { resolvePackagePath } from '@backstage/backend-common';
import { Rating, Response } from '@backstage/plugin-entity-feedback-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-entity-feedback-backend',
'migrations',
);
type RatingsAggregatesResult = {
entityRef: string;
rating: string;
count: number;
}[];
type Options = {
database: Knex;
};
/**
* @public
*/
export class DatabaseHandler {
static async create(options: Options): Promise<DatabaseHandler> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseHandler(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async getAllRatedEntities(): Promise<string[]> {
return (await this.database('ratings').distinct('entity_ref')).map(
({ entity_ref }) => entity_ref,
);
}
async getRatingsAggregates(
entityRefs: string[],
): Promise<RatingsAggregatesResult> {
return (
await this.database('ratings')
.whereIn('ratings.entity_ref', entityRefs)
.innerJoin(
this.database('ratings')
.as('latest_ratings')
.select('entity_ref', 'user_ref')
.max('timestamp', { as: 'timestamp' })
.whereIn('entity_ref', entityRefs)
.groupBy('entity_ref', 'user_ref'),
function joinClause() {
this.on('ratings.entity_ref', '=', 'latest_ratings.entity_ref')
.andOn('ratings.user_ref', '=', 'latest_ratings.user_ref')
.andOn('ratings.timestamp', '=', 'latest_ratings.timestamp');
},
)
.select('ratings.entity_ref', 'rating')
.count('ratings.user_ref as count')
.groupBy('ratings.entity_ref', 'rating')
).map(({ entity_ref, rating, count }) => ({
entityRef: entity_ref as string,
rating: rating as string,
count: Number(count),
}));
}
async recordRating(rating: Rating) {
await this.database('ratings').insert({
entity_ref: rating.entityRef,
rating: rating.rating,
user_ref: rating.userRef,
});
}
async getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]> {
return (
await this.database('ratings')
.where('entity_ref', entityRef)
.innerJoin(
this.database('ratings')
.as('latest_ratings')
.select('user_ref')
.max('timestamp', { as: 'timestamp' })
.where('entity_ref', entityRef)
.groupBy('user_ref'),
function joinClause() {
this.on('ratings.user_ref', '=', 'latest_ratings.user_ref').andOn(
'ratings.timestamp',
'=',
'latest_ratings.timestamp',
);
},
)
.select('ratings.user_ref', 'rating')
).map(rating => ({ userRef: rating.user_ref, rating: rating.rating }));
}
async recordResponse(response: Response) {
await this.database('responses').insert({
entity_ref: response.entityRef,
response: response.response,
comments: response.comments,
consent: response.consent,
user_ref: response.userRef,
});
}
async getResponses(
entityRef: string,
): Promise<Omit<Response, 'entityRef'>[]> {
return (
await this.database('responses')
.where('entity_ref', entityRef)
.innerJoin(
this.database('responses')
.as('latest_responses')
.select('user_ref')
.max('timestamp', { as: 'timestamp' })
.where('entity_ref', entityRef)
.groupBy('user_ref'),
function joinClause() {
this.on(
'responses.user_ref',
'=',
'latest_responses.user_ref',
).andOn('responses.timestamp', '=', 'latest_responses.timestamp');
},
)
.select('responses.user_ref', 'response', 'comments', 'consent')
).map(response => ({
userRef: response.user_ref,
response: response.response,
comments: response.comments,
consent: Boolean(response.consent),
}));
}
}
@@ -0,0 +1,290 @@
/*
* 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 {
DatabaseManager,
getVoidLogger,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { IdentityApi } from '@backstage/plugin-auth-node';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
const sampleOwnedEntities = [
{
kind: 'component',
metadata: {
namespace: 'default',
name: 'foo',
title: 'Foo Component',
},
},
{
kind: 'component',
metadata: {
namespace: 'default',
name: 'bar',
title: 'Bar Component',
},
},
];
const sampleEntities = [
...sampleOwnedEntities,
{
kind: 'user',
metadata: {
namespace: 'default',
name: 'foo',
},
},
null,
{
kind: 'user',
metadata: {
namespace: 'default',
name: 'bar',
},
},
];
const mockGetEntties = jest
.fn()
.mockImplementation(async () => ({ items: sampleOwnedEntities }));
const mockGetEnttiesByRefs = jest
.fn()
.mockImplementation(async () => ({ items: sampleEntities }));
jest.mock('@backstage/catalog-client', () => ({
CatalogClient: jest.fn().mockImplementation(() => ({
getEntities: mockGetEntties,
getEntitiesByRefs: mockGetEnttiesByRefs,
})),
}));
jest.mock('@backstage/plugin-auth-node', () => ({
getBearerTokenFromAuthorizationHeader: () => 'token',
}));
const mockRatings = [
{ userRef: 'user:default/foo', rating: 'LIKE' },
{ userRef: 'user:default/bar', rating: 'LIKE' },
{ userRef: 'user:default/test', rating: 'DISLIKE' },
];
const mockResponses = [
{
userRef: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
},
{
userRef: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
},
{
userRef: 'user:default/test',
response: 'err',
comments: 'no comment',
consent: false,
},
];
const mockDbHandler = {
getAllRatedEntities: jest
.fn()
.mockImplementation(async () => [
'component:default/foo',
'component:default/bar',
'component:default/test',
]),
getRatingsAggregates: jest.fn().mockImplementation(async () => [
{ entityRef: 'component:default/foo', rating: 'LIKE', count: 3 },
{ entityRef: 'component:default/foo', rating: 'DISLIKE', count: 1 },
{ entityRef: 'component:default/bar', rating: 'LIKE', count: 5 },
]),
recordRating: jest.fn().mockImplementation(async () => {}),
getRatings: jest.fn().mockImplementation(async () => mockRatings),
recordResponse: jest.fn().mockImplementation(async () => {}),
getResponses: jest.fn().mockImplementation(async () => mockResponses),
};
jest.mock('./DatabaseHandler', () => ({
DatabaseHandler: { create: async () => mockDbHandler },
}));
describe('createRouter', () => {
let app: express.Express;
const createDatabase = () =>
DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
}),
).forPlugin('entity-feedback');
const mockIdentityClient = {
getIdentity: jest.fn().mockImplementation(async () => ({
identity: { userEntityRef: 'user:default/me' },
})),
} as unknown as IdentityApi;
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
beforeEach(async () => {
const router = await createRouter({
database: createDatabase(),
discovery,
identity: mockIdentityClient,
logger: getVoidLogger(),
});
app = express().use(router);
jest.clearAllMocks();
});
describe('GET /ratings', () => {
it('should get ratings for all entities correctly', async () => {
const response = await request(app).get('/ratings').send();
expect(mockDbHandler.getAllRatedEntities).toHaveBeenCalled();
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
sampleEntities
.filter(Boolean)
.map((ent: any) => stringifyEntityRef(ent)),
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
entityRef: 'component:default/foo',
entityTitle: 'Foo Component',
ratings: { LIKE: 3, DISLIKE: 1 },
},
{
entityRef: 'component:default/bar',
entityTitle: 'Bar Component',
ratings: { LIKE: 5 },
},
]);
});
it('should get ratings for all owned entities correctly', async () => {
const response = await request(app)
.get('/ratings?ownerRef=group:default/test-team')
.send();
expect(mockGetEntties).toHaveBeenCalledWith(
expect.objectContaining({
filter: { 'relations.ownedBy': 'group:default/test-team' },
}),
{ token: 'token' },
);
expect(mockDbHandler.getAllRatedEntities).not.toHaveBeenCalled();
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
sampleOwnedEntities.map((ent: any) => stringifyEntityRef(ent)),
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
entityRef: 'component:default/foo',
entityTitle: 'Foo Component',
ratings: { LIKE: 3, DISLIKE: 1 },
},
{
entityRef: 'component:default/bar',
entityTitle: 'Bar Component',
ratings: { LIKE: 5 },
},
]);
});
});
describe('POST /ratings/:entityRef', () => {
it('should record a rating correctly', async () => {
const body = { rating: 'LIKE' };
const response = await request(app)
.post('/ratings/component%3Adefault%2Fservice')
.send(body);
expect(mockDbHandler.recordRating).toHaveBeenCalledWith({
entityRef: 'component:default/service',
userRef: 'user:default/me',
...body,
});
expect(response.status).toEqual(201);
});
});
describe('GET /ratings/:entityRef', () => {
it('should get ratings for an entity correctly', async () => {
const response = await request(app)
.get('/ratings/component%3Adefault%2Fservice')
.send();
expect(mockDbHandler.getRatings).toHaveBeenCalledWith(
'component:default/service',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(
mockRatings.filter(r => r.userRef !== 'user:default/test'),
);
});
});
describe('POST /responses/:entityRef', () => {
it('should record a response correctly', async () => {
const body = { response: 'blah', comments: 'feedback', consent: true };
const response = await request(app)
.post('/responses/component%3Adefault%2Fservice')
.send(body);
expect(mockDbHandler.recordResponse).toHaveBeenCalledWith({
entityRef: 'component:default/service',
userRef: 'user:default/me',
...body,
});
expect(response.status).toEqual(201);
});
});
describe('GET /responses/:entityRef', () => {
it('should get responses for an entity correctly', async () => {
const response = await request(app)
.get('/responses/component%3Adefault%2Fservice')
.send();
expect(mockDbHandler.getResponses).toHaveBeenCalledWith(
'component:default/service',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(
mockResponses.filter(r => r.userRef !== 'user:default/test'),
);
});
});
});
@@ -0,0 +1,215 @@
/*
* 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 {
errorHandler,
PluginDatabaseManager,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
getBearerTokenFromAuthorizationHeader,
IdentityApi,
} from '@backstage/plugin-auth-node';
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { DatabaseHandler } from './DatabaseHandler';
/**
* @public
*/
export interface RouterOptions {
database: PluginDatabaseManager;
discovery: PluginEndpointDiscovery;
identity: IdentityApi;
logger: Logger;
}
/**
* @public
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { database, discovery, identity, logger } = options;
logger.info('Initializing Entity Feedback backend');
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const db = await database.getClient();
const dbHandler = await DatabaseHandler.create({ database: db });
const router = Router();
router.use(express.json());
router.get('/ratings', async (req, res) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
const requestedEntities: { [ref: string]: Entity } = {};
if (req.query.ownerRef) {
// Get ratings from all owned entities (also ensures only accessible entities are requested)
(
await catalogClient.getEntities(
{
filter: { 'relations.ownedBy': req.query.ownerRef as string },
fields: [
'kind',
'metadata.name',
'metadata.namespace',
'metadata.title',
],
},
{ token },
)
).items.forEach(ent => {
requestedEntities[stringifyEntityRef(ent)] = ent;
});
} else {
const allRatedEntities = await dbHandler.getAllRatedEntities();
// Filter entities to only expose entity refs accessible by current user
(
await catalogClient.getEntitiesByRefs(
{
entityRefs: allRatedEntities,
fields: [
'kind',
'metadata.namespace',
'metadata.name',
'metadata.title',
],
},
{ token },
)
).items
.filter(Boolean)
.forEach(ent => {
requestedEntities[stringifyEntityRef(ent!)] = ent!;
});
}
const entityRatings = await dbHandler.getRatingsAggregates(
Object.keys(requestedEntities),
);
// Merge rating aggregates into a condensed per entity structure
const entityRatingsMap = entityRatings.reduce<{
[ref: string]: EntityRatingsData;
}>((ratingsMap, { entityRef, rating, count }) => {
ratingsMap[entityRef] = ratingsMap[entityRef] ?? {
entityRef,
entityTitle: requestedEntities[entityRef].metadata.title,
ratings: {},
};
ratingsMap[entityRef].ratings[rating] = count;
return ratingsMap;
}, {});
res.json(Object.values(entityRatingsMap));
});
router.post('/ratings/:entityRef', async (req, res) => {
const user = await identity.getIdentity({ request: req });
const rating = req.body.rating;
if (!user || !rating) {
res.status(400).end();
return;
}
await dbHandler.recordRating({
entityRef: req.params.entityRef,
rating,
userRef: user.identity.userEntityRef,
});
res.status(201).end();
});
router.get('/ratings/:entityRef', async (req, res) => {
const ratings = await dbHandler.getRatings(req.params.entityRef);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
// Filter ratings via user refs to only expose entity refs accessible by current user
const accessibleEntityRefs = (
await catalogClient.getEntitiesByRefs(
{
entityRefs: ratings.map(r => r.userRef),
fields: ['kind', 'metadata.namespace', 'metadata.name'],
},
{ token },
)
).items
.filter(Boolean)
.map(ent => stringifyEntityRef(ent!));
res.json(ratings.filter(r => accessibleEntityRefs.includes(r.userRef)));
});
router.post('/responses/:entityRef', async (req, res) => {
const user = await identity.getIdentity({ request: req });
const { response, comments, consent } = req.body;
if (!user) {
res.status(400).end();
return;
}
await dbHandler.recordResponse({
entityRef: req.params.entityRef,
response,
comments,
consent,
userRef: user.identity.userEntityRef,
});
res.status(201).end();
});
router.get('/responses/:entityRef', async (req, res) => {
const responses = await dbHandler.getResponses(req.params.entityRef);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
// Filter responses via user refs to only expose entity refs accessible by current user
const accessibleEntityRefs = (
await catalogClient.getEntitiesByRefs(
{
entityRefs: responses.map(r => r.userRef),
fields: ['kind', 'metadata.namespace', 'metadata.name'],
},
{ token },
)
).items
.filter(Boolean)
.map(ent => stringifyEntityRef(ent!));
res.json(responses.filter(r => accessibleEntityRefs.includes(r.userRef)));
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,80 @@
/*
* 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 {
createServiceBuilder,
DatabaseManager,
loadBackendConfig,
SingleHostDiscovery,
useHotMemoize,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'entity-feedback-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = SingleHostDiscovery.fromConfig(config);
const database = useHotMemoize(module, () => {
const manager = DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: { client: 'better-sqlite3', connection: ':memory:' },
},
}),
);
return manager.forPlugin('entity-feedback');
});
const identity = DefaultIdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
});
logger.debug('Starting application server...');
const router = await createRouter({
database,
discovery,
identity,
logger,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/entity-feedback', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -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 {};