diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 6a23de1edd..ec0088e219 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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()); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts new file mode 100644 index 0000000000..920ee56811 --- /dev/null +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -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 = { + 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(key: string): T | undefined; + + /** + * Remove persistent data. + * + * @param {String} key Unique key associated with the data. + */ + remove(key: string): Promise; + + /** + * 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; + + /** + * Observe changes on a particular key in the bucket + * @param {String} key Unique key associated with the data + */ + observe$(key: string): Observable>; +} + +export const storageApiRef = createApiRef({ + id: 'core.storage', + description: 'Provides the ability to store data which is unique to the user', +}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 2e9db325dc..41fbc839f9 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -28,3 +28,4 @@ export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..a96813347e --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -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, + ): 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$('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', + }), + ); + }); +}); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..8af8f760cf --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -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(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(key: string, data: T): Promise { + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + localStorage.removeItem(this.getKeyName(key)); + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/core-api/src/apis/implementations/StorageApi/index.ts b/packages/core-api/src/apis/implementations/StorageApi/index.ts new file mode 100644 index 0000000000..33b0094551 --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/index.ts @@ -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'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index b5cc250ae4..e6d23fee21 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -25,3 +25,4 @@ export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; export * from './OAuthRequestApi'; +export * from './StorageApi';