Skip to content

Commit 8cb1e50

Browse files
authored
feat(javascript): add bridge to transformation on algoliasearch (#4852)
1 parent 77ed32e commit 8cb1e50

File tree

4 files changed

+207
-116
lines changed

4 files changed

+207
-116
lines changed

clients/algoliasearch-client-javascript/packages/algoliasearch/__tests__/algoliasearch.common.test.ts

Lines changed: 78 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -143,131 +143,110 @@ describe('api', () => {
143143
});
144144
});
145145

146-
describe('init clients', () => {
147-
test('provides an init method for the analytics client', () => {
148-
expect(client.initAnalytics).not.toBeUndefined();
149-
});
150-
151-
test('provides an init method for the abtesting client', () => {
152-
expect(client.initAbtesting).not.toBeUndefined();
146+
describe('bridge methods', () => {
147+
test('throws when missing transformation.region', () => {
148+
//@ts-expect-error
149+
expect(() => algoliasearch('APP_ID', 'API_KEY', { transformation: {} })).toThrow(
150+
'`region` must be provided when leveraging the transformation pipeline',
151+
);
153152
});
154153

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

159-
test('provides an init method for the recommend client', () => {
160-
expect(client.initRecommend).not.toBeUndefined();
163+
await expect(
164+
client.partialUpdateObjectsWithTransformation({
165+
indexName: 'foo',
166+
objects: [{ objectID: 'bar', baz: 42 }],
167+
waitForTasks: true,
168+
}),
169+
).rejects.toThrow('`transformation.region` must be provided at client instantiation before calling this method.');
161170
});
162171

163-
test('default `init` clients to the root `algoliasearch` credentials', async () => {
164-
const abtestingClient = client.initAbtesting({ options: { requester: browserEchoRequester() } });
165-
const analyticsClient = client.initAnalytics({ options: { requester: browserEchoRequester() } });
166-
const recommendClient = client.initRecommend({ options: { requester: browserEchoRequester() } });
167-
const personalizationClient = client.initPersonalization({
168-
region: 'eu',
169-
options: { requester: browserEchoRequester() },
172+
test('exposes the transformation methods at the root of the client', async () => {
173+
const ingestionClient = algoliasearch('APP_ID', 'API_KEY', {
174+
requester: browserEchoRequester(),
175+
transformation: { region: 'us' },
170176
});
171177

172-
const res1 = (await abtestingClient.customGet({
173-
path: 'abtestingClient',
174-
})) as unknown as EchoResponse;
175-
const res2 = (await analyticsClient.customGet({
176-
path: 'analyticsClient',
177-
})) as unknown as EchoResponse;
178-
const res3 = (await personalizationClient.customGet({
179-
path: 'personalizationClient',
180-
})) as unknown as EchoResponse;
181-
const res4 = (await recommendClient.customGet({
182-
path: 'recommendClient',
178+
expect(ingestionClient.saveObjectsWithTransformation).not.toBeUndefined();
179+
180+
let res = (await ingestionClient.saveObjectsWithTransformation({
181+
indexName: 'foo',
182+
objects: [{ objectID: 'bar', baz: 42 }],
183+
waitForTasks: true,
183184
})) as unknown as EchoResponse;
184185

185-
expect(res1.headers).toEqual(
186-
expect.objectContaining({
187-
'x-algolia-application-id': 'APP_ID',
188-
'x-algolia-api-key': 'API_KEY',
189-
}),
190-
);
191-
expect(res2.headers).toEqual(
186+
expect(res.headers).toEqual(
192187
expect.objectContaining({
193188
'x-algolia-application-id': 'APP_ID',
194189
'x-algolia-api-key': 'API_KEY',
195190
}),
196191
);
197-
expect(res3.headers).toEqual(
198-
expect.objectContaining({
199-
'x-algolia-application-id': 'APP_ID',
200-
'x-algolia-api-key': 'API_KEY',
201-
}),
202-
);
203-
expect(res4.headers).toEqual(
192+
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
193+
expect(res.data).toEqual({
194+
action: 'addObject',
195+
records: [
196+
{
197+
baz: 42,
198+
objectID: 'bar',
199+
},
200+
],
201+
});
202+
expect(ingestionClient.partialUpdateObjectsWithTransformation).not.toBeUndefined();
203+
204+
res = (await ingestionClient.partialUpdateObjectsWithTransformation({
205+
indexName: 'foo',
206+
objects: [{ objectID: 'bar', baz: 42 }],
207+
waitForTasks: true,
208+
createIfNotExists: true,
209+
})) as unknown as EchoResponse;
210+
211+
expect(res.headers).toEqual(
204212
expect.objectContaining({
205213
'x-algolia-application-id': 'APP_ID',
206214
'x-algolia-api-key': 'API_KEY',
207215
}),
208216
);
209-
});
210-
211-
test('`init` clients accept different credentials', async () => {
212-
const abtestingClient = client.initAbtesting({
213-
appId: 'appId1',
214-
apiKey: 'apiKey1',
215-
options: { requester: browserEchoRequester() },
216-
});
217-
const analyticsClient = client.initAnalytics({
218-
appId: 'appId2',
219-
apiKey: 'apiKey2',
220-
options: { requester: browserEchoRequester() },
221-
});
222-
const personalizationClient = client.initPersonalization({
223-
appId: 'appId3',
224-
apiKey: 'apiKey3',
225-
region: 'eu',
226-
options: { requester: browserEchoRequester() },
227-
});
228-
const recommendClient = client.initRecommend({
229-
appId: 'appId4',
230-
apiKey: 'apiKey4',
231-
options: { requester: browserEchoRequester() },
217+
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
218+
expect(res.data).toEqual({
219+
action: 'partialUpdateObject',
220+
records: [
221+
{
222+
baz: 42,
223+
objectID: 'bar',
224+
},
225+
],
232226
});
233227

234-
const res1 = (await abtestingClient.customGet({
235-
path: 'abtestingClient',
236-
})) as unknown as EchoResponse;
237-
const res2 = (await analyticsClient.customGet({
238-
path: 'analyticsClient',
239-
})) as unknown as EchoResponse;
240-
const res3 = (await personalizationClient.customGet({
241-
path: 'personalizationClient',
242-
})) as unknown as EchoResponse;
243-
const res4 = (await recommendClient.customGet({
244-
path: 'recommendClient',
228+
res = (await ingestionClient.partialUpdateObjectsWithTransformation({
229+
indexName: 'foo',
230+
objects: [{ objectID: 'bar', baz: 42 }],
231+
waitForTasks: true,
245232
})) as unknown as EchoResponse;
246233

247-
expect(res1.headers).toEqual(
248-
expect.objectContaining({
249-
'x-algolia-application-id': 'appId1',
250-
'x-algolia-api-key': 'apiKey1',
251-
}),
252-
);
253-
expect(res2.headers).toEqual(
254-
expect.objectContaining({
255-
'x-algolia-application-id': 'appId2',
256-
'x-algolia-api-key': 'apiKey2',
257-
}),
258-
);
259-
expect(res3.headers).toEqual(
234+
expect(res.headers).toEqual(
260235
expect.objectContaining({
261-
'x-algolia-application-id': 'appId3',
262-
'x-algolia-api-key': 'apiKey3',
263-
}),
264-
);
265-
expect(res4.headers).toEqual(
266-
expect.objectContaining({
267-
'x-algolia-application-id': 'appId4',
268-
'x-algolia-api-key': 'apiKey4',
236+
'x-algolia-application-id': 'APP_ID',
237+
'x-algolia-api-key': 'API_KEY',
269238
}),
270239
);
240+
expect(res.url.startsWith('https://data.us.algolia.com/1/push/foo?watch=true')).toBeTruthy();
241+
expect(res.data).toEqual({
242+
action: 'partialUpdateObjectNoCreate',
243+
records: [
244+
{
245+
baz: 42,
246+
objectID: 'bar',
247+
},
248+
],
249+
});
271250
});
272251
});
273252
});

playground/javascript/node/algoliasearch.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@ import type { SearchResponses } from 'algoliasearch';
66

77
const appId = process.env.ALGOLIA_APPLICATION_ID || '**** APP_ID *****';
88
const apiKey = process.env.ALGOLIA_SEARCH_KEY || '**** SEARCH_API_KEY *****';
9+
const adminApiKey = process.env.ALGOLIA_ADMIN_KEY || '**** ADMIN_API_KEY *****';
910

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

14-
// Init client with appId and apiKey
15-
const client = algoliasearch(appId, apiKey);
16-
const clientLite = liteClient(appId, apiKey);
17-
18-
client.addAlgoliaAgent('algoliasearch node playground', '0.0.1');
19-
2015
async function testAlgoliasearch() {
16+
// Init client with appId and apiKey
17+
const client = algoliasearch(appId, apiKey);
18+
const clientLite = liteClient(appId, apiKey);
19+
20+
client.addAlgoliaAgent('algoliasearch node playground', '0.0.1');
21+
2122
try {
2223
const res: SearchResponses = await client.search({
2324
requests: [
@@ -131,4 +132,14 @@ async function testAlgoliasearch() {
131132
}
132133
}
133134

134-
testAlgoliasearch();
135+
async function testAlgoliasearchBridgeIngestion() {
136+
// Init client with appId and apiKey
137+
const client = algoliasearch(appId, adminApiKey, { transformation: { region: 'eu'}});
138+
139+
await client.saveObjectsWithTransformation({indexName: "foo", objects: [{objectID: "foo", data: {baz: "baz", win: 42}}], waitForTasks: true })
140+
141+
await client.partialUpdateObjectsWithTransformation({indexName: "foo", objects: [{objectID: "foo", data: {baz: "baz", win: 42}}], waitForTasks: true, createIfNotExists: false })
142+
}
143+
144+
// testAlgoliasearch();
145+
testAlgoliasearchBridgeIngestion()

templates/javascript/clients/algoliasearch/builds/definition.mustache

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// {{{generationBanner}}}
22

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

55
{{#dependencies}}
66
import { {{{dependencyName}}}Client } from '{{{dependencyPackage}}}';
77
import type { {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client } from '{{{dependencyPackage}}}';
88
{{/dependencies}}
99

10+
import type { PartialUpdateObjectsOptions, SaveObjectsOptions } from '@algolia/client-search';
11+
import type { PushTaskRecords, WatchResponse } from '@algolia/ingestion';
12+
1013
import type {
1114
InitClientOptions,
1215
{{#dependencies}}
@@ -20,14 +23,55 @@ import type {
2023
export * from './models';
2124

2225
export type Algoliasearch = SearchClient & {
23-
{{#dependencies}}
24-
{{#withInitMethod}}
25-
init{{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}: (initOptions{{^dependencyHasRegionalHosts}}?{{/dependencyHasRegionalHosts}}: InitClientOptions {{#dependencyHasRegionalHosts}}& {{#lambda.titlecase}}{{{dependencyName}}}RegionOptions{{/lambda.titlecase}}{{/dependencyHasRegionalHosts}}) => {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client;
26-
{{/withInitMethod}}
27-
{{/dependencies}}
26+
{{#dependencies}}
27+
{{#withInitMethod}}
28+
init{{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}: (initOptions{{^dependencyHasRegionalHosts}}?{{/dependencyHasRegionalHosts}}: InitClientOptions {{#dependencyHasRegionalHosts}}& {{#lambda.titlecase}}{{{dependencyName}}}RegionOptions{{/lambda.titlecase}}{{/dependencyHasRegionalHosts}}) => {{#lambda.titlecase}}{{{dependencyName}}}{{/lambda.titlecase}}Client;
29+
{{/withInitMethod}}
30+
{{/dependencies}}
31+
32+
// Bridge helpers to expose along with the search endpoints at the root of the API client
33+
34+
/**
35+
* 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.
36+
*
37+
* @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/).
38+
* @param saveObjects - The `saveObjects` object.
39+
* @param saveObjects.indexName - The `indexName` to save `objects` in.
40+
* @param saveObjects.objects - The array of `objects` to store in the given Algolia `indexName`.
41+
* @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.
42+
* @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.
43+
* @param requestOptions - The requestOptions to send along with the query, they will be forwarded to the `batch` method and merged with the transporter requestOptions.
44+
*/
45+
saveObjectsWithTransformation: (options: SaveObjectsOptions, requestOptions?: RequestOptions) => Promise<WatchResponse>;
46+
47+
/**
48+
* 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.
49+
*
50+
* @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/).
51+
* @param partialUpdateObjects - The `partialUpdateObjects` object.
52+
* @param partialUpdateObjects.indexName - The `indexName` to update `objects` in.
53+
* @param partialUpdateObjects.objects - The array of `objects` to update in the given Algolia `indexName`.
54+
* @param partialUpdateObjects.createIfNotExists - To be provided if non-existing objects are passed, otherwise, the call will fail..
55+
* @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.
56+
* @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.
57+
* @param requestOptions - The requestOptions to send along with the query, they will be forwarded to the `getTask` method and merged with the transporter requestOptions.
58+
*/
59+
partialUpdateObjectsWithTransformation: (options: PartialUpdateObjectsOptions, requestOptions?: RequestOptions) => Promise<WatchResponse>;
60+
};
61+
62+
export type TransformationOptions = {
63+
// 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/).
64+
transformation?: {
65+
// The region of your Algolia application ID, used to target the correct hosts of the transformation service.
66+
region: IngestionRegion;
67+
};
2868
};
2969

30-
export function algoliasearch(appId: string, apiKey: string, options?: ClientOptions): Algoliasearch {
70+
export function algoliasearch(
71+
appId: string,
72+
apiKey: string,
73+
options?: ClientOptions & TransformationOptions,
74+
): Algoliasearch {
3175
if (!appId || typeof appId !== 'string') {
3276
throw new Error('`appId` is missing.');
3377
}
@@ -38,9 +82,66 @@ export function algoliasearch(appId: string, apiKey: string, options?: ClientOpt
3882

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

85+
let ingestionTransporter: IngestionClient | undefined;
86+
87+
if (options?.transformation) {
88+
if (!options.transformation.region) {
89+
throw new Error('`region` must be provided when leveraging the transformation pipeline');
90+
}
91+
92+
ingestionTransporter = ingestionClient(appId, apiKey, options.transformation.region, options);
93+
}
94+
4195
return {
4296
...client,
4397
98+
async saveObjectsWithTransformation({ indexName, objects, waitForTasks }, requestOptions): Promise<WatchResponse> {
99+
if (!ingestionTransporter) {
100+
throw new Error('`transformation.region` must be provided at client instantiation before calling this method.');
101+
}
102+
103+
if (!options?.transformation?.region) {
104+
throw new Error('`region` must be provided when leveraging the transformation pipeline');
105+
}
106+
107+
return ingestionTransporter?.push(
108+
{
109+
indexName,
110+
watch: waitForTasks,
111+
pushTaskPayload: {
112+
action: 'addObject',
113+
records: objects as PushTaskRecords[],
114+
},
115+
},
116+
requestOptions,
117+
);
118+
},
119+
120+
async partialUpdateObjectsWithTransformation(
121+
{ indexName, objects, createIfNotExists, waitForTasks },
122+
requestOptions,
123+
): Promise<WatchResponse> {
124+
if (!ingestionTransporter) {
125+
throw new Error('`transformation.region` must be provided at client instantiation before calling this method.');
126+
}
127+
128+
if (!options?.transformation?.region) {
129+
throw new Error('`region` must be provided when leveraging the transformation pipeline');
130+
}
131+
132+
return ingestionTransporter?.push(
133+
{
134+
indexName,
135+
watch: waitForTasks,
136+
pushTaskPayload: {
137+
action: createIfNotExists ? 'partialUpdateObject' : 'partialUpdateObjectNoCreate',
138+
records: objects as PushTaskRecords[],
139+
},
140+
},
141+
requestOptions,
142+
);
143+
},
144+
44145
/**
45146
* Get the value of the `algoliaAgent`, used by our libraries internally and telemetry system.
46147
*/

0 commit comments

Comments
 (0)