feat: add new user settings backend

Adding a new user settings backend and PersistentStorage to store user related settings in the
database.

Signed-off-by: Dominik Schwank <dominik.schwank@sda.se>
This commit is contained in:
Dominik Schwank
2022-08-26 10:46:16 +02:00
committed by Fredrik Adelöw
parent 5b2d037b94
commit 108cdc3912
24 changed files with 2215 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/core-app-api': minor
'@backstage/plugin-user-settings-backend': minor
---
Add new plugin @backstage/user-settings-backend to store user related settings
in the database. Additionally adding a PersistentStorage implementation to use
easily use the new plugin as drop-in replacement for the WebStorage.
+1
View File
@@ -56,6 +56,7 @@ yarn.lock @backstage/reviewers @backst
/plugins/stack-overflow-backend @backstage/reviewers @backstage/techdocs-core
/plugins/techdocs @backstage/reviewers @backstage/techdocs-core
/plugins/techdocs-* @backstage/reviewers @backstage/techdocs-core
/plugins/user-settings-backend @backstage/reviewers @backstage/sda-se-reviewers
/tech-insights-backend @backstage/reviewers @xantier @iain-b
/tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b
/tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b
+29
View File
@@ -506,6 +506,35 @@ export type OneLoginAuthCreateOptions = {
provider?: AuthProviderInfo;
};
// @public
export class PersistentStorage implements StorageApi {
constructor(
namespace: string,
fetchApi: FetchApi,
discoveryApi: DiscoveryApi,
errorApi: ErrorApi,
);
// (undocumented)
static create(options: {
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
namespace?: string;
}): PersistentStorage;
// (undocumented)
forBucket(name: string): StorageApi;
// (undocumented)
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>>;
// (undocumented)
remove(key: string): Promise<void>;
// (undocumented)
set<T extends JsonValue>(key: string, data: T): Promise<void>;
// (undocumented)
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T>;
}
// @public
export class SamlAuth
implements ProfileInfoApi, BackstageIdentityApi, SessionApi
+2
View File
@@ -34,6 +34,8 @@
"dependencies": {
"@backstage/config": "^1.0.2-next.0",
"@backstage/core-plugin-api": "^1.0.6-next.3",
"@backstage/errors": "^1.1.0",
"@backstage/plugin-permission-common": "^0.6.4-next.0",
"@backstage/types": "^1.0.0",
"@backstage/version-bridge": "^1.0.1",
"@types/prop-types": "^15.7.3",
@@ -0,0 +1,324 @@
/*
* Copyright 2022 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 { PersistentStorage } from './PersistentStorage';
import { ErrorApi, FetchApi, StorageApi } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/plugin-permission-common';
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer();
describe('Persistent Storage API', () => {
setupRequestMockHandlers(server);
const mockBaseUrl = 'http://backstage:9191/api';
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const mockDiscoveryApi = { getBaseUrl: async () => mockBaseUrl };
const createPersistentStorage = (
args?: Partial<{
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
namespace?: string;
}>,
): StorageApi => {
return PersistentStorage.create({
errorApi: mockErrorApi,
fetchApi: new MockFetchApi(),
discoveryApi: mockDiscoveryApi,
...args,
});
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/`, async (_req, res, ctx) => {
return res(ctx.json([]));
}),
);
});
afterEach(() => jest.resetAllMocks());
it('should return undefined for values which are unset', async () => {
const storage = createPersistentStorage();
server.use(
rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => {
return res(ctx.json({ value: 'a' }));
}),
);
expect(storage.snapshot('myfakekey').value).toBeUndefined();
expect(storage.snapshot('myfakekey')).toEqual({
key: 'myfakekey',
presence: 'unknown',
value: undefined,
});
});
it('should allow setting of a simple data structure', async () => {
const storage = createPersistentStorage();
const dummyValue = 'a';
server.use(
rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const body = await req.json();
const data = { value: JSON.stringify(dummyValue) };
expect(body).toEqual(data);
return res(ctx.json(data));
}),
);
await storage.set('my-key', dummyValue);
});
it('should allow setting of a complex data structure', async () => {
const storage = createPersistentStorage();
const dummyValue = {
some: 'nice data',
with: { nested: 'values', nice: true },
};
server.use(
rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const body = await req.json();
const data = { value: JSON.stringify(dummyValue) };
expect(body).toEqual(data);
return res(ctx.json(data));
}),
);
await storage.set('my-key', dummyValue);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createPersistentStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
server.use(
rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const body = await req.json();
const data = { value: JSON.stringify(mockData) };
expect(body).toEqual(data);
return res(ctx.json(data));
}),
);
await new Promise<void>(resolve => {
storage.observe$<typeof mockData>('correctKey').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'present') {
resolve();
}
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'present',
value: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createPersistentStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
server.use(
rest.delete(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => {
return res(ctx.status(204));
}),
);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'absent',
value: undefined,
});
});
it('should not clash with other namespaces when creating buckets', async () => {
const rootStorage = createPersistentStorage();
const selectedKeyNextHandler = jest.fn();
server.use(
rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const { bucket, key } = req.params;
const { value } = await req.json();
expect(bucket).toEqual('default.profile.something.deep');
expect(key).toEqual('test2');
return res(ctx.json({ value }));
}),
rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const { bucket, key } = req.params;
expect(bucket).toEqual('default.profile/something');
expect(key).toEqual('deep/test2');
return res(ctx.status(404));
}),
);
// when getting key test2 it will translate to default.profile.something.deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to default.profile.something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
await new Promise<void>(resolve => {
secondStorage.observe$('deep/test2').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
secondStorage.snapshot('deep/test2');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'deep/test2',
presence: 'absent',
value: undefined,
});
expect(mockErrorApi.post).not.toHaveBeenCalled();
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const selectedKeyNextHandler = jest.fn();
const rootStorage = createPersistentStorage({
namespace: 'Test.Mock.Thing',
});
server.use(
rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => {
const { bucket, key } = req.params;
expect(bucket).toEqual('Test.Mock.Thing');
expect(key).toEqual('key');
return res(ctx.text('{ invalid: json string }'));
}),
);
await new Promise<void>(resolve => {
rootStorage.observe$('key').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
rootStorage.snapshot('key');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'key',
presence: 'absent',
value: undefined,
});
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
});
it('should freeze the snapshot value', async () => {
const storage = createPersistentStorage();
const selectedKeyNextHandler = jest.fn();
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
server.use(
rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => {
return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) })));
}),
);
await new Promise<void>(resolve => {
storage.observe$('key').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'present') {
resolve();
}
},
});
storage.snapshot('key');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'key',
presence: 'present',
value: { baz: [{ foo: 'bar' }], foo: 'bar' },
});
const snapshot = selectedKeyNextHandler.mock.calls[0][0];
expect(() => {
snapshot.value.foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz[0].foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz.push({ foo: 'buzz' });
}).toThrow(/Cannot add property 1, object is not extensible/);
});
});
@@ -0,0 +1,193 @@
/*
* Copyright 2022 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 {
DiscoveryApi,
ErrorApi,
FetchApi,
StorageApi,
StorageValueSnapshot,
} from '@backstage/core-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { JsonValue, Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
const buckets = new Map<string, PersistentStorage>();
/**
* An implementation of the storage API, that uses the user-settings backend to
* persist the data in the DB.
*
* @public
*/
export class PersistentStorage implements StorageApi {
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
>();
private readonly observable = new ObservableImpl<
StorageValueSnapshot<JsonValue>
>(subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
constructor(
private readonly namespace: string,
private readonly fetchApi: FetchApi,
private readonly discoveryApi: DiscoveryApi,
private readonly errorApi: ErrorApi,
) {}
static create(options: {
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
namespace?: string;
}): PersistentStorage {
return new PersistentStorage(
options.namespace ?? 'default',
options.fetchApi,
options.discoveryApi,
options.errorApi,
);
}
forBucket(name: string): StorageApi {
// use dot instead of slash separator to have nicer URLs
const bucketPath = `${this.namespace}.${name}`;
if (!buckets.has(bucketPath)) {
buckets.set(
bucketPath,
new PersistentStorage(
bucketPath,
this.fetchApi,
this.discoveryApi,
this.errorApi,
),
);
}
return buckets.get(bucketPath)!;
}
async remove(key: string): Promise<void> {
const fetchUrl = await this.getFetchUrl(key);
await this.fetchApi.fetch(fetchUrl, {
method: 'DELETE',
});
this.notifyChanges({
key,
presence: 'absent',
});
}
async set<T extends JsonValue>(key: string, data: T): Promise<void> {
const fetchUrl = await this.getFetchUrl(key);
const body = JSON.stringify({ value: JSON.stringify(data) });
const response = await this.fetchApi.fetch(fetchUrl, {
method: 'PUT',
body,
});
const { value } = await response.json();
this.notifyChanges({
key,
value: JSON.parse(value),
presence: 'present',
});
}
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
// trigger a reload
this.get(key).then(snapshot => this.notifyChanges(snapshot));
return {
key,
presence: 'unknown',
};
}
private async get<T extends JsonValue>(
key: string,
): Promise<StorageValueSnapshot<T>> {
try {
const fetchUrl = await this.getFetchUrl(key);
const response = await this.fetchApi.fetch(fetchUrl);
if (response.status === 404) {
throw new NotFoundError(
`Setting '${key}' is not set in bucket '${this.namespace}'`,
);
} else if (!response.ok) {
throw new Error(
`Unable to fetch '${key}' from bucket '${this.namespace}'`,
);
}
const { value: rawValue } = await response.json();
const value = JSON.parse(rawValue, (_key, val) => {
if (typeof val === 'object' && val !== null) {
Object.freeze(val);
}
return val;
});
return {
key,
presence: 'present',
value,
};
} catch (error) {
// NotFoundError shouldn't be recorded
if (error && !(error instanceof NotFoundError)) {
this.errorApi.post(error);
}
return {
key,
presence: 'absent',
value: undefined,
};
}
}
private async getFetchUrl(key: string) {
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
const encodedNamespace = encodeURIComponent(this.namespace);
const encodedKey = encodeURIComponent(key);
return `${baseUrl}/${encodedNamespace}/${encodedKey}`;
}
private async notifyChanges<T>(snapshot: StorageValueSnapshot<T>) {
for (const subscription of this.subscribers) {
subscription.next(snapshot);
}
}
}
@@ -15,3 +15,4 @@
*/
export { WebStorage } from './WebStorage';
export { PersistentStorage } from './PersistentStorage';
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+104
View File
@@ -0,0 +1,104 @@
# User settings backend
This backend allows to save user specific settings. All requests need to be
authorized, as the user identifier (`userEntityRef`) is resolved using the
authorization token.
## Setup backend
1. Install the backend plugin:
```bash
# From your Backstage root directory
yarn --cwd packages/backend add @backstage/plugin-user-settings-backend
```
1. Configure the routes by adding a new `userSettings.ts` file in
`packages/backend/src/plugins/`:
```ts
// packages/backend/src/plugins/userSettings.ts
import { IdentityClient } from '@backstage/plugin-auth-node';
import {
createRouter,
createUserSettingsStore,
} from '@backstage/plugin-user-settings-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
database,
discovery,
}: PluginEnvironment): Promise<Router> {
const identity = IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
});
return await createRouter({
userSettingsStore: await createUserSettingsStore(database),
identity,
});
}
```
3. Add the new routes to your backend by modifying the
`packages/backend/src/index.ts`:
```diff
// packages/backend/src/index.ts
+ import userSettings from './plugins/userSettings';
async function main() {
+ const userSettingsEnv = useHotMemoize(module, () =>
+ createEnv('userSettings'),
+ );
const apiRouter = Router();
+ apiRouter.use('/user-settings', await userSettings(userSettingsEnv));
}
```
## Setup app
To make use of the user settings backend, replace the `WebStorage` with the
`PersistentStorage` by using the `storageApiRef`.
```diff
// packages/app/src/apis.ts
import {
AnyApiFactory,
createApiFactory,
+ discoveryApiRef,
+ fetchApiRef
errorApiRef,
+ storageApiRef,
} from '@backstage/core-plugin-api';
+ import { PersistentStorage } from '@backstage/core-app-api';
export const apis: AnyApiFactory[] = [
+ createApiFactory({
+ api: storageApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ errorApi: errorApiRef,
+ fetchApi: fetchApiRef,
+ },
+ factory: ({ discoveryApi, errorApi, fetchApi }) =>
+ PersistentStorage.create({ discoveryApi, errorApi, fetchApi }),
+ }),
];
```
## Development
Use `yarn start` to start the local dev environment. To simplify the access to
the API, the token will automatically be set to `user:default/john_doe`.
You can change the user by setting a custom `Authorization` header. To keep
things simple, the raw `Bearer` value will directly be used as user.
_Example:_
```bash
curl --request GET 'http://localhost:7007/user-settings' --header 'Authorization: Bearer user:default/custom-user'
```
+177
View File
@@ -0,0 +1,177 @@
## API Report File for "@backstage/plugin-user-settings-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import express from 'express';
import { IdentityClient } from '@backstage/plugin-auth-node';
import { Knex } from 'knex';
import { PluginDatabaseManager } from '@backstage/backend-common';
// @public
export function createRouter<T>(
options: RouterOptions<T>,
): Promise<express.Router>;
// @public (undocumented)
export function createUserSettingsStore(
database: PluginDatabaseManager,
): Promise<DatabaseUserSettingsStore>;
// @public
export class DatabaseUserSettingsStore
implements UserSettingsStore<Knex.Transaction>
{
// (undocumented)
static create(knex: Knex): Promise<DatabaseUserSettingsStore>;
// (undocumented)
delete(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
bucket: string;
key: string;
},
): Promise<void>;
// (undocumented)
deleteAll(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
},
): Promise<void>;
// (undocumented)
deleteBucket(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
bucket: string;
},
): Promise<void>;
// (undocumented)
get(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
bucket: string;
key: string;
},
): Promise<UserSetting>;
// (undocumented)
getAll(
tx: Knex.Transaction,
opts: {
userEntityRef: string;
},
): Promise<Pick<RawDbUserSettingsRow, 'key' | 'value' | 'bucket'>[]>;
// (undocumented)
getBucket(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
bucket: string;
},
): Promise<UserSetting[]>;
// (undocumented)
set(
tx: Knex.Transaction<any, any[]>,
opts: {
userEntityRef: string;
bucket: string;
key: string;
value: string;
},
): Promise<void>;
// (undocumented)
transaction<T>(fn: (tx: Knex.Transaction) => Promise<T>): Promise<T>;
}
// @public (undocumented)
export type RawDbUserSettingsRow = {
user_entity_ref: string;
bucket: string;
key: string;
value: string;
};
// @public (undocumented)
export interface RouterOptions<T> {
// (undocumented)
identity: IdentityClient;
// (undocumented)
userSettingsStore: UserSettingsStore<T>;
}
// @public (undocumented)
export type UserSetting = {
bucket: string;
key: string;
value: string;
};
// @public
export interface UserSettingsStore<Transaction> {
// (undocumented)
delete(
tx: Transaction,
opts: {
userEntityRef: string;
bucket: string;
key: string;
},
): Promise<void>;
// (undocumented)
deleteAll(
tx: Transaction,
opts: {
userEntityRef: string;
},
): Promise<void>;
// (undocumented)
deleteBucket(
tx: Transaction,
opts: {
userEntityRef: string;
bucket: string;
},
): Promise<void>;
// (undocumented)
get(
tx: Transaction,
opts: {
userEntityRef: string;
bucket: string;
key: string;
},
): Promise<UserSetting>;
// (undocumented)
getAll(
tx: Transaction,
opts: {
userEntityRef: string;
},
): Promise<UserSetting[]>;
// (undocumented)
getBucket(
tx: Transaction,
opts: {
userEntityRef: string;
bucket: string;
},
): Promise<UserSetting[]>;
// (undocumented)
set(
tx: Transaction,
opts: {
userEntityRef: string;
bucket: string;
key: string;
value: string;
},
): Promise<void>;
// (undocumented)
transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T>;
}
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,41 @@
/*
* Copyright 2022 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.
*/
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.createTable('user_settings', table => {
table.comment('The table of user related settings');
table
.text('user_entity_ref')
.notNullable()
.comment('The entityRef of the user');
table.text('bucket').notNullable().comment('Name of the bucket');
table.text('key').notNullable().comment('Key of a bucket value');
table.text('value').notNullable().comment('The value');
table.primary(['user_entity_ref', 'bucket', 'key']);
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('user_settings');
};
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-user-settings-backend",
"description": "The Backstage backend plugin to manage user settings",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"backstage": {
"role": "backend-plugin"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/user-settings-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "^0.15.1-next.1",
"@backstage/catalog-model": "^1.1.0",
"@backstage/errors": "^1.1.0",
"@backstage/plugin-auth-node": "^0.2.5-next.1",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^2.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "^0.1.28-next.1",
"@backstage/cli": "^0.19.0-next.1",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
},
"files": [
"dist",
"migrations"
]
}
@@ -0,0 +1,373 @@
/*
* Copyright 2022 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 {
DatabaseUserSettingsStore,
RawDbUserSettingsRow,
} from './DatabaseUserSettingsStore';
jest.setTimeout(60_000);
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'SQLITE_3'],
});
async function createStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
return {
knex,
storage: await DatabaseUserSettingsStore.create(knex),
};
}
describe.each(databases.eachSupportedId())(
'DatabaseUserSettingsStore (%s)',
databaseId => {
let storage: DatabaseUserSettingsStore;
let knex: Knex;
beforeAll(async () => {
({ storage, knex } = await createStore(databaseId));
});
afterEach(async () => {
jest.resetAllMocks();
await knex('user_settings').del();
});
const insert = (data: RawDbUserSettingsRow[]) =>
knex<RawDbUserSettingsRow>('user_settings').insert(data);
const query = () =>
knex<RawDbUserSettingsRow>('user_settings')
.orderBy('user_entity_ref')
.select();
describe('getAll', () => {
it('should return empty user settings', async () => {
expect(
await storage.transaction(tx =>
storage.getAll(tx, { userEntityRef: 'user-1' }),
),
).toEqual([]);
});
it('should return all user settings', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-b',
value: 'value-b',
},
{
user_entity_ref: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
expect(
await storage.transaction(tx =>
storage.getAll(tx, { userEntityRef: 'user-1' }),
),
).toEqual([
{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' },
{ bucket: 'bucket-a', key: 'key-b', value: 'value-b' },
{ bucket: 'bucket-c', key: 'key-c', value: 'value-c' },
]);
});
});
describe('deleteAll', () => {
it('should delete all user settings', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
await storage.transaction(tx =>
storage.deleteAll(tx, { userEntityRef: 'user-1' }),
);
expect(await query()).toEqual([
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
});
});
describe('getBucket', () => {
it('should return an empty bucket', async () => {
expect(
await storage.transaction(tx =>
storage.getBucket(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-c',
}),
),
).toEqual([]);
});
it('should return the settings of the bucket', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
expect(
await storage.transaction(tx =>
storage.getBucket(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
}),
),
).toEqual([{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }]);
});
});
describe('deleteBucket', () => {
it('should delete a bucket', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
await storage.transaction(tx =>
storage.deleteBucket(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
}),
);
expect(await query()).toEqual([
{
user_entity_ref: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
});
});
describe('get', () => {
it('should throw an error', async () => {
await expect(() =>
storage.transaction(tx =>
storage.get(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
}),
),
).rejects.toThrow(`Unable to find 'key-c' in bucket 'bucket-c'`);
});
it('should return the setting', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
expect(
await storage.transaction(tx =>
storage.get(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
}),
),
).toEqual({ bucket: 'bucket-a', key: 'key-a', value: 'value-a' });
});
});
describe('set', () => {
it('should insert a new setting', async () => {
await storage.transaction(tx =>
storage.set(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
}),
);
expect(await query()).toEqual([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
]);
});
it('should overwrite an existing setting', async () => {
await storage.transaction(tx =>
storage.set(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
}),
);
await storage.transaction(tx =>
storage.set(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-b',
}),
);
expect(await query()).toEqual([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-b',
},
]);
});
});
describe('delete', () => {
it('should not throw an error if the entry does not exist', async () => {
await expect(() =>
storage.transaction(tx =>
storage.delete(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-c',
key: 'key-c',
}),
),
).not.toThrow();
});
it('should return the setting', async () => {
await insert([
{
user_entity_ref: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
value: 'value-a',
},
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
await storage.transaction(tx =>
storage.delete(tx, {
userEntityRef: 'user-1',
bucket: 'bucket-a',
key: 'key-a',
}),
);
expect(await query()).toEqual([
{
user_entity_ref: 'user-2',
bucket: 'bucket-c',
key: 'key-c',
value: 'value-c',
},
]);
});
});
},
);
@@ -0,0 +1,143 @@
/*
* Copyright 2022 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 { NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { type UserSetting, UserSettingsStore } from './UserSettingsStore';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-user-settings-backend',
'migrations',
);
/**
* @public
*/
export type RawDbUserSettingsRow = {
user_entity_ref: string;
bucket: string;
key: string;
value: string;
};
/**
* Store to manage the user settings.
*
* @public
*/
export class DatabaseUserSettingsStore
implements UserSettingsStore<Knex.Transaction>
{
static async create(knex: Knex): Promise<DatabaseUserSettingsStore> {
await knex.migrate.latest({
directory: migrationsDir,
});
return new DatabaseUserSettingsStore(knex);
}
private constructor(private readonly db: Knex) {}
async getAll(tx: Knex.Transaction, opts: { userEntityRef: string }) {
const settings = await tx<RawDbUserSettingsRow>('user_settings')
.where({ user_entity_ref: opts.userEntityRef })
.select('bucket', 'key', 'value');
return settings;
}
async deleteAll(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string },
): Promise<void> {
await tx('user_settings')
.where({ user_entity_ref: opts.userEntityRef })
.delete();
}
async getBucket(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string; bucket: string },
): Promise<UserSetting[]> {
const settings = await tx<RawDbUserSettingsRow>('user_settings')
.where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket })
.select(['bucket', 'key', 'value']);
return settings;
}
async deleteBucket(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string; bucket: string },
): Promise<void> {
await tx('user_settings')
.where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket })
.delete();
}
async get(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string; bucket: string; key: string },
): Promise<UserSetting> {
const setting = await tx<RawDbUserSettingsRow>('user_settings')
.where({
user_entity_ref: opts.userEntityRef,
bucket: opts.bucket,
key: opts.key,
})
.select(['bucket', 'key', 'value']);
if (!setting.length) {
throw new NotFoundError(
`Unable to find '${opts.key}' in bucket '${opts.bucket}'`,
);
}
return setting[0];
}
async set(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string; bucket: string; key: string; value: string },
): Promise<void> {
await tx<RawDbUserSettingsRow>('user_settings')
.insert({
user_entity_ref: opts.userEntityRef,
bucket: opts.bucket,
key: opts.key,
value: opts.value,
})
.onConflict(['user_entity_ref', 'bucket', 'key'])
.merge({ value: opts.value });
}
async delete(
tx: Knex.Transaction<any, any[]>,
opts: { userEntityRef: string; bucket: string; key: string },
): Promise<void> {
await tx<RawDbUserSettingsRow>('user_settings')
.where({
user_entity_ref: opts.userEntityRef,
bucket: opts.bucket,
key: opts.key,
})
.delete();
}
async transaction<T>(fn: (tx: Knex.Transaction) => Promise<T>) {
return await this.db.transaction(fn);
}
}
@@ -0,0 +1,65 @@
/*
* Copyright 2022 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.
*/
/**
* @public
*/
export type UserSetting = {
bucket: string;
key: string;
value: string;
};
/**
* Store definition for the user settings.
*
* @public
*/
export interface UserSettingsStore<Transaction> {
transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T>;
get(
tx: Transaction,
opts: { userEntityRef: string; bucket: string; key: string },
): Promise<UserSetting>;
set(
tx: Transaction,
opts: { userEntityRef: string; bucket: string; key: string; value: string },
): Promise<void>;
delete(
tx: Transaction,
opts: { userEntityRef: string; bucket: string; key: string },
): Promise<void>;
getBucket(
tx: Transaction,
opts: { userEntityRef: string; bucket: string },
): Promise<UserSetting[]>;
deleteBucket(
tx: Transaction,
opts: { userEntityRef: string; bucket: string },
): Promise<void>;
getAll(
tx: Transaction,
opts: { userEntityRef: string },
): Promise<UserSetting[]>;
deleteAll(tx: Transaction, opts: { userEntityRef: string }): Promise<void>;
}
@@ -0,0 +1,20 @@
/*
* Copyright 2022 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 {
DatabaseUserSettingsStore,
type RawDbUserSettingsRow,
} from './DatabaseUserSettingsStore';
export type { UserSettingsStore, UserSetting } from './UserSettingsStore';
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 './service';
export * from './database';
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2022 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,20 @@
/*
* Copyright 2022 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 {
createRouter,
createUserSettingsStore,
type RouterOptions,
} from './router';
@@ -0,0 +1,319 @@
/*
* Copyright 2022 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 { AuthenticationError } from '@backstage/errors';
import { IdentityClient } from '@backstage/plugin-auth-node';
import express from 'express';
import request from 'supertest';
import { UserSettingsStore } from '../database';
import { createRouter } from './router';
describe('createRouter', () => {
const userSettingsStore: jest.Mocked<UserSettingsStore<'tx'>> = {
transaction: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
getBucket: jest.fn(),
deleteBucket: jest.fn(),
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
const authenticateMock = jest.fn();
const identityClient: jest.Mocked<Partial<IdentityClient>> = {
authenticate: authenticateMock,
};
let app: express.Express;
beforeEach(async () => {
userSettingsStore.transaction.mockImplementation(fn => fn('tx'));
const router = await createRouter({
userSettingsStore,
identity: identityClient as IdentityClient,
});
app = express().use(router);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('GET /', () => {
it('returns ok', async () => {
const settings = [
{ bucket: 'a', key: 'a', value: 'a' },
{ bucket: 'b', key: 'b', value: 'b' },
];
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.getAll.mockResolvedValue(settings);
const responses = await request(app)
.get('/')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(200);
expect(responses.body).toEqual(settings);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.getAll).toBeCalledTimes(1);
expect(userSettingsStore.getAll).toBeCalledWith('tx', {
userEntityRef: 'user-1',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).get('/');
expect(responses.status).toEqual(401);
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
});
it('returns an error if the token is not valid', async () => {
authenticateMock.mockRejectedValue(
new AuthenticationError('Invalid token'),
);
const responses = await request(app)
.get('/')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(401);
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
});
});
describe('DELETE /', () => {
it('returns ok', async () => {
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.deleteAll.mockResolvedValue();
const responses = await request(app)
.delete('/')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(204);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.deleteAll).toBeCalledTimes(1);
expect(userSettingsStore.deleteAll).toBeCalledWith('tx', {
userEntityRef: 'user-1',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).delete('/');
expect(responses.status).toEqual(401);
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
});
});
describe('GET /:bucket', () => {
it('returns ok', async () => {
const settings = [
{ bucket: 'my-bucket', key: 'a', value: 'a' },
{ bucket: 'my-bucket', key: 'b', value: 'b' },
];
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.getBucket.mockResolvedValue(settings);
const responses = await request(app)
.get('/my-bucket')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(200);
expect(responses.body).toEqual(settings);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.getBucket).toBeCalledTimes(1);
expect(userSettingsStore.getBucket).toBeCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).get('/my-bucket');
expect(responses.status).toEqual(401);
expect(userSettingsStore.getBucket).not.toHaveBeenCalled();
});
});
describe('DELETE /:bucket', () => {
it('returns ok', async () => {
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.deleteBucket.mockResolvedValue();
const responses = await request(app)
.delete('/my-bucket')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(204);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.deleteBucket).toBeCalledTimes(1);
expect(userSettingsStore.deleteBucket).toBeCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).delete('/my-bucket');
expect(responses.status).toEqual(401);
expect(userSettingsStore.deleteBucket).not.toHaveBeenCalled();
});
});
describe('GET /:bucket/:key', () => {
it('returns ok', async () => {
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.get.mockResolvedValue(setting);
const responses = await request(app)
.get('/my-bucket/my-key')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(200);
expect(responses.body).toEqual(setting);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.get).toBeCalledTimes(1);
expect(userSettingsStore.get).toBeCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
key: 'my-key',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).get('/my-bucket/my-key');
expect(responses.status).toEqual(401);
expect(userSettingsStore.get).not.toHaveBeenCalled();
});
});
describe('DELETE /:bucket/:key', () => {
it('returns ok', async () => {
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.delete.mockResolvedValue();
const responses = await request(app)
.delete('/my-bucket/my-key')
.set('Authorization', 'Bearer foo');
expect(responses.status).toEqual(204);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.delete).toBeCalledTimes(1);
expect(userSettingsStore.delete).toBeCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
key: 'my-key',
});
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).delete('/my-bucket/my-key');
expect(responses.status).toEqual(401);
expect(userSettingsStore.delete).not.toHaveBeenCalled();
});
});
describe('PUT /:bucket/:key', () => {
it('returns ok', async () => {
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
userSettingsStore.set.mockResolvedValue();
userSettingsStore.get.mockResolvedValue(setting);
const responses = await request(app)
.put('/my-bucket/my-key')
.set('Authorization', 'Bearer foo')
.send({ value: 'a' });
expect(responses.status).toEqual(200);
expect(responses.body).toEqual(setting);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.set).toBeCalledTimes(1);
expect(userSettingsStore.set).toHaveBeenCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
key: 'my-key',
value: 'a',
});
expect(userSettingsStore.get).toBeCalledTimes(1);
expect(userSettingsStore.get).toBeCalledWith('tx', {
userEntityRef: 'user-1',
bucket: 'my-bucket',
key: 'my-key',
});
});
it('returns an error if the value is not a string', async () => {
authenticateMock.mockResolvedValue({
identity: { userEntityRef: 'user-1' },
});
const responses = await request(app)
.put('/my-bucket/my-key')
.set('Authorization', 'Bearer foo')
.send({ value: { invalid: 'because not a string' } });
expect(responses.status).toEqual(400);
expect(authenticateMock).toHaveBeenCalledWith('foo');
expect(userSettingsStore.set).not.toHaveBeenCalled();
});
it('returns an error if the Authorization header is missing', async () => {
const responses = await request(app).get('/my-bucket/my-key');
expect(responses.status).toEqual(401);
expect(userSettingsStore.get).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,168 @@
/*
* Copyright 2022 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, errorHandler } from '@backstage/backend-common';
import { AuthenticationError, InputError } from '@backstage/errors';
import {
getBearerTokenFromAuthorizationHeader,
IdentityClient,
} from '@backstage/plugin-auth-node';
import express, { Request } from 'express';
import Router from 'express-promise-router';
import { DatabaseUserSettingsStore, UserSettingsStore } from '../database';
/**
* @public
*/
export async function createUserSettingsStore(database: PluginDatabaseManager) {
return await DatabaseUserSettingsStore.create(await database.getClient());
}
/**
* @public
*/
export interface RouterOptions<T> {
userSettingsStore: UserSettingsStore<T>;
identity: IdentityClient;
}
/**
* Create the user settings backend routes.
*
* @public
*/
export async function createRouter<T>(
options: RouterOptions<T>,
): Promise<express.Router> {
const { userSettingsStore, identity } = options;
const router = Router();
router.use(express.json());
/**
* Helper method to extract the userEntityRef from the request.
*/
const getUserEntityRef = async (req: Request): Promise<string> => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
if (!token) {
throw new AuthenticationError(`Missing token in 'authorization' header`);
}
// throws an AuthenticationError in case the token is invalid
const user = await identity.authenticate(token);
return user.identity.userEntityRef;
};
// get all user related settings
router.get('/', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const settings = await userSettingsStore.transaction(tx =>
userSettingsStore.getAll(tx, { userEntityRef }),
);
res.json(settings);
});
// remove all user related settings
router.delete('/', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
await userSettingsStore.transaction(tx =>
userSettingsStore.deleteAll(tx, { userEntityRef }),
);
res.send(204).end();
});
// get a single bucket
router.get('/:bucket', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const { bucket } = req.params;
const settings = await userSettingsStore.transaction(tx =>
userSettingsStore.getBucket(tx, { userEntityRef, bucket }),
);
res.json(settings);
});
// delete a whole bucket
router.delete('/:bucket', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const { bucket } = req.params;
await userSettingsStore.transaction(tx =>
userSettingsStore.deleteBucket(tx, { userEntityRef, bucket }),
);
res.status(204).end();
});
// get a single value
router.get('/:bucket/:key', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const { bucket, key } = req.params;
const setting = await userSettingsStore.transaction(tx =>
userSettingsStore.get(tx, { userEntityRef, bucket, key }),
);
res.json(setting);
});
// set a single value
router.put('/:bucket/:key', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const { bucket, key } = req.params;
const { value } = req.body;
if (typeof value !== 'string') {
throw new InputError('Value must be a string');
}
const setting = await userSettingsStore.transaction(async tx => {
await userSettingsStore.set(tx, {
userEntityRef,
bucket,
key,
value,
});
return userSettingsStore.get(tx, { userEntityRef, bucket, key });
});
res.json(setting);
});
// get a single value
router.delete('/:bucket/:key', async (req, res) => {
const userEntityRef = await getUserEntityRef(req);
const { bucket, key } = req.params;
await userSettingsStore.transaction(tx =>
userSettingsStore.delete(tx, { userEntityRef, bucket, key }),
);
res.send(204).end();
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,82 @@
/*
* Copyright 2022 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 { Server } from 'http';
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
import Knex from 'knex';
import { Logger } from 'winston';
import { DatabaseUserSettingsStore } from '../database';
import { createRouter } from './router';
import { IdentityClient } from '@backstage/plugin-auth-node';
import { Router } from 'express';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'storage-backend' });
const database = useHotMemoize(module, () => {
return Knex({
client: 'better-sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
});
logger.debug('Starting application server...');
const identityMock = {
authenticate: async token => ({
identity: { userEntityRef: token ?? 'user:default/john_doe' },
}),
} as IdentityClient;
const router = await createRouter({
userSettingsStore: await DatabaseUserSettingsStore.create(database),
identity: identityMock,
});
// set a custom authorization header to simplify the development
const authWrapper = Router();
authWrapper.use((req, _res, next) => {
req.headers.authorization =
req.headers.authorization ?? 'Bearer user:default/john_doe';
next();
});
authWrapper.use(router);
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/user-settings', authWrapper);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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 {};
+26 -3
View File
@@ -2855,7 +2855,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common":
"@backstage/backend-common@^0.15.1-next.1, @backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common":
version: 0.0.0-use.local
resolution: "@backstage/backend-common@workspace:packages/backend-common"
dependencies:
@@ -2991,7 +2991,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils":
"@backstage/backend-test-utils@^0.1.28-next.1, @backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils":
version: 0.0.0-use.local
resolution: "@backstage/backend-test-utils@workspace:packages/backend-test-utils"
dependencies:
@@ -3291,6 +3291,8 @@ __metadata:
"@backstage/cli": ^0.19.0-next.3
"@backstage/config": ^1.0.2-next.0
"@backstage/core-plugin-api": ^1.0.6-next.3
"@backstage/errors": ^1.1.0
"@backstage/plugin-permission-common": ^0.6.4-next.0
"@backstage/test-utils": ^1.2.0-next.3
"@backstage/types": ^1.0.0
"@backstage/version-bridge": ^1.0.1
@@ -4027,7 +4029,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node":
"@backstage/plugin-auth-node@^0.2.5-next.1, @backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-node@workspace:plugins/auth-node"
dependencies:
@@ -7310,6 +7312,27 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-user-settings-backend@workspace:plugins/user-settings-backend":
version: 0.0.0-use.local
resolution: "@backstage/plugin-user-settings-backend@workspace:plugins/user-settings-backend"
dependencies:
"@backstage/backend-common": ^0.15.1-next.1
"@backstage/backend-test-utils": ^0.1.28-next.1
"@backstage/catalog-model": ^1.1.0
"@backstage/cli": ^0.19.0-next.1
"@backstage/errors": ^1.1.0
"@backstage/plugin-auth-node": ^0.2.5-next.1
"@types/express": ^4.17.6
"@types/supertest": ^2.0.8
express: ^4.17.1
express-promise-router: ^4.1.0
knex: ^2.0.0
supertest: ^6.1.3
winston: ^3.2.1
yn: ^4.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-user-settings@^0.4.8-next.3, @backstage/plugin-user-settings@workspace:plugins/user-settings":
version: 0.0.0-use.local
resolution: "@backstage/plugin-user-settings@workspace:plugins/user-settings"