diff --git a/.changeset/healthy-oranges-fly.md b/.changeset/healthy-oranges-fly.md new file mode 100644 index 0000000000..a10c56f4d0 --- /dev/null +++ b/.changeset/healthy-oranges-fly.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-user-settings-backend': minor +--- + +Added new plugin `@backstage/plugin-user-settings-backend` to store user related +settings in the database. diff --git a/.changeset/six-apricots-add.md b/.changeset/six-apricots-add.md new file mode 100644 index 0000000000..95ec68b1d5 --- /dev/null +++ b/.changeset/six-apricots-add.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Added a `UserSettingsStorage` implementation of the `StorageApi` for use as +drop-in replacement for the `WebStorage`, in conjunction with the newly created +`@backstage/plugin-user-settings-backend`. diff --git a/.changeset/sixty-apes-wait.md b/.changeset/sixty-apes-wait.md new file mode 100644 index 0000000000..1273307519 --- /dev/null +++ b/.changeset/sixty-apes-wait.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-shortcuts': patch +--- + +Ensure that a stable observable is used in the shortcuts API diff --git a/.changeset/strange-geckos-know.md b/.changeset/strange-geckos-know.md new file mode 100644 index 0000000000..b14762d3e5 --- /dev/null +++ b/.changeset/strange-geckos-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Clarify that the `WebStorage` observable returns `JsonValue` items. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b0f1e828cd..cb1dd5d322 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -60,6 +60,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 diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index f2bc4b6665..4d1db6fde1 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -556,7 +556,9 @@ export class WebStorage implements StorageApi { // (undocumented) get(key: string): T | undefined; // (undocumented) - observe$(key: string): Observable>; + observe$( + key: string, + ): Observable>; // (undocumented) remove(key: string): Promise; // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts index ac3d3f20f2..b0cbfbd883 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -86,7 +86,9 @@ export class WebStorage implements StorageApi { this.notifyChanges(key); } - observe$(key: string): Observable> { + observe$( + key: string, + ): Observable> { return this.observable.filter(({ key: messageKey }) => messageKey === key); } diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts index 9f3561be67..65aeb86e3f 100644 --- a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts @@ -27,12 +27,16 @@ import Observable from 'zen-observable'; * @public */ export class LocalStoredShortcuts implements ShortcutApi { - constructor(private readonly storageApi: StorageApi) {} + private readonly shortcuts: Observable; + + constructor(private readonly storageApi: StorageApi) { + this.shortcuts = Observable.from( + this.storageApi.observe$('items'), + ).map(snapshot => snapshot.value ?? []); + } shortcut$() { - return Observable.from(this.storageApi.observe$('items')).map( - snapshot => snapshot.value ?? [], - ); + return this.shortcuts; } get() { diff --git a/plugins/user-settings-backend/.eslintrc.js b/plugins/user-settings-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/user-settings-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md new file mode 100644 index 0000000000..d12ee0aa89 --- /dev/null +++ b/plugins/user-settings-backend/README.md @@ -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' +``` diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md new file mode 100644 index 0000000000..1bc37bbec7 --- /dev/null +++ b/plugins/user-settings-backend/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + identity: IdentityApi; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/user-settings-backend/migrations/20220824183000_init.js b/plugins/user-settings-backend/migrations/20220824183000_init.js new file mode 100644 index 0000000000..a519969a98 --- /dev/null +++ b/plugins/user-settings-backend/migrations/20220824183000_init.js @@ -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'); +}; diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json new file mode 100644 index 0000000000..f1b4b503e1 --- /dev/null +++ b/plugins/user-settings-backend/package.json @@ -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" + ] +} diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts new file mode 100644 index 0000000000..eb50e26179 --- /dev/null +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -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('user_settings').insert(data); + const query = () => + knex('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'), + }, + ]); + }); + }); + }, +); diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts new file mode 100644 index 0000000000..b0fe8fdecc --- /dev/null +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -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 { + 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 { + const rows = await this.db('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 { + await this.db('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 { + await this.db('user_settings') + .where({ + user_entity_ref: options.userEntityRef, + bucket: options.bucket, + key: options.key, + }) + .delete(); + } +} diff --git a/plugins/user-settings-backend/src/database/UserSettingsStore.ts b/plugins/user-settings-backend/src/database/UserSettingsStore.ts new file mode 100644 index 0000000000..289c37523e --- /dev/null +++ b/plugins/user-settings-backend/src/database/UserSettingsStore.ts @@ -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; + + set(options: { + userEntityRef: string; + bucket: string; + key: string; + value: JsonValue; + }): Promise; + + delete(options: { + userEntityRef: string; + bucket: string; + key: string; + }): Promise; +} diff --git a/plugins/user-settings-backend/src/database/index.ts b/plugins/user-settings-backend/src/database/index.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/user-settings-backend/src/database/index.ts @@ -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 {}; diff --git a/plugins/user-settings-backend/src/index.ts b/plugins/user-settings-backend/src/index.ts new file mode 100644 index 0000000000..04d8accdaf --- /dev/null +++ b/plugins/user-settings-backend/src/index.ts @@ -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'; diff --git a/plugins/user-settings-backend/src/run.ts b/plugins/user-settings-backend/src/run.ts new file mode 100644 index 0000000000..178de0f786 --- /dev/null +++ b/plugins/user-settings-backend/src/run.ts @@ -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); +}); diff --git a/plugins/user-settings-backend/src/service/index.ts b/plugins/user-settings-backend/src/service/index.ts new file mode 100644 index 0000000000..8ec00d13c8 --- /dev/null +++ b/plugins/user-settings-backend/src/service/index.ts @@ -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'; diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts new file mode 100644 index 0000000000..64cdfcc465 --- /dev/null +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -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 = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + const getIdentityMock = jest.fn< + Promise, + any + >(); + const identityApi: jest.Mocked> = { + 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(); + }); + }); +}); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts new file mode 100644 index 0000000000..6792042585 --- /dev/null +++ b/plugins/user-settings-backend/src/service/router.ts @@ -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 { + 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 { + const router = Router(); + router.use(express.json()); + + /** + * Helper method to extract the userEntityRef from the request. + */ + const getUserEntityRef = async (req: Request): Promise => { + // 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; +} diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..6dd8c7f72d --- /dev/null +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -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 { + 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(); diff --git a/plugins/user-settings-backend/src/setupTests.ts b/plugins/user-settings-backend/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/user-settings-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md index 13cfe720c8..abe66120fe 100644 --- a/plugins/user-settings/README.md +++ b/plugins/user-settings/README.md @@ -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, `` is intended to be used within the [``](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. +This plugin provides two components, `` is intended to be used within the [``](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: diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index bc5c3a9c70..76eaed940e 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -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$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + // @public export const UserSettingsTab: (props: UserSettingsTabProps) => JSX.Element; diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index f99c0d206e..b4afc6766b 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -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", diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts new file mode 100644 index 0000000000..0ebd96d19c --- /dev/null +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts @@ -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 = { + 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(resolve => { + storage.observe$('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(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(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(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(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/); + }); +}); diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts new file mode 100644 index 0000000000..8905b4aeea --- /dev/null +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts @@ -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(); + +/** + * 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> + >(); + + private readonly observables = new Map< + string, + Observable> + >(); + + 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 { + 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(key: string, data: T): Promise { + 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$( + key: string, + ): Observable> { + if (!this.observables.has(key)) { + this.observables.set( + key, + new ObservableImpl>(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>; + } + + snapshot(key: string): StorageValueSnapshot { + return { key, presence: 'unknown' }; + } + + private async get( + key: string, + ): Promise> { + 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( + snapshot: StorageValueSnapshot, + ) { + for (const subscription of this.subscribers) { + try { + subscription.next(snapshot); + } catch { + // ignore + } + } + } + + private async isSignedIn(): Promise { + try { + const credentials = await this.identityApi.getCredentials(); + return credentials?.token ? true : false; + } catch { + return false; + } + } +} diff --git a/plugins/user-settings/src/apis/StorageApi/index.ts b/plugins/user-settings/src/apis/StorageApi/index.ts new file mode 100644 index 0000000000..9127cdfef4 --- /dev/null +++ b/plugins/user-settings/src/apis/StorageApi/index.ts @@ -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'; diff --git a/plugins/user-settings/src/apis/index.ts b/plugins/user-settings/src/apis/index.ts new file mode 100644 index 0000000000..1598430773 --- /dev/null +++ b/plugins/user-settings/src/apis/index.ts @@ -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'; diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts index 0654b7fced..bc878ecbb3 100644 --- a/plugins/user-settings/src/index.ts +++ b/plugins/user-settings/src/index.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index 10e0f5e4f4..5ff7d3e304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7390,6 +7390,28 @@ __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.3 + "@backstage/backend-test-utils": ^0.1.28-next.3 + "@backstage/catalog-model": ^1.1.1-next.0 + "@backstage/cli": ^0.19.0-next.1 + "@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 + "@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" @@ -7399,8 +7421,10 @@ __metadata: "@backstage/core-components": ^0.11.1-next.3 "@backstage/core-plugin-api": ^1.0.6-next.3 "@backstage/dev-utils": ^1.0.6-next.2 + "@backstage/errors": ^1.1.1-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@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 @@ -7412,6 +7436,7 @@ __metadata: cross-fetch: ^3.1.5 msw: ^0.47.0 react-use: ^17.2.4 + zen-observable: ^0.8.15 peerDependencies: react: ^16.13.1 || ^17.0.0 react-router: 6.0.0-beta.0 || ^6.3.0