move the settings storage to the user settings frontend
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
DiscoveryApi,
|
||||
ErrorApi,
|
||||
FetchApi,
|
||||
IdentityApi,
|
||||
StorageApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { UserSettingsStorage } from './UserSettingsStorage';
|
||||
|
||||
describe('Persistent Storage API', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/api';
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const mockDiscoveryApi = {
|
||||
getBaseUrl: async () => mockBaseUrl,
|
||||
};
|
||||
const mockIdentityApi: Partial<IdentityApi> = {
|
||||
getCredentials: async () => ({ token: 'a-token' }),
|
||||
};
|
||||
|
||||
const createPersistentStorage = (
|
||||
args?: Partial<{
|
||||
fetchApi: FetchApi;
|
||||
discoveryApi: DiscoveryApi;
|
||||
errorApi: ErrorApi;
|
||||
namespace?: string;
|
||||
}>,
|
||||
): StorageApi => {
|
||||
return UserSettingsStorage.create({
|
||||
errorApi: mockErrorApi,
|
||||
fetchApi: new MockFetchApi(),
|
||||
discoveryApi: mockDiscoveryApi,
|
||||
identityApi: mockIdentityApi as IdentityApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined for values which are unset', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (_req, res, ctx) => {
|
||||
return res(ctx.json({ value: 'a' }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(storage.snapshot('myfakekey').value).toBeUndefined();
|
||||
expect(storage.snapshot('myfakekey')).toEqual({
|
||||
key: 'myfakekey',
|
||||
presence: 'unknown',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow setting of a simple data structure', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
const dummyValue = 'a';
|
||||
|
||||
server.use(
|
||||
rest.put(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: dummyValue };
|
||||
|
||||
expect(body).toEqual(data);
|
||||
|
||||
return res(ctx.json(data));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await storage.set('my-key', dummyValue);
|
||||
});
|
||||
|
||||
it('should allow setting of a complex data structure', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
const dummyValue = {
|
||||
some: 'nice data',
|
||||
with: { nested: 'values', nice: true },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.put(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: dummyValue };
|
||||
expect(body).toEqual(data);
|
||||
|
||||
return res(ctx.json(data));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await storage.set('my-key', dummyValue);
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when setting a new value', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const mockData = { hello: 'im a great new value' };
|
||||
|
||||
server.use(
|
||||
rest.put(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const data = { value: mockData };
|
||||
expect(body).toEqual(data);
|
||||
|
||||
return res(ctx.json(data));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
storage.observe$<typeof mockData>('correctKey').subscribe({
|
||||
next: snapshot => {
|
||||
selectedKeyNextHandler(snapshot);
|
||||
if (snapshot.presence === 'present') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.set('correctKey', mockData);
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
presence: 'present',
|
||||
value: mockData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when deleting a value', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.delete(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (_req, res, ctx) => {
|
||||
return res(ctx.status(204));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
storage.observe$('correctKey').subscribe({
|
||||
next: snapshot => {
|
||||
selectedKeyNextHandler(snapshot);
|
||||
if (snapshot.presence === 'absent') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.remove('correctKey');
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not clash with other namespaces when creating buckets', async () => {
|
||||
const rootStorage = createPersistentStorage();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.put(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const { bucket, key } = req.params;
|
||||
const { value } = await req.json();
|
||||
|
||||
expect(bucket).toEqual('default.profile.something.deep');
|
||||
expect(key).toEqual('test2');
|
||||
|
||||
return res(ctx.json({ value }));
|
||||
},
|
||||
),
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const { bucket, key } = req.params;
|
||||
|
||||
expect(bucket).toEqual('default.profile/something');
|
||||
expect(key).toEqual('deep/test2');
|
||||
|
||||
return res(ctx.status(404));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// when getting key test2 it will translate to default.profile.something.deep/test2
|
||||
const firstStorage = rootStorage
|
||||
.forBucket('profile')
|
||||
.forBucket('something')
|
||||
.forBucket('deep');
|
||||
// when getting key deep/test2 it will translate to default.profile.something/deep/test2
|
||||
const secondStorage = rootStorage.forBucket('profile/something');
|
||||
|
||||
await firstStorage.set('test2', { error: true });
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
secondStorage.observe$('deep/test2').subscribe({
|
||||
next: snapshot => {
|
||||
selectedKeyNextHandler(snapshot);
|
||||
if (snapshot.presence === 'absent') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
secondStorage.snapshot('deep/test2');
|
||||
});
|
||||
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'deep/test2',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
expect(mockErrorApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should silently treat the value as absent when the json can not be parsed', async () => {
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const rootStorage = createPersistentStorage({
|
||||
namespace: 'Test.Mock.Thing',
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (req, res, ctx) => {
|
||||
const { bucket, key } = req.params;
|
||||
expect(bucket).toEqual('Test.Mock.Thing');
|
||||
expect(key).toEqual('key');
|
||||
return res(ctx.text('{ invalid: json string }'));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
rootStorage.observe$('key').subscribe({
|
||||
next: snapshot => {
|
||||
selectedKeyNextHandler(snapshot);
|
||||
if (snapshot.presence === 'absent') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
rootStorage.snapshot('key');
|
||||
});
|
||||
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'key',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should freeze the snapshot value', async () => {
|
||||
const storage = createPersistentStorage();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
|
||||
async (_req, res, ctx) => {
|
||||
return res(ctx.json({ value: data }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
storage.observe$('key').subscribe({
|
||||
next: snapshot => {
|
||||
selectedKeyNextHandler(snapshot);
|
||||
if (snapshot.presence === 'present') {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
storage.snapshot('key');
|
||||
});
|
||||
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'key',
|
||||
presence: 'present',
|
||||
value: { baz: [{ foo: 'bar' }], foo: 'bar' },
|
||||
});
|
||||
|
||||
const snapshot = selectedKeyNextHandler.mock.calls[0][0];
|
||||
expect(() => {
|
||||
snapshot.value.foo = 'buzz';
|
||||
}).toThrow(/Cannot assign to read only property/);
|
||||
expect(() => {
|
||||
snapshot.value.baz[0].foo = 'buzz';
|
||||
}).toThrow(/Cannot assign to read only property/);
|
||||
expect(() => {
|
||||
snapshot.value.baz.push({ foo: 'buzz' });
|
||||
}).toThrow(/Cannot add property 1, object is not extensible/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { WebStorage } from '@backstage/core-app-api';
|
||||
import {
|
||||
DiscoveryApi,
|
||||
ErrorApi,
|
||||
FetchApi,
|
||||
IdentityApi,
|
||||
StorageApi,
|
||||
StorageValueSnapshot,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { JsonValue, Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
const JSON_HEADERS = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const buckets = new Map<string, UserSettingsStorage>();
|
||||
|
||||
/**
|
||||
* An implementation of the storage API, that uses the user-settings backend to
|
||||
* persist the data in the DB.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class UserSettingsStorage implements StorageApi {
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
|
||||
>();
|
||||
|
||||
private readonly observables = new Map<
|
||||
string,
|
||||
Observable<StorageValueSnapshot<JsonValue>>
|
||||
>();
|
||||
|
||||
private constructor(
|
||||
private readonly namespace: string,
|
||||
private readonly fetchApi: FetchApi,
|
||||
private readonly discoveryApi: DiscoveryApi,
|
||||
private readonly errorApi: ErrorApi,
|
||||
private readonly identityApi: IdentityApi,
|
||||
private readonly fallback: WebStorage,
|
||||
) {}
|
||||
|
||||
static create(options: {
|
||||
fetchApi: FetchApi;
|
||||
discoveryApi: DiscoveryApi;
|
||||
errorApi: ErrorApi;
|
||||
identityApi: IdentityApi;
|
||||
namespace?: string;
|
||||
}): UserSettingsStorage {
|
||||
return new UserSettingsStorage(
|
||||
options.namespace ?? 'default',
|
||||
options.fetchApi,
|
||||
options.discoveryApi,
|
||||
options.errorApi,
|
||||
options.identityApi,
|
||||
WebStorage.create({
|
||||
namespace: options.namespace,
|
||||
errorApi: options.errorApi,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
forBucket(name: string): StorageApi {
|
||||
// use dot instead of slash separator to have nicer URLs
|
||||
const bucketPath = `${this.namespace}.${name}`;
|
||||
|
||||
if (!buckets.has(bucketPath)) {
|
||||
buckets.set(
|
||||
bucketPath,
|
||||
new UserSettingsStorage(
|
||||
bucketPath,
|
||||
this.fetchApi,
|
||||
this.discoveryApi,
|
||||
this.errorApi,
|
||||
this.identityApi,
|
||||
this.fallback,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return buckets.get(bucketPath)!;
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
|
||||
const response = await this.fetchApi.fetch(fetchUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
this.notifyChanges({ key, presence: 'absent' });
|
||||
}
|
||||
|
||||
async set<T extends JsonValue>(key: string, data: T): Promise<void> {
|
||||
if (!(await this.isSignedIn())) {
|
||||
await this.fallback.set(key, data);
|
||||
this.notifyChanges({ key, presence: 'present', value: data });
|
||||
}
|
||||
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
|
||||
const response = await this.fetchApi.fetch(fetchUrl, {
|
||||
method: 'PUT',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ value: data }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
const { value } = await response.json();
|
||||
|
||||
this.notifyChanges({ key, value, presence: 'present' });
|
||||
}
|
||||
|
||||
observe$<T extends JsonValue>(
|
||||
key: string,
|
||||
): Observable<StorageValueSnapshot<T>> {
|
||||
if (!this.observables.has(key)) {
|
||||
this.observables.set(
|
||||
key,
|
||||
new ObservableImpl<StorageValueSnapshot<JsonValue>>(subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
|
||||
// TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change
|
||||
Promise.resolve()
|
||||
.then(() => this.get(key))
|
||||
.then(snapshot => subscriber.next(snapshot))
|
||||
.catch(error => this.errorApi.post(error));
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}).filter(({ key: messageKey }) => messageKey === key),
|
||||
);
|
||||
}
|
||||
|
||||
return this.observables.get(key) as Observable<StorageValueSnapshot<T>>;
|
||||
}
|
||||
|
||||
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
|
||||
return { key, presence: 'unknown' };
|
||||
}
|
||||
|
||||
private async get<T extends JsonValue>(
|
||||
key: string,
|
||||
): Promise<StorageValueSnapshot<T>> {
|
||||
if (!(await this.isSignedIn())) {
|
||||
// This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown
|
||||
return this.fallback.snapshot(key);
|
||||
}
|
||||
|
||||
const fetchUrl = await this.getFetchUrl(key);
|
||||
const response = await this.fetchApi.fetch(fetchUrl);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { key, presence: 'absent' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
try {
|
||||
const { value: rawValue } = await response.json();
|
||||
const value = JSON.parse(JSON.stringify(rawValue), (_key, val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
Object.freeze(val);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
|
||||
return { key, presence: 'present', value };
|
||||
} catch {
|
||||
// If the value is not valid JSON, we return an unknown presence. This should never happen
|
||||
return { key, presence: 'absent' };
|
||||
}
|
||||
}
|
||||
|
||||
private async getFetchUrl(key: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
|
||||
const encodedNamespace = encodeURIComponent(this.namespace);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
return `${baseUrl}/buckets/${encodedNamespace}/keys/${encodedKey}`;
|
||||
}
|
||||
|
||||
private async notifyChanges<T extends JsonValue>(
|
||||
snapshot: StorageValueSnapshot<T>,
|
||||
) {
|
||||
for (const subscription of this.subscribers) {
|
||||
try {
|
||||
subscription.next(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isSignedIn(): Promise<boolean> {
|
||||
try {
|
||||
const credentials = await this.identityApi.getCredentials();
|
||||
return credentials?.token ? true : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { UserSettingsStorage } from './UserSettingsStorage';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './StorageApi';
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Backstage plugin that provides a settings page
|
||||
* A Backstage plugin that provides various per-user settings functionality.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './apis';
|
||||
export {
|
||||
userSettingsPlugin,
|
||||
userSettingsPlugin as plugin,
|
||||
|
||||
Reference in New Issue
Block a user