Skip to content

feat(javascript): add bridge to transformation on algoliasearch #4852

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,131 +143,110 @@ describe('api', () => {
});
});

describe('init clients', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed anymore, the cts uses and asserts them

test('provides an init method for the analytics client', () => {
expect(client.initAnalytics).not.toBeUndefined();
});

test('provides an init method for the abtesting client', () => {
expect(client.initAbtesting).not.toBeUndefined();
describe('bridge methods', () => {
test('throws when missing transformation.region', () => {
//@ts-expect-error
expect(() => algoliasearch('APP_ID', 'API_KEY', { transformation: {} })).toThrow(
'`region` must be provided when leveraging the transformation pipeline',
);
});

test('provides an init method for the personalization client', () => {
expect(client.initPersonalization).not.toBeUndefined();
});
test('throws when calling the transformation methods without init parameters', async () => {
await expect(
client.saveObjectsWithTransformation({
indexName: 'foo',
objects: [{ objectID: 'bar', baz: 42 }],
waitForTasks: true,
}),
).rejects.toThrow('`transformation.region` must be provided at client instantiation before calling this method.');

test('provides an init method for the recommend client', () => {
expect(client.initRecommend).not.toBeUndefined();
await expect(
client.partialUpdateObjectsWithTransformation({
indexName: 'foo',
objects: [{ objectID: 'bar', baz: 42 }],
waitForTasks: true,
}),
).rejects.toThrow('`transformation.region` must be provided at client instantiation before calling this method.');
});

test('default `init` clients to the root `algoliasearch` credentials', async () => {
const abtestingClient = client.initAbtesting({ options: { requester: browserEchoRequester() } });
const analyticsClient = client.initAnalytics({ options: { requester: browserEchoRequester() } });
const recommendClient = client.initRecommend({ options: { requester: browserEchoRequester() } });
const personalizationClient = client.initPersonalization({
region: 'eu',
options: { requester: browserEchoRequester() },
test('exposes the transformation methods at the root of the client', async () => {
const ingestionClient = algoliasearch('APP_ID', 'API_KEY', {
requester: browserEchoRequester(),
transformation: { region: 'us' },
});

const res1 = (await abtestingClient.customGet({
path: 'abtestingClient',
})) as unknown as EchoResponse;
const res2 = (await analyticsClient.customGet({
path: 'analyticsClient',
})) as unknown as EchoResponse;
const res3 = (await personalizationClient.customGet({
path: 'personalizationClient',
})) as unknown as EchoResponse;
const res4 = (await recommendClient.customGet({
path: 'recommendClient',
expect(ingestionClient.saveObjectsWithTransformation).not.toBeUndefined();

let res = (await ingestionClient.saveObjectsWithTransformation({
indexName: 'foo',
objects: [{ objectID: 'bar', baz: 42 }],
waitForTasks: true,
})) as unknown as EchoResponse;

expect(res1.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'APP_ID',
'x-algolia-api-key': 'API_KEY',
}),
);
expect(res2.headers).toEqual(
expect(res.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'APP_ID',
'x-algolia-api-key': 'API_KEY',
}),
);
expect(res3.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'APP_ID',
'x-algolia-api-key': 'API_KEY',
}),
);
expect(res4.headers).toEqual(
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
expect(res.data).toEqual({
action: 'addObject',
records: [
{
baz: 42,
objectID: 'bar',
},
],
});
expect(ingestionClient.partialUpdateObjectsWithTransformation).not.toBeUndefined();

res = (await ingestionClient.partialUpdateObjectsWithTransformation({
indexName: 'foo',
objects: [{ objectID: 'bar', baz: 42 }],
waitForTasks: true,
createIfNotExists: true,
})) as unknown as EchoResponse;

expect(res.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'APP_ID',
'x-algolia-api-key': 'API_KEY',
}),
);
});

test('`init` clients accept different credentials', async () => {
const abtestingClient = client.initAbtesting({
appId: 'appId1',
apiKey: 'apiKey1',
options: { requester: browserEchoRequester() },
});
const analyticsClient = client.initAnalytics({
appId: 'appId2',
apiKey: 'apiKey2',
options: { requester: browserEchoRequester() },
});
const personalizationClient = client.initPersonalization({
appId: 'appId3',
apiKey: 'apiKey3',
region: 'eu',
options: { requester: browserEchoRequester() },
});
const recommendClient = client.initRecommend({
appId: 'appId4',
apiKey: 'apiKey4',
options: { requester: browserEchoRequester() },
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
expect(res.data).toEqual({
action: 'partialUpdateObject',
records: [
{
baz: 42,
objectID: 'bar',
},
],
});

const res1 = (await abtestingClient.customGet({
path: 'abtestingClient',
})) as unknown as EchoResponse;
const res2 = (await analyticsClient.customGet({
path: 'analyticsClient',
})) as unknown as EchoResponse;
const res3 = (await personalizationClient.customGet({
path: 'personalizationClient',
})) as unknown as EchoResponse;
const res4 = (await recommendClient.customGet({
path: 'recommendClient',
res = (await ingestionClient.partialUpdateObjectsWithTransformation({
indexName: 'foo',
objects: [{ objectID: 'bar', baz: 42 }],
waitForTasks: true,
})) as unknown as EchoResponse;

expect(res1.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'appId1',
'x-algolia-api-key': 'apiKey1',
}),
);
expect(res2.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'appId2',
'x-algolia-api-key': 'apiKey2',
}),
);
expect(res3.headers).toEqual(
expect(res.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'appId3',
'x-algolia-api-key': 'apiKey3',
}),
);
expect(res4.headers).toEqual(
expect.objectContaining({
'x-algolia-application-id': 'appId4',
'x-algolia-api-key': 'apiKey4',
'x-algolia-application-id': 'APP_ID',
'x-algolia-api-key': 'API_KEY',
}),
);
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
expect(res.data).toEqual({
action: 'partialUpdateObjectNoCreate',
records: [
{
baz: 42,
objectID: 'bar',
},
],
});
});
});
});
Expand Down
25 changes: 18 additions & 7 deletions playground/javascript/node/algoliasearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import type { SearchResponses } from 'algoliasearch';

const appId = process.env.ALGOLIA_APPLICATION_ID || '**** APP_ID *****';
const apiKey = process.env.ALGOLIA_SEARCH_KEY || '**** SEARCH_API_KEY *****';
const adminApiKey = process.env.ALGOLIA_ADMIN_KEY || '**** ADMIN_API_KEY *****';

const searchIndex = process.env.SEARCH_INDEX || 'test_index';
const searchQuery = process.env.SEARCH_QUERY || 'test_query';
const analyticsIndex = process.env.ANALYTICS_INDEX || 'test_index';

// Init client with appId and apiKey
const client = algoliasearch(appId, apiKey);
const clientLite = liteClient(appId, apiKey);

client.addAlgoliaAgent('algoliasearch node playground', '0.0.1');

async function testAlgoliasearch() {
// Init client with appId and apiKey
const client = algoliasearch(appId, apiKey);
const clientLite = liteClient(appId, apiKey);

client.addAlgoliaAgent('algoliasearch node playground', '0.0.1');

try {
const res: SearchResponses = await client.search({
requests: [
Expand Down Expand Up @@ -131,4 +132,14 @@ async function testAlgoliasearch() {
}
}

testAlgoliasearch();
async function testAlgoliasearchBridgeIngestion() {
// Init client with appId and apiKey
const client = algoliasearch(appId, adminApiKey, { transformation: { region: 'eu'}});

await client.saveObjectsWithTransformation({indexName: "foo", objects: [{objectID: "foo", data: {baz: "baz", win: 42}}], waitForTasks: true })

await client.partialUpdateObjectsWithTransformation({indexName: "foo", objects: [{objectID: "foo", data: {baz: "baz", win: 42}}], waitForTasks: true, createIfNotExists: false })
}

// testAlgoliasearch();
testAlgoliasearchBridgeIngestion()
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// {{{generationBanner}}}

import type { ClientOptions } from '@algolia/client-common';
import type { ClientOptions, RequestOptions } from '@algolia/client-common';

{{#dependencies}}
import { {{{dependencyName}}}Client } from '{{{dependencyPackage}}}';
import type { {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client } from '{{{dependencyPackage}}}';
{{/dependencies}}

import type { PartialUpdateObjectsOptions, SaveObjectsOptions } from '@algolia/client-search';
import type { PushTaskRecords, WatchResponse } from '@algolia/ingestion';

import type {
InitClientOptions,
{{#dependencies}}
Expand All @@ -20,14 +23,55 @@ import type {
export * from './models';

export type Algoliasearch = SearchClient & {
{{#dependencies}}
{{#withInitMethod}}
init{{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}: (initOptions{{^dependencyHasRegionalHosts}}?{{/dependencyHasRegionalHosts}}: InitClientOptions {{#dependencyHasRegionalHosts}}& {{#lambda.titlecase}}{{{dependencyName}}}RegionOptions{{/lambda.titlecase}}{{/dependencyHasRegionalHosts}}) => {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client;
{{/withInitMethod}}
{{/dependencies}}
{{#dependencies}}
{{#withInitMethod}}
init{{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}: (initOptions{{^dependencyHasRegionalHosts}}?{{/dependencyHasRegionalHosts}}: InitClientOptions {{#dependencyHasRegionalHosts}}& {{#lambda.titlecase}}{{{dependencyName}}}RegionOptions{{/lambda.titlecase}}{{/dependencyHasRegionalHosts}}) => {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client;
{{/withInitMethod}}
{{/dependencies}}

// Bridge helpers to expose along with the search endpoints at the root of the API client

/**
* Helper: Similar to the `saveObjects` method but requires a Push connector (https://www.algolia.com/doc/guides/sending-and-managing-data/send-and-update-your-data/connectors/push/) to be created first, in order to transform records before indexing them to Algolia. The `region` must've been passed to the client instantiation method.
*
* @summary Save objects to an Algolia index by leveraging the Transformation pipeline setup in the Push connector (https://www.algolia.com/doc/guides/sending-and-managing-data/send-and-update-your-data/connectors/push/).
* @param saveObjects - The `saveObjects` object.
* @param saveObjects.indexName - The `indexName` to save `objects` in.
* @param saveObjects.objects - The array of `objects` to store in the given Algolia `indexName`.
* @param saveObjects.batchSize - The size of the chunk of `objects`. The number of `batch` calls will be equal to `length(objects) / batchSize`. Defaults to 1000.
* @param saveObjects.waitForTasks - Whether or not we should wait until every `batch` tasks has been processed, this operation may slow the total execution time of this method but is more reliable.
* @param requestOptions - The requestOptions to send along with the query, they will be forwarded to the `batch` method and merged with the transporter requestOptions.
*/
saveObjectsWithTransformation: (options: SaveObjectsOptions, requestOptions?: RequestOptions) => Promise<WatchResponse>;

/**
* Helper: Similar to the `partialUpdateObjects` method but requires a Push connector (https://www.algolia.com/doc/guides/sending-and-managing-data/send-and-update-your-data/connectors/push/) to be created first, in order to transform records before indexing them to Algolia. The `region` must've been passed to the client instantiation method.
*
* @summary Save objects to an Algolia index by leveraging the Transformation pipeline setup in the Push connector (https://www.algolia.com/doc/guides/sending-and-managing-data/send-and-update-your-data/connectors/push/).
* @param partialUpdateObjects - The `partialUpdateObjects` object.
* @param partialUpdateObjects.indexName - The `indexName` to update `objects` in.
* @param partialUpdateObjects.objects - The array of `objects` to update in the given Algolia `indexName`.
* @param partialUpdateObjects.createIfNotExists - To be provided if non-existing objects are passed, otherwise, the call will fail..
* @param partialUpdateObjects.batchSize - The size of the chunk of `objects`. The number of `batch` calls will be equal to `length(objects) / batchSize`. Defaults to 1000.
* @param partialUpdateObjects.waitForTasks - Whether or not we should wait until every `batch` tasks has been processed, this operation may slow the total execution time of this method but is more reliable.
* @param requestOptions - The requestOptions to send along with the query, they will be forwarded to the `getTask` method and merged with the transporter requestOptions.
*/
partialUpdateObjectsWithTransformation: (options: PartialUpdateObjectsOptions, requestOptions?: RequestOptions) => Promise<WatchResponse>;
};

export type TransformationOptions = {
// When provided, a second transporter will be created in order to leverage the `*WithTransformation` methods exposed by the Push connector (https://www.algolia.com/doc/guides/sending-and-managing-data/send-and-update-your-data/connectors/push/).
transformation?: {
// The region of your Algolia application ID, used to target the correct hosts of the transformation service.
region: IngestionRegion;
};
};

export function algoliasearch(appId: string, apiKey: string, options?: ClientOptions): Algoliasearch {
export function algoliasearch(
appId: string,
apiKey: string,
options?: ClientOptions & TransformationOptions,
): Algoliasearch {
if (!appId || typeof appId !== 'string') {
throw new Error('`appId` is missing.');
}
Expand All @@ -38,9 +82,66 @@ export function algoliasearch(appId: string, apiKey: string, options?: ClientOpt

const client = searchClient(appId, apiKey, options);

let ingestionTransporter: IngestionClient | undefined;

if (options?.transformation) {
if (!options.transformation.region) {
throw new Error('`region` must be provided when leveraging the transformation pipeline');
}

ingestionTransporter = ingestionClient(appId, apiKey, options.transformation.region, options);
}

return {
...client,

async saveObjectsWithTransformation({ indexName, objects, waitForTasks }, requestOptions): Promise<WatchResponse> {
if (!ingestionTransporter) {
throw new Error('`transformation.region` must be provided at client instantiation before calling this method.');
}

if (!options?.transformation?.region) {
throw new Error('`region` must be provided when leveraging the transformation pipeline');
}

return ingestionTransporter?.push(
{
indexName,
watch: waitForTasks,
pushTaskPayload: {
action: 'addObject',
records: objects as PushTaskRecords[],
},
},
requestOptions,
);
},

async partialUpdateObjectsWithTransformation(
{ indexName, objects, createIfNotExists, waitForTasks },
requestOptions,
): Promise<WatchResponse> {
if (!ingestionTransporter) {
throw new Error('`transformation.region` must be provided at client instantiation before calling this method.');
}

if (!options?.transformation?.region) {
throw new Error('`region` must be provided when leveraging the transformation pipeline');
}

return ingestionTransporter?.push(
{
indexName,
watch: waitForTasks,
pushTaskPayload: {
action: createIfNotExists ? 'partialUpdateObject' : 'partialUpdateObjectNoCreate',
records: objects as PushTaskRecords[],
},
},
requestOptions,
);
},

/**
* Get the value of the `algoliaAgent`, used by our libraries internally and telemetry system.
*/
Expand Down
Loading