From 8ed066d4ba78f52966795f26422937e1634283af Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 6 May 2024 10:00:30 -0600 Subject: [PATCH] Add isomorphic content API utilities (#271) --- package-lock.json | 20 ++-- package.json | 2 +- src/api/evaluate.ts | 50 ++++++++ src/api/fetchContent.ts | 68 +++++++++++ src/api/index.ts | 2 + src/plug.ts | 19 ++- test/api/evaluate.test.ts | 96 +++++++++++++++ test/api/fetchContent.test.ts | 216 ++++++++++++++++++++++++++++++++++ test/plug.test.ts | 80 ++++++++++++- 9 files changed, 537 insertions(+), 16 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..7d7e7891 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": { @@ -1934,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" @@ -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", 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 new file mode 100644 index 00000000..b7db5cc6 --- /dev/null +++ b/src/api/evaluate.ts @@ -0,0 +1,50 @@ +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; + +type FetchingOptions = { + baseEndpointUrl?: string, + fallback?: T, + logger?: Logger, +}; + +type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; + +type ServerSideAuthOptions = { + apiKey: string|ApiKey, + appId?: never, +}; + +type ClientSideAuthOptions = { + appId: string, + apiKey?: never, +}; + +export function evaluate(query: string, options: EvaluationOptions): Promise { + 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( + 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 new file mode 100644 index 00000000..7fc50504 --- /dev/null +++ b/src/api/fetchContent.ts @@ -0,0 +1,68 @@ +import { + ContentFetcher, + 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'; + +type FetchingOptions = { + baseEndpointUrl?: string, + fallback?: T, + logger?: Logger, +}; + +type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; + +type ServerSideAuthOptions = { + apiKey: string|ApiKey, + 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, logger, ...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( + error => { + if (logger !== undefined) { + logger.error(`Failed to fetch content for slot "${id}@${version}": ${formatCause(error)}`); + } + + return {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/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 new file mode 100644 index 00000000..491326c3 --- /dev/null +++ b/test/api/evaluate.test.ts @@ -0,0 +1,96 @@ +import {Evaluator} from '@croct/sdk/evaluator'; +import {Logger} from '@croct/sdk/logging'; +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 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('Reason')); + + await expect(evaluate('"this is a long query"', 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 new file mode 100644 index 00000000..37c09019 --- /dev/null +++ b/test/api/fetchContent.test.ts @@ -0,0 +1,216 @@ +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'; + +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 logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const fallback: SlotContent = { + _component: 'component-id', + id: 'fallback', + }; + + const options: FetchOptions = { + apiKey: apiKey, + timeout: 100, + fallback: fallback, + logger: logger, + }; + + 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..5a6c2667 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 the 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);