diff --git a/.github/actions/cache/action.yml b/.github/actions/cache/action.yml index a434de9b1c..87927944c1 100644 --- a/.github/actions/cache/action.yml +++ b/.github/actions/cache/action.yml @@ -153,7 +153,7 @@ runs: key: | ${{ env.CACHE_VERSION }}-${{ hashFiles( - 'clients/algoliasearch-client-javascript/packages/client-common/**' + 'clients/algoliasearch-client-javascript/packages/client-common/src/**' )}} - name: Restore built JavaScript node requester @@ -164,7 +164,7 @@ runs: key: | ${{ env.CACHE_VERSION }}-${{ hashFiles( - 'clients/algoliasearch-client-javascript/packages/requester-node-http/**' + 'clients/algoliasearch-client-javascript/packages/requester-node-http/src/**' )}} - name: Restore built JavaScript browser requester @@ -175,7 +175,7 @@ runs: key: | ${{ env.CACHE_VERSION }}-${{ hashFiles( - 'clients/algoliasearch-client-javascript/packages/requester-browser-xhr/**' + 'clients/algoliasearch-client-javascript/packages/requester-browser-xhr/src/**' )}} # Restore JavaScript clients: used during 'cts' or 'codegen' diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 306fc2007a..612f2433a7 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -58,6 +58,7 @@ runs: echo "::set-output name=JS_CLIENT_CHANGED::$(git diff --shortstat $origin..HEAD -- clients/algoliasearch-client-javascript | wc -l)" echo "::set-output name=JS_ALGOLIASEARCH_CHANGED::$(git diff --shortstat $origin..HEAD -- clients/algoliasearch-client-javascript/packages/algoliasearch clients/algoliasearch-client-javascript/packages/client-search clients/algoliasearch-client-javascript/packages/client-analytics clients/algoliasearch-client-javascript/packages/client-personalization | wc -l)" echo "::set-output name=JS_COMMON_CHANGED::$(git diff --shortstat $origin..HEAD -- clients/algoliasearch-client-javascript/packages/client-common clients/algoliasearch-client-javascript/packages/requester-browser-xhr clients/algoliasearch-client-javascript/packages/requester-node-http | wc -l)" + echo "::set-output name=JS_COMMON_TESTS_CHANGED::$(git diff --shortstat $origin..HEAD -- clients/algoliasearch-client-javascript/packages/client-common/src/__tests__ | wc -l)" echo "::set-output name=JS_TEMPLATE_CHANGED::$(git diff --shortstat $origin..HEAD -- templates/javascript | wc -l)" echo "::set-output name=JAVA_CLIENT_CHANGED::$(git diff --shortstat $origin..HEAD -- clients/algoliasearch-client-java-2 | wc -l)" @@ -208,6 +209,10 @@ outputs: description: The generated `client-php` matrix value: ${{ steps.php-matrix.outputs.MATRIX }} + RUN_JS_TESTS: + description: Determine if the `client_javascript_tests` job should run + value: ${{ steps.diff.outputs.JS_COMMON_TESTS_CHANGED > 0 }} + RUN_CTS: description: Determine if the `cts` job should run value: ${{ diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 229f4d69f1..df70ae2ec8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,6 +36,7 @@ jobs: RUN_JS: ${{ steps.setup.outputs.RUN_JS }} RUN_JS_ALGOLIASEARCH: ${{ steps.setup.outputs.RUN_JS_ALGOLIASEARCH }} RUN_JS_COMMON: ${{ steps.setup.outputs.RUN_JS_COMMON }} + RUN_JS_TESTS: ${{ steps.setup.outputs.RUN_JS_TESTS }} JS_MATRIX: ${{ steps.setup.outputs.JS_MATRIX }} RUN_JAVA: ${{ steps.setup.outputs.RUN_JAVA }} @@ -119,7 +120,7 @@ jobs: key: | ${{ env.CACHE_VERSION }}-${{ hashFiles( - format('clients/algoliasearch-client-javascript/packages/{0}/**', matrix.client) + format('clients/algoliasearch-client-javascript/packages/{0}/src/**', matrix.client) )}} - name: Build '${{ matrix.client }}' client @@ -299,6 +300,28 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: yarn cli build clients php ${{ matrix.client.name }} + client_javascript_tests: + runs-on: ubuntu-20.04 + timeout-minutes: 10 + needs: + - client_javascript + - client_javascript_algoliasearch + if: | + always() && + needs.setup.outputs.RUN_JS_TESTS == 'true' && + contains(needs.*.result, 'success') && + !contains(needs.*.result, 'failure') + steps: + - uses: actions/checkout@v2 + + - name: Restore cache + uses: ./.github/actions/cache + with: + job: cts + + - name: Run client-common tests + run: yarn workspace @experimental-api-clients-automation/client-common test + cts: runs-on: ubuntu-20.04 timeout-minutes: 20 @@ -320,6 +343,9 @@ jobs: with: job: cts + - name: Check JavaScript client size + run: exit $(yarn workspace algoliasearch-client-javascript test:size | echo $?) + - name: Generate run: yarn cli cts generate @@ -334,7 +360,9 @@ jobs: codegen: runs-on: ubuntu-20.04 timeout-minutes: 10 - needs: cts + needs: + - cts + - client_javascript_tests if: | always() && needs.setup.outputs.RUN_CODEGEN == 'true' && diff --git a/clients/algoliasearch-client-javascript/bundlesize.config.json b/clients/algoliasearch-client-javascript/bundlesize.config.json index 0a137425e4..00ed91ad69 100644 --- a/clients/algoliasearch-client-javascript/bundlesize.config.json +++ b/clients/algoliasearch-client-javascript/bundlesize.config.json @@ -2,51 +2,51 @@ "files": [ { "path": "packages/algoliasearch/dist/algoliasearch.umd.browser.js", - "maxSize": "6.50KB" + "maxSize": "6.90KB" }, { "path": "packages/client-abtesting/dist/client-abtesting.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.65KB" }, { "path": "packages/client-analytics/dist/client-analytics.umd.browser.js", - "maxSize": "4.00KB" + "maxSize": "4.20KB" }, { "path": "packages/client-insights/dist/client-insights.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.45KB" }, { "path": "packages/client-personalization/dist/client-personalization.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.60KB" }, { "path": "packages/client-query-suggestions/dist/client-query-suggestions.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.65KB" }, { "path": "packages/client-search/dist/client-search.umd.browser.js", - "maxSize": "5.25KB" + "maxSize": "5.65KB" }, { "path": "packages/client-sources/dist/client-sources.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.50KB" }, { "path": "packages/recommend/dist/recommend.umd.browser.js", - "maxSize": "3.25KB" + "maxSize": "3.55KB" }, { "path": "packages/client-common/dist/client-common.esm.node.js", - "maxSize": "3.00KB" + "maxSize": "3.45KB" }, { "path": "packages/requester-browser-xhr/dist/requester-browser-xhr.esm.node.js", - "maxSize": "1.00KB" + "maxSize": "900B" }, { "path": "packages/requester-node-http/dist/requester-node-http.esm.node.js", - "maxSize": "1.00KB" + "maxSize": "1.10KB" } ] } diff --git a/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/browser.ts b/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/browser.ts index c91efaae73..53544310a6 100644 --- a/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/browser.ts +++ b/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/browser.ts @@ -8,12 +8,20 @@ import type { Host, Requester, } from '@experimental-api-clients-automation/client-common'; +import { + createMemoryCache, + createFallbackableCache, + createBrowserLocalStorageCache, +} from '@experimental-api-clients-automation/client-common'; import type { PersonalizationApi, Region as PersonalizationRegion, } from '@experimental-api-clients-automation/client-personalization/src/personalizationApi'; import { createPersonalizationApi } from '@experimental-api-clients-automation/client-personalization/src/personalizationApi'; -import { createSearchApi } from '@experimental-api-clients-automation/client-search/src/searchApi'; +import { + createSearchApi, + apiClientVersion, +} from '@experimental-api-clients-automation/client-search/src/searchApi'; import { createXhrRequester } from '@experimental-api-clients-automation/requester-browser-xhr'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -39,6 +47,14 @@ export function algoliasearch( requester: options?.requester ?? createXhrRequester(), userAgents: [{ segment: 'Browser' }], authMode: 'WithinQueryParameters', + responsesCache: createMemoryCache(), + requestsCache: createMemoryCache({ serializable: false }), + hostsCache: createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ key: `${apiClientVersion}-${appId}` }), + createMemoryCache(), + ], + }), ...options, }; diff --git a/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/node.ts b/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/node.ts index 14fc0cba69..8bd40f1d36 100644 --- a/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/node.ts +++ b/clients/algoliasearch-client-javascript/packages/algoliasearch/builds/node.ts @@ -8,6 +8,10 @@ import type { Host, Requester, } from '@experimental-api-clients-automation/client-common'; +import { + createMemoryCache, + createNullCache, +} from '@experimental-api-clients-automation/client-common'; import type { PersonalizationApi, Region as PersonalizationRegion, @@ -38,6 +42,9 @@ export function algoliasearch( }, requester: options?.requester ?? createHttpRequester(), userAgents: [{ segment: 'Node.js', version: process.versions.node }], + responsesCache: createNullCache(), + requestsCache: createNullCache(), + hostsCache: createMemoryCache(), ...options, }; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/index.ts b/clients/algoliasearch-client-javascript/packages/client-common/index.ts index 3834d850e9..8439b00be2 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/index.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/index.ts @@ -1,6 +1,6 @@ export * from './src/createAuth'; export * from './src/createEchoRequester'; -export * from './src/createMemoryCache'; +export * from './src/cache'; export * from './src/createStatefulHost'; export * from './src/createTransporter'; export * from './src/createUserAgent'; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/jest.config.ts b/clients/algoliasearch-client-javascript/packages/client-common/jest.config.ts new file mode 100644 index 0000000000..97f9b9eafb --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/jest.config.ts @@ -0,0 +1,29 @@ +import type { Config } from '@jest/types'; + +const baseConfig: Config.InitialOptions = { + preset: 'ts-jest', + roots: ['src/__tests__'], +}; + +const config: Config.InitialOptions = { + projects: [ + { + ...baseConfig, + testEnvironment: 'jsdom', + testPathIgnorePatterns: [ + 'src/__tests__/cache/null-cache.test.ts', + 'src/__tests__/cache/memory-cache.test.ts', + ], + }, + { + ...baseConfig, + testEnvironment: 'node', + testPathIgnorePatterns: [ + 'src/__tests__/cache/browser-local-storage-cache.test.ts', + 'src/__tests__/cache/fallbackable-cache.test.ts', + ], + }, + ], +}; + +export default config; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/package.json b/clients/algoliasearch-client-javascript/packages/client-common/package.json index a4334747e9..e366860358 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/package.json +++ b/clients/algoliasearch-client-javascript/packages/client-common/package.json @@ -9,13 +9,16 @@ "module": "dist/client-common.esm.node.js", "types": "dist/index.d.ts", "scripts": { - "clean": "rm -rf dist/" + "clean": "rm -rf dist/", + "test": "jest" }, "engines": { "node": ">= 14.0.0" }, "devDependencies": { + "@types/jest": "27.4.1", "@types/node": "16.11.11", + "jest": "27.4.7", "typescript": "4.5.4" } } diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts new file mode 100644 index 0000000000..3b33423ce9 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts @@ -0,0 +1,136 @@ +import { createBrowserLocalStorageCache } from '../../cache'; + +const version = 'foobar'; +const notAvailableStorage = new Proxy(window.localStorage, { + get() { + return (): void => { + throw new Error('Component is not available'); + }; + }, +}); + +type DefaultValue = Promise<{ bar: number }>; + +describe('browser local storage cache', () => { + const missMock = jest.fn(); + const events = { + miss: (): Promise => Promise.resolve(missMock()), + }; + + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + it('sets/gets values', async () => { + const cache = createBrowserLocalStorageCache({ key: version }); + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 1 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 1 } + ); + expect(missMock.mock.calls.length).toBe(1); + + await cache.set({ key: 'foo' }, { foo: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { foo: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); + + it('deletes keys', async () => { + const cache = createBrowserLocalStorageCache({ key: version }); + + await cache.set({ key: 'foo' }, { bar: 1 }); + await cache.delete({ key: 'foo' }); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); + + it('can be cleared', async () => { + const cache = createBrowserLocalStorageCache({ key: version }); + + await cache.set({ key: 'foo' }, { bar: 1 }); + await cache.clear(); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + expect(localStorage.length).toBe(0); + }); + + it('do throws localstorage exceptions on access', async () => { + const message = + "Failed to read the 'localStorage' property from 'Window': Access is denied for this document."; + const cache = createBrowserLocalStorageCache( + new Proxy( + { key: 'foo' }, + { + get(_, key): DOMException | string { + if (key === 'key') { + return 'foo'; + } + + // Simulates a window.localStorage access. + throw new DOMException(message); + }, + } + ) + ); + const key = { foo: 'bar' }; + const value = 'foo'; + const fallback = 'bar'; + + await expect(cache.delete(key)).rejects.toEqual(new DOMException(message)); + await expect(cache.set(key, value)).rejects.toEqual( + new DOMException(message) + ); + await expect( + cache.get(key, () => Promise.resolve(fallback)) + ).rejects.toEqual(new DOMException(message)); + }); + + it('do throws localstorage exceptions after access', async () => { + const cache = createBrowserLocalStorageCache({ + key: version, + localStorage: notAvailableStorage, + }); + const key = { foo: 'bar' }; + const value = 'foo'; + const fallback = 'bar'; + const message = 'Component is not available'; + + await expect(cache.delete(key)).rejects.toEqual(new Error(message)); + await expect(cache.set(key, value)).rejects.toEqual(new Error(message)); + await expect( + cache.get(key, () => Promise.resolve(fallback)) + ).rejects.toEqual(new Error(message)); + }); + + it('creates a namespace within local storage', async () => { + const cache = createBrowserLocalStorageCache({ + key: version, + }); + const key = { foo: 'bar' }; + const value = 'foo'; + + expect( + localStorage.getItem(`algoliasearch-client-js-${version}`) + ).toBeNull(); + + await cache.set(key, value); + + expect(localStorage.getItem(`algoliasearch-client-js-${version}`)).toBe( + '{"{\\"foo\\":\\"bar\\"}":"foo"}' + ); + }); +}); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/fallbackable-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/fallbackable-cache.test.ts new file mode 100644 index 0000000000..5bf455678a --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/fallbackable-cache.test.ts @@ -0,0 +1,126 @@ +import { + createBrowserLocalStorageCache, + createFallbackableCache, + createMemoryCache, + createNullCache, +} from '../../cache'; + +const version = 'foobar'; +const notAvailableStorage = new Proxy(window.localStorage, { + get() { + return (): void => { + throw new Error('Component is not available'); + }; + }, +}); + +type DefaultValue = Promise<{ [k: number]: number }>; + +describe('fallbackable cache', () => { + const key = { 1: 2 }; + const value = { 3: 4 }; + const defaultValue = (): DefaultValue => Promise.resolve({ 5: 6 }); + + it('always fallback in null cache', async () => { + const cache = createFallbackableCache({ caches: [] }); + + await cache.set(key, value); + expect(await cache.get(key, defaultValue)).toEqual({ + 5: 6, + }); + }); + + describe('order', () => { + it('use memory cache', async () => { + const cache = createFallbackableCache({ + caches: [createMemoryCache()], + }); + + await cache.set(key, value); + + expect(await cache.get(key, defaultValue)).toEqual({ + 3: 4, + }); + }); + + it('use null cache first', async () => { + const cache = createFallbackableCache({ + caches: [createNullCache(), createMemoryCache()], + }); + + await cache.set(key, value); + + expect(await cache.get(key, defaultValue)).toEqual({ + 5: 6, + }); + }); + }); + + describe('fallbacks', () => { + it('to memory cache', async () => { + const cache = createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ + key: version, + // @ts-expect-error this will make the cache fail, and normally we fallback on memory cache + localStorage: {}, + }), + createMemoryCache(), + ], + }); + + await cache.set(key, value); + + expect(await cache.get(key, defaultValue)).toEqual({ + 3: 4, + }); + }); + + it('to null cache', async () => { + const cache = createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ + key: version, + // @ts-expect-error this will make the cache fail, and normally we fallback on memory cache + localStorage: {}, + }), + ], + }); + + await cache.set(key, value); + + expect(await cache.get(key, defaultValue)).toEqual({ + 5: 6, + }); + }); + + it('to memory cache', async () => { + const cache = createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ + key: version, + // @ts-expect-error this will make the cache fail + localStorage: {}, + }), + createBrowserLocalStorageCache({ + key: version, + localStorage: notAvailableStorage, // this will make the cache fail due localStorage not available + }), + createMemoryCache(), + ], + }); + + await cache.set(key, value); + + expect(await cache.get(key, defaultValue)).toEqual({ + 3: 4, + }); + + await cache.clear(); + + expect(await cache.get(key, defaultValue)).toEqual({ + 5: 6, + }); + }); + }); +}); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/memory-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/memory-cache.test.ts new file mode 100644 index 0000000000..80671616bb --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/memory-cache.test.ts @@ -0,0 +1,90 @@ +import { createMemoryCache } from '../../cache'; + +type DefaultValue = Promise<{ bar: number }>; + +describe('memory cache', () => { + const missMock = jest.fn(); + const events = { + miss: (): Promise => Promise.resolve(missMock()), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sets/gets values', async () => { + const cache = createMemoryCache(); + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 1 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { + bar: 1, + } + ); + + await cache.set({ key: 'foo' }, { foo: 2 }); + + expect(missMock.mock.calls.length).toBe(1); + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { foo: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); + + it('getted values do not have references to the value on cache', async () => { + const cache = createMemoryCache(); + const key = { foo: 'bar' }; + const obj = { 1: { 2: 'bar' } }; + const defaultObj = { 1: { 2: 'too' } }; + + await cache.set(key, obj); + const gettedValue = await cache.get(key, () => Promise.resolve(defaultObj)); + gettedValue[1][2] = 'foo'; + + expect(await cache.get(key, () => Promise.resolve(defaultObj))).toEqual({ + 1: { 2: 'bar' }, + }); + }); + + it('deletes keys', async () => { + const cache = createMemoryCache(); + + await cache.set({ key: 'foo' }, { bar: 1 }); + await cache.delete({ key: 'foo' }); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); + + it('can be cleared', async () => { + const cache = createMemoryCache(); + + await cache.set({ key: 'foo' }, { bar: 1 }); + await cache.clear(); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); + + it('do not force promise based api for clearing cache', async () => { + const cache = createMemoryCache(); + + cache.set({ key: 'foo' }, { bar: 1 }); + cache.clear(); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( + { bar: 2 } + ); + expect(missMock.mock.calls.length).toBe(1); + }); +}); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/null-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/null-cache.test.ts new file mode 100644 index 0000000000..bf86215211 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/null-cache.test.ts @@ -0,0 +1,49 @@ +import { createNullCache } from '../../cache'; + +type DefaultValue = Promise<{ bar: number }>; + +describe('null cache', () => { + const cache = createNullCache(); + const missMock = jest.fn(); + const events = { + miss: (): Promise => Promise.resolve(missMock()), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not set value', async () => { + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 12 }); + + await cache.set({ key: 'key' }, { foo: 10 }); + + expect(await cache.get({ key: 'key' }, defaultValue, events)).toMatchObject( + { + bar: 12, + } + ); + + expect(missMock.mock.calls.length).toBe(1); + }); + + it('returns default value', async () => { + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 12 }); + + expect(await cache.get({ foo: 'foo' }, defaultValue, events)).toMatchObject( + { + bar: 12, + } + ); + + expect(missMock.mock.calls.length).toBe(1); + }); + + it('can be deleted', async () => { + await cache.delete('foo'); + }); + + it('can be cleared', async () => { + await cache.clear(); + }); +}); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts new file mode 100644 index 0000000000..845ecdd3a3 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts @@ -0,0 +1,73 @@ +import type { BrowserLocalStorageOptions, Cache, CacheEvents } from '../types'; + +export function createBrowserLocalStorageCache( + options: BrowserLocalStorageOptions +): Cache { + let storage: Storage; + const namespaceKey = `algoliasearch-client-js-${options.key}`; + + function getStorage(): Storage { + if (storage === undefined) { + storage = options.localStorage || window.localStorage; + } + + return storage; + } + + function getNamespace(): Record { + return JSON.parse(getStorage().getItem(namespaceKey) || '{}'); + } + + return { + get( + key: Record | string, + defaultValue: () => Promise, + events: CacheEvents = { + miss: (): Promise => Promise.resolve(), + } + ): Promise { + return Promise.resolve() + .then(() => { + const keyAsString = JSON.stringify(key); + const value = getNamespace()[keyAsString]; + + return Promise.all([value || defaultValue(), value !== undefined]); + }) + .then(([value, exists]) => { + return Promise.all([value, exists || events.miss(value)]); + }) + .then(([value]) => value); + }, + + set( + key: Record | string, + value: TValue + ): Promise { + return Promise.resolve().then(() => { + const namespace = getNamespace(); + + namespace[JSON.stringify(key)] = value; + + getStorage().setItem(namespaceKey, JSON.stringify(namespace)); + + return value; + }); + }, + + delete(key: Record | string): Promise { + return Promise.resolve().then(() => { + const namespace = getNamespace(); + + delete namespace[JSON.stringify(key)]; + + getStorage().setItem(namespaceKey, JSON.stringify(namespace)); + }); + }, + + clear(): Promise { + return Promise.resolve().then(() => { + getStorage().removeItem(namespaceKey); + }); + }, + }; +} diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createFallbackableCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createFallbackableCache.ts new file mode 100644 index 0000000000..682eacd41c --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createFallbackableCache.ts @@ -0,0 +1,53 @@ +import type { FallbackableCacheOptions, Cache, CacheEvents } from '../types'; + +import { createNullCache } from './createNullCache'; + +export function createFallbackableCache( + options: FallbackableCacheOptions +): Cache { + const caches = [...options.caches]; + const current = caches.shift(); + + if (current === undefined) { + return createNullCache(); + } + + return { + get( + key: Record | string, + defaultValue: () => Promise, + events: CacheEvents = { + miss: (): Promise => Promise.resolve(), + } + ): Promise { + return current.get(key, defaultValue, events).catch(() => { + return createFallbackableCache({ caches }).get( + key, + defaultValue, + events + ); + }); + }, + + set( + key: Record | string, + value: TValue + ): Promise { + return current.set(key, value).catch(() => { + return createFallbackableCache({ caches }).set(key, value); + }); + }, + + delete(key: Record | string): Promise { + return current.delete(key).catch(() => { + return createFallbackableCache({ caches }).delete(key); + }); + }, + + clear(): Promise { + return current.clear().catch(() => { + return createFallbackableCache({ caches }).clear(); + }); + }, + }; +} diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createMemoryCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createMemoryCache.ts new file mode 100644 index 0000000000..30074cb268 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createMemoryCache.ts @@ -0,0 +1,56 @@ +import type { Cache, CacheEvents, MemoryCacheOptions } from '../types'; + +export function createMemoryCache( + options: MemoryCacheOptions = { serializable: true } +): Cache { + let cache: Record = {}; + + return { + get( + key: Record | string, + defaultValue: () => Promise, + events: CacheEvents = { + miss: (): Promise => Promise.resolve(), + } + ): Promise { + const keyAsString = JSON.stringify(key); + + if (keyAsString in cache) { + return Promise.resolve( + options.serializable + ? JSON.parse(cache[keyAsString]) + : cache[keyAsString] + ); + } + + const promise = defaultValue(); + + return promise + .then((value: TValue) => events.miss(value)) + .then(() => promise); + }, + + set( + key: Record | string, + value: TValue + ): Promise { + cache[JSON.stringify(key)] = options.serializable + ? JSON.stringify(value) + : value; + + return Promise.resolve(value); + }, + + delete(key: Record | string): Promise { + delete cache[JSON.stringify(key)]; + + return Promise.resolve(); + }, + + clear(): Promise { + cache = {}; + + return Promise.resolve(); + }, + }; +} diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createNullCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createNullCache.ts new file mode 100644 index 0000000000..3bec1039c4 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createNullCache.ts @@ -0,0 +1,34 @@ +import type { Cache, CacheEvents } from '../types'; + +export function createNullCache(): Cache { + return { + get( + _key: Record | string, + defaultValue: () => Promise, + events: CacheEvents = { + miss: (): Promise => Promise.resolve(), + } + ): Promise { + const value = defaultValue(); + + return value + .then((result) => Promise.all([result, events.miss(result)])) + .then(([result]) => result); + }, + + set( + _key: Record | string, + value: TValue + ): Promise { + return Promise.resolve(value); + }, + + delete(_key: Record | string): Promise { + return Promise.resolve(); + }, + + clear(): Promise { + return Promise.resolve(); + }, + }; +} diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/index.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/index.ts new file mode 100644 index 0000000000..ecb01b3864 --- /dev/null +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/index.ts @@ -0,0 +1,4 @@ +export * from './createBrowserLocalStorageCache'; +export * from './createFallbackableCache'; +export * from './createMemoryCache'; +export * from './createNullCache'; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/createMemoryCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/createMemoryCache.ts deleted file mode 100644 index 2f7af183d6..0000000000 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/createMemoryCache.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Cache } from './types'; - -export function createMemoryCache(): Cache { - let cache: Record = {}; - - return { - async get( - key: Record | string, - defaultValue: () => Promise - ): Promise { - const keyAsString = JSON.stringify(key); - - if (keyAsString in cache) { - return Promise.resolve(cache[keyAsString]); - } - - return await defaultValue(); - }, - - set( - key: Record | string, - value: TValue - ): Promise { - cache[JSON.stringify(key)] = value; - - return Promise.resolve(value); - }, - - delete(key: Record | string): Promise { - delete cache[JSON.stringify(key)]; - - return Promise.resolve(); - }, - - clear(): Promise { - cache = {}; - - return Promise.resolve(); - }, - }; -} diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/types/Cache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/types/Cache.ts index ad35ba9c90..7616c0238d 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/types/Cache.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/types/Cache.ts @@ -4,7 +4,8 @@ export type Cache = { */ get: ( key: Record | string, - defaultValue: () => Promise + defaultValue: () => Promise, + events?: CacheEvents ) => Promise; /** @@ -25,3 +26,36 @@ export type Cache = { */ clear: () => Promise; }; + +export type CacheEvents = { + /** + * The callback when the given `key` is missing from the cache. + */ + miss: (value: TValue) => Promise; +}; + +export type MemoryCacheOptions = { + /** + * If keys and values should be serialized using `JSON.stringify`. + */ + serializable?: boolean; +}; + +export type BrowserLocalStorageOptions = { + /** + * The cache key. + */ + key: string; + + /** + * The native local storage implementation. + */ + localStorage?: Storage; +}; + +export type FallbackableCacheOptions = { + /** + * List of caches order by priority. + */ + caches: Cache[]; +}; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/types/CreateClient.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/types/CreateClient.ts index 1ee437db11..84b79f3c52 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/types/CreateClient.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/types/CreateClient.ts @@ -1,10 +1,17 @@ import type { Host } from './Host'; import type { Requester } from './Requester'; -import type { Timeouts, UserAgentOptions } from './Transporter'; +import type { + Timeouts, + UserAgentOptions, + TransporterOptions, +} from './Transporter'; export type AuthMode = 'WithinHeaders' | 'WithinQueryParameters'; -export type CreateClientOptions = { +export type CreateClientOptions = Pick< + TransporterOptions, + 'hostsCache' | 'requestsCache' | 'responsesCache' +> & { appId: string; apiKey: string; requester: Requester; diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/types/Transporter.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/types/Transporter.ts index d49ebf17cd..6486262c80 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/types/Transporter.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/types/Transporter.ts @@ -79,6 +79,21 @@ export type TransporterOptions = { */ requester: Requester; + /** + * The cache of the requests. When requests are + * `cacheable`, the returned promised persists + * in this cache to shared in similar resquests + * before being resolved. + */ + requestsCache: Cache; + + /** + * The cache of the responses. When requests are + * `cacheable`, the returned responses persists + * in this cache to shared in similar resquests. + */ + responsesCache: Cache; + /** * The timeouts used by the requester. The transporter * layer may increase this timeouts as defined on the diff --git a/clients/algoliasearch-client-javascript/packages/client-common/tsconfig.json b/clients/algoliasearch-client-javascript/packages/client-common/tsconfig.json index e14af78d72..d3b837be8a 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/tsconfig.json +++ b/clients/algoliasearch-client-javascript/packages/client-common/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "types": ["node", "jest"], "outDir": "dist" }, "include": ["src", "index.ts"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules", "src/__tests__"] } diff --git a/templates/javascript/api-all.mustache b/templates/javascript/api-all.mustache index c49643223d..b37ae535ed 100644 --- a/templates/javascript/api-all.mustache +++ b/templates/javascript/api-all.mustache @@ -1,9 +1,10 @@ {{! This file will be renamed and moved to `builds/browser.ts` after generating the client }} import type { Host, Requester } from '@experimental-api-clients-automation/client-common'; +import { createMemoryCache, createFallbackableCache, createBrowserLocalStorageCache } from '@experimental-api-clients-automation/client-common'; import { createXhrRequester } from '@experimental-api-clients-automation/requester-browser-xhr'; -import { create{{capitalizedApiName}}Api } from '../src/{{apiName}}Api'; +import { create{{capitalizedApiName}}Api, apiClientVersion } from '../src/{{apiName}}Api'; import type { {{capitalizedApiName}}Api } from '../src/{{apiName}}Api'; {{#hasRegionalHost}} @@ -45,6 +46,14 @@ export function {{apiName}}Api( requester: options?.requester ?? createXhrRequester(), userAgents: [{ segment: 'Browser' }], authMode: 'WithinQueryParameters', + responsesCache: createMemoryCache(), + requestsCache: createMemoryCache({ serializable: false }), + hostsCache: createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ key: `${apiClientVersion}-${appId}` }), + createMemoryCache(), + ], + }), ...options, }); } diff --git a/templates/javascript/api-single.mustache b/templates/javascript/api-single.mustache index d79f8ae9bd..311240c892 100644 --- a/templates/javascript/api-single.mustache +++ b/templates/javascript/api-single.mustache @@ -1,6 +1,5 @@ import { createAuth, - createMemoryCache, createTransporter, getUserAgent, shuffle, @@ -86,7 +85,9 @@ export function create{{capitalizedApiName}}Api(options: CreateClientOptions{{#h const auth = createAuth(options.appId, options.apiKey, options.authMode); const transporter = createTransporter({ hosts: options?.hosts ?? getDefaultHosts({{^hasRegionalHost}}{{^experimentalHost}}options.appId{{/experimentalHost}}{{/hasRegionalHost}}{{#hasRegionalHost}}options.region{{/hasRegionalHost}}), - hostsCache: createMemoryCache(), + hostsCache: options.hostsCache, + requestsCache: options.requestsCache, + responsesCache: options.responsesCache, baseHeaders: { 'content-type': 'application/x-www-form-urlencoded', ...auth.headers(), diff --git a/templates/javascript/api.mustache b/templates/javascript/api.mustache index c9f862efbe..9c0d7ffa65 100644 --- a/templates/javascript/api.mustache +++ b/templates/javascript/api.mustache @@ -1,6 +1,7 @@ {{! This file will be renamed and moved to `builds/node.ts` after generating the client }} import type { Host, Requester } from '@experimental-api-clients-automation/client-common'; +import { createMemoryCache, createNullCache } from '@experimental-api-clients-automation/client-common'; import { createHttpRequester } from '@experimental-api-clients-automation/requester-node-http'; import { create{{capitalizedApiName}}Api } from '../src/{{apiName}}Api'; @@ -43,6 +44,9 @@ export function {{apiName}}Api( }, requester: options?.requester ?? createHttpRequester(), userAgents: [{ segment: 'Node.js', version: process.versions.node }], + responsesCache: createNullCache(), + requestsCache: createNullCache(), + hostsCache: createMemoryCache(), ...options, }); } diff --git a/yarn.lock b/yarn.lock index b45f8632f0..e07343b12e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2409,7 +2409,9 @@ __metadata: version: 0.0.0-use.local resolution: "@experimental-api-clients-automation/client-common@workspace:clients/algoliasearch-client-javascript/packages/client-common" dependencies: + "@types/jest": 27.4.1 "@types/node": 16.11.11 + jest: 27.4.7 typescript: 4.5.4 languageName: unknown linkType: soft @@ -5346,6 +5348,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:27.4.1": + version: 27.4.1 + resolution: "@types/jest@npm:27.4.1" + dependencies: + jest-matcher-utils: ^27.0.0 + pretty-format: ^27.0.0 + checksum: 5184f3eef4832d01ee8f59bed15eec45ccc8e29c724a5e6ce37bf74396b37bdf04f557000f45ba4fc38ae6075cf9cfcce3d7a75abc981023c61ceb27230a93e4 + languageName: node + linkType: hard + "@types/js-yaml@npm:4.0.5": version: 4.0.5 resolution: "@types/js-yaml@npm:4.0.5" @@ -12915,7 +12927,7 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^27.5.1": +"jest-matcher-utils@npm:^27.0.0, jest-matcher-utils@npm:^27.5.1": version: 27.5.1 resolution: "jest-matcher-utils@npm:27.5.1" dependencies: