From 5f61e7c42b6f3777565fdd232509a6d2fc1ca81e Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Sun, 28 Apr 2024 12:57:55 -0600 Subject: [PATCH 1/6] Add isomorphic content API utilities --- package-lock.json | 8 +- package.json | 2 +- src/api/evaluate.ts | 34 ++++++ src/api/fetchContent.ts | 60 ++++++++++ src/api/index.ts | 2 + test/api/evaluate.test.ts | 85 ++++++++++++++ test/api/fetchContent.test.ts | 206 ++++++++++++++++++++++++++++++++++ 7 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 src/api/evaluate.ts create mode 100644 src/api/fetchContent.ts create mode 100644 src/api/index.ts create mode 100644 test/api/evaluate.test.ts create mode 100644 test/api/fetchContent.test.ts diff --git a/package-lock.json b/package-lock.json index 79ac79c2..8177d5fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "^0.14.0", + "@croct/sdk": "file:/Users/marcospassos/WebstormProjects/sdk-js/build/croct-sdk-0.0.0-dev.tgz", "tslib": "^2.2.0" }, "devDependencies": { @@ -929,9 +929,9 @@ "integrity": "sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==" }, "node_modules/@croct/sdk": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.14.0.tgz", - "integrity": "sha512-Xuhv1Zz/9joTktEOlRHaVsjGdqKALQcVwJtWMbJuTHpq8IyE1WJZ3tBuNGOXv+YuPIehF5CMfs93NSUUnb5Xyg==", + "version": "0.0.0-dev", + "resolved": "file:../sdk-js/build/croct-sdk-0.0.0-dev.tgz", + "integrity": "sha512-hDbzs+TH1JQSCLLjRIw/DbHQ/yjdYIt8LO8z8cDaYuF5hMtuz/rPsZZH2xlPmXJL2NmwFRDRbykzo+YanwYivA==", "dependencies": { "@croct/json": "^2.0.1", "tslib": "^2.5.0" diff --git a/package.json b/package.json index 7aeda763..5eb8cbd8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "^0.14.0", + "@croct/sdk": "file:/Users/marcospassos/WebstormProjects/sdk-js/build/croct-sdk-0.0.0-dev.tgz", "tslib": "^2.2.0" }, "devDependencies": { diff --git a/src/api/evaluate.ts b/src/api/evaluate.ts new file mode 100644 index 00000000..b3157b80 --- /dev/null +++ b/src/api/evaluate.ts @@ -0,0 +1,34 @@ +import {Evaluator, EvaluationOptions as BaseOptions} from '@croct/sdk/evaluator'; +import {JsonValue} from '../sdk/json'; + +export type EvaluationOptions = BaseOptions & AuthOptions & FetchingOptions; + +type FetchingOptions = { + baseEndpointUrl?: string, + fallback?: T, +}; + +type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; + +type ServerSideAuthOptions = { + apiKey: string, + appId?: never, +}; + +type ClientSideAuthOptions = { + appId: string, + apiKey?: never, +}; + +export function evaluate(query: string, options: EvaluationOptions): Promise { + const {baseEndpointUrl, fallback, apiKey, appId, ...evaluation} = options; + const auth: AuthOptions = apiKey !== undefined ? {apiKey: apiKey} : {appId: appId}; + const promise = (new Evaluator({...auth, baseEndpointUrl: baseEndpointUrl})) + .evaluate(query, evaluation) as Promise; + + if (fallback !== undefined) { + return promise.catch(() => fallback); + } + + return promise; +} diff --git a/src/api/fetchContent.ts b/src/api/fetchContent.ts new file mode 100644 index 00000000..42f5ea2e --- /dev/null +++ b/src/api/fetchContent.ts @@ -0,0 +1,60 @@ +import { + ContentFetcher, + DynamicContentOptions as BaseDynamicOptions, + StaticContentOptions as BaseStaticOptions, +} from '@croct/sdk/contentFetcher'; +import {JsonObject, JsonValue} from '../sdk/json'; +import {FetchResponse} from '../plug'; +import {SlotContent, VersionedSlotId} from '../slot'; + +type FetchingOptions = { + baseEndpointUrl?: string, + fallback?: T, +}; + +type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; + +type ServerSideAuthOptions = { + apiKey: string, + appId?: never, +}; + +type ClientSideAuthOptions = { + appId: string, + apiKey?: never, +}; + +export type DynamicContentOptions = + Omit & FetchingOptions & AuthOptions; + +export type StaticContentOptions = + Omit & FetchingOptions & ServerSideAuthOptions; + +export type FetchOptions = DynamicContentOptions | StaticContentOptions; + +export function fetchContent( + slotId: I, + options?: FetchOptions>, +): Promise, 'payload'>> { + const {apiKey, appId, fallback, baseEndpointUrl, ...fetchOptions} = options ?? {}; + const auth = {appId: appId, apiKey: apiKey}; + const [id, version = 'latest'] = slotId.split('@') as [I, `${number}` | 'latest' | undefined]; + + const promise = (new ContentFetcher({...auth, baseEndpointUrl: baseEndpointUrl})) + .fetch>( + id, + version === 'latest' + ? fetchOptions + : {...fetchOptions, version: version}, + ); + + if (fallback !== undefined) { + return promise.catch( + () => ({ + content: fallback, + }), + ); + } + + return promise; +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 00000000..bb73cd91 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,2 @@ +export * from './evaluate'; +export * from './fetchContent'; diff --git a/test/api/evaluate.test.ts b/test/api/evaluate.test.ts new file mode 100644 index 00000000..cd850f78 --- /dev/null +++ b/test/api/evaluate.test.ts @@ -0,0 +1,85 @@ +import {Evaluator} from '@croct/sdk/evaluator'; +import {evaluate, EvaluationOptions} from '../../src/api'; + +const mockEvaluate: Evaluator['evaluate'] = jest.fn(); + +jest.mock( + '@croct/sdk/evaluator', + () => ({ + __esModule: true, + /* + * eslint-disable-next-line prefer-arrow-callback -- + * The mock can't be an arrow function because calling new on + * an arrow function is not allowed in JavaScript. + */ + Evaluator: jest.fn(function constructor(this: Evaluator) { + this.evaluate = mockEvaluate; + }), + }), +); + +describe('evaluate', () => { + const apiKey = '00000000-0000-0000-0000-000000000000'; + const appId = '00000000-0000-0000-0000-000000000000'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should forward a server-side evaluation request', async () => { + const options: EvaluationOptions = { + apiKey: apiKey, + timeout: 100, + baseEndpointUrl: 'https://croct.example.com', + }; + + const query = 'true'; + + jest.mocked(mockEvaluate).mockResolvedValue(true); + + await expect(evaluate(query, options)).resolves.toBe(true); + + expect(Evaluator).toHaveBeenCalledWith({ + apiKey: options.apiKey, + baseEndpointUrl: options.baseEndpointUrl, + }); + + expect(mockEvaluate).toHaveBeenCalledWith(query, { + timeout: options.timeout, + }); + }); + + it('should forward a client-side evaluation request', async () => { + const options: EvaluationOptions = { + appId: appId, + timeout: 100, + baseEndpointUrl: 'https://croct.example.com', + }; + + const query = 'true'; + + jest.mocked(mockEvaluate).mockResolvedValue(true); + + await expect(evaluate(query, options)).resolves.toBe(true); + + expect(Evaluator).toHaveBeenCalledWith({ + appId: options.appId, + baseEndpointUrl: options.baseEndpointUrl, + }); + + expect(mockEvaluate).toHaveBeenCalledWith(query, { + timeout: options.timeout, + }); + }); + + it('should return the fallback value on error', async () => { + const options: EvaluationOptions = { + apiKey: apiKey, + fallback: false, + }; + + jest.mocked(mockEvaluate).mockRejectedValue(new Error('error')); + + await expect(evaluate('true', options)).resolves.toBe(false); + }); +}); diff --git a/test/api/fetchContent.test.ts b/test/api/fetchContent.test.ts new file mode 100644 index 00000000..beeacf22 --- /dev/null +++ b/test/api/fetchContent.test.ts @@ -0,0 +1,206 @@ +import {ContentFetcher} from '@croct/sdk/contentFetcher'; +import {FetchResponse} from '../../src/plug'; +import {SlotContent} from '../../src/slot'; +import {fetchContent, FetchOptions} from '../../src/api'; + +const mockFetch: ContentFetcher['fetch'] = jest.fn(); + +jest.mock( + '@croct/sdk/contentFetcher', + () => ({ + __esModule: true, + /* + * eslint-disable-next-line prefer-arrow-callback -- + * The mock can't be an arrow function because calling new on + * an arrow function is not allowed in JavaScript. + */ + ContentFetcher: jest.fn(function constructor(this: ContentFetcher) { + this.fetch = mockFetch; + }), + }), +); + +describe('fetchContent', () => { + const apiKey = '00000000-0000-0000-0000-000000000000'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should forward a server-side content request', async () => { + const slotId = 'slot-id'; + + const options: FetchOptions = { + apiKey: apiKey, + baseEndpointUrl: 'https://croct.example.com', + timeout: 100, + fallback: { + _component: 'component-id', + }, + }; + + const result: FetchResponse = { + content: { + _component: 'component', + id: 'test', + }, + }; + + jest.mocked(mockFetch).mockResolvedValue(result); + + await expect(fetchContent(slotId, options)).resolves.toEqual(result); + + expect(ContentFetcher).toHaveBeenCalledWith({ + apiKey: options.apiKey, + baseEndpointUrl: options.baseEndpointUrl, + }); + + expect(mockFetch).toHaveBeenCalledWith(slotId, { + timeout: options.timeout, + }); + }); + + it('should forward a static content request', async () => { + const slotId = 'slot-id'; + + const options: FetchOptions = { + apiKey: apiKey, + static: true, + fallback: { + _component: 'component-id', + }, + }; + + const result: FetchResponse = { + content: { + _component: 'component', + id: 'test', + }, + }; + + jest.mocked(mockFetch).mockResolvedValue(result); + + await expect(fetchContent(slotId, options)).resolves.toEqual(result); + + expect(ContentFetcher).toHaveBeenCalledWith({ + apiKey: options.apiKey, + }); + + expect(mockFetch).toHaveBeenCalledWith(slotId, { + static: true, + }); + }); + + it('should forward a client-side content request', async () => { + const slotId = 'slot-id'; + + const options: FetchOptions = { + appId: '00000000-0000-0000-0000-000000000000', + timeout: 100, + fallback: { + _component: 'component-id', + }, + }; + + const result: FetchResponse = { + content: { + _component: 'component', + id: 'test', + }, + }; + + jest.mocked(mockFetch).mockResolvedValue(result); + + await expect(fetchContent(slotId, options)).resolves.toEqual(result); + + expect(ContentFetcher).toHaveBeenCalledWith({ + appId: options.appId, + }); + + expect(mockFetch).toHaveBeenCalledWith(slotId, { + timeout: options.timeout, + }); + }); + + it('should extract the slot ID and version', async () => { + const slotId = 'slot-id'; + const version = '1'; + const versionedSlotId = `${slotId}@${version}`; + + const options: FetchOptions = { + apiKey: apiKey, + timeout: 100, + }; + + const result: FetchResponse = { + content: { + _component: 'component', + id: 'test', + }, + }; + + jest.mocked(mockFetch).mockResolvedValue(result); + + await expect(fetchContent(versionedSlotId, options)).resolves.toEqual(result); + + expect(ContentFetcher).toHaveBeenCalledWith({ + apiKey: options.apiKey, + }); + + expect(mockFetch).toHaveBeenCalledWith(slotId, { + timeout: options.timeout, + version: version, + }); + }); + + it('should fetch content omitting the latest alias', async () => { + const slotId = 'slot-id'; + const version = 'latest'; + const versionedSlotId = `${slotId}@${version}`; + + const options: FetchOptions = { + apiKey: apiKey, + timeout: 100, + }; + + const result: FetchResponse = { + content: { + _component: 'component', + id: 'test', + }, + }; + + jest.mocked(mockFetch).mockResolvedValue(result); + + await expect(fetchContent(versionedSlotId, options)).resolves.toEqual(result); + + expect(ContentFetcher).toHaveBeenCalledWith({ + apiKey: options.apiKey, + }); + + expect(mockFetch).toHaveBeenCalledWith(slotId, { + timeout: options.timeout, + }); + }); + + it('should return the fallback value on error', async () => { + const slotId = 'slot-id'; + + const fallback: SlotContent = { + _component: 'component-id', + id: 'fallback', + }; + + const options: FetchOptions = { + apiKey: apiKey, + timeout: 100, + fallback: fallback, + }; + + jest.mocked(mockFetch).mockRejectedValue(new Error('error')); + + await expect(fetchContent(slotId, options)).resolves.toEqual({ + content: fallback, + }); + }); +}); From ea42e6451b19d3489691e6a1212cdb7d24798721 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Sun, 28 Apr 2024 13:07:36 -0600 Subject: [PATCH 2/6] Revert dependencies changes --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8177d5fc..79ac79c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "file:/Users/marcospassos/WebstormProjects/sdk-js/build/croct-sdk-0.0.0-dev.tgz", + "@croct/sdk": "^0.14.0", "tslib": "^2.2.0" }, "devDependencies": { @@ -929,9 +929,9 @@ "integrity": "sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==" }, "node_modules/@croct/sdk": { - "version": "0.0.0-dev", - "resolved": "file:../sdk-js/build/croct-sdk-0.0.0-dev.tgz", - "integrity": "sha512-hDbzs+TH1JQSCLLjRIw/DbHQ/yjdYIt8LO8z8cDaYuF5hMtuz/rPsZZH2xlPmXJL2NmwFRDRbykzo+YanwYivA==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.14.0.tgz", + "integrity": "sha512-Xuhv1Zz/9joTktEOlRHaVsjGdqKALQcVwJtWMbJuTHpq8IyE1WJZ3tBuNGOXv+YuPIehF5CMfs93NSUUnb5Xyg==", "dependencies": { "@croct/json": "^2.0.1", "tslib": "^2.5.0" diff --git a/package.json b/package.json index 5eb8cbd8..7aeda763 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "file:/Users/marcospassos/WebstormProjects/sdk-js/build/croct-sdk-0.0.0-dev.tgz", + "@croct/sdk": "^0.14.0", "tslib": "^2.2.0" }, "devDependencies": { From bf31ef7d6dd7aa75f3e6e98e062cbe826d0b5c9f Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 6 May 2024 09:49:53 -0600 Subject: [PATCH 3/6] Add error logging --- package-lock.json | 32 ++++++++------ package.json | 2 +- src/api/evaluate.ts | 22 ++++++++-- src/api/fetchContent.ts | 18 +++++--- src/plug.ts | 19 +++++++-- test/api/evaluate.test.ts | 23 +++++++--- test/api/fetchContent.test.ts | 12 +++++- test/plug.test.ts | 80 ++++++++++++++++++++++++++++++++--- 8 files changed, 171 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79ac79c2..bc9680bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "^0.14.0", + "@croct/sdk": "^0.15.2", "tslib": "^2.2.0" }, "devDependencies": { @@ -929,11 +929,12 @@ "integrity": "sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==" }, "node_modules/@croct/sdk": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.14.0.tgz", - "integrity": "sha512-Xuhv1Zz/9joTktEOlRHaVsjGdqKALQcVwJtWMbJuTHpq8IyE1WJZ3tBuNGOXv+YuPIehF5CMfs93NSUUnb5Xyg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.15.2.tgz", + "integrity": "sha512-NKi3ptAluKMCmlxCLmonL3QH7hPB6Rog/j/1/ZlJrvkz2esGZiB1lDBMU0A93mEXQ4M1yJhQwhn/u4F6uz6BAg==", "dependencies": { "@croct/json": "^2.0.1", + "js-base64": "^3.7.7", "tslib": "^2.5.0" }, "engines": { @@ -2930,9 +2931,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001616", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001616.tgz", - "integrity": "sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==", + "version": "1.0.30001614", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", + "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", "dev": true, "funding": [ { @@ -3390,9 +3391,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.756", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.756.tgz", - "integrity": "sha512-RJKZ9+vEBMeiPAvKNWyZjuYyUqMndcP1f335oHqn3BEQbs2NFtVrnK5+6Xg5wSM9TknNNpWghGDUCKGYF+xWXw==", + "version": "1.4.753", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.753.tgz", + "integrity": "sha512-Wn1XKa0Lc5kMe5UIwQc4+i5lhhBggF0l82C1bE3oOMASt4JVqdOyRIVc8mh0kiuL5CCptqwQJBmFbaPglLrN0Q==", "dev": true }, "node_modules/emittery": { @@ -6247,6 +6248,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-base64": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", + "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8304,9 +8310,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz", - "integrity": "sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.14.tgz", + "integrity": "sha512-JixKH8GR2pWYshIPUg/NujK3JO7JiqEEUiNArE86NQyrgUuZeTlZQN3xuS/yiV5Kb48ev9K6RqNkaJjXsdg7Jw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 7aeda763..e3667451 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@croct/json": "^2.0.1", - "@croct/sdk": "^0.14.0", + "@croct/sdk": "^0.15.2", "tslib": "^2.2.0" }, "devDependencies": { diff --git a/src/api/evaluate.ts b/src/api/evaluate.ts index b3157b80..b7db5cc6 100644 --- a/src/api/evaluate.ts +++ b/src/api/evaluate.ts @@ -1,4 +1,7 @@ import {Evaluator, EvaluationOptions as BaseOptions} from '@croct/sdk/evaluator'; +import type {ApiKey} from '@croct/sdk/apiKey'; +import type {Logger} from '@croct/sdk/logging'; +import {formatCause} from '@croct/sdk/error'; import {JsonValue} from '../sdk/json'; export type EvaluationOptions = BaseOptions & AuthOptions & FetchingOptions; @@ -6,12 +9,13 @@ export type EvaluationOptions = BaseOptions & A type FetchingOptions = { baseEndpointUrl?: string, fallback?: T, + logger?: Logger, }; type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; type ServerSideAuthOptions = { - apiKey: string, + apiKey: string|ApiKey, appId?: never, }; @@ -21,13 +25,25 @@ type ClientSideAuthOptions = { }; export function evaluate(query: string, options: EvaluationOptions): Promise { - const {baseEndpointUrl, fallback, apiKey, appId, ...evaluation} = options; + const {baseEndpointUrl, fallback, apiKey, appId, logger, ...evaluation} = options; const auth: AuthOptions = apiKey !== undefined ? {apiKey: apiKey} : {appId: appId}; const promise = (new Evaluator({...auth, baseEndpointUrl: baseEndpointUrl})) .evaluate(query, evaluation) as Promise; if (fallback !== undefined) { - return promise.catch(() => fallback); + return promise.catch( + error => { + if (logger !== undefined) { + const reference = query.length > 20 + ? `${query.slice(0, 20)}...` + : query; + + logger.error(`Failed to evaluate query "${reference}": ${formatCause(error)}`); + } + + return fallback; + }, + ); } return promise; diff --git a/src/api/fetchContent.ts b/src/api/fetchContent.ts index 42f5ea2e..7fc50504 100644 --- a/src/api/fetchContent.ts +++ b/src/api/fetchContent.ts @@ -3,6 +3,9 @@ import { DynamicContentOptions as BaseDynamicOptions, StaticContentOptions as BaseStaticOptions, } from '@croct/sdk/contentFetcher'; +import type {ApiKey} from '@croct/sdk/apiKey'; +import type {Logger} from '@croct/sdk/logging'; +import {formatCause} from '@croct/sdk/error'; import {JsonObject, JsonValue} from '../sdk/json'; import {FetchResponse} from '../plug'; import {SlotContent, VersionedSlotId} from '../slot'; @@ -10,12 +13,13 @@ import {SlotContent, VersionedSlotId} from '../slot'; type FetchingOptions = { baseEndpointUrl?: string, fallback?: T, + logger?: Logger, }; type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; type ServerSideAuthOptions = { - apiKey: string, + apiKey: string|ApiKey, appId?: never, }; @@ -36,7 +40,7 @@ export function fetchContent( slotId: I, options?: FetchOptions>, ): Promise, 'payload'>> { - const {apiKey, appId, fallback, baseEndpointUrl, ...fetchOptions} = options ?? {}; + const {apiKey, appId, fallback, baseEndpointUrl, logger, ...fetchOptions} = options ?? {}; const auth = {appId: appId, apiKey: apiKey}; const [id, version = 'latest'] = slotId.split('@') as [I, `${number}` | 'latest' | undefined]; @@ -50,9 +54,13 @@ export function fetchContent( if (fallback !== undefined) { return promise.catch( - () => ({ - content: fallback, - }), + error => { + if (logger !== undefined) { + logger.error(`Failed to fetch content for slot "${id}@${version}": ${formatCause(error)}`); + } + + return {content: fallback}; + }, ); } diff --git a/src/plug.ts b/src/plug.ts index 0129d36b..b1d87678 100644 --- a/src/plug.ts +++ b/src/plug.ts @@ -355,10 +355,18 @@ export class GlobalPlug implements Plug { .track(type, payload); } - public evaluate(expression: string, options: EvaluationOptions = {}): Promise { + public evaluate(query: string, options: EvaluationOptions = {}): Promise { return this.sdk .evaluator - .evaluate(expression, options) as Promise; + .evaluate(query, options) + .catch(error => { + const logger = this.sdk.getLogger(); + const reference = query.length > 20 ? `${query.slice(0, 20)}...` : query; + + logger.error(`Failed to evaluate query "${reference}": ${formatCause(error)}`); + + throw error; + }) as Promise; } public test(expression: string, options: EvaluationOptions = {}): Promise { @@ -391,7 +399,12 @@ export class GlobalPlug implements Plug { }, content: response.content, }), - ); + ) + .catch(error => { + logger.error(`Failed to fetch content for slot "${id}@${version}": ${formatCause(error)}`); + + throw error; + }); }, ); } diff --git a/test/api/evaluate.test.ts b/test/api/evaluate.test.ts index cd850f78..491326c3 100644 --- a/test/api/evaluate.test.ts +++ b/test/api/evaluate.test.ts @@ -1,4 +1,5 @@ import {Evaluator} from '@croct/sdk/evaluator'; +import {Logger} from '@croct/sdk/logging'; import {evaluate, EvaluationOptions} from '../../src/api'; const mockEvaluate: Evaluator['evaluate'] = jest.fn(); @@ -8,10 +9,10 @@ jest.mock( () => ({ __esModule: true, /* - * eslint-disable-next-line prefer-arrow-callback -- - * The mock can't be an arrow function because calling new on - * an arrow function is not allowed in JavaScript. - */ + * eslint-disable-next-line prefer-arrow-callback -- + * The mock can't be an arrow function because calling new on + * an arrow function is not allowed in JavaScript. + */ Evaluator: jest.fn(function constructor(this: Evaluator) { this.evaluate = mockEvaluate; }), @@ -73,13 +74,23 @@ describe('evaluate', () => { }); it('should return the fallback value on error', async () => { + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const options: EvaluationOptions = { apiKey: apiKey, fallback: false, + logger: logger, }; - jest.mocked(mockEvaluate).mockRejectedValue(new Error('error')); + jest.mocked(mockEvaluate).mockRejectedValue(new Error('Reason')); + + await expect(evaluate('"this is a long query"', options)).resolves.toBe(false); - await expect(evaluate('true', options)).resolves.toBe(false); + expect(logger.error).toHaveBeenCalledWith('Failed to evaluate query ""this is a long quer...": reason'); }); }); diff --git a/test/api/fetchContent.test.ts b/test/api/fetchContent.test.ts index beeacf22..37c09019 100644 --- a/test/api/fetchContent.test.ts +++ b/test/api/fetchContent.test.ts @@ -1,4 +1,5 @@ import {ContentFetcher} from '@croct/sdk/contentFetcher'; +import {Logger} from '@croct/sdk/logging'; import {FetchResponse} from '../../src/plug'; import {SlotContent} from '../../src/slot'; import {fetchContent, FetchOptions} from '../../src/api'; @@ -185,6 +186,12 @@ describe('fetchContent', () => { it('should return the fallback value on error', async () => { const slotId = 'slot-id'; + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; const fallback: SlotContent = { _component: 'component-id', @@ -195,12 +202,15 @@ describe('fetchContent', () => { apiKey: apiKey, timeout: 100, fallback: fallback, + logger: logger, }; - jest.mocked(mockFetch).mockRejectedValue(new Error('error')); + jest.mocked(mockFetch).mockRejectedValue(new Error('Reason')); await expect(fetchContent(slotId, options)).resolves.toEqual({ content: fallback, }); + + expect(logger.error).toHaveBeenCalledWith(`Failed to fetch content for slot "${slotId}@latest": reason`); }); }); diff --git a/test/plug.test.ts b/test/plug.test.ts index f706d032..154ad370 100644 --- a/test/plug.test.ts +++ b/test/plug.test.ts @@ -752,7 +752,7 @@ describe('The Croct plug', () => { expect(() => croct.track('userSignedUp', {userId: 'c4r0l'})).toThrow('Croct is not plugged in.'); }); - it('should allow to evaluate expressions', async () => { + it('should allow to evaluate queries', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); @@ -771,11 +771,47 @@ describe('The Croct plug', () => { expect(evaluate).toHaveBeenCalledWith('user\'s name', {timeout: 5}); }); - it('should not allow to evaluate expressions if unplugged', () => { + it('should log an error when the query evaluation fails', async () => { + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const config: SdkFacadeConfiguration = { + appId: APP_ID, + logger: logger, + }; + + const sdkFacade = SdkFacade.init(config); + + const initialize = jest.spyOn(SdkFacade, 'init').mockReturnValue(sdkFacade); + + croct.plug(config); + + expect(initialize).toHaveBeenCalledWith(expect.objectContaining(config)); + + const evaluate = jest.spyOn(sdkFacade.evaluator, 'evaluate').mockRejectedValue(new Error('Reason')); + + const query = '"a long query with spaces"'; + + const promise = croct.evaluate(query, {timeout: 5}); + + await expect(promise).rejects.toThrow('Reason'); + + expect(evaluate).toHaveBeenCalledWith(query, {timeout: 5}); + + expect(logger.error).toHaveBeenCalledWith( + '[Croct] Failed to evaluate query ""a long query with s...": reason', + ); + }); + + it('should not allow to evaluate query if unplugged', () => { expect(() => croct.evaluate('foo', {timeout: 5})).toThrow('Croct is not plugged in.'); }); - it('should allow to test expressions', async () => { + it('should allow to test query', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); @@ -794,7 +830,7 @@ describe('The Croct plug', () => { expect(evaluate).toHaveBeenCalledWith('user\'s name is "Carol"', {timeout: 5}); }); - it('should test expressions assuming non-boolean results as false', async () => { + it('should test query assuming non-boolean results as false', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); @@ -813,7 +849,7 @@ describe('The Croct plug', () => { expect(evaluate).toHaveBeenCalledWith('user\'s name is "Carol"', {timeout: 5}); }); - it('should not test expressions assuming errors as false', async () => { + it('should not test query assuming errors as false', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); @@ -861,6 +897,40 @@ describe('The Croct plug', () => { expect(fetch).toHaveBeenLastCalledWith('foo', options); }); + it('should log an error when fetching content fails', async () => { + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const config: SdkFacadeConfiguration = { + appId: APP_ID, + logger: logger, + }; + + const sdkFacade = SdkFacade.init(config); + + const initialize = jest.spyOn(SdkFacade, 'init').mockReturnValue(sdkFacade); + + croct.plug(config); + + expect(initialize).toHaveBeenCalledWith(expect.objectContaining(config)); + + const fetch = jest.spyOn(sdkFacade.contentFetcher, 'fetch').mockRejectedValue(new Error('Reason')); + + const slotId = 'foo'; + + await expect(croct.fetch(slotId)).rejects.toThrow('Reason'); + + expect(fetch).toHaveBeenCalledWith('foo', {}); + + expect(logger.error).toHaveBeenCalledWith( + `[Croct] Failed to fetch content for slot "${slotId}@latest": Reason`, + ); + }); + it('should extract the slot ID and version', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); From 1486469a9fee56488773d2094acf75f8ae54e469 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 6 May 2024 09:55:11 -0600 Subject: [PATCH 4/6] Update package lock --- package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc9680bd..7d7e7891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1935,9 +1935,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.8.tgz", - "integrity": "sha512-NU0rJLJnshZWdE/097cdCBbyW1h4hEg0xpovcoAQYHl8dnEyp/NAOiE45pvc+Bd1Dt+2r94v2eGFpQJ4R7g+2w==", + "version": "20.12.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.9.tgz", + "integrity": "sha512-o93r47yu04MHumPBCFg0bMPBMNgtMg3jzbhl7e68z50+BMHmRMGDJv13eBlUgOdc9i/uoJXGMGYLtJV4ReTXEg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2931,9 +2931,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001614", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", - "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", + "version": "1.0.30001616", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001616.tgz", + "integrity": "sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==", "dev": true, "funding": [ { @@ -3391,9 +3391,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.753", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.753.tgz", - "integrity": "sha512-Wn1XKa0Lc5kMe5UIwQc4+i5lhhBggF0l82C1bE3oOMASt4JVqdOyRIVc8mh0kiuL5CCptqwQJBmFbaPglLrN0Q==", + "version": "1.4.756", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.756.tgz", + "integrity": "sha512-RJKZ9+vEBMeiPAvKNWyZjuYyUqMndcP1f335oHqn3BEQbs2NFtVrnK5+6Xg5wSM9TknNNpWghGDUCKGYF+xWXw==", "dev": true }, "node_modules/emittery": { @@ -8310,9 +8310,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.14.tgz", - "integrity": "sha512-JixKH8GR2pWYshIPUg/NujK3JO7JiqEEUiNArE86NQyrgUuZeTlZQN3xuS/yiV5Kb48ev9K6RqNkaJjXsdg7Jw==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz", + "integrity": "sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==", "dev": true, "funding": [ { From 5597e34cadb55d369435375da599d559d5480c68 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 6 May 2024 09:55:32 -0600 Subject: [PATCH 5/6] Update test/plug.test.ts Co-authored-by: Denis Rossati --- test/plug.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plug.test.ts b/test/plug.test.ts index 154ad370..ec91bc3b 100644 --- a/test/plug.test.ts +++ b/test/plug.test.ts @@ -811,7 +811,7 @@ describe('The Croct plug', () => { expect(() => croct.evaluate('foo', {timeout: 5})).toThrow('Croct is not plugged in.'); }); - it('should allow to test query', async () => { + it('should allow to test the query', async () => { const config: SdkFacadeConfiguration = {appId: APP_ID}; const sdkFacade = SdkFacade.init(config); From 1524c60bd95641cea2666ee0e989344ae7135e11 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 6 May 2024 09:57:03 -0600 Subject: [PATCH 6/6] Fix test --- test/plug.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plug.test.ts b/test/plug.test.ts index ec91bc3b..5a6c2667 100644 --- a/test/plug.test.ts +++ b/test/plug.test.ts @@ -927,7 +927,7 @@ describe('The Croct plug', () => { expect(fetch).toHaveBeenCalledWith('foo', {}); expect(logger.error).toHaveBeenCalledWith( - `[Croct] Failed to fetch content for slot "${slotId}@latest": Reason`, + `[Croct] Failed to fetch content for slot "${slotId}@latest": reason`, ); });