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