Merge pull request #13570 from backstage/freben-dschwank/user-settings-backend
feat: add user-settings-backend and related frontend store
This commit is contained in:
@@ -27,12 +27,16 @@ import Observable from 'zen-observable';
|
||||
* @public
|
||||
*/
|
||||
export class LocalStoredShortcuts implements ShortcutApi {
|
||||
constructor(private readonly storageApi: StorageApi) {}
|
||||
private readonly shortcuts: Observable<Shortcut[]>;
|
||||
|
||||
constructor(private readonly storageApi: StorageApi) {
|
||||
this.shortcuts = Observable.from(
|
||||
this.storageApi.observe$<Shortcut[]>('items'),
|
||||
).map(snapshot => snapshot.value ?? []);
|
||||
}
|
||||
|
||||
shortcut$() {
|
||||
return Observable.from(this.storageApi.observe$<Shortcut[]>('items')).map(
|
||||
snapshot => snapshot.value ?? [],
|
||||
);
|
||||
return this.shortcuts;
|
||||
}
|
||||
|
||||
get() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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 { createRouter } from '@backstage/plugin-user-settings-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(env: PluginEnvironment) {
|
||||
return await createRouter({
|
||||
database: env.database,
|
||||
identity: env.identity,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
3. Add the new routes to your backend by modifying `packages/backend/src/index.ts`:
|
||||
|
||||
```diff
|
||||
// packages/backend/src/index.ts
|
||||
+import userSettings from './plugins/userSettings';
|
||||
async function main() {
|
||||
+ const userSettingsEnv = useHotMemoize(module, () => createEnv('user-settings'));
|
||||
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
|
||||
`UserSettingsStorage` by using the `storageApiRef`.
|
||||
|
||||
```diff
|
||||
// packages/app/src/apis.ts
|
||||
import {
|
||||
AnyApiFactory,
|
||||
createApiFactory,
|
||||
+ discoveryApiRef,
|
||||
+ fetchApiRef,
|
||||
errorApiRef,
|
||||
+ identityApi,
|
||||
+ storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
+import { UserSettingsStorage } from '@backstage/plugin-user-settings';
|
||||
|
||||
export const apis: AnyApiFactory[] = [
|
||||
+ createApiFactory({
|
||||
+ api: storageApiRef,
|
||||
+ deps: {
|
||||
+ discoveryApi: discoveryApiRef,
|
||||
+ errorApi: errorApiRef,
|
||||
+ fetchApi: fetchApiRef,
|
||||
+ identityApi: identityApiRef
|
||||
+ },
|
||||
+ factory: deps => UserSettingsStorage.create(deps),
|
||||
+ }),
|
||||
];
|
||||
```
|
||||
|
||||
## 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'
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
## 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 { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
|
||||
// @public
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
database: PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
identity: IdentityApi;
|
||||
}
|
||||
|
||||
// (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
|
||||
.string('user_entity_ref')
|
||||
.notNullable()
|
||||
.comment('The entityRef of the user');
|
||||
table.string('bucket').notNullable().comment('Name of the bucket');
|
||||
table.string('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,53 @@
|
||||
{
|
||||
"name": "@backstage/plugin-user-settings-backend",
|
||||
"description": "The Backstage backend plugin to manage user settings",
|
||||
"version": "0.0.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.3",
|
||||
"@backstage/catalog-model": "^1.1.1-next.0",
|
||||
"@backstage/errors": "^1.1.1-next.0",
|
||||
"@backstage/plugin-auth-node": "^0.2.5-next.3",
|
||||
"@backstage/types": "^1.0.0",
|
||||
"@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.3",
|
||||
"@backstage/cli": "^0.19.0-next.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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);
|
||||
const databaseManager = {
|
||||
getClient: async () => knex,
|
||||
migrations: {
|
||||
skip: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
knex,
|
||||
storage: await DatabaseUserSettingsStore.create({
|
||||
database: databaseManager,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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('get', () => {
|
||||
it('should throw an error', async () => {
|
||||
await expect(() =>
|
||||
storage.get({
|
||||
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: JSON.stringify('value-a'),
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: JSON.stringify('value-c'),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
storage.get({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should insert a new setting', async () => {
|
||||
await storage.set({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
});
|
||||
|
||||
await expect(query()).resolves.toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: JSON.stringify('value-a'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should overwrite an existing setting', async () => {
|
||||
await storage.set({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
});
|
||||
|
||||
await storage.set({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-b',
|
||||
});
|
||||
|
||||
await expect(query()).resolves.toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: JSON.stringify('value-b'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should not throw an error if the entry does not exist', async () => {
|
||||
await expect(
|
||||
storage.delete({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the setting', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: JSON.stringify('value-a'),
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: JSON.stringify('value-c'),
|
||||
},
|
||||
]);
|
||||
|
||||
await storage.delete({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
});
|
||||
await expect(query()).resolves.toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: JSON.stringify('value-c'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Knex } from 'knex';
|
||||
import { UserSettingsStore, type UserSetting } 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 {
|
||||
static async create(options: {
|
||||
database: PluginDatabaseManager;
|
||||
}): Promise<DatabaseUserSettingsStore> {
|
||||
const { database } = options;
|
||||
const client = await database.getClient();
|
||||
|
||||
if (!database.migrations?.skip) {
|
||||
await client.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
}
|
||||
|
||||
return new DatabaseUserSettingsStore(client);
|
||||
}
|
||||
|
||||
private constructor(private readonly db: Knex) {}
|
||||
|
||||
async get(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
}): Promise<UserSetting> {
|
||||
const rows = await this.db<RawDbUserSettingsRow>('user_settings')
|
||||
.where({
|
||||
user_entity_ref: options.userEntityRef,
|
||||
bucket: options.bucket,
|
||||
key: options.key,
|
||||
})
|
||||
.select(['bucket', 'key', 'value']);
|
||||
|
||||
if (!rows.length) {
|
||||
throw new NotFoundError(
|
||||
`Unable to find '${options.key}' in bucket '${options.bucket}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bucket: rows[0].bucket,
|
||||
key: rows[0].key,
|
||||
value: JSON.parse(rows[0].value),
|
||||
};
|
||||
}
|
||||
|
||||
async set(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
value: JsonValue;
|
||||
}): Promise<void> {
|
||||
await this.db<RawDbUserSettingsRow>('user_settings')
|
||||
.insert({
|
||||
user_entity_ref: options.userEntityRef,
|
||||
bucket: options.bucket,
|
||||
key: options.key,
|
||||
value: JSON.stringify(options.value),
|
||||
})
|
||||
.onConflict(['user_entity_ref', 'bucket', 'key'])
|
||||
.merge(['value']);
|
||||
}
|
||||
|
||||
async delete(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
await this.db<RawDbUserSettingsRow>('user_settings')
|
||||
.where({
|
||||
user_entity_ref: options.userEntityRef,
|
||||
bucket: options.bucket,
|
||||
key: options.key,
|
||||
})
|
||||
.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* A single setting in a bucket
|
||||
*/
|
||||
export type UserSetting = {
|
||||
bucket: string;
|
||||
key: string;
|
||||
value: JsonValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store definition for the user settings.
|
||||
*/
|
||||
export interface UserSettingsStore {
|
||||
get(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
}): Promise<UserSetting>;
|
||||
|
||||
set(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
value: JsonValue;
|
||||
}): Promise<void>;
|
||||
|
||||
delete(options: {
|
||||
userEntityRef: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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';
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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,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 { createRouter, type RouterOptions } from './router';
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstageIdentityResponse,
|
||||
IdentityApi,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { UserSettingsStore } from '../database/UserSettingsStore';
|
||||
import { createRouterInternal } from './router';
|
||||
|
||||
describe('createRouter', () => {
|
||||
const userSettingsStore: jest.Mocked<UserSettingsStore> = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const getIdentityMock = jest.fn<
|
||||
Promise<BackstageIdentityResponse | undefined>,
|
||||
any
|
||||
>();
|
||||
const identityApi: jest.Mocked<Partial<IdentityApi>> = {
|
||||
getIdentity: getIdentityMock,
|
||||
};
|
||||
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
const router = await createRouterInternal({
|
||||
userSettingsStore,
|
||||
identity: identityApi as IdentityApi,
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /buckets/:bucket/keys/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
|
||||
getIdentityMock.mockResolvedValue({
|
||||
token: 'a',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user-1',
|
||||
},
|
||||
});
|
||||
|
||||
userSettingsStore.get.mockResolvedValue(setting);
|
||||
|
||||
const responses = await request(app)
|
||||
.get('/buckets/my-bucket/keys/my-key')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(setting);
|
||||
|
||||
expect(getIdentityMock).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.get).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.get).toHaveBeenCalledWith({
|
||||
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(
|
||||
'/buckets/my-bucket/keys/my-key',
|
||||
);
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /buckets/:bucket/keys/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
getIdentityMock.mockResolvedValue({
|
||||
token: 'a',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user-1',
|
||||
},
|
||||
});
|
||||
|
||||
userSettingsStore.delete.mockResolvedValue();
|
||||
|
||||
const responses = await request(app)
|
||||
.delete('/buckets/my-bucket/keys/my-key')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(204);
|
||||
|
||||
expect(getIdentityMock).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.delete).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.delete).toHaveBeenCalledWith({
|
||||
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(
|
||||
'/buckets/my-bucket/keys/my-key',
|
||||
);
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /buckets/:bucket/keys/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
|
||||
getIdentityMock.mockResolvedValue({
|
||||
token: 'a',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user-1',
|
||||
},
|
||||
});
|
||||
|
||||
userSettingsStore.set.mockResolvedValue();
|
||||
userSettingsStore.get.mockResolvedValue(setting);
|
||||
|
||||
const responses = await request(app)
|
||||
.put('/buckets/my-bucket/keys/my-key')
|
||||
.set('Authorization', 'Bearer foo')
|
||||
.send({ value: 'a' });
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(setting);
|
||||
|
||||
expect(getIdentityMock).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.set).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.set).toHaveBeenCalledWith({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
value: 'a',
|
||||
});
|
||||
expect(userSettingsStore.get).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.get).toHaveBeenCalledWith({
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the value is not given', async () => {
|
||||
getIdentityMock.mockResolvedValue({
|
||||
token: 'a',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user-1',
|
||||
},
|
||||
});
|
||||
|
||||
const responses = await request(app)
|
||||
.put('/buckets/my-bucket/keys/my-key')
|
||||
.set('Authorization', 'Bearer foo')
|
||||
.send({});
|
||||
|
||||
expect(responses.status).toEqual(400);
|
||||
|
||||
expect(getIdentityMock).toHaveBeenCalledTimes(1);
|
||||
expect(userSettingsStore.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).get(
|
||||
'/buckets/my-bucket/keys/my-key',
|
||||
);
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { errorHandler, PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { AuthenticationError, InputError } from '@backstage/errors';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import express, { Request } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore';
|
||||
import { UserSettingsStore } from '../database/UserSettingsStore';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
database: PluginDatabaseManager;
|
||||
identity: IdentityApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the user settings backend routes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const userSettingsStore = await DatabaseUserSettingsStore.create({
|
||||
database: options.database,
|
||||
});
|
||||
|
||||
return await createRouterInternal({
|
||||
userSettingsStore,
|
||||
identity: options.identity,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRouterInternal(options: {
|
||||
identity: IdentityApi;
|
||||
userSettingsStore: UserSettingsStore;
|
||||
}): Promise<express.Router> {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
/**
|
||||
* Helper method to extract the userEntityRef from the request.
|
||||
*/
|
||||
const getUserEntityRef = async (req: Request): Promise<string> => {
|
||||
// throws an AuthenticationError in case the token exists but is invalid
|
||||
const identity = await options.identity.getIdentity({ request: req });
|
||||
if (!identity) {
|
||||
throw new AuthenticationError(`Missing token in 'authorization' header`);
|
||||
}
|
||||
|
||||
return identity.identity.userEntityRef;
|
||||
};
|
||||
|
||||
// get a single value
|
||||
router.get('/buckets/:bucket/keys/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
|
||||
const setting = await options.userSettingsStore.get({
|
||||
userEntityRef,
|
||||
bucket,
|
||||
key,
|
||||
});
|
||||
|
||||
res.json(setting);
|
||||
});
|
||||
|
||||
// set a single value
|
||||
router.put('/buckets/:bucket/keys/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
if (value === undefined) {
|
||||
throw new InputError('Missing required field "value"');
|
||||
}
|
||||
|
||||
await options.userSettingsStore.set({
|
||||
userEntityRef,
|
||||
bucket,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const setting = await options.userSettingsStore.get({
|
||||
userEntityRef,
|
||||
bucket,
|
||||
key,
|
||||
});
|
||||
|
||||
res.json(setting);
|
||||
});
|
||||
|
||||
// get a single value
|
||||
router.delete('/buckets/:bucket/keys/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
|
||||
await options.userSettingsStore.delete({ userEntityRef, bucket, key });
|
||||
|
||||
res.send(204).end();
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { Server } from 'http';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore';
|
||||
import { createRouterInternal } 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: 'storage-backend' });
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
return Knex({
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
|
||||
const identityMock: IdentityApi = {
|
||||
async getIdentity({ request }) {
|
||||
const token = request.headers.authorization?.split(' ')[1];
|
||||
return {
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: token || 'user:default/john_doe',
|
||||
},
|
||||
token: token || 'no-token',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const router = await createRouterInternal({
|
||||
userSettingsStore: await DatabaseUserSettingsStore.create({
|
||||
database: { getClient: async () => database },
|
||||
}),
|
||||
identity: identityMock,
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/user-settings', router);
|
||||
|
||||
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,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 {};
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
Welcome to the user-settings plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
|
||||
## About the plugin
|
||||
|
||||
This plugin provides two components, `<UserSettings />` is intended to be used within the [`<Sidebar>`](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name.
|
||||
This plugin provides two components, `<UserSettings />` is intended to be used within the [`<Sidebar>`](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. The second component is a settings page where the user can control different settings across the App.
|
||||
|
||||
The second component is a settings page where the user can control different settings across the App.
|
||||
It also provides a `UserSettingsStorage` implementation of the `StorageApi`, to
|
||||
be used in the frontend as a persistent alternative to the builtin `WebStorage`.
|
||||
Please see [the backend
|
||||
README](https://github.com/backstage/backstage/tree/master/plugins/user-settings-backend)
|
||||
for installation instructions.
|
||||
|
||||
## Usage
|
||||
## Components Usage
|
||||
|
||||
Add the item to the Sidebar:
|
||||
|
||||
|
||||
@@ -8,11 +8,19 @@
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { ErrorApi } from '@backstage/core-plugin-api';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { ProfileInfo } from '@backstage/core-plugin-api';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { SessionApi } from '@backstage/core-plugin-api';
|
||||
import { StorageApi } from '@backstage/core-plugin-api';
|
||||
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export const DefaultProviderSettings: (props: {
|
||||
@@ -83,6 +91,30 @@ export const UserSettingsSignInAvatar: (props: {
|
||||
size?: number;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export class UserSettingsStorage implements StorageApi {
|
||||
// (undocumented)
|
||||
static create(options: {
|
||||
fetchApi: FetchApi;
|
||||
discoveryApi: DiscoveryApi;
|
||||
errorApi: ErrorApi;
|
||||
identityApi: IdentityApi;
|
||||
namespace?: string;
|
||||
}): UserSettingsStorage;
|
||||
// (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 const UserSettingsTab: (props: UserSettingsTabProps) => JSX.Element;
|
||||
|
||||
|
||||
@@ -32,14 +32,18 @@
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-app-api": "^1.1.0-next.3",
|
||||
"@backstage/core-components": "^0.11.1-next.3",
|
||||
"@backstage/core-plugin-api": "^1.0.6-next.3",
|
||||
"@backstage/errors": "^1.1.1-next.0",
|
||||
"@backstage/theme": "^0.2.16",
|
||||
"@backstage/types": "^1.0.0",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
"react-use": "^17.2.4",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
@@ -47,7 +51,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.19.0-next.3",
|
||||
"@backstage/core-app-api": "^1.1.0-next.3",
|
||||
"@backstage/dev-utils": "^1.0.6-next.2",
|
||||
"@backstage/test-utils": "^1.2.0-next.3",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* 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,
|
||||
IdentityApi,
|
||||
StorageApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { UserSettingsStorage } from './UserSettingsStorage';
|
||||
|
||||
describe('Persistent Storage API', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/api';
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const mockDiscoveryApi = {
|
||||
getBaseUrl: async () => mockBaseUrl,
|
||||
};
|
||||
const mockIdentityApi: Partial<IdentityApi> = {
|
||||
getCredentials: async () => ({ token: 'a-token' }),
|
||||
};
|
||||
|
||||
const createPersistentStorage = (
|
||||
args?: Partial<{
|
||||
fetchApi: FetchApi;
|
||||
discoveryApi: DiscoveryApi;
|
||||
errorApi: ErrorApi;
|
||||
namespace?: string;
|
||||
}>,
|
||||
): StorageApi => {
|
||||
return UserSettingsStorage.create({
|
||||
errorApi: mockErrorApi,
|
||||
fetchApi: new MockFetchApi(),
|
||||
discoveryApi: mockDiscoveryApi,
|
||||
identityApi: mockIdentityApi as IdentityApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined for values which are unset', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/: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}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: 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}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: 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}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: 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}/buckets/:bucket/keys/: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}/buckets/:bucket/keys/: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}/buckets/:bucket/keys/: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 silently treat the value as absent when the json can not be parsed', async () => {
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const rootStorage = createPersistentStorage({
|
||||
namespace: 'Test.Mock.Thing',
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/: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,
|
||||
});
|
||||
});
|
||||
|
||||
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}/buckets/:bucket/keys/:key`,
|
||||
async (_req, res, ctx) => {
|
||||
return res(ctx.json({ value: 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,231 @@
|
||||
/*
|
||||
* 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 { WebStorage } from '@backstage/core-app-api';
|
||||
import {
|
||||
DiscoveryApi,
|
||||
ErrorApi,
|
||||
FetchApi,
|
||||
IdentityApi,
|
||||
StorageApi,
|
||||
StorageValueSnapshot,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { JsonValue, Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
const JSON_HEADERS = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const buckets = new Map<string, UserSettingsStorage>();
|
||||
|
||||
/**
|
||||
* An implementation of the storage API, that uses the user-settings backend to
|
||||
* persist the data in the DB.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class UserSettingsStorage implements StorageApi {
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
|
||||
>();
|
||||
|
||||
private readonly observables = new Map<
|
||||
string,
|
||||
Observable<StorageValueSnapshot<JsonValue>>
|
||||
>();
|
||||
|
||||
private constructor(
|
||||
private readonly namespace: string,
|
||||
private readonly fetchApi: FetchApi,
|
||||
private readonly discoveryApi: DiscoveryApi,
|
||||
private readonly errorApi: ErrorApi,
|
||||
private readonly identityApi: IdentityApi,
|
||||
private readonly fallback: WebStorage,
|
||||
) {}
|
||||
|
||||
static create(options: {
|
||||
fetchApi: FetchApi;
|
||||
discoveryApi: DiscoveryApi;
|
||||
errorApi: ErrorApi;
|
||||
identityApi: IdentityApi;
|
||||
namespace?: string;
|
||||
}): UserSettingsStorage {
|
||||
return new UserSettingsStorage(
|
||||
options.namespace ?? 'default',
|
||||
options.fetchApi,
|
||||
options.discoveryApi,
|
||||
options.errorApi,
|
||||
options.identityApi,
|
||||
WebStorage.create({
|
||||
namespace: options.namespace,
|
||||
errorApi: 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 UserSettingsStorage(
|
||||
bucketPath,
|
||||
this.fetchApi,
|
||||
this.discoveryApi,
|
||||
this.errorApi,
|
||||
this.identityApi,
|
||||
this.fallback,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return buckets.get(bucketPath)!;
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
|
||||
const response = await this.fetchApi.fetch(fetchUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
this.notifyChanges({ key, presence: 'absent' });
|
||||
}
|
||||
|
||||
async set<T extends JsonValue>(key: string, data: T): Promise<void> {
|
||||
if (!(await this.isSignedIn())) {
|
||||
await this.fallback.set(key, data);
|
||||
this.notifyChanges({ key, presence: 'present', value: data });
|
||||
}
|
||||
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
|
||||
const response = await this.fetchApi.fetch(fetchUrl, {
|
||||
method: 'PUT',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ value: data }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
const { value } = await response.json();
|
||||
|
||||
this.notifyChanges({ key, value, presence: 'present' });
|
||||
}
|
||||
|
||||
observe$<T extends JsonValue>(
|
||||
key: string,
|
||||
): Observable<StorageValueSnapshot<T>> {
|
||||
if (!this.observables.has(key)) {
|
||||
this.observables.set(
|
||||
key,
|
||||
new ObservableImpl<StorageValueSnapshot<JsonValue>>(subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
|
||||
// TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change
|
||||
Promise.resolve()
|
||||
.then(() => this.get(key))
|
||||
.then(snapshot => subscriber.next(snapshot))
|
||||
.catch(error => this.errorApi.post(error));
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}).filter(({ key: messageKey }) => messageKey === key),
|
||||
);
|
||||
}
|
||||
|
||||
return this.observables.get(key) as Observable<StorageValueSnapshot<T>>;
|
||||
}
|
||||
|
||||
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
|
||||
return { key, presence: 'unknown' };
|
||||
}
|
||||
|
||||
private async get<T extends JsonValue>(
|
||||
key: string,
|
||||
): Promise<StorageValueSnapshot<T>> {
|
||||
if (!(await this.isSignedIn())) {
|
||||
// This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown
|
||||
return this.fallback.snapshot(key);
|
||||
}
|
||||
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
const response = await this.fetchApi.fetch(fetchUrl);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { key, presence: 'absent' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
try {
|
||||
const { value: rawValue } = await response.json();
|
||||
const value = JSON.parse(JSON.stringify(rawValue), (_key, val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
Object.freeze(val);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
|
||||
return { key, presence: 'present', value };
|
||||
} catch {
|
||||
// If the value is not valid JSON, we return an unknown presence. This should never happen
|
||||
return { key, presence: 'absent' };
|
||||
}
|
||||
}
|
||||
|
||||
private async getFetchUrl(key: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
|
||||
const encodedNamespace = encodeURIComponent(this.namespace);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
return `${baseUrl}/buckets/${encodedNamespace}/keys/${encodedKey}`;
|
||||
}
|
||||
|
||||
private async notifyChanges<T extends JsonValue>(
|
||||
snapshot: StorageValueSnapshot<T>,
|
||||
) {
|
||||
for (const subscription of this.subscribers) {
|
||||
try {
|
||||
subscription.next(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isSignedIn(): Promise<boolean> {
|
||||
try {
|
||||
const credentials = await this.identityApi.getCredentials();
|
||||
return credentials?.token ? true : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 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 { UserSettingsStorage } from './UserSettingsStorage';
|
||||
@@ -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 './StorageApi';
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Backstage plugin that provides a settings page
|
||||
* A Backstage plugin that provides various per-user settings functionality.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './apis';
|
||||
export {
|
||||
userSettingsPlugin,
|
||||
userSettingsPlugin as plugin,
|
||||
|
||||
Reference in New Issue
Block a user