feat(entity-feedback): implement plugin
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -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 {};
|
||||
Reference in New Issue
Block a user