From 5f61e7c42b6f3777565fdd232509a6d2fc1ca81e Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Sun, 28 Apr 2024 12:57:55 -0600 Subject: [PATCH] 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, + }); + }); +});