@decocms/apps-algolia 7.71.6 → 8.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -8
- package/src/README.md +6 -1
- package/src/__tests__/searchClient.test.ts +63 -0
- package/src/index.ts +86 -8
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-algolia",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Thin client for the Algolia Search REST API",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/decocms/blocks.git",
|
|
@@ -22,12 +22,9 @@
|
|
|
22
22
|
"lint:unused": "knip"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@decocms/blocks": "
|
|
26
|
-
"@decocms/apps-commerce": "7.71.6"
|
|
25
|
+
"@decocms/blocks": "8.0.0-next.0"
|
|
27
26
|
},
|
|
28
27
|
"peerDependencies": {
|
|
29
|
-
"react": "^19.0.0",
|
|
30
|
-
"react-dom": "^19.0.0",
|
|
31
28
|
"algoliasearch": "^5"
|
|
32
29
|
},
|
|
33
30
|
"peerDependenciesMeta": {
|
|
@@ -36,8 +33,6 @@
|
|
|
36
33
|
}
|
|
37
34
|
},
|
|
38
35
|
"devDependencies": {
|
|
39
|
-
"@types/react": "^19.0.0",
|
|
40
|
-
"@types/react-dom": "^19.0.0",
|
|
41
36
|
"algoliasearch": "^5.53.0",
|
|
42
37
|
"knip": "^5.86.0",
|
|
43
38
|
"typescript": "^5.9.0"
|
package/src/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Algolia app — initial scaffold
|
|
2
2
|
|
|
3
|
+
> **Next major:** the package root exports `createAlgoliaClient`, a thin
|
|
4
|
+
> client over the Algolia REST API built on `@decocms/blocks/fetch`
|
|
5
|
+
> (see `/next/upstream-clients`). Everything below is the v7 surface, which
|
|
6
|
+
> stays on the `./client`, `./loaders/client` and `./types` subpaths.
|
|
7
|
+
|
|
3
8
|
This folder ports the Algolia integration from `deco-cx/apps/algolia`
|
|
4
9
|
(Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following
|
|
5
10
|
the same shape as `vtex/`, `magento/`, and `shopify/`.
|
|
@@ -56,7 +61,7 @@ the storefront-specific overlays will keep living in the site.
|
|
|
56
61
|
|
|
57
62
|
```ts
|
|
58
63
|
// src/setup.ts
|
|
59
|
-
import { initAlgoliaFromBlocks } from "@decocms/apps/
|
|
64
|
+
import { initAlgoliaFromBlocks } from "@decocms/apps-algolia/client";
|
|
60
65
|
import { blocks } from "./server/cms/blocks.gen";
|
|
61
66
|
|
|
62
67
|
createSiteSetup({
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v8 Algolia client (/next/upstream-clients): typed REST calls through
|
|
3
|
+
* the instrumented fetch, credentials in headers, errors without bodies.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AlgoliaError, createAlgoliaClient } from "../index";
|
|
7
|
+
|
|
8
|
+
function fakeFetch(status: number, body: unknown) {
|
|
9
|
+
return vi.fn(
|
|
10
|
+
async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
11
|
+
new Response(JSON.stringify(body), { status }),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const config = { applicationId: "APPID", apiKey: "search-key" };
|
|
16
|
+
|
|
17
|
+
describe("createAlgoliaClient", () => {
|
|
18
|
+
it("search posts every query to the multi-query endpoint", async () => {
|
|
19
|
+
const fetch = fakeFetch(200, { results: [{ hits: [{ objectID: "1" }], nbHits: 1 }] });
|
|
20
|
+
const algolia = createAlgoliaClient(config, { fetch });
|
|
21
|
+
|
|
22
|
+
const { results } = await algolia.search([
|
|
23
|
+
{ indexName: "products", query: "linen shirt", hitsPerPage: 12 },
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
expect(results[0].hits[0].objectID).toBe("1");
|
|
27
|
+
const [url, init] = fetch.mock.calls[0];
|
|
28
|
+
expect(String(url)).toBe("https://APPID-dsn.algolia.net/1/indexes/*/queries");
|
|
29
|
+
expect(init?.method).toBe("POST");
|
|
30
|
+
expect(init?.headers).toMatchObject({
|
|
31
|
+
"x-algolia-application-id": "APPID",
|
|
32
|
+
"x-algolia-api-key": "search-key",
|
|
33
|
+
});
|
|
34
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
35
|
+
requests: [{ indexName: "products", query: "linen shirt", hitsPerPage: 12 }],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects an applicationId that would change the host the key is sent to", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
createAlgoliaClient({ applicationId: "evil.example/#", apiKey: "search-key" }),
|
|
42
|
+
).toThrow("invalid applicationId");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws the operation and status, never the body or the key", async () => {
|
|
46
|
+
const fetch = fakeFetch(403, { message: "Invalid API key search-key" });
|
|
47
|
+
const algolia = createAlgoliaClient(config, { fetch });
|
|
48
|
+
|
|
49
|
+
const error = await algolia.search([{ indexName: "products" }]).catch((e: unknown) => e);
|
|
50
|
+
|
|
51
|
+
expect(error).toBeInstanceOf(AlgoliaError);
|
|
52
|
+
expect(error).toMatchObject({ operation: "search", status: 403 });
|
|
53
|
+
expect((error as Error).message).not.toContain("search-key");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not retry", async () => {
|
|
57
|
+
const fetch = fakeFetch(503, {});
|
|
58
|
+
const algolia = createAlgoliaClient(config, { fetch });
|
|
59
|
+
|
|
60
|
+
await expect(algolia.search([{ indexName: "products" }])).rejects.toThrow(AlgoliaError);
|
|
61
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* `@decocms/apps-algolia`: a thin client for the Algolia Search REST API.
|
|
3
|
+
* See /next/upstream-clients.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* It calls the REST API directly rather than through the `algoliasearch`
|
|
6
|
+
* SDK, so every request goes through `createInstrumentedFetch` (provider
|
|
7
|
+
* `algolia`) like every other client. No retries or circuit breaker, and no
|
|
8
|
+
* response cache: caching upstream data is the framework binding's job.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* The v7 SDK wiring (`configureAlgolia`, `getAlgoliaClient`,
|
|
11
|
+
* `initAlgoliaFromBlocks`) stays on the `./client` and `./loaders/client`
|
|
12
|
+
* subpaths for v7 sites; it needs the optional `algoliasearch` peer.
|
|
10
13
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
import { createInstrumentedFetch } from "@decocms/blocks/fetch";
|
|
15
|
+
|
|
16
|
+
export interface AlgoliaClientConfig {
|
|
17
|
+
applicationId: string;
|
|
18
|
+
/** A search-only key is enough for search; never send an admin key to the browser. */
|
|
19
|
+
apiKey: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One query: the index plus search parameters as the REST API names them (`query`, `hitsPerPage`, `filters`, ...). */
|
|
23
|
+
export interface AlgoliaSearchRequest {
|
|
24
|
+
indexName: string;
|
|
25
|
+
query?: string;
|
|
26
|
+
[param: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AlgoliaHit<T> = T & { objectID: string; [field: string]: unknown };
|
|
30
|
+
|
|
31
|
+
export interface AlgoliaSearchResponse<T = Record<string, unknown>> {
|
|
32
|
+
hits: AlgoliaHit<T>[];
|
|
33
|
+
nbHits: number;
|
|
34
|
+
page: number;
|
|
35
|
+
nbPages: number;
|
|
36
|
+
hitsPerPage: number;
|
|
37
|
+
processingTimeMS: number;
|
|
38
|
+
query: string;
|
|
39
|
+
params: string;
|
|
40
|
+
index?: string;
|
|
41
|
+
queryID?: string;
|
|
42
|
+
facets?: Record<string, Record<string, number>>;
|
|
43
|
+
[field: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AlgoliaError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
readonly operation: string,
|
|
49
|
+
readonly status: number,
|
|
50
|
+
) {
|
|
51
|
+
super(`algolia ${operation} failed with HTTP ${status}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createAlgoliaClient(
|
|
56
|
+
config: AlgoliaClientConfig,
|
|
57
|
+
options: { fetch?: typeof fetch } = {},
|
|
58
|
+
) {
|
|
59
|
+
// The id becomes part of the host; reject anything that could redirect the API key elsewhere.
|
|
60
|
+
if (!/^[A-Za-z0-9]+$/.test(config.applicationId))
|
|
61
|
+
throw new Error("algolia: invalid applicationId");
|
|
62
|
+
const request = createInstrumentedFetch({ provider: "algolia", fetch: options.fetch });
|
|
63
|
+
const host = `https://${config.applicationId}-dsn.algolia.net`;
|
|
64
|
+
|
|
65
|
+
async function post<R>(operation: string, path: string, body: unknown): Promise<R> {
|
|
66
|
+
const response = await request(`${host}${path}`, {
|
|
67
|
+
operation,
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/json",
|
|
71
|
+
"x-algolia-application-id": config.applicationId,
|
|
72
|
+
"x-algolia-api-key": config.apiKey,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) throw new AlgoliaError(operation, response.status);
|
|
77
|
+
return (await response.json()) as R;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
/** Runs several queries, on one or more indices, in one request. */
|
|
82
|
+
search<T = Record<string, unknown>>(
|
|
83
|
+
requests: AlgoliaSearchRequest[],
|
|
84
|
+
): Promise<{ results: AlgoliaSearchResponse<T>[] }> {
|
|
85
|
+
return post("search", "/1/indexes/*/queries", { requests });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type AlgoliaClient = ReturnType<typeof createAlgoliaClient>;
|