Skip to content

Commit

Permalink
Add isomorphic content API utilities (#271)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospassos authored May 6, 2024
1 parent c647d97 commit 8ed066d
Show file tree
Hide file tree
Showing 9 changed files with 537 additions and 16 deletions.
20 changes: 13 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
50 changes: 50 additions & 0 deletions src/api/evaluate.ts
Original file line number Diff line number Diff line change
@@ -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<T extends JsonValue = JsonValue> = BaseOptions & AuthOptions & FetchingOptions<T>;

type FetchingOptions<T extends JsonValue> = {
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<T extends JsonValue>(query: string, options: EvaluationOptions<T>): Promise<T> {
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<T>;

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;
}
68 changes: 68 additions & 0 deletions src/api/fetchContent.ts
Original file line number Diff line number Diff line change
@@ -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<T extends JsonValue> = {
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<T extends JsonObject = JsonObject> =
Omit<BaseDynamicOptions, 'version'> & FetchingOptions<T> & AuthOptions;

export type StaticContentOptions<T extends JsonObject = JsonObject> =
Omit<BaseStaticOptions, 'version'> & FetchingOptions<T> & ServerSideAuthOptions;

export type FetchOptions<T extends JsonObject = SlotContent> = DynamicContentOptions<T> | StaticContentOptions<T>;

export function fetchContent<I extends VersionedSlotId, C extends JsonObject>(
slotId: I,
options?: FetchOptions<SlotContent<I, C>>,
): Promise<Omit<FetchResponse<I, C>, '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<SlotContent<I, C>>(
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;
}
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './evaluate';
export * from './fetchContent';
19 changes: 16 additions & 3 deletions src/plug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,18 @@ export class GlobalPlug implements Plug {
.track(type, payload);
}

public evaluate<T extends JsonValue>(expression: string, options: EvaluationOptions = {}): Promise<T> {
public evaluate<T extends JsonValue>(query: string, options: EvaluationOptions = {}): Promise<T> {
return this.sdk
.evaluator
.evaluate(expression, options) as Promise<T>;
.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<T>;
}

public test(expression: string, options: EvaluationOptions = {}): Promise<boolean> {
Expand Down Expand Up @@ -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;
});
},
);
}
Expand Down
96 changes: 96 additions & 0 deletions test/api/evaluate.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading

0 comments on commit 8ed066d

Please sign in to comment.