feat: add new user settings backend
Adding a new user settings backend and PersistentStorage to store user related settings in the database. Signed-off-by: Dominik Schwank <dominik.schwank@sda.se>
This commit is contained in:
committed by
Fredrik Adelöw
parent
5b2d037b94
commit
108cdc3912
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import {
|
||||
DatabaseUserSettingsStore,
|
||||
RawDbUserSettingsRow,
|
||||
} from './DatabaseUserSettingsStore';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_13', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
async function createStore(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
return {
|
||||
knex,
|
||||
storage: await DatabaseUserSettingsStore.create(knex),
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DatabaseUserSettingsStore (%s)',
|
||||
databaseId => {
|
||||
let storage: DatabaseUserSettingsStore;
|
||||
let knex: Knex;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, knex } = await createStore(databaseId));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
await knex('user_settings').del();
|
||||
});
|
||||
|
||||
const insert = (data: RawDbUserSettingsRow[]) =>
|
||||
knex<RawDbUserSettingsRow>('user_settings').insert(data);
|
||||
const query = () =>
|
||||
knex<RawDbUserSettingsRow>('user_settings')
|
||||
.orderBy('user_entity_ref')
|
||||
.select();
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return empty user settings', async () => {
|
||||
expect(
|
||||
await storage.transaction(tx =>
|
||||
storage.getAll(tx, { userEntityRef: 'user-1' }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all user settings', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-b',
|
||||
value: 'value-b',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
await storage.transaction(tx =>
|
||||
storage.getAll(tx, { userEntityRef: 'user-1' }),
|
||||
),
|
||||
).toEqual([
|
||||
{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' },
|
||||
{ bucket: 'bucket-a', key: 'key-b', value: 'value-b' },
|
||||
{ bucket: 'bucket-c', key: 'key-c', value: 'value-c' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should delete all user settings', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
await storage.transaction(tx =>
|
||||
storage.deleteAll(tx, { userEntityRef: 'user-1' }),
|
||||
);
|
||||
|
||||
expect(await query()).toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBucket', () => {
|
||||
it('should return an empty bucket', async () => {
|
||||
expect(
|
||||
await storage.transaction(tx =>
|
||||
storage.getBucket(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the settings of the bucket', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
await storage.transaction(tx =>
|
||||
storage.getBucket(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
}),
|
||||
),
|
||||
).toEqual([{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBucket', () => {
|
||||
it('should delete a bucket', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
await storage.transaction(tx =>
|
||||
storage.deleteBucket(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await query()).toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should throw an error', async () => {
|
||||
await expect(() =>
|
||||
storage.transaction(tx =>
|
||||
storage.get(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(`Unable to find 'key-c' in bucket 'bucket-c'`);
|
||||
});
|
||||
|
||||
it('should return the setting', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
await storage.transaction(tx =>
|
||||
storage.get(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
}),
|
||||
),
|
||||
).toEqual({ bucket: 'bucket-a', key: 'key-a', value: 'value-a' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should insert a new setting', async () => {
|
||||
await storage.transaction(tx =>
|
||||
storage.set(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await query()).toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should overwrite an existing setting', async () => {
|
||||
await storage.transaction(tx =>
|
||||
storage.set(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
}),
|
||||
);
|
||||
|
||||
await storage.transaction(tx =>
|
||||
storage.set(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-b',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await query()).toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-b',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should not throw an error if the entry does not exist', async () => {
|
||||
await expect(() =>
|
||||
storage.transaction(tx =>
|
||||
storage.delete(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return the setting', async () => {
|
||||
await insert([
|
||||
{
|
||||
user_entity_ref: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
value: 'value-a',
|
||||
},
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
|
||||
await storage.transaction(tx =>
|
||||
storage.delete(tx, {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'bucket-a',
|
||||
key: 'key-a',
|
||||
}),
|
||||
);
|
||||
expect(await query()).toEqual([
|
||||
{
|
||||
user_entity_ref: 'user-2',
|
||||
bucket: 'bucket-c',
|
||||
key: 'key-c',
|
||||
value: 'value-c',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import { type UserSetting, UserSettingsStore } from './UserSettingsStore';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-user-settings-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type RawDbUserSettingsRow = {
|
||||
user_entity_ref: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store to manage the user settings.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class DatabaseUserSettingsStore
|
||||
implements UserSettingsStore<Knex.Transaction>
|
||||
{
|
||||
static async create(knex: Knex): Promise<DatabaseUserSettingsStore> {
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
return new DatabaseUserSettingsStore(knex);
|
||||
}
|
||||
|
||||
private constructor(private readonly db: Knex) {}
|
||||
|
||||
async getAll(tx: Knex.Transaction, opts: { userEntityRef: string }) {
|
||||
const settings = await tx<RawDbUserSettingsRow>('user_settings')
|
||||
.where({ user_entity_ref: opts.userEntityRef })
|
||||
.select('bucket', 'key', 'value');
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
async deleteAll(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string },
|
||||
): Promise<void> {
|
||||
await tx('user_settings')
|
||||
.where({ user_entity_ref: opts.userEntityRef })
|
||||
.delete();
|
||||
}
|
||||
|
||||
async getBucket(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string; bucket: string },
|
||||
): Promise<UserSetting[]> {
|
||||
const settings = await tx<RawDbUserSettingsRow>('user_settings')
|
||||
.where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket })
|
||||
.select(['bucket', 'key', 'value']);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
async deleteBucket(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string; bucket: string },
|
||||
): Promise<void> {
|
||||
await tx('user_settings')
|
||||
.where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket })
|
||||
.delete();
|
||||
}
|
||||
|
||||
async get(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string; bucket: string; key: string },
|
||||
): Promise<UserSetting> {
|
||||
const setting = await tx<RawDbUserSettingsRow>('user_settings')
|
||||
.where({
|
||||
user_entity_ref: opts.userEntityRef,
|
||||
bucket: opts.bucket,
|
||||
key: opts.key,
|
||||
})
|
||||
.select(['bucket', 'key', 'value']);
|
||||
|
||||
if (!setting.length) {
|
||||
throw new NotFoundError(
|
||||
`Unable to find '${opts.key}' in bucket '${opts.bucket}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return setting[0];
|
||||
}
|
||||
|
||||
async set(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string; bucket: string; key: string; value: string },
|
||||
): Promise<void> {
|
||||
await tx<RawDbUserSettingsRow>('user_settings')
|
||||
.insert({
|
||||
user_entity_ref: opts.userEntityRef,
|
||||
bucket: opts.bucket,
|
||||
key: opts.key,
|
||||
value: opts.value,
|
||||
})
|
||||
.onConflict(['user_entity_ref', 'bucket', 'key'])
|
||||
.merge({ value: opts.value });
|
||||
}
|
||||
|
||||
async delete(
|
||||
tx: Knex.Transaction<any, any[]>,
|
||||
opts: { userEntityRef: string; bucket: string; key: string },
|
||||
): Promise<void> {
|
||||
await tx<RawDbUserSettingsRow>('user_settings')
|
||||
.where({
|
||||
user_entity_ref: opts.userEntityRef,
|
||||
bucket: opts.bucket,
|
||||
key: opts.key,
|
||||
})
|
||||
.delete();
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Knex.Transaction) => Promise<T>) {
|
||||
return await this.db.transaction(fn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type UserSetting = {
|
||||
bucket: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store definition for the user settings.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface UserSettingsStore<Transaction> {
|
||||
transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T>;
|
||||
|
||||
get(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string; bucket: string; key: string },
|
||||
): Promise<UserSetting>;
|
||||
|
||||
set(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string; bucket: string; key: string; value: string },
|
||||
): Promise<void>;
|
||||
|
||||
delete(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string; bucket: string; key: string },
|
||||
): Promise<void>;
|
||||
|
||||
getBucket(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string; bucket: string },
|
||||
): Promise<UserSetting[]>;
|
||||
|
||||
deleteBucket(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string; bucket: string },
|
||||
): Promise<void>;
|
||||
|
||||
getAll(
|
||||
tx: Transaction,
|
||||
opts: { userEntityRef: string },
|
||||
): Promise<UserSetting[]>;
|
||||
|
||||
deleteAll(tx: Transaction, opts: { userEntityRef: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export {
|
||||
DatabaseUserSettingsStore,
|
||||
type RawDbUserSettingsRow,
|
||||
} from './DatabaseUserSettingsStore';
|
||||
export type { UserSettingsStore, UserSetting } from './UserSettingsStore';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './service';
|
||||
export * from './database';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export {
|
||||
createRouter,
|
||||
createUserSettingsStore,
|
||||
type RouterOptions,
|
||||
} from './router';
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { UserSettingsStore } from '../database';
|
||||
import { createRouter } from './router';
|
||||
|
||||
describe('createRouter', () => {
|
||||
const userSettingsStore: jest.Mocked<UserSettingsStore<'tx'>> = {
|
||||
transaction: jest.fn(),
|
||||
deleteAll: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
getBucket: jest.fn(),
|
||||
deleteBucket: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const authenticateMock = jest.fn();
|
||||
const identityClient: jest.Mocked<Partial<IdentityClient>> = {
|
||||
authenticate: authenticateMock,
|
||||
};
|
||||
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
userSettingsStore.transaction.mockImplementation(fn => fn('tx'));
|
||||
|
||||
const router = await createRouter({
|
||||
userSettingsStore,
|
||||
identity: identityClient as IdentityClient,
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('returns ok', async () => {
|
||||
const settings = [
|
||||
{ bucket: 'a', key: 'a', value: 'a' },
|
||||
{ bucket: 'b', key: 'b', value: 'b' },
|
||||
];
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.getAll.mockResolvedValue(settings);
|
||||
|
||||
const responses = await request(app)
|
||||
.get('/')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(settings);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.getAll).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.getAll).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).get('/');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error if the token is not valid', async () => {
|
||||
authenticateMock.mockRejectedValue(
|
||||
new AuthenticationError('Invalid token'),
|
||||
);
|
||||
|
||||
const responses = await request(app)
|
||||
.get('/')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', () => {
|
||||
it('returns ok', async () => {
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.deleteAll.mockResolvedValue();
|
||||
|
||||
const responses = await request(app)
|
||||
.delete('/')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(204);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.deleteAll).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.deleteAll).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).delete('/');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:bucket', () => {
|
||||
it('returns ok', async () => {
|
||||
const settings = [
|
||||
{ bucket: 'my-bucket', key: 'a', value: 'a' },
|
||||
{ bucket: 'my-bucket', key: 'b', value: 'b' },
|
||||
];
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.getBucket.mockResolvedValue(settings);
|
||||
|
||||
const responses = await request(app)
|
||||
.get('/my-bucket')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(settings);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.getBucket).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.getBucket).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).get('/my-bucket');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.getBucket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:bucket', () => {
|
||||
it('returns ok', async () => {
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.deleteBucket.mockResolvedValue();
|
||||
|
||||
const responses = await request(app)
|
||||
.delete('/my-bucket')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(204);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.deleteBucket).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.deleteBucket).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).delete('/my-bucket');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.deleteBucket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:bucket/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.get.mockResolvedValue(setting);
|
||||
|
||||
const responses = await request(app)
|
||||
.get('/my-bucket/my-key')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(setting);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.get).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.get).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).get('/my-bucket/my-key');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:bucket/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.delete.mockResolvedValue();
|
||||
|
||||
const responses = await request(app)
|
||||
.delete('/my-bucket/my-key')
|
||||
.set('Authorization', 'Bearer foo');
|
||||
|
||||
expect(responses.status).toEqual(204);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.delete).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.delete).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).delete('/my-bucket/my-key');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:bucket/:key', () => {
|
||||
it('returns ok', async () => {
|
||||
const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' };
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
userSettingsStore.set.mockResolvedValue();
|
||||
userSettingsStore.get.mockResolvedValue(setting);
|
||||
|
||||
const responses = await request(app)
|
||||
.put('/my-bucket/my-key')
|
||||
.set('Authorization', 'Bearer foo')
|
||||
.send({ value: 'a' });
|
||||
|
||||
expect(responses.status).toEqual(200);
|
||||
expect(responses.body).toEqual(setting);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.set).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.set).toHaveBeenCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
value: 'a',
|
||||
});
|
||||
expect(userSettingsStore.get).toBeCalledTimes(1);
|
||||
expect(userSettingsStore.get).toBeCalledWith('tx', {
|
||||
userEntityRef: 'user-1',
|
||||
bucket: 'my-bucket',
|
||||
key: 'my-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if the value is not a string', async () => {
|
||||
authenticateMock.mockResolvedValue({
|
||||
identity: { userEntityRef: 'user-1' },
|
||||
});
|
||||
|
||||
const responses = await request(app)
|
||||
.put('/my-bucket/my-key')
|
||||
.set('Authorization', 'Bearer foo')
|
||||
.send({ value: { invalid: 'because not a string' } });
|
||||
|
||||
expect(responses.status).toEqual(400);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledWith('foo');
|
||||
expect(userSettingsStore.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error if the Authorization header is missing', async () => {
|
||||
const responses = await request(app).get('/my-bucket/my-key');
|
||||
|
||||
expect(responses.status).toEqual(401);
|
||||
expect(userSettingsStore.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { PluginDatabaseManager, errorHandler } from '@backstage/backend-common';
|
||||
import { AuthenticationError, InputError } from '@backstage/errors';
|
||||
import {
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
IdentityClient,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import express, { Request } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
|
||||
import { DatabaseUserSettingsStore, UserSettingsStore } from '../database';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function createUserSettingsStore(database: PluginDatabaseManager) {
|
||||
return await DatabaseUserSettingsStore.create(await database.getClient());
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions<T> {
|
||||
userSettingsStore: UserSettingsStore<T>;
|
||||
identity: IdentityClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the user settings backend routes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter<T>(
|
||||
options: RouterOptions<T>,
|
||||
): Promise<express.Router> {
|
||||
const { userSettingsStore, identity } = options;
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
/**
|
||||
* Helper method to extract the userEntityRef from the request.
|
||||
*/
|
||||
const getUserEntityRef = async (req: Request): Promise<string> => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(`Missing token in 'authorization' header`);
|
||||
}
|
||||
|
||||
// throws an AuthenticationError in case the token is invalid
|
||||
const user = await identity.authenticate(token);
|
||||
|
||||
return user.identity.userEntityRef;
|
||||
};
|
||||
|
||||
// get all user related settings
|
||||
router.get('/', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
|
||||
const settings = await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.getAll(tx, { userEntityRef }),
|
||||
);
|
||||
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
// remove all user related settings
|
||||
router.delete('/', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
|
||||
await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.deleteAll(tx, { userEntityRef }),
|
||||
);
|
||||
|
||||
res.send(204).end();
|
||||
});
|
||||
|
||||
// get a single bucket
|
||||
router.get('/:bucket', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket } = req.params;
|
||||
|
||||
const settings = await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.getBucket(tx, { userEntityRef, bucket }),
|
||||
);
|
||||
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
// delete a whole bucket
|
||||
router.delete('/:bucket', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket } = req.params;
|
||||
|
||||
await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.deleteBucket(tx, { userEntityRef, bucket }),
|
||||
);
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// get a single value
|
||||
router.get('/:bucket/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
|
||||
const setting = await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.get(tx, { userEntityRef, bucket, key }),
|
||||
);
|
||||
|
||||
res.json(setting);
|
||||
});
|
||||
|
||||
// set a single value
|
||||
router.put('/:bucket/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new InputError('Value must be a string');
|
||||
}
|
||||
|
||||
const setting = await userSettingsStore.transaction(async tx => {
|
||||
await userSettingsStore.set(tx, {
|
||||
userEntityRef,
|
||||
bucket,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
return userSettingsStore.get(tx, { userEntityRef, bucket, key });
|
||||
});
|
||||
|
||||
res.json(setting);
|
||||
});
|
||||
|
||||
// get a single value
|
||||
router.delete('/:bucket/:key', async (req, res) => {
|
||||
const userEntityRef = await getUserEntityRef(req);
|
||||
const { bucket, key } = req.params;
|
||||
|
||||
await userSettingsStore.transaction(tx =>
|
||||
userSettingsStore.delete(tx, { userEntityRef, bucket, key }),
|
||||
);
|
||||
|
||||
res.send(204).end();
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Server } from 'http';
|
||||
|
||||
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { DatabaseUserSettingsStore } from '../database';
|
||||
import { createRouter } from './router';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { Router } from 'express';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'storage-backend' });
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
return Knex({
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
|
||||
const identityMock = {
|
||||
authenticate: async token => ({
|
||||
identity: { userEntityRef: token ?? 'user:default/john_doe' },
|
||||
}),
|
||||
} as IdentityClient;
|
||||
|
||||
const router = await createRouter({
|
||||
userSettingsStore: await DatabaseUserSettingsStore.create(database),
|
||||
identity: identityMock,
|
||||
});
|
||||
|
||||
// set a custom authorization header to simplify the development
|
||||
const authWrapper = Router();
|
||||
authWrapper.use((req, _res, next) => {
|
||||
req.headers.authorization =
|
||||
req.headers.authorization ?? 'Bearer user:default/john_doe';
|
||||
next();
|
||||
});
|
||||
authWrapper.use(router);
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/user-settings', authWrapper);
|
||||
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export {};
|
||||
Reference in New Issue
Block a user