diff --git a/.eslintrc.js b/.eslintrc.js index 65a2f36ef..6530cf849 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -61,10 +61,12 @@ module.exports = { ['@algolia/client-account', './packages/client-account/src'], ['@algolia/client-analytics', './packages/client-analytics/src'], ['@algolia/client-common', './packages/client-common/src'], - ['@algolia/client-search', './packages/client-search/src'], + ['@algolia/client-personalization', './packages/client-personalization/src'], ['@algolia/client-recommendation', './packages/client-recommendation/src'], + ['@algolia/client-search', './packages/client-search/src'], ['@algolia/logger-common', './packages/logger-common/src'], ['@algolia/logger-console', './packages/logger-console/src'], + ['@algolia/recommend', './packages/recommend/src'], ['@algolia/requester-browser-xhr', './packages/requester-browser-xhr/src'], ['@algolia/requester-common', './packages/requester-common/src'], ['@algolia/requester-node-http', './packages/requester-node-http/src'], diff --git a/README.md b/README.md index c5d2a6047..8d23dc4d5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ NPM version NPM downloads jsDelivr Downloads - License + License

@@ -88,4 +88,4 @@ Encountering an issue? Before reaching out to support, we recommend heading to o ## 📄 License -Algolia JavaScript API Client is an open-sourced software licensed under the [MIT license](LICENSE.txt). +Algolia JavaScript API Client is an open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/package.json b/package.json index c3297dd33..fbd9d2796 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { + "version": "4.9.3", "private": true, "license": "MIT", - "version": "4.9.3", "workspaces": [ "packages/*" ], @@ -98,11 +98,15 @@ "bundlesize": [ { "path": "packages/algoliasearch/dist/algoliasearch.umd.js", - "maxSize": "7.85KB" + "maxSize": "7.95KB" }, { "path": "packages/algoliasearch/dist/algoliasearch-lite.umd.js", "maxSize": "4.35KB" + }, + { + "path": "packages/recommend/dist/recommend.umd.js", + "maxSize": "4.1KB" } ] } diff --git a/packages/algoliasearch/README.md b/packages/algoliasearch/README.md index 3a9d10485..9e227e857 100644 --- a/packages/algoliasearch/README.md +++ b/packages/algoliasearch/README.md @@ -9,7 +9,7 @@ NPM version NPM downloads jsDelivr Downloads - License + License

@@ -79,4 +79,4 @@ For full documentation, visit the **[online documentation](https://www.algolia.c ## 📄 License -Algolia JavaScript API Client is an open-sourced software licensed under the [MIT license](LICENSE.txt). +Algolia JavaScript API Client is an open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/packages/algoliasearch/package.json b/packages/algoliasearch/package.json index d63721690..e9d6c354a 100644 --- a/packages/algoliasearch/package.json +++ b/packages/algoliasearch/package.json @@ -31,7 +31,7 @@ "@algolia/client-account": "4.9.3", "@algolia/client-analytics": "4.9.3", "@algolia/client-common": "4.9.3", - "@algolia/client-recommendation": "4.9.3", + "@algolia/client-personalization": "4.9.3", "@algolia/client-search": "4.9.3", "@algolia/logger-common": "4.9.3", "@algolia/logger-console": "4.9.3", diff --git a/packages/algoliasearch/src/__tests__/default.test.ts b/packages/algoliasearch/src/__tests__/default.test.ts index 3e37ab4b4..b86c866a0 100644 --- a/packages/algoliasearch/src/__tests__/default.test.ts +++ b/packages/algoliasearch/src/__tests__/default.test.ts @@ -83,7 +83,7 @@ describe('default preset', () => { expect(client.transporter.requestsCache).not.toBe(cache); expect(customClient.transporter.requestsCache).toBe(cache); - expect(customClient.initRecommendation().transporter.requestsCache).not.toBe(cache); + expect(customClient.initPersonalization().transporter.requestsCache).not.toBe(cache); expect(customClient.initAnalytics({ requestsCache: cache }).transporter.requestsCache).toBe( cache ); @@ -92,7 +92,7 @@ describe('default preset', () => { read: 46, write: 47, }); - expect(customClient.initRecommendation().transporter.timeouts).toEqual({ + expect(customClient.initPersonalization().transporter.timeouts).toEqual({ connect: testing.isBrowser() ? 1 : 2, read: testing.isBrowser() ? 2 : 5, write: 30, @@ -110,7 +110,7 @@ describe('default preset', () => { expect(customClient.transporter.hosts).toEqual([createStatelessHost({ url: 'foo.com' })]); expect(customClient.initAnalytics().transporter.queryParameters).toEqual({}); - expect(customClient.initRecommendation().transporter.headers).toEqual({ + expect(customClient.initPersonalization().transporter.headers).toEqual({ 'content-type': 'application/json', 'x-algolia-application-id': 'appId', 'x-algolia-api-key': 'apiKey', @@ -119,31 +119,31 @@ describe('default preset', () => { createStatelessHost({ url: 'foo.com' }), ]); - expect(customClient.initRecommendation().transporter.queryParameters).not.toEqual({ + expect(customClient.initPersonalization().transporter.queryParameters).not.toEqual({ queryParameter: 'bar', }); - expect(customClient.initRecommendation().transporter.headers).toEqual({ + expect(customClient.initPersonalization().transporter.headers).toEqual({ 'content-type': 'application/json', 'x-algolia-application-id': 'appId', 'x-algolia-api-key': 'apiKey', }); - expect(customClient.initRecommendation().transporter.hosts).not.toEqual([ + expect(customClient.initPersonalization().transporter.hosts).not.toEqual([ createStatelessHost({ url: 'foo.com' }), ]); }); test('shared implementations between clients', () => { const analytics = client.initAnalytics(); - const recommendation = client.initRecommendation(); + const personalization = client.initPersonalization(); expect(analytics.transporter).not.toBe(client.transporter); expect(analytics.transporter.hostsCache).toBe(client.transporter.hostsCache); expect(analytics.transporter.userAgent).toBe(client.transporter.userAgent); - expect(recommendation.transporter).not.toBe(client.transporter); - expect(recommendation.transporter.hostsCache).toBe(client.transporter.hostsCache); - expect(recommendation.transporter.userAgent).toBe(client.transporter.userAgent); + expect(personalization.transporter).not.toBe(client.transporter); + expect(personalization.transporter.hostsCache).toBe(client.transporter.hostsCache); + expect(personalization.transporter.userAgent).toBe(client.transporter.userAgent); }); test('allows clients to override credentials', () => { @@ -154,12 +154,12 @@ describe('default preset', () => { const analytics = clientWithOptions.initAnalytics({ apiKey: 'analytics', }); - const recommendation = clientWithOptions.initRecommendation({ - apiKey: 'recommendation', + const personalization = clientWithOptions.initPersonalization({ + apiKey: 'personalization', }); expect(analytics.transporter.headers['x-algolia-api-key']).toBe('analytics'); - expect(recommendation.transporter.headers['x-algolia-api-key']).toBe('recommendation'); + expect(personalization.transporter.headers['x-algolia-api-key']).toBe('personalization'); }); test('allows clients to keep default credentials', () => { @@ -168,10 +168,10 @@ describe('default preset', () => { expect(clientWithOptions.transporter.headers['x-algolia-api-key']).toBe('apiKey'); const analytics = clientWithOptions.initAnalytics(); - const recommendation = clientWithOptions.initRecommendation(); + const personalization = clientWithOptions.initPersonalization(); expect(analytics.transporter.headers['x-algolia-api-key']).toBe('apiKey'); - expect(recommendation.transporter.headers['x-algolia-api-key']).toBe('apiKey'); + expect(personalization.transporter.headers['x-algolia-api-key']).toBe('apiKey'); }); it('can be destroyed', () => { diff --git a/packages/algoliasearch/src/builds/browser.ts b/packages/algoliasearch/src/builds/browser.ts index cb7bb484b..232fce240 100644 --- a/packages/algoliasearch/src/builds/browser.ts +++ b/packages/algoliasearch/src/builds/browser.ts @@ -19,14 +19,14 @@ import { } from '@algolia/client-analytics'; import { version, WaitablePromise } from '@algolia/client-common'; import { - createRecommendationClient, + createPersonalizationClient, getPersonalizationStrategy, GetPersonalizationStrategyResponse, + PersonalizationClient as BasePersonalizationClient, PersonalizationStrategy, - RecommendationClient as BaseRecommendationClient, setPersonalizationStrategy, SetPersonalizationStrategyResponse, -} from '@algolia/client-recommendation'; +} from '@algolia/client-personalization'; import { addApiKey, AddApiKeyOptions, @@ -192,7 +192,7 @@ import { createConsoleLogger } from '@algolia/logger-console'; import { createBrowserXhrRequester } from '@algolia/requester-browser-xhr'; import { createUserAgent, RequestOptions } from '@algolia/transporter'; -import { AlgoliaSearchOptions, InitAnalyticsOptions, InitRecommendationOptions } from '../types'; +import { AlgoliaSearchOptions, InitAnalyticsOptions, InitPersonalizationOptions } from '../types'; export default function algoliasearch( appId: string, @@ -219,10 +219,22 @@ export default function algoliasearch( }), userAgent: createUserAgent(version).add({ segment: 'Browser' }), }; + const searchClientOptions = { ...commonOptions, ...options }; + const initPersonalization = () => ( + clientOptions?: InitPersonalizationOptions + ): PersonalizationClient => { + return createPersonalizationClient({ + ...commonOptions, + ...clientOptions, + methods: { + getPersonalizationStrategy, + setPersonalizationStrategy, + }, + }); + }; return createSearchClient({ - ...commonOptions, - ...options, + ...searchClientOptions, methods: { search: multipleQueries, searchForFacetValues: multipleSearchForFacetValues, @@ -319,17 +331,15 @@ export default function algoliasearch( }, }); }, + initPersonalization, initRecommendation: () => ( - clientOptions?: InitRecommendationOptions - ): RecommendationClient => { - return createRecommendationClient({ - ...commonOptions, - ...clientOptions, - methods: { - getPersonalizationStrategy, - setPersonalizationStrategy, - }, - }); + clientOptions?: InitPersonalizationOptions + ): PersonalizationClient => { + searchClientOptions.logger.info( + 'The `initRecommendation` method is deprecated. Use `initPersonalization` instead.' + ); + + return initPersonalization()(clientOptions); }, }, }); @@ -338,7 +348,7 @@ export default function algoliasearch( // eslint-disable-next-line functional/immutable-data algoliasearch.version = version; -export type RecommendationClient = BaseRecommendationClient & { +export type PersonalizationClient = BasePersonalizationClient & { readonly getPersonalizationStrategy: ( requestOptions?: RequestOptions ) => Readonly>; @@ -348,6 +358,11 @@ export type RecommendationClient = BaseRecommendationClient & { ) => Readonly>; }; +/** + * @deprecated Use `PersonalizationClient` instead. + */ +export type RecommendationClient = PersonalizationClient; + export type AnalyticsClient = BaseAnalyticsClient & { readonly addABTest: ( abTest: ABTest, @@ -663,7 +678,11 @@ export type SearchClient = BaseSearchClient & { requestOptions?: RequestOptions ) => Readonly>; readonly initAnalytics: (options?: InitAnalyticsOptions) => AnalyticsClient; - readonly initRecommendation: (options?: InitRecommendationOptions) => RecommendationClient; + readonly initPersonalization: (options?: InitPersonalizationOptions) => PersonalizationClient; + /** + * @deprecated Use `initPersonalization` instead. + */ + readonly initRecommendation: (options?: InitPersonalizationOptions) => PersonalizationClient; }; export * from '../types'; diff --git a/packages/algoliasearch/src/builds/node.ts b/packages/algoliasearch/src/builds/node.ts index 439605f04..4c4c37377 100644 --- a/packages/algoliasearch/src/builds/node.ts +++ b/packages/algoliasearch/src/builds/node.ts @@ -18,14 +18,14 @@ import { } from '@algolia/client-analytics'; import { destroy, version, WaitablePromise } from '@algolia/client-common'; import { - createRecommendationClient, + createPersonalizationClient, getPersonalizationStrategy, GetPersonalizationStrategyResponse, + PersonalizationClient as BasePersonalizationClient, PersonalizationStrategy, - RecommendationClient as BaseRecommendationClient, setPersonalizationStrategy, SetPersonalizationStrategyResponse, -} from '@algolia/client-recommendation'; +} from '@algolia/client-personalization'; import { addApiKey, AddApiKeyOptions, @@ -194,7 +194,7 @@ import { Destroyable } from '@algolia/requester-common'; import { createNodeHttpRequester } from '@algolia/requester-node-http'; import { createUserAgent, RequestOptions } from '@algolia/transporter'; -import { AlgoliaSearchOptions, InitAnalyticsOptions, InitRecommendationOptions } from '../types'; +import { AlgoliaSearchOptions, InitAnalyticsOptions, InitPersonalizationOptions } from '../types'; export default function algoliasearch( appId: string, @@ -219,10 +219,22 @@ export default function algoliasearch( version: process.versions.node, }), }; + const searchClientOptions = { ...commonOptions, ...options }; + const initPersonalization = () => ( + clientOptions?: InitPersonalizationOptions + ): PersonalizationClient => { + return createPersonalizationClient({ + ...commonOptions, + ...clientOptions, + methods: { + getPersonalizationStrategy, + setPersonalizationStrategy, + }, + }); + }; return createSearchClient({ - ...commonOptions, - ...options, + ...searchClientOptions, methods: { search: multipleQueries, searchForFacetValues: multipleSearchForFacetValues, @@ -322,17 +334,15 @@ export default function algoliasearch( }, }); }, + initPersonalization, initRecommendation: () => ( - clientOptions?: InitRecommendationOptions - ): RecommendationClient => { - return createRecommendationClient({ - ...commonOptions, - ...clientOptions, - methods: { - getPersonalizationStrategy, - setPersonalizationStrategy, - }, - }); + clientOptions?: InitPersonalizationOptions + ): PersonalizationClient => { + searchClientOptions.logger.info( + 'The `initRecommendation` method is deprecated. Use `initPersonalization` instead.' + ); + + return initPersonalization()(clientOptions); }, }, }); @@ -341,7 +351,7 @@ export default function algoliasearch( // eslint-disable-next-line functional/immutable-data algoliasearch.version = version; -export type RecommendationClient = BaseRecommendationClient & { +export type PersonalizationClient = BasePersonalizationClient & { readonly getPersonalizationStrategy: ( requestOptions?: RequestOptions ) => Readonly>; @@ -351,6 +361,11 @@ export type RecommendationClient = BaseRecommendationClient & { ) => Readonly>; }; +/** + * @deprecated Use `PersonalizationClient` instead. + */ +export type RecommendationClient = PersonalizationClient; + export type AnalyticsClient = BaseAnalyticsClient & { readonly addABTest: ( abTest: ABTest, @@ -671,7 +686,11 @@ export type SearchClient = BaseSearchClient & { requestOptions?: RequestOptions ) => Readonly>; readonly initAnalytics: (options?: InitAnalyticsOptions) => AnalyticsClient; - readonly initRecommendation: (options?: InitRecommendationOptions) => RecommendationClient; + readonly initPersonalization: (options?: InitPersonalizationOptions) => PersonalizationClient; + /** + * @deprecated Use `initPersonalization` instead. + */ + readonly initRecommendation: (options?: InitPersonalizationOptions) => PersonalizationClient; } & Destroyable; export * from '../types'; diff --git a/packages/algoliasearch/src/types/AlgoliaSearchOptions.ts b/packages/algoliasearch/src/types/AlgoliaSearchOptions.ts index 61e9cc81c..70ee0522a 100644 --- a/packages/algoliasearch/src/types/AlgoliaSearchOptions.ts +++ b/packages/algoliasearch/src/types/AlgoliaSearchOptions.ts @@ -1,6 +1,6 @@ import { AnalyticsClientOptions } from '@algolia/client-analytics'; import { ClientTransporterOptions } from '@algolia/client-common'; -import { RecommendationClientOptions } from '@algolia/client-recommendation'; +import { PersonalizationClientOptions } from '@algolia/client-personalization'; import { SearchClientOptions } from '@algolia/client-search'; type Credentials = { readonly appId: string; readonly apiKey: string }; @@ -20,5 +20,10 @@ export type AlgoliaSearchOptions = Partial & export type InitAnalyticsOptions = Partial & OptionalCredentials; -export type InitRecommendationOptions = Partial & - OptionalCredentials; +export type InitPersonalizationOptions = Partial & + OptionalCredentials; + +/** + * @deprecated Use `InitPersonalizationOptions` instead. + */ +export type InitRecommendationOptions = InitPersonalizationOptions; diff --git a/packages/client-common/src/__tests__/TestSuite.ts b/packages/client-common/src/__tests__/TestSuite.ts index 8ddbae2c1..b33f3a79b 100644 --- a/packages/client-common/src/__tests__/TestSuite.ts +++ b/packages/client-common/src/__tests__/TestSuite.ts @@ -12,6 +12,8 @@ import { addMethods } from '..'; import algoliasearchForBrowser from '../../../algoliasearch/src/builds/browser'; import algoliasearchForBrowserLite from '../../../algoliasearch/src/builds/browserLite'; import algoliasearchForNode from '../../../algoliasearch/src/builds/node'; +import recommendForBrowser from '../../../recommend/src/builds/browser'; +import recommendForNode from '../../../recommend/src/builds/node'; /* eslint functional/no-class: 0 */ export class TestSuite { @@ -29,6 +31,11 @@ export class TestSuite { ? algoliasearchForBrowser : algoliasearchForNode; + // @ts-ignore `destroy` only exists on the Node build + public readonly recommend: typeof recommendForNode = this.isBrowser + ? recommendForBrowser + : recommendForNode; + public indicesCount = 0; public constructor(testName?: string) { @@ -68,13 +75,6 @@ export class TestSuite { return client; } - public makeRecommendationClient( - appIdEnv: string = 'ALGOLIA_APPLICATION_ID_1', - apiKeyEnv: string = 'ALGOLIA_ADMIN_KEY_1' - ) { - return this.makeSearchClient(appIdEnv, apiKeyEnv).initRecommendation(); - } - public makeIndex(indexName?: string) { const index = this.makeSearchClient().initIndex(indexName || this.makeIndexName()); diff --git a/packages/client-personalization/api-extractor.json b/packages/client-personalization/api-extractor.json new file mode 100644 index 000000000..d182b70fb --- /dev/null +++ b/packages/client-personalization/api-extractor.json @@ -0,0 +1,7 @@ +{ + "extends": "../../api-extractor.json", + "mainEntryPointFilePath": "./dist/packages//src/index.d.ts", + "dtsRollup": { + "untrimmedFilePath": "./dist/.d.ts" + } +} diff --git a/packages/client-personalization/index.js b/packages/client-personalization/index.js new file mode 100644 index 000000000..df07d60ae --- /dev/null +++ b/packages/client-personalization/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line functional/immutable-data, import/no-commonjs +module.exports = require('./dist/client-personalization.cjs.js'); diff --git a/packages/client-personalization/package.json b/packages/client-personalization/package.json new file mode 100644 index 000000000..f07b400b6 --- /dev/null +++ b/packages/client-personalization/package.json @@ -0,0 +1,23 @@ +{ + "name": "@algolia/client-personalization", + "version": "4.9.3", + "private": false, + "repository": { + "type": "git", + "url": "git://github.com/algolia/algoliasearch-client-javascript.git" + }, + "license": "MIT", + "sideEffects": false, + "main": "index.js", + "module": "dist/client-personalization.esm.js", + "types": "dist/client-personalization.d.ts", + "files": [ + "index.js", + "dist" + ], + "dependencies": { + "@algolia/client-common": "4.9.3", + "@algolia/requester-common": "4.9.3", + "@algolia/transporter": "4.9.3" + } +} diff --git a/packages/client-personalization/src/__tests__/features/personalization-strategy.test.ts b/packages/client-personalization/src/__tests__/features/personalization-strategy.test.ts new file mode 100644 index 000000000..cbb3cefb1 --- /dev/null +++ b/packages/client-personalization/src/__tests__/features/personalization-strategy.test.ts @@ -0,0 +1,58 @@ +import { PersonalizationStrategy } from '../..'; +import { TestSuite } from '../../../../client-common/src/__tests__/TestSuite'; +import { SetPersonalizationStrategyResponse } from '../../types'; + +const testSuite = new TestSuite('personalization_strategy'); + +test(testSuite.testName, async () => { + // On the CI, we parallelize too many calls to this endpoint. Making + // the API slow to respond, and reaching a timeout. These specific + // timeouts should ensure the test suite works as expected. + const client = testSuite.makeSearchClient().initRecommendation({ + timeouts: { + connect: 30, + read: 30, + write: 30, + }, + }); + + const personalizationStrategy: PersonalizationStrategy = { + eventsScoring: [ + { + eventName: 'Add to cart', + eventType: 'conversion', + score: 50, + }, + { + eventName: 'Purchase', + eventType: 'conversion', + score: 100, + }, + ], + facetsScoring: [ + { facetName: 'brand', score: 100 }, + { facetName: 'categories', score: 10 }, + ], + personalizationImpact: 0, + }; + + const successResponse: SetPersonalizationStrategyResponse = { + status: 200, + message: 'Strategy was successfully updated', + }; + + const errorResponse: SetPersonalizationStrategyResponse = { + status: 429, + message: 'Number of strategy saves exceeded for the day', + }; + + try { + const response = await client.setPersonalizationStrategy(personalizationStrategy); + expect(response).toEqual(successResponse); + } catch (error) { + // eslint-disable-next-line jest/no-try-expect + expect(error).toEqual(expect.objectContaining({ name: 'ApiError', ...errorResponse })); + } + + await expect(client.getPersonalizationStrategy()).resolves.toEqual(personalizationStrategy); +}); diff --git a/packages/client-personalization/src/__tests__/unit/personalization-client.test.ts b/packages/client-personalization/src/__tests__/unit/personalization-client.test.ts new file mode 100644 index 000000000..801c27930 --- /dev/null +++ b/packages/client-personalization/src/__tests__/unit/personalization-client.test.ts @@ -0,0 +1,84 @@ +import { MethodEnum } from '@algolia/requester-common'; +import { anything, deepEqual, spy, verify, when } from 'ts-mockito'; + +import { TestSuite } from '../../../../client-common/src/__tests__/TestSuite'; +import { PersonalizationStrategy, SetPersonalizationStrategyResponse } from '../../types'; + +const personalizationClient = new TestSuite() + .algoliasearch('appId', 'apiKey') + .initPersonalization(); + +describe('personalization client', () => { + it('uses region to define the host', () => { + expect(personalizationClient.transporter.hosts[0].url).toBe('personalization.us.algolia.com'); + }); + + it('sets default headers', () => { + expect(personalizationClient.transporter.headers).toEqual({ + 'content-type': 'application/json', + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + }); + + expect(personalizationClient.transporter.queryParameters).toEqual({}); + }); +}); + +describe('personalization', () => { + const personalizationStrategy: PersonalizationStrategy = { + eventsScoring: [ + { eventName: 'Add to cart', eventType: 'conversion', score: 50 }, + { eventName: 'Purchase', eventType: 'conversion', score: 100 }, + ], + facetsScoring: [ + { facetName: 'brand', score: 100 }, + { facetName: 'categories', score: 10 }, + ], + personalizationImpact: 0, + }; + + it('set personalization strategy', async () => { + const transporterMock = spy(personalizationClient.transporter); + const response: SetPersonalizationStrategyResponse = { + status: 200, + message: 'Strategy was successfully updated', + }; + when(transporterMock.write(anything(), anything())).thenResolve(response); + + const setPersonalizationStrategyResponse = await personalizationClient.setPersonalizationStrategy( + personalizationStrategy, + { foo: 'bar' } + ); + expect(setPersonalizationStrategyResponse).toEqual(response); + + verify( + transporterMock.write( + deepEqual({ + method: MethodEnum.Post, + path: '1/strategies/personalization', + data: personalizationStrategy, + }), + deepEqual({ foo: 'bar' }) + ) + ).once(); + }); + + it('get personalization strategy', async () => { + const transporterMock = spy(personalizationClient.transporter); + when(transporterMock.read(anything(), anything())).thenResolve(personalizationStrategy); + + const getPersonalizationStrategyResponse = await personalizationClient.getPersonalizationStrategy(); + + verify( + transporterMock.read( + deepEqual({ + method: MethodEnum.Get, + path: '1/strategies/personalization', + }), + anything() + ) + ).once(); + + expect(getPersonalizationStrategyResponse).toEqual(personalizationStrategy); + }); +}); diff --git a/packages/client-personalization/src/createPersonalizationClient.ts b/packages/client-personalization/src/createPersonalizationClient.ts new file mode 100644 index 000000000..2fc37f4a3 --- /dev/null +++ b/packages/client-personalization/src/createPersonalizationClient.ts @@ -0,0 +1,35 @@ +import { + addMethods, + AuthMode, + ClientTransporterOptions, + createAuth, + CreateClient, +} from '@algolia/client-common'; +import { createTransporter } from '@algolia/transporter'; + +import { PersonalizationClient, PersonalizationClientOptions } from '.'; + +export const createPersonalizationClient: CreateClient< + PersonalizationClient, + PersonalizationClientOptions & ClientTransporterOptions +> = options => { + const region = options.region || 'us'; + const auth = createAuth(AuthMode.WithinHeaders, options.appId, options.apiKey); + + const transporter = createTransporter({ + hosts: [{ url: `personalization.${region}.algolia.com` }], + ...options, + headers: { + ...auth.headers(), + ...{ 'content-type': 'application/json' }, + ...options.headers, + }, + + queryParameters: { + ...auth.queryParameters(), + ...options.queryParameters, + }, + }); + + return addMethods({ appId: options.appId, transporter }, options.methods); +}; diff --git a/packages/client-personalization/src/index.ts b/packages/client-personalization/src/index.ts new file mode 100644 index 000000000..18b3a1b76 --- /dev/null +++ b/packages/client-personalization/src/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './createPersonalizationClient'; +export * from './methods/index'; +export * from './types/index'; diff --git a/packages/client-personalization/src/methods/getPersonalizationStrategy.ts b/packages/client-personalization/src/methods/getPersonalizationStrategy.ts new file mode 100644 index 000000000..8a96147d3 --- /dev/null +++ b/packages/client-personalization/src/methods/getPersonalizationStrategy.ts @@ -0,0 +1,18 @@ +import { MethodEnum } from '@algolia/requester-common'; +import { RequestOptions } from '@algolia/transporter'; + +import { GetPersonalizationStrategyResponse, PersonalizationClient } from '..'; + +export const getPersonalizationStrategy = (base: PersonalizationClient) => { + return ( + requestOptions?: RequestOptions + ): Readonly> => { + return base.transporter.read( + { + method: MethodEnum.Get, + path: '1/strategies/personalization', + }, + requestOptions + ); + }; +}; diff --git a/packages/client-personalization/src/methods/index.ts b/packages/client-personalization/src/methods/index.ts new file mode 100644 index 000000000..c64d44a9a --- /dev/null +++ b/packages/client-personalization/src/methods/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './getPersonalizationStrategy'; +export * from './setPersonalizationStrategy'; diff --git a/packages/client-personalization/src/methods/setPersonalizationStrategy.ts b/packages/client-personalization/src/methods/setPersonalizationStrategy.ts new file mode 100644 index 000000000..846d8c954 --- /dev/null +++ b/packages/client-personalization/src/methods/setPersonalizationStrategy.ts @@ -0,0 +1,24 @@ +import { MethodEnum } from '@algolia/requester-common'; +import { RequestOptions } from '@algolia/transporter'; + +import { + PersonalizationClient, + PersonalizationStrategy, + SetPersonalizationStrategyResponse, +} from '..'; + +export const setPersonalizationStrategy = (base: PersonalizationClient) => { + return ( + personalizationStrategy: PersonalizationStrategy, + requestOptions?: RequestOptions + ): Readonly> => { + return base.transporter.write( + { + method: MethodEnum.Post, + path: '1/strategies/personalization', + data: personalizationStrategy, + }, + requestOptions + ); + }; +}; diff --git a/packages/client-personalization/src/types/GetPersonalizationStrategyResponse.ts b/packages/client-personalization/src/types/GetPersonalizationStrategyResponse.ts new file mode 100644 index 000000000..d0ae3b865 --- /dev/null +++ b/packages/client-personalization/src/types/GetPersonalizationStrategyResponse.ts @@ -0,0 +1,23 @@ +export type GetPersonalizationStrategyResponse = { + /** + * Events scoring + */ + eventsScoring: Array<{ + eventName: string; + eventType: string; + score: number; + }>; + + /** + * Facets scoring + */ + facetsScoring: Array<{ + facetName: string; + score: number; + }>; + + /** + * Personalization impact + */ + personalizationImpact: number; +}; diff --git a/packages/client-personalization/src/types/PersonalizationClient.ts b/packages/client-personalization/src/types/PersonalizationClient.ts new file mode 100644 index 000000000..87235c140 --- /dev/null +++ b/packages/client-personalization/src/types/PersonalizationClient.ts @@ -0,0 +1,13 @@ +import { Transporter } from '@algolia/transporter'; + +export type PersonalizationClient = { + /** + * The application id. + */ + readonly appId: string; + + /** + * The underlying transporter. + */ + readonly transporter: Transporter; +}; diff --git a/packages/client-personalization/src/types/PersonalizationClientOptions.ts b/packages/client-personalization/src/types/PersonalizationClientOptions.ts new file mode 100644 index 000000000..6539401df --- /dev/null +++ b/packages/client-personalization/src/types/PersonalizationClientOptions.ts @@ -0,0 +1,16 @@ +export type PersonalizationClientOptions = { + /** + * The application id. + */ + readonly appId: string; + + /** + * The api key. + */ + readonly apiKey: string; + + /** + * The prefered region. + */ + readonly region?: string; +}; diff --git a/packages/client-personalization/src/types/PersonalizationStrategy.ts b/packages/client-personalization/src/types/PersonalizationStrategy.ts new file mode 100644 index 000000000..4af503cdc --- /dev/null +++ b/packages/client-personalization/src/types/PersonalizationStrategy.ts @@ -0,0 +1,23 @@ +export type PersonalizationStrategy = { + /** + * Events scoring + */ + readonly eventsScoring: ReadonlyArray<{ + readonly eventName: string; + readonly eventType: string; + readonly score: number; + }>; + + /** + * Facets scoring + */ + readonly facetsScoring: ReadonlyArray<{ + readonly facetName: string; + readonly score: number; + }>; + + /** + * Personalization impact + */ + readonly personalizationImpact: number; +}; diff --git a/packages/client-personalization/src/types/SetPersonalizationStrategyResponse.ts b/packages/client-personalization/src/types/SetPersonalizationStrategyResponse.ts new file mode 100644 index 000000000..f005b29c8 --- /dev/null +++ b/packages/client-personalization/src/types/SetPersonalizationStrategyResponse.ts @@ -0,0 +1,11 @@ +export type SetPersonalizationStrategyResponse = { + /** + * The status code. + */ + status?: number; + + /** + * The message. + */ + message: string; +}; diff --git a/packages/client-personalization/src/types/index.ts b/packages/client-personalization/src/types/index.ts new file mode 100644 index 000000000..83c20c8c7 --- /dev/null +++ b/packages/client-personalization/src/types/index.ts @@ -0,0 +1,9 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './GetPersonalizationStrategyResponse'; +export * from './PersonalizationStrategy'; +export * from './PersonalizationClient'; +export * from './PersonalizationClientOptions'; +export * from './SetPersonalizationStrategyResponse'; diff --git a/packages/client-recommendation/package.json b/packages/client-recommendation/package.json index 8b8c82a6a..7cf9c280c 100644 --- a/packages/client-recommendation/package.json +++ b/packages/client-recommendation/package.json @@ -17,7 +17,7 @@ ], "dependencies": { "@algolia/client-common": "4.9.3", - "@algolia/requester-common": "4.9.3", - "@algolia/transporter": "4.9.3" + "@algolia/client-personalization": "4.9.3", + "@algolia/requester-common": "4.9.3" } } diff --git a/packages/client-recommendation/src/__tests__/unit/recommendation-client.test.ts b/packages/client-recommendation/src/__tests__/unit/recommendation-client.test.ts index ef79956cc..1cf32617b 100644 --- a/packages/client-recommendation/src/__tests__/unit/recommendation-client.test.ts +++ b/packages/client-recommendation/src/__tests__/unit/recommendation-client.test.ts @@ -4,11 +4,25 @@ import { anything, deepEqual, spy, verify, when } from 'ts-mockito'; import { TestSuite } from '../../../../client-common/src/__tests__/TestSuite'; import { PersonalizationStrategy, SetPersonalizationStrategyResponse } from '../../types'; -const recommendationClient = new TestSuite().algoliasearch('appId', 'apiKey').initRecommendation(); +const searchClient = new TestSuite().algoliasearch('appId', 'apiKey', { + logger: { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +}); +const recommendationClient = searchClient.initRecommendation(); describe('recommendation client', () => { + it('logs a deprecation message', () => { + expect(searchClient.transporter.logger.info).toHaveBeenCalledTimes(1); + expect(searchClient.transporter.logger.info).toHaveBeenCalledWith( + 'The `initRecommendation` method is deprecated. Use `initPersonalization` instead.' + ); + }); + it('uses region to define the host', () => { - expect(recommendationClient.transporter.hosts[0].url).toBe('recommendation.us.algolia.com'); + expect(recommendationClient.transporter.hosts[0].url).toBe('personalization.us.algolia.com'); }); it('sets default headers', () => { diff --git a/packages/client-recommendation/src/createRecommendationClient.ts b/packages/client-recommendation/src/createRecommendationClient.ts index cfd38ff9a..15bcb3e93 100644 --- a/packages/client-recommendation/src/createRecommendationClient.ts +++ b/packages/client-recommendation/src/createRecommendationClient.ts @@ -1,35 +1,21 @@ -import { - addMethods, - AuthMode, - ClientTransporterOptions, - createAuth, - CreateClient, -} from '@algolia/client-common'; -import { createTransporter } from '@algolia/transporter'; +import { ClientTransporterOptions, CreateClient } from '@algolia/client-common'; +import { createPersonalizationClient } from '@algolia/client-personalization'; import { RecommendationClient, RecommendationClientOptions } from '.'; +/** + * @deprecated The `@algolia/client-recommendation` package is deprecated and you should use `@algolia/client-personalization` instead. To migrate, install the new package and replace `createRecommendationClient` with `createPersonalizationClient`. + */ export const createRecommendationClient: CreateClient< RecommendationClient, RecommendationClientOptions & ClientTransporterOptions > = options => { - const region = options.region || 'us'; - const auth = createAuth(AuthMode.WithinHeaders, options.appId, options.apiKey); + /* eslint-disable max-len */ + options.logger.info( + 'The `@algolia/client-recommendation` package is deprecated and you should use `@algolia/client-personalization` instead.\n' + + 'To migrate, install the new package and replace `createRecommendationClient` with `createPersonalizationClient`.' + ); + /* eslint-enable max-len */ - const transporter = createTransporter({ - hosts: [{ url: `recommendation.${region}.algolia.com` }], - ...options, - headers: { - ...auth.headers(), - ...{ 'content-type': 'application/json' }, - ...options.headers, - }, - - queryParameters: { - ...auth.queryParameters(), - ...options.queryParameters, - }, - }); - - return addMethods({ appId: options.appId, transporter }, options.methods); + return createPersonalizationClient(options); }; diff --git a/packages/client-search/src/types/ApiKeyType.ts b/packages/client-search/src/types/ApiKeyType.ts index 155ace2d3..093e12e6c 100644 --- a/packages/client-search/src/types/ApiKeyType.ts +++ b/packages/client-search/src/types/ApiKeyType.ts @@ -7,6 +7,7 @@ export const ApiKeyACLEnum: Readonly> = { EditSettings: 'editSettings', ListIndexes: 'listIndexes', Logs: 'logs', + Personalization: 'personalization', Recommendation: 'recommendation', Search: 'search', SeeUnretrievableAttributes: 'seeUnretrievableAttributes', @@ -23,6 +24,7 @@ export type ApiKeyACLType = | 'editSettings' | 'listIndexes' | 'logs' + | 'personalization' | 'recommendation' | 'search' | 'seeUnretrievableAttributes' diff --git a/packages/recommend/README.md b/packages/recommend/README.md new file mode 100644 index 000000000..1cb2198eb --- /dev/null +++ b/packages/recommend/README.md @@ -0,0 +1,72 @@ +

+

Algolia Recommend

+ +

The perfect starting point to integrate Algolia Recommend within your JavaScript project

+ +

+ NPM version + License +

+

+ +

+ Documentation • + UI library • + Community Forum • + Stack Overflow • + Report a bug • + Support +

+ +## ✨ Features + +- Thin & **minimal low-level HTTP client** to interact with Algolia's Recommend API +- Works both on the **browser** and **node.js** +- **UMD compatible**, you can use it with any module loader +- Built with TypeScript + +## 💡 Getting Started + +First, install Algolia Recommend API Client via the [npm](https://www.npmjs.com/get-npm) package manager: + +```bash +npm install @algolia/recommend +``` + +Then, let's retrieve recommendations: + +```js +const algoliarecommend = require('@algolia/recommend'); + +const client = algoliarecommend('YourApplicationID', 'YourAdminAPIKey'); + +client + .getFrequentlyBoughtTogether({ + indexName: 'your_index_name', + objectID: 'your_object_id', + }) + .then(({ results }) => { + console.log(results); + }) + .catch(err => { + console.log(err); + }); + +client + .getRelatedProducts({ + indexName: 'your_index_name', + objectID: 'your_object_id', + }) + .then(({ results }) => { + console.log(results); + }) + .catch(err => { + console.log(err); + }); +``` + +For full documentation, visit the **[online documentation](https://www.algolia.com/doc/api-client/methods/recommend/)**. + +## 📄 License + +Algolia Recommend API Client is an open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/packages/recommend/api-extractor.json b/packages/recommend/api-extractor.json new file mode 100644 index 000000000..a522871c3 --- /dev/null +++ b/packages/recommend/api-extractor.json @@ -0,0 +1,7 @@ +{ + "extends": "../../api-extractor.json", + "mainEntryPointFilePath": "./dist/packages/recommend/src/builds/node.d.ts", + "dtsRollup": { + "untrimmedFilePath": "./dist/recommend.d.ts" + } +} diff --git a/packages/recommend/index.d.ts b/packages/recommend/index.d.ts new file mode 100644 index 000000000..70d1e5942 --- /dev/null +++ b/packages/recommend/index.d.ts @@ -0,0 +1,3 @@ +/* eslint-disable import/no-unresolved*/ +export * from './dist/recommend'; +export { default } from './dist/recommend'; diff --git a/packages/recommend/index.js b/packages/recommend/index.js new file mode 100644 index 000000000..8122c2480 --- /dev/null +++ b/packages/recommend/index.js @@ -0,0 +1,15 @@ +/* eslint-disable functional/immutable-data, import/no-commonjs */ +const recommend = require('./dist/recommend.cjs.js'); + +/** + * The Common JS build is the default entry point for the Node environment. Keep in + * in mind, that for the browser environment, we hint the bundler to use the UMD + * build instead as specified on the key `browser` of our `package.json` file. + */ +module.exports = recommend; + +/** + * In addition, we also set explicitly the default export below making + * this Common JS module in compliance with es6 modules specification. + */ +module.exports.default = recommend; diff --git a/packages/recommend/package.json b/packages/recommend/package.json new file mode 100644 index 000000000..5b4463a90 --- /dev/null +++ b/packages/recommend/package.json @@ -0,0 +1,37 @@ +{ + "name": "@algolia/recommend", + "version": "4.9.3", + "private": false, + "description": "The perfect starting point to integrate Algolia Recommend within your JavaScript project.", + "repository": { + "type": "git", + "url": "git://github.com/algolia/algoliasearch-client-javascript.git" + }, + "license": "MIT", + "sideEffects": false, + "main": "index.js", + "jsdelivr": "./dist/recommend.umd.js", + "unpkg": "./dist/recommend.umd.js", + "browser": { + "./index.js": "./dist/recommend.umd.js" + }, + "types": "index.d.ts", + "files": [ + "dist", + "index.js", + "index.d.ts" + ], + "dependencies": { + "@algolia/cache-browser-local-storage": "4.9.3", + "@algolia/cache-common": "4.9.3", + "@algolia/cache-in-memory": "4.9.3", + "@algolia/client-common": "4.9.3", + "@algolia/client-search": "4.9.3", + "@algolia/logger-common": "4.9.3", + "@algolia/logger-console": "4.9.3", + "@algolia/requester-browser-xhr": "4.9.3", + "@algolia/requester-common": "4.9.3", + "@algolia/requester-node-http": "4.9.3", + "@algolia/transporter": "4.9.3" + } +} diff --git a/packages/recommend/src/__tests__/getFrequentlyBoughtTogether.test.ts b/packages/recommend/src/__tests__/getFrequentlyBoughtTogether.test.ts new file mode 100644 index 000000000..2a6908a64 --- /dev/null +++ b/packages/recommend/src/__tests__/getFrequentlyBoughtTogether.test.ts @@ -0,0 +1,77 @@ +import { TestSuite } from '../../../client-common/src/__tests__/TestSuite'; + +const recommend = new TestSuite('recommend').recommend; + +function createMockedClient() { + const client = recommend('appId', 'apiKey'); + jest.spyOn(client.transporter, 'read').mockImplementation(() => Promise.resolve()); + + return client; +} + +describe('getFrequentlyBoughtTogether', () => { + test('builds the request', async () => { + const client = createMockedClient(); + + await client.getFrequentlyBoughtTogether( + { + indexName: 'products', + objectID: 'B018APC4LE', + }, + {} + ); + + expect(client.transporter.read).toHaveBeenCalledTimes(1); + expect(client.transporter.read).toHaveBeenCalledWith( + { + cacheable: true, + data: { + requests: [ + { + fallbackParameters: {}, + indexName: 'products', + model: 'bought-together', + objectID: 'B018APC4LE', + threshold: 0, + }, + ], + }, + method: 'POST', + path: '1/indexes/*/recommendations', + }, + {} + ); + }); + + test('ignores `fallbackParameters`', async () => { + const client = createMockedClient(); + + await client.getFrequentlyBoughtTogether({ + // @ts-ignore `fallbackParameters` are not supposed to be passed + // according to the types + fallbackParameters: { + facetFilters: [], + }, + indexName: 'products', + objectID: 'B018APC4LE', + }); + + expect(client.transporter.read).toHaveBeenCalledTimes(1); + expect(client.transporter.read).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + requests: [ + { + fallbackParameters: {}, + indexName: 'products', + model: 'bought-together', + objectID: 'B018APC4LE', + threshold: 0, + }, + ], + }, + }), + undefined + ); + }); +}); diff --git a/packages/recommend/src/__tests__/getRecommendations.test.ts b/packages/recommend/src/__tests__/getRecommendations.test.ts new file mode 100644 index 000000000..2852033a8 --- /dev/null +++ b/packages/recommend/src/__tests__/getRecommendations.test.ts @@ -0,0 +1,78 @@ +import { TestSuite } from '../../../client-common/src/__tests__/TestSuite'; + +const recommend = new TestSuite('recommend').recommend; + +function createMockedClient() { + const client = recommend('appId', 'apiKey'); + jest.spyOn(client.transporter, 'read').mockImplementation(() => Promise.resolve()); + + return client; +} + +describe('getRecommendations', () => { + test('builds the request for "bought-together" model', async () => { + const client = createMockedClient(); + + await client.getRecommendations( + { + model: 'bought-together', + indexName: 'products', + objectID: 'B018APC4LE', + }, + {} + ); + + expect(client.transporter.read).toHaveBeenCalledTimes(1); + expect(client.transporter.read).toHaveBeenCalledWith( + { + cacheable: true, + data: { + requests: [ + { + indexName: 'products', + model: 'bought-together', + objectID: 'B018APC4LE', + threshold: 0, + }, + ], + }, + method: 'POST', + path: '1/indexes/*/recommendations', + }, + {} + ); + }); + + test('builds the request for "related-products" model', async () => { + const client = createMockedClient(); + + await client.getRecommendations( + { + model: 'related-products', + indexName: 'products', + objectID: 'B018APC4LE', + }, + {} + ); + + expect(client.transporter.read).toHaveBeenCalledTimes(1); + expect(client.transporter.read).toHaveBeenCalledWith( + { + cacheable: true, + data: { + requests: [ + { + indexName: 'products', + model: 'related-products', + objectID: 'B018APC4LE', + threshold: 0, + }, + ], + }, + method: 'POST', + path: '1/indexes/*/recommendations', + }, + {} + ); + }); +}); diff --git a/packages/recommend/src/__tests__/getRelatedProducts.test.ts b/packages/recommend/src/__tests__/getRelatedProducts.test.ts new file mode 100644 index 000000000..6e91cc950 --- /dev/null +++ b/packages/recommend/src/__tests__/getRelatedProducts.test.ts @@ -0,0 +1,44 @@ +import { TestSuite } from '../../../client-common/src/__tests__/TestSuite'; + +const recommend = new TestSuite('recommend').recommend; + +function createMockedClient() { + const client = recommend('appId', 'apiKey'); + jest.spyOn(client.transporter, 'read').mockImplementation(() => Promise.resolve()); + + return client; +} + +describe('getRelatedProducts', () => { + test('builds the request', async () => { + const client = createMockedClient(); + + await client.getRelatedProducts( + { + indexName: 'products', + objectID: 'B018APC4LE', + }, + {} + ); + + expect(client.transporter.read).toHaveBeenCalledTimes(1); + expect(client.transporter.read).toHaveBeenCalledWith( + { + cacheable: true, + data: { + requests: [ + { + indexName: 'products', + model: 'related-products', + objectID: 'B018APC4LE', + threshold: 0, + }, + ], + }, + method: 'POST', + path: '1/indexes/*/recommendations', + }, + {} + ); + }); +}); diff --git a/packages/recommend/src/__tests__/recommend-client.test.ts b/packages/recommend/src/__tests__/recommend-client.test.ts new file mode 100644 index 000000000..d3798a54d --- /dev/null +++ b/packages/recommend/src/__tests__/recommend-client.test.ts @@ -0,0 +1,179 @@ +import { createInMemoryCache } from '@algolia/cache-in-memory'; +import { version } from '@algolia/client-common'; +import { createStatelessHost, createUserAgent } from '@algolia/transporter'; + +import { TestSuite } from '../../../client-common/src/__tests__/TestSuite'; + +const recommend = new TestSuite('recommend').recommend; + +describe('recommend', () => { + test('has a version property', () => { + expect(recommend.version).toBe(version); + expect(recommend.version.startsWith('4.')).toBe(true); + }); + + test('gives access to appId', () => { + expect(recommend('appId', 'apiKey').appId).toEqual('appId'); + }); + + test('clearCache', async () => { + const client = recommend('appId', 'apiKey'); + + client.transporter.requestsCache.set('bla', 'blo'); + client.transporter.responsesCache.set('bla', 'blo'); + + if (testing.isBrowser()) { + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('blo'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('blo'); + } else { + // node uses a null cache, so these assertions don't make sense there + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + } + + await client.clearCache(); + + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + }); + + test('clearCache without promise', async () => { + const client = recommend('appId', 'apiKey'); + + client.transporter.requestsCache.set('bla', 'blo'); + client.transporter.responsesCache.set('bla', 'blo'); + + if (testing.isBrowser()) { + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('blo'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('blo'); + } else { + // node uses a null cache, so these assertions don't make sense there + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + } + + // no await, since default memory caches _actually_ are instant + client.clearCache(); + + await expect( + client.transporter.requestsCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + await expect( + client.transporter.responsesCache.get('bla', () => Promise.resolve('wrong')) + ).resolves.toBe('wrong'); + }); + + it('sets default headers and queryParameters', () => { + const client = recommend('appId', 'apiKey'); + + if (testing.isBrowser()) { + expect(client.transporter.headers).toEqual({ + 'content-type': 'application/x-www-form-urlencoded', + }); + expect(client.transporter.queryParameters).toEqual({ + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + }); + } else { + expect(client.transporter.headers).toEqual({ + 'content-type': 'application/x-www-form-urlencoded', + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + }); + expect(client.transporter.queryParameters).toEqual({}); + } + }); + + test('sets default user agent', () => { + const client = recommend('appId', 'apiKey'); + + if (testing.isBrowser()) { + expect(client.transporter.userAgent.value).toEqual( + `Algolia for JavaScript (${version}); Recommend (${version}); Browser` + ); + } else { + const nodeVersion = process.versions.node; + + expect(client.transporter.userAgent.value).toEqual( + `Algolia for JavaScript (${version}); Recommend (${version}); Node.js (${nodeVersion})` + ); + } + }); + + test('allows to customize options', () => { + const client = recommend('appId', 'apiKey'); + const cache = createInMemoryCache(); + const userAgent = createUserAgent('0.2.0'); + + const customClient = recommend('appId', 'apiKey', { + hostsCache: cache, + requestsCache: cache, + userAgent, + timeouts: { + connect: 45, + read: 46, + write: 47, + }, + queryParameters: { + queryParameter: 'bar', + }, + headers: { + header: 'foo', + }, + hosts: [{ url: 'foo.com' }], + }); + + // Then, on custom options, only the client is impacted + expect(client.transporter.hostsCache).not.toBe(cache); + expect(customClient.transporter.hostsCache).toBe(cache); + + expect(client.transporter.requestsCache).not.toBe(cache); + expect(customClient.transporter.requestsCache).toBe(cache); + expect(customClient.transporter.timeouts).toEqual({ + connect: 45, + read: 46, + write: 47, + }); + + expect(customClient.transporter.queryParameters).toEqual( + expect.objectContaining({ + queryParameter: 'bar', + }) + ); + expect(customClient.transporter.headers).toEqual( + expect.objectContaining({ + 'content-type': 'application/x-www-form-urlencoded', + header: 'foo', + }) + ); + expect(customClient.transporter.hosts).toEqual([createStatelessHost({ url: 'foo.com' })]); + }); + + test('can be destroyed', () => { + const client = recommend('appId', 'apiKey'); + + if (!testing.isBrowser()) { + expect(client).toHaveProperty('destroy'); + } + }); +}); diff --git a/packages/recommend/src/builds/browser.ts b/packages/recommend/src/builds/browser.ts new file mode 100644 index 000000000..4e791606f --- /dev/null +++ b/packages/recommend/src/builds/browser.ts @@ -0,0 +1,57 @@ +import { createBrowserLocalStorageCache } from '@algolia/cache-browser-local-storage'; +import { createFallbackableCache } from '@algolia/cache-common'; +import { createInMemoryCache } from '@algolia/cache-in-memory'; +import { AuthMode, version } from '@algolia/client-common'; +import { LogLevelEnum } from '@algolia/logger-common'; +import { createConsoleLogger } from '@algolia/logger-console'; +import { createBrowserXhrRequester } from '@algolia/requester-browser-xhr'; +import { createUserAgent } from '@algolia/transporter'; + +import { createRecommendClient } from '../createRecommendClient'; +import { getFrequentlyBoughtTogether, getRecommendations, getRelatedProducts } from '../methods'; +import { RecommendClient, RecommendOptions, WithRecommendMethods } from '../types'; + +export default function recommend( + appId: string, + apiKey: string, + options?: RecommendOptions +): WithRecommendMethods { + const commonOptions = { + appId, + apiKey, + timeouts: { + connect: 1, + read: 2, + write: 30, + }, + requester: createBrowserXhrRequester(), + logger: createConsoleLogger(LogLevelEnum.Error), + responsesCache: createInMemoryCache(), + requestsCache: createInMemoryCache({ serializable: false }), + hostsCache: createFallbackableCache({ + caches: [ + createBrowserLocalStorageCache({ key: `${version}-${appId}` }), + createInMemoryCache(), + ], + }), + userAgent: createUserAgent(version) + .add({ segment: 'Recommend', version }) + .add({ segment: 'Browser' }), + authMode: AuthMode.WithinQueryParameters, + }; + + return createRecommendClient({ + ...commonOptions, + ...options, + methods: { + getFrequentlyBoughtTogether, + getRecommendations, + getRelatedProducts, + }, + }); +} + +// eslint-disable-next-line functional/immutable-data +recommend.version = version; + +export * from '../types'; diff --git a/packages/recommend/src/builds/node.ts b/packages/recommend/src/builds/node.ts new file mode 100644 index 000000000..2bd5a74d6 --- /dev/null +++ b/packages/recommend/src/builds/node.ts @@ -0,0 +1,57 @@ +import { createNullCache } from '@algolia/cache-common'; +import { createInMemoryCache } from '@algolia/cache-in-memory'; +import { destroy, version } from '@algolia/client-common'; +import { createNullLogger } from '@algolia/logger-common'; +import { Destroyable } from '@algolia/requester-common'; +import { createNodeHttpRequester } from '@algolia/requester-node-http'; +import { createUserAgent } from '@algolia/transporter'; + +import { createRecommendClient } from '../createRecommendClient'; +import { getFrequentlyBoughtTogether, getRecommendations, getRelatedProducts } from '../methods'; +import { + RecommendClient as BaseRecommendClient, + RecommendOptions, + WithRecommendMethods, +} from '../types'; + +export default function recommend( + appId: string, + apiKey: string, + options?: RecommendOptions +): WithRecommendMethods { + const commonOptions = { + appId, + apiKey, + timeouts: { + connect: 2, + read: 5, + write: 30, + }, + requester: createNodeHttpRequester(), + logger: createNullLogger(), + responsesCache: createNullCache(), + requestsCache: createNullCache(), + hostsCache: createInMemoryCache(), + userAgent: createUserAgent(version) + .add({ segment: 'Recommend', version }) + .add({ segment: 'Node.js', version: process.versions.node }), + }; + + return createRecommendClient({ + ...commonOptions, + ...options, + methods: { + destroy, + getFrequentlyBoughtTogether, + getRecommendations, + getRelatedProducts, + }, + }); +} + +// eslint-disable-next-line functional/immutable-data +recommend.version = version; + +export type RecommendClient = BaseRecommendClient & Destroyable; + +export * from '../types'; diff --git a/packages/recommend/src/createRecommendClient.ts b/packages/recommend/src/createRecommendClient.ts new file mode 100644 index 000000000..83ed830c7 --- /dev/null +++ b/packages/recommend/src/createRecommendClient.ts @@ -0,0 +1,64 @@ +import { + addMethods, + AuthMode, + ClientTransporterOptions, + createAuth, + CreateClient, + shuffle, +} from '@algolia/client-common'; +import { CallEnum, createTransporter, HostOptions } from '@algolia/transporter'; + +import { RecommendClient, RecommendClientOptions } from './types'; + +export const createRecommendClient: CreateClient< + RecommendClient, + RecommendClientOptions & ClientTransporterOptions +> = options => { + const appId = options.appId; + + const auth = createAuth( + options.authMode !== undefined ? options.authMode : AuthMode.WithinHeaders, + appId, + options.apiKey + ); + + const transporter = createTransporter({ + hosts: ([ + { url: `${appId}-dsn.algolia.net`, accept: CallEnum.Read }, + { url: `${appId}.algolia.net`, accept: CallEnum.Write }, + ] as readonly HostOptions[]).concat( + shuffle([ + { url: `${appId}-1.algolianet.com` }, + { url: `${appId}-2.algolianet.com` }, + { url: `${appId}-3.algolianet.com` }, + ]) + ), + ...options, + headers: { + ...auth.headers(), + ...{ 'content-type': 'application/x-www-form-urlencoded' }, + ...options.headers, + }, + + queryParameters: { + ...auth.queryParameters(), + ...options.queryParameters, + }, + }); + + const base = { + transporter, + appId, + addAlgoliaAgent(segment: string, version?: string): void { + transporter.userAgent.add({ segment, version }); + }, + clearCache(): Readonly> { + return Promise.all([ + transporter.requestsCache.clear(), + transporter.responsesCache.clear(), + ]).then(() => undefined); + }, + }; + + return addMethods(base, options.methods); +}; diff --git a/packages/recommend/src/index.ts b/packages/recommend/src/index.ts new file mode 100644 index 000000000..e067cd20c --- /dev/null +++ b/packages/recommend/src/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './types/index'; diff --git a/packages/recommend/src/methods/getFrequentlyBoughtTogether.ts b/packages/recommend/src/methods/getFrequentlyBoughtTogether.ts new file mode 100644 index 000000000..46b950c42 --- /dev/null +++ b/packages/recommend/src/methods/getFrequentlyBoughtTogether.ts @@ -0,0 +1,24 @@ +import { RecommendClient, WithRecommendMethods } from '../types'; +import { getRecommendations, GetRecommendationsOptions } from './getRecommendations'; + +export type GetFrequentlyBoughtTogetherOptions = Omit< + GetRecommendationsOptions, + 'model' | 'fallbackParameters' +>; + +type GetFrequentlyBoughtTogether = ( + base: RecommendClient +) => WithRecommendMethods['getFrequentlyBoughtTogether']; + +export const getFrequentlyBoughtTogether: GetFrequentlyBoughtTogether = base => { + return (options, requestOptions) => { + return getRecommendations(base)( + { + ...options, + fallbackParameters: {}, + model: 'bought-together', + }, + requestOptions + ); + }; +}; diff --git a/packages/recommend/src/methods/getRecommendations.ts b/packages/recommend/src/methods/getRecommendations.ts new file mode 100644 index 000000000..850e83da2 --- /dev/null +++ b/packages/recommend/src/methods/getRecommendations.ts @@ -0,0 +1,48 @@ +import { MethodEnum } from '@algolia/requester-common'; + +import { + RecommendClient, + RecommendModel, + RecommendSearchOptions, + WithRecommendMethods, +} from '../types'; + +export type GetRecommendationsOptions = { + readonly indexName: string; + readonly model: RecommendModel; + readonly objectID: string; + readonly threshold?: number; + readonly maxRecommendations?: number; + readonly queryParameters?: RecommendSearchOptions; + readonly fallbackParameters?: RecommendSearchOptions; +}; + +type GetRecommendations = ( + base: RecommendClient +) => WithRecommendMethods['getRecommendations']; + +export const getRecommendations: GetRecommendations = base => { + return (options, requestOptions) => { + const requests: readonly GetRecommendationsOptions[] = [ + { + // The `threshold` param is required by the endpoint to make it easier + // to provide a default value later, so we default it in the client + // so that users don't have to provide a value. + threshold: 0, + ...options, + }, + ]; + + return base.transporter.read( + { + method: MethodEnum.Post, + path: '1/indexes/*/recommendations', + data: { + requests, + }, + cacheable: true, + }, + requestOptions + ); + }; +}; diff --git a/packages/recommend/src/methods/getRelatedProducts.ts b/packages/recommend/src/methods/getRelatedProducts.ts new file mode 100644 index 000000000..10b718727 --- /dev/null +++ b/packages/recommend/src/methods/getRelatedProducts.ts @@ -0,0 +1,20 @@ +import { RecommendClient, WithRecommendMethods } from '../types'; +import { getRecommendations, GetRecommendationsOptions } from './getRecommendations'; + +export type GetRelatedProductsOptions = Omit; + +type GetRelatedProducts = ( + base: RecommendClient +) => WithRecommendMethods['getRelatedProducts']; + +export const getRelatedProducts: GetRelatedProducts = base => { + return (options, requestOptions) => { + return getRecommendations(base)( + { + ...options, + model: 'related-products', + }, + requestOptions + ); + }; +}; diff --git a/packages/recommend/src/methods/index.ts b/packages/recommend/src/methods/index.ts new file mode 100644 index 000000000..a53053aef --- /dev/null +++ b/packages/recommend/src/methods/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './getFrequentlyBoughtTogether'; +export * from './getRecommendations'; +export * from './getRelatedProducts'; diff --git a/packages/recommend/src/types/RecommendClient.ts b/packages/recommend/src/types/RecommendClient.ts new file mode 100644 index 000000000..ee94cab74 --- /dev/null +++ b/packages/recommend/src/types/RecommendClient.ts @@ -0,0 +1,23 @@ +import { Transporter } from '@algolia/transporter'; + +export type RecommendClient = { + /** + * The application id. + */ + readonly appId: string; + + /** + * The underlying transporter. + */ + readonly transporter: Transporter; + + /** + * Mutates the transporter, adding the given user agent. + */ + readonly addAlgoliaAgent: (segment: string, version?: string) => void; + + /** + * Clears both requests and responses caches. + */ + readonly clearCache: () => Readonly>; +}; diff --git a/packages/recommend/src/types/RecommendClientOptions.ts b/packages/recommend/src/types/RecommendClientOptions.ts new file mode 100644 index 000000000..a493f7846 --- /dev/null +++ b/packages/recommend/src/types/RecommendClientOptions.ts @@ -0,0 +1,19 @@ +import { AuthModeType } from '@algolia/client-common'; + +export type RecommendClientOptions = { + /** + * The application id. + */ + readonly appId: string; + + /** + * The api key. + */ + readonly apiKey: string; + + /** + * The auth mode type. In browser environments credentials may + * be passed within the headers. + */ + readonly authMode?: AuthModeType; +}; diff --git a/packages/recommend/src/types/RecommendModel.ts b/packages/recommend/src/types/RecommendModel.ts new file mode 100644 index 000000000..8e3cdfcdf --- /dev/null +++ b/packages/recommend/src/types/RecommendModel.ts @@ -0,0 +1 @@ +export type RecommendModel = 'related-products' | 'bought-together'; diff --git a/packages/recommend/src/types/RecommendOptions.ts b/packages/recommend/src/types/RecommendOptions.ts new file mode 100644 index 000000000..b37634837 --- /dev/null +++ b/packages/recommend/src/types/RecommendOptions.ts @@ -0,0 +1,3 @@ +import { ClientTransporterOptions } from '@algolia/client-common'; + +export type RecommendOptions = Partial; diff --git a/packages/recommend/src/types/RecommendSearchOptions.ts b/packages/recommend/src/types/RecommendSearchOptions.ts new file mode 100644 index 000000000..e14d9ab6c --- /dev/null +++ b/packages/recommend/src/types/RecommendSearchOptions.ts @@ -0,0 +1,6 @@ +import { SearchOptions } from '@algolia/client-search'; + +export type RecommendSearchOptions = Omit< + SearchOptions, + 'page' | 'hitsPerPage' | 'offset' | 'length' +>; diff --git a/packages/recommend/src/types/WithRecommendMethods.ts b/packages/recommend/src/types/WithRecommendMethods.ts new file mode 100644 index 000000000..fe5aac59a --- /dev/null +++ b/packages/recommend/src/types/WithRecommendMethods.ts @@ -0,0 +1,34 @@ +import { MultipleQueriesResponse, SearchOptions } from '@algolia/client-search'; +import { RequestOptions } from '@algolia/transporter'; + +import { + GetFrequentlyBoughtTogetherOptions, + GetRecommendationsOptions, + GetRelatedProductsOptions, +} from '../methods'; + +export type WithRecommendMethods = TType & { + /** + * Returns recommendations. + */ + readonly getRecommendations: ( + options: GetRecommendationsOptions, + requestOptions?: RequestOptions & SearchOptions + ) => Readonly>>; + + /** + * Returns [Related Products](https://algolia.com/doc/guides/algolia-ai/recommend/#related-products). + */ + readonly getRelatedProducts: ( + options: GetRelatedProductsOptions, + requestOptions?: RequestOptions & SearchOptions + ) => Readonly>>; + + /** + * Returns [Frequently Bought Together](https://algolia.com/doc/guides/algolia-ai/recommend/#frequently-bought-together) products. + */ + readonly getFrequentlyBoughtTogether: ( + options: GetFrequentlyBoughtTogetherOptions, + requestOptions?: RequestOptions & SearchOptions + ) => Readonly>>; +}; diff --git a/packages/recommend/src/types/index.ts b/packages/recommend/src/types/index.ts new file mode 100644 index 000000000..b598043e4 --- /dev/null +++ b/packages/recommend/src/types/index.ts @@ -0,0 +1,10 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './RecommendClient'; +export * from './RecommendClientOptions'; +export * from './RecommendModel'; +export * from './RecommendOptions'; +export * from './RecommendSearchOptions'; +export * from './WithRecommendMethods'; diff --git a/packages/recommend/tsconfig.json b/packages/recommend/tsconfig.json new file mode 100644 index 000000000..39df687c5 --- /dev/null +++ b/packages/recommend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false, + "allowJs": false, + "emitDeclarationOnly": true, + "outFile": "dist/types.d.ts" + } +} diff --git a/rollup.config.js b/rollup.config.js index 4d6e8b850..76e16ff93 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -29,6 +29,7 @@ const packagesConfig = [ 'client-account', 'client-analytics', 'client-common', + 'client-personalization', 'client-recommendation', 'logger-common', 'logger-console', @@ -98,6 +99,21 @@ packagesConfig.push({ }); }); +packagesConfig.push( + { + output: 'recommend', + package: 'recommend', + input: `src/builds/browser.ts`, + formats: ['esm-browser', 'umd'], + }, + { + output: 'recommend', + package: 'recommend', + input: `src/builds/node.ts`, + formats: ['cjs'], + } +); + const packagesDir = path.resolve(__dirname, 'packages'); const aliasOptions = { resolve: ['.ts'] }; diff --git a/scripts/test-build-declarations.js b/scripts/test-build-declarations.js index 19eac4ce5..2e871750d 100755 --- a/scripts/test-build-declarations.js +++ b/scripts/test-build-declarations.js @@ -2,7 +2,7 @@ const glob = require('glob'); const execa = require('execa'); -const NUMBER_OF_DECLARATIONS = 18; +const NUMBER_OF_DECLARATIONS = 21; (async () => { const declarations = await new Promise(resolve => {