Merge pull request #1064 from spotify/blam/persistant-storage

Simple WebStorage Persistant API
This commit is contained in:
Ben Lambert
2020-06-03 21:58:31 +02:00
committed by GitHub
7 changed files with 349 additions and 1 deletions
+7 -1
View File
@@ -30,6 +30,8 @@ import {
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
import {
@@ -45,8 +47,12 @@ import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(circleCIApiRef, new CircleCIApi());
builder.add(featureFlagsApiRef, new FeatureFlags());
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createApiRef } from '../ApiRef';
import { Observable } from '../../types';
import { ErrorApi } from './ErrorApi';
export type StorageValueChange<T = any> = {
key: string;
newValue?: T;
};
export type CreateStorageApiOptions = {
errorApi: ErrorApi;
namespace?: string;
};
export interface StorageApi {
/**
* Create a bucket to store data in.
* @param {String} name Namespace for the storage to be stored under,
* will inherit previous namespaces too
*/
forBucket(name: string): StorageApi;
/**
* Get the current value for persistent data, use observe$ to be notified of updates.
*
* @param {String} key Unique key associated with the data.
* @return {Object} data The data that should is stored.
*/
get<T>(key: string): T | undefined;
/**
* Remove persistent data.
*
* @param {String} key Unique key associated with the data.
*/
remove(key: string): Promise<void>;
/**
* Save persistant data, and emit messages to anyone that is using observe$ for this key
*
* @param {String} key Unique key associated with the data.
*/
set(key: string, data: any): Promise<void>;
/**
* Observe changes on a particular key in the bucket
* @param {String} key Unique key associated with the data
*/
observe$<T>(key: string): Observable<StorageValueChange<T>>;
}
export const storageApiRef = createApiRef<StorageApi>({
id: 'core.storage',
description: 'Provides the ability to store data which is unique to the user',
});
@@ -28,3 +28,4 @@ export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -0,0 +1,164 @@
/*
* Copyright 2020 Spotify AB
*
* 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 './WebStorage';
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
describe('WebStorage Storage API', () => {
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const createWebStorage = (
args?: Partial<CreateStorageApiOptions>,
): StorageApi => {
return WebStorage.create({
errorApi: mockErrorApi,
...args,
});
};
it('should return undefined for values which are unset', async () => {
const storage = createWebStorage();
expect(storage.get('myfakekey')).toBeUndefined();
});
it('should allow the setting and getting of the simple data structures', async () => {
const storage = createWebStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.get('myfakekey')).toBe('helloimastring');
expect(storage.get('mysecondfakekey')).toBe(1234);
expect(storage.get('mythirdfakekey')).toBe(true);
});
it('should allow setting of complex datastructures', async () => {
const storage = createWebStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.get('myfakekey')).toEqual(mockData);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise(resolve => {
storage.observe$<String>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createWebStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
expect(firstStorage.get(keyName)).toBe('boop');
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
const rootStorage = createWebStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing',
});
localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
const value = rootStorage.get('key');
expect(value).toBe(undefined);
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
expect(mockErrorApi.post).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Error when parsing JSON config from storage for: key',
}),
);
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
StorageApi,
StorageValueChange,
ErrorApi,
CreateStorageApiOptions,
} from '../../definitions';
import { Observable } from '../../../types';
import ObservableImpl from 'zen-observable';
export class WebStorage implements StorageApi {
constructor(
private readonly namespace: string,
private readonly errorApi: ErrorApi,
) {}
static create(options: CreateStorageApiOptions): WebStorage {
return new WebStorage(options.namespace ?? '', options.errorApi);
}
get<T>(key: string): T | undefined {
try {
const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
return storage ?? undefined;
} catch (e) {
this.errorApi.post(
new Error(`Error when parsing JSON config from storage for: ${key}`),
);
}
return undefined;
}
forBucket(name: string): WebStorage {
return new WebStorage(`${this.namespace}/${name}`, this.errorApi);
}
async set<T>(key: string, data: T): Promise<void> {
localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
this.notifyChanges({ key, newValue: data });
}
async remove(key: string): Promise<void> {
localStorage.removeItem(this.getKeyName(key));
this.notifyChanges({ key, newValue: undefined });
}
observe$<T>(key: string): Observable<StorageValueChange<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T>(message: StorageValueChange<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueChange>
>();
private readonly observable = new ObservableImpl<StorageValueChange>(
subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
},
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { WebStorage } from './WebStorage';
@@ -25,3 +25,4 @@ export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
export * from './StorageApi';