diff --git a/packages/embed-services/package.json b/packages/embed-services/package.json index 98ca1836b..0e4ba04b6 100644 --- a/packages/embed-services/package.json +++ b/packages/embed-services/package.json @@ -36,6 +36,7 @@ "devDependencies": { }, "dependencies": { + "@looker/sdk-rtl": "^21.6.0" }, "keywords": [ "Looker", diff --git a/packages/embed-services/src/EntityService.ts b/packages/embed-services/src/EntityService.ts new file mode 100644 index 000000000..822e283fb --- /dev/null +++ b/packages/embed-services/src/EntityService.ts @@ -0,0 +1,41 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import type { IAPIMethods } from '@looker/sdk-rtl' + +export interface IEmbedService { + /** Instantiated browser sdk */ + get sdk(): IAPIMethods +} + +export abstract class EntityService implements IEmbedService { + /** + * + * @param sdk + * @param timeToLive + */ + constructor(public sdk: IAPIMethods, readonly timeToLive: number) {} +} diff --git a/packages/embed-services/src/ItemList.spec.ts b/packages/embed-services/src/ItemList.spec.ts new file mode 100644 index 000000000..785a821da --- /dev/null +++ b/packages/embed-services/src/ItemList.spec.ts @@ -0,0 +1,142 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import type { IAPIMethods } from '@looker/sdk-rtl' +import { DEFAULT_TTL, ItemList } from './ItemList' + +interface TestItem { + id: string + name: string + content: string +} + +const mockSdk = {} as IAPIMethods + +const items: TestItem[] = [ + { id: 'foo', name: 'fooName', content: 'foo content' }, + { id: 'bar', name: 'barName', content: 'bar content' }, +] + +class TestItemList extends ItemList { + items = items +} + +describe('ItemList', () => { + let ItemList: ItemList + + beforeEach(() => { + ItemList = new TestItemList(mockSdk) + }) + + describe('indexBy', () => { + it('creates a keyed collection indexed by "id" by default', () => { + ItemList.index() + const actual = ItemList.indexedItems + expect(actual).toEqual({ + foo: items[0], + bar: items[1], + }) + }) + + it('creates a keyed collection by specified id', () => { + ItemList.index('name') + const expected = { + fooName: items[0], + barName: items[1], + } + const actual = ItemList.indexedItems + expect(actual).toEqual(expected) + + // remembers specified index on re index + ItemList.index() + expect(actual).toEqual(expected) + }) + }) + + describe('caching', () => { + let now: number + + beforeEach(() => { + now = Date.now() + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now) + }) + + it('correctly computes expiration time', () => { + const expected = now + DEFAULT_TTL * 1000 + ItemList.setExpiration() + expect(ItemList.expiresAt).toEqual(expected) + }) + + it('accepts a custom ttl', () => { + const customTtl = 3600 + ItemList = new TestItemList(mockSdk, customTtl) + ItemList.setExpiration() + const expected = now + customTtl * 1000 + expect(ItemList.expiresAt).toEqual(expected) + }) + + it('clearIfExpired clears when expired', () => { + ItemList = new TestItemList(mockSdk, -100) + expect(ItemList.items).toHaveLength(2) + ItemList.clearIfExpired() + expect(ItemList.items).toHaveLength(0) + }) + + it('clearIfExpired does not clear if still valid', () => { + ItemList.setExpiration() + expect(ItemList.items).toHaveLength(2) + ItemList.clearIfExpired() + expect(ItemList.items).toHaveLength(2) + expect(ItemList.items).toEqual(items) + }) + }) + + describe('find', () => { + it('finds by key and value', () => { + const actual = ItemList.find('id', 'foo') + expect(actual).toEqual(items[0]) + }) + + it('returns undefined if item not found', () => { + const actual = ItemList.find('name', 'bogus') + expect(actual).toBeUndefined() + + expect(ItemList.find('name', 'barName')).toEqual(items[1]) + }) + }) + + describe('getCacheDefault', () => { + it('gets the default', () => { + const actual = ItemList.getCacheDefault() + expect(actual).toBe(true) + }) + + it('gets value from itemCache option when specified', () => { + const actual = ItemList.getCacheDefault({ itemCache: false }) + expect(actual).toBe(false) + }) + }) +}) diff --git a/packages/embed-services/src/ItemList.ts b/packages/embed-services/src/ItemList.ts new file mode 100644 index 000000000..053eaf76c --- /dev/null +++ b/packages/embed-services/src/ItemList.ts @@ -0,0 +1,128 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import type { IAPIMethods } from '@looker/sdk-rtl' +import { EntityService } from './EntityService' + +export const DEFAULT_TTL = 900 // 15 minutes + +export interface GetOptions { + itemCache?: boolean + [key: string]: any +} + +export interface IItemList { + /** Cache time to live in seconds, defaults to 15 minutes */ + readonly timeToLive: number + items: T[] + readonly expiresAt: number + index(key?: keyof T): ItemList + indexedItems: Record + setExpiration(): void + clearIfExpired(): void + find(key: keyof T, value: any): T | undefined +} + +export interface IEntityService extends IItemList { + get(id: string, options?: GetOptions): Promise + set(id: string, item: T): Promise + getAll(...options: any[]): Promise> + delete(id: string): void +} + +export abstract class ItemList> + extends EntityService + implements IItemList +{ + /** Cached items */ + items: T[] = [] + /** An indexed collection of items */ + indexedItems: Record = {} + /** Time when cache expires */ + expiresAt = 0 + /** Key to index by */ + private keyField = 'id' + + constructor(sdk: IAPIMethods, timeToLive = DEFAULT_TTL) { + super(sdk, timeToLive) + } + + /** + * Creates an indexed collection from the cached items + */ + index(key: keyof T = this.keyField) { + this.keyField = key as string + this.indexedItems = {} + this.items.forEach((el) => { + if (el && el?.[this.keyField]) { + this.indexedItems[el[this.keyField]] = el + } + }) + return this + } + + /** Computes the expiration time based on timeToLive */ + setExpiration() { + this.expiresAt = Date.now() + this.timeToLive * 1000 + } + + /** + * Determines if the cache has expired + */ + protected expired() { + return this.expiresAt <= Date.now() + } + + /** + * Ejects cache if expired + */ + clearIfExpired() { + if (this.expired()) { + this.items = [] + this.indexedItems = {} + } + } + + /** + * Searches the collection for an item with the specified key/value pair + * @param key to search + * @param value to match + */ + find(key: keyof T, value: any): T | undefined { + return this.items.find((item) => item[key as string] === value) as + | T + | undefined + } + + /** + * Gets the cache option value if present, otherwise defaults to true + * @param options to check + */ + getCacheDefault(options?: GetOptions) { + const cache = options && 'itemCache' in options ? options.itemCache : true + return cache + } +}