Skip to content

Commit 259ce36

Browse files
authored
feat(js-api-client): modernize, harden, and test the API client (#259)
* fix(catalogue): fix 'chidlren' typo in GraphQL query builder The onFolder helper in create-catalogue-fetcher.ts produced a field named 'chidlren' instead of 'children' in generated GraphQL queries, causing incorrect query results for folder children. * fix(api-caller): remove dead try/catch block that only rethrows The outer try/catch in the `post` function was a no-op that added visual noise and misleadingly suggested error handling was happening. * chore: delete dead commented-out subscription.ts file The file contained 543 lines of entirely commented-out code, replaced by the modules in src/core/pim/subscriptions/. Git history preserves it if ever needed. * fix(catalogue): simplify double spread to single spread in product hydrater The query object construction used `{ ...{ ...productListQuery } }` which is functionally identical to `{ ...productListQuery }`. Simplified to remove the unnecessary nesting. * fix(mass-call): use object literal instead of array for results initialization The `results` variable was typed as `{ [key: string]: any }` but initialized as `[]`. While JS allows property assignment on arrays, this is misleading. Changed to `{}` to match the declared type and actual usage. * fix(package): correct module field to match actual ESM build output The module field pointed to ./dist/index.mjs which doesn't exist. The build outputs ./dist/index.js for ESM. This mismatch could cause import failures in bundlers (Webpack, Rollup) that use the module field. * fix(error): set name property on JSApiClientCallError for better debugging Stack traces and error.name now correctly show 'JSApiClientCallError' instead of generic 'Error', making it easier to identify API client errors in logs and error handlers. * fix(types): replace 'any' with proper GrabOptions type in grabber and API caller The `RequestInit | any | undefined` union types collapsed to `any`, defeating TypeScript's type checking for all library consumers. Introduced a focused GrabOptions type covering method, headers, and body, and updated all signatures. * fix(security): replace tracked .env with .env.example template Add .env to .gitignore and create .env.example with placeholder values to prevent accidental credential commits. * feat(auth): warn when no authentication credentials are configured Adds a console.warn when the fallback auth path is reached with empty accessTokenId and accessTokenSecret, helping developers catch missing auth configuration early instead of getting cryptic 401/403 errors. Uses a WeakSet to ensure the warning fires only once per config object. * fix(profiling): use regex to parse Server-Timing header reliably Replace fragile split-based parsing with a regex that extracts the dur= value per the Server-Timing spec, avoiding garbage results from non-standard header formats. * refactor(types): rename cryptic generic parameters to descriptive names in fetchers/managers Replace abbreviations like OO, OOI, OC, OSC, OP with readable names (OrderExtra, OrderItemExtra, CustomerExtra, SubscriptionContractExtra, PaymentExtra) across order, customer, and subscription modules for better IDE tooltip readability. * feat(client): add Symbol.dispose support for automatic cleanup Enables `using client = createClient({...})` syntax (TypeScript 5.2+) so HTTP/2 connections are automatically closed when the scope exits. Added `esnext.disposable` to tsconfig lib and implemented Symbol.dispose on both ClientInterface and MassClientInterface. * feat(client): add request timeout support via AbortController Add optional `timeout` field (in milliseconds) to CreateClientOptions. When configured, requests that exceed the timeout are automatically aborted using AbortSignal.timeout(). Works for both fetch and HTTP/2 code paths. Default is no timeout (backward compatible). * refactor(types): replace any with proper types in mass call client - Replace Promise<any> with Promise<MassCallResults> (Record<string, unknown>) - Replace any in afterRequest callback with Record<string, unknown> - Replace any for exception in onFailure with unknown - Type buildStandardPromise return as { key: string; result: unknown } | undefined - Fix typo: situaion → situation in changeIncrementFor parameter * refactor(mass-call): replace repetitive enqueue methods with generated approach The five identical enqueue methods (catalogueApi, discoveryApi, pimApi, nextPimApi, shopCartApi) differed only in their key prefix and caller reference. Replaced with Object.fromEntries to eliminate ~20 lines of boilerplate while preserving the public API and types. * test: add unit tests with mocked HTTP for core modules Add 38 unit tests covering create-api-caller, create-grabber, and create-mass-call-client without requiring API credentials or network access. Tests cover: authentication headers, successful responses, HTTP errors, GraphQL errors, Core Next wrapped errors, 204 handling, profiling, mass call batching, retry logic, and adaptive concurrency. * test: add comprehensive error-path tests for API caller Cover HTTP error codes (400-503), GraphQL errors in 200 responses, Core Next wrapped errors, network failures, timeout scenarios, malformed JSON responses, 204 No Content, and JSApiClientCallError property validation. * ci: add GitHub Actions workflow for PR and main branch checks Runs build and unit tests across Node.js 20, 22, and 24 on every pull request and push to main, so broken code is caught before merge. * docs: add JSDoc comments to all main exported factory functions Add comprehensive JSDoc with @PARAM, @returns, and @example tags to 12 exported factory functions for better IDE discoverability. * feat: review * fix: await onBatchDone callback in mass call client The onBatchDone callback is typed as returning Promise<void> but was not awaited, causing batch callbacks to overlap with subsequent batch execution. * fix: remove Promise constructor antipattern in mass call client Replace `new Promise(async (resolve) => { ... })` with plain async/await flow. The old pattern swallowed errors from beforeRequest/afterRequest hooks, causing the promise to silently hang instead of rejecting. * fix: type HTTP/2 clients Map and add null guard in grabber The clients Map was untyped (new Map()), hiding potential null-access bugs. Added proper generic type parameters and a guard in resetIdleTimeout for when clientObj is undefined. * fix: stop sending empty auth headers when no credentials configured When no accessTokenId/accessTokenSecret are set, return an empty object instead of headers with empty string values. Crystallize APIs already handle missing headers correctly. * refactor: extract shared lifecycleAction helper in subscription contract manager cancel, pause, resume, renew, create, and update all followed the same pattern: build a mutation object, call nextPimApi, unwrap the response. A private lifecycleAction helper now encapsulates this, eliminating ~80 lines of duplication while keeping the public API identical. * refactor: extract shared helpers in cart manager to eliminate duplication Cart manager methods (fetch, place, abandon, fulfill, addSkuItem, removeItem, setMeta, setCustomer, hydrate) all followed the same pattern of building a GraphQL query/mutation, calling shopCartApi, and unwrapping the response. Extracted cartQuery and cartMutation helpers to remove ~80 lines of structural duplication. Public API unchanged. * deprecate: add deprecation notice to mass call client, recommend p-limit/p-queue The mass call client duplicates functionality that mature ecosystem packages provide better. Added @deprecated JSDoc tag, one-time console.warn on first use, and updated README with deprecation notice and p-limit usage example. The function remains fully functional. * feat: add statusCode getter alias on JSApiClientCallError Adds a `get statusCode()` getter that returns `this.code`, following Node.js conventions where `code` is typically a string error code (ECONNREFUSED, etc.) and `statusCode` is the numeric HTTP status. Non-breaking — existing `.code` usage is unaffected. * feat: make HTTP/2 idle timeout configurable via http2IdleTimeout option Expose http2IdleTimeout on CreateClientOptions so users can tune the idle timeout for serverless (short) and long-running server (long) use cases. Defaults to 300000ms (5 minutes) when not set. * docs: update README with http2IdleTimeout, timeout options and statusCode alias Document the new timeout and http2IdleTimeout client options, and add an error handling section showing the statusCode getter on JSApiClientCallError. * clean * review 1 * review
1 parent 0761847 commit 259ce36

30 files changed

Lines changed: 2559 additions & 1101 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# PROD
2+
CRYSTALLIZE_TENANT_ID=your-tenant-id-here
3+
CRYSTALLIZE_TENANT_IDENTIFIER=your-tenant-identifier-here
4+
CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here
5+
CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-token-secret-here
6+
7+
# # DEV
8+
# CRYSTALLIZE_TENANT_ID=your-dev-tenant-id-here
9+
# CRYSTALLIZE_TENANT_IDENTIFIER=your-dev-tenant-identifier-here
10+
# CRYSTALLIZE_ACCESS_TOKEN_ID=your-dev-token-id-here
11+
# CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-dev-token-secret-here
12+
# CRYSTALLIZE_ORIGIN=-dev.crystallize.digital
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules/
22
dist/
3+
.env
34

45
yarn.lock
56
package-lock.json

‎components/js-api-client/README.md‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ api.close();
6363
- `origin` custom host suffix (defaults to `.crystallize.com`)
6464
- options
6565
- `useHttp2` enable HTTP/2 transport
66+
- `timeout` request timeout in milliseconds; requests that take longer will be aborted
67+
- `http2IdleTimeout` HTTP/2 idle timeout in milliseconds (default `300000` — 5 minutes). Use a shorter value for serverless functions, a longer one for long-running servers
6668
- `profiling` callbacks
6769
- `extraHeaders` extra request headers for all calls
6870
- `shopApiToken` controls auto-fetch: `{ doNotFetch?: boolean; scopes?: string[]; expiresIn?: number }`
@@ -89,6 +91,23 @@ Pass the relevant credentials to `createClient`:
8991

9092
See the official docs for auth: https://crystallize.com/learn/developer-guides/api-overview/authentication
9193

94+
### Error handling
95+
96+
API call errors throw a `JSApiClientCallError` with both `code` and `statusCode` properties for the HTTP status:
97+
98+
```typescript
99+
import { JSApiClientCallError } from '@crystallize/js-api-client';
100+
101+
try {
102+
await api.pimApi(`query { … }`);
103+
} catch (e) {
104+
if (e instanceof JSApiClientCallError) {
105+
console.error(`HTTP ${e.statusCode}:`, e.message);
106+
// e.code also works (same value)
107+
}
108+
}
109+
```
110+
92111
## Profiling requests
93112

94113
Log queries, timings and server timing if available.
@@ -378,7 +397,36 @@ const bulkKey = await files.uploadMassOperationFile('/absolute/path/to/import.zi
378397

379398
[crystallizeobject]: crystallize_marketing|folder|625619f6615e162541535959
380399

381-
## Mass Call Client
400+
## Mass Call Client (Deprecated)
401+
402+
> **Deprecated:** Use mature ecosystem packages like [`p-limit`](https://www.npmjs.com/package/p-limit) or [`p-queue`](https://www.npmjs.com/package/p-queue) instead. They provide better error handling, TypeScript support, and are actively maintained.
403+
404+
### Recommended alternative using p-limit
405+
406+
```typescript
407+
import pLimit from 'p-limit';
408+
import { createClient } from '@crystallize/js-api-client';
409+
410+
const api = createClient({ tenantIdentifier: 'my-tenant', accessTokenId: '…', accessTokenSecret: '…' });
411+
const limit = pLimit(5); // max 5 concurrent requests
412+
413+
const mutations = items.map((item) =>
414+
limit(() =>
415+
api.pimApi(
416+
`mutation UpdateItem($id: ID!, $name: String!) { product { update(id: $id, input: { name: $name }) { id } } }`,
417+
{ id: item.id, name: item.name },
418+
),
419+
),
420+
);
421+
422+
const results = await Promise.allSettled(mutations);
423+
const failed = results.filter((r) => r.status === 'rejected');
424+
console.log(`Done: ${results.length - failed.length} succeeded, ${failed.length} failed`);
425+
```
426+
427+
### Legacy usage
428+
429+
The mass call client is still functional but will emit a deprecation warning on first use.
382430

383431
Sometimes, when you have many calls to do, whether they are queries or mutations, you want to be able to manage them asynchronously. This is the purpose of the Mass Call Client. It will let you be asynchronous, managing the heavy lifting of lifecycle, retry, incremental increase or decrease of the pace, etc.
384432

@@ -396,8 +444,7 @@ These are the main features:
396444
- Optional lifecycle function *afterRequest* (sync) to execute after each request. You also get the result in there, if needed
397445

398446
```javascript
399-
// import { createMassCallClient } from '@crystallize/js-api-client';
400-
const client = createMassCallClient(api, { initialSpawn: 1 }); // api created via createClient(...)
447+
const client = createMassCallClient(api, { initialSpawn: 1 });
401448

402449
async function run() {
403450
for (let i = 1; i <= 54; i++) {
@@ -416,5 +463,3 @@ async function run() {
416463
}
417464
run();
418465
```
419-
420-
Full example: https://github.com/CrystallizeAPI/libraries/blob/main/components/js-api-client/src/examples/dump-tenant.ts

‎components/js-api-client/UPGRADE.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
# Upgrade Guide
2+
3+
## From v5
4+
5+
### `extraHeaders` type widened
6+
7+
The `extraHeaders` option on `createClient` now accepts `Record<string, string> | Headers | [string, string][]` instead of only `Record<string, string>`. This is **not a breaking change** — all existing code continues to work. If you were casting headers to `Record<string, string>`, you can now pass `Headers` instances or tuple arrays directly.
8+
9+
### HTTP/2 stability
10+
11+
The HTTP/2 transport now guards against double-settlement of promises when abort signals fire after a request has already completed. No API changes — this is a reliability fix.
12+
13+
### `JSApiClientCallError.statusCode` alias
14+
15+
A read-only `statusCode` getter was added as an alias for `code`, following the Node.js convention. Both properties return the same numeric HTTP status.
16+
17+
### `http2IdleTimeout` option
18+
19+
You can now configure the HTTP/2 session idle timeout via `createClient(config, { http2IdleTimeout: 60000 })`. The default remains 300 000 ms (5 minutes).
20+
21+
### `timeout` option
22+
23+
A request-level timeout can be set via `createClient(config, { timeout: 10000 })`. When set, requests that exceed the timeout are aborted with an `AbortError`.
24+
25+
---
26+
127
# Upgrade Guide to v5
228

329
This guide helps you migrate from v4 to v5 of `@crystallize/js-api-client`.

‎components/js-api-client/package.json‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@crystallize/js-api-client",
33
"license": "MIT",
4-
"version": "5.3.0",
4+
"version": "6.0.0",
55
"type": "module",
66
"author": "Crystallize <hello@crystallize.com> (https://crystallize.com)",
77
"contributors": [
@@ -26,20 +26,20 @@
2626
},
2727
"types": "./dist/index.d.ts",
2828
"main": "./dist/index.cjs",
29-
"module": "./dist/index.mjs",
29+
"module": "./dist/index.js",
3030
"devDependencies": {
31-
"@tsconfig/node22": "^22.0.2",
32-
"@types/node": "^24.2.0",
33-
"dotenv": "^16.6.1",
34-
"tsup": "^8.5.0",
35-
"typescript": "^5.9.2",
36-
"vitest": "^3.2.4"
31+
"@tsconfig/node22": "^22.0.5",
32+
"@types/node": "^25.5.2",
33+
"dotenv": "^17.4.0",
34+
"tsup": "^8.5.1",
35+
"typescript": "^6.0.2",
36+
"vitest": "^4.1.2"
3737
},
3838
"dependencies": {
3939
"@crystallize/schema": "workspace:*",
4040
"json-to-graphql-query": "^2.3.0",
4141
"mime-lite": "^1.0.3",
42-
"zod": "^4.1.12"
42+
"zod": "^4.3.6"
4343
},
4444
"browser": {
4545
"fs": false,

‎components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ export type CatalogueFetcherGrapqhqlOnFolder<OC = unknown> = {
2222
onChildren?: OC;
2323
};
2424

25+
/**
26+
* Creates a catalogue fetcher that executes queries against the Crystallize Catalogue API using JSON-based query objects.
27+
* Use this when you want to build catalogue queries programmatically instead of writing raw GraphQL strings.
28+
*
29+
* @param client - A Crystallize client instance created via `createClient`.
30+
* @returns A function that accepts a JSON query object and optional variables, and returns the catalogue data.
31+
*
32+
* @example
33+
* ```ts
34+
* const fetcher = createCatalogueFetcher(client);
35+
* const data = await fetcher({
36+
* catalogue: {
37+
* __args: { path: '/my-product', language: 'en' },
38+
* name: true,
39+
* path: true,
40+
* },
41+
* });
42+
* ```
43+
*/
2544
export const createCatalogueFetcher = (client: ClientInterface) => {
2645
return <T = unknown>(query: object, variables?: VariablesType): Promise<T> => {
2746
return client.catalogueApi<T>(jsonToGraphQLQuery({ query }), variables);
@@ -64,7 +83,7 @@ function onFolder<OF = unknown>(onFolder?: OF, c?: CatalogueFetcherGrapqhqlOnFol
6483
const children = () => {
6584
if (c?.onChildren) {
6685
return {
67-
chidlren: {
86+
children: {
6887
...c.onChildren,
6988
},
7089
};

‎components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ function buildNestedNavigationQuery(
112112
return jsonToGraphQLQuery({ query });
113113
}
114114

115+
/**
116+
* Creates a navigation fetcher that builds nested tree queries for folder-based or topic-based navigation.
117+
* Use this to retrieve hierarchical navigation structures from the Crystallize catalogue.
118+
*
119+
* @param client - A Crystallize client instance created via `createClient`.
120+
* @returns An object with `byFolders` and `byTopics` methods for fetching navigation trees at a given depth.
121+
*
122+
* @example
123+
* ```ts
124+
* const nav = createNavigationFetcher(client);
125+
* const folderTree = await nav.byFolders('/', 'en', 3);
126+
* const topicTree = await nav.byTopics('/', 'en', 2);
127+
* ```
128+
*/
115129
export function createNavigationFetcher(client: ClientInterface): {
116130
byFolders: TreeFetcher;
117131
byTopics: TreeFetcher;

‎components/js-api-client/src/core/catalogue/create-product-hydrater.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function byPaths(client: ClientInterface, options?: ProductHydraterOptions): Pro
8787
}, {} as any);
8888

8989
const query = {
90-
...{ ...productListQuery },
90+
...productListQuery,
9191
...(extraQuery !== undefined ? extraQuery : {}),
9292
};
9393

@@ -140,6 +140,21 @@ function bySkus(client: ClientInterface, options?: ProductHydraterOptions): Prod
140140
};
141141
}
142142

143+
/**
144+
* Creates a product hydrater that fetches full product data from the catalogue by paths or SKUs.
145+
* Use this to enrich a list of product references with complete variant, pricing, and attribute data.
146+
*
147+
* @param client - A Crystallize client instance created via `createClient`.
148+
* @param options - Optional settings for market identifiers, price lists, and price-for-everyone inclusion.
149+
* @returns An object with `byPaths` and `bySkus` methods for hydrating products.
150+
*
151+
* @example
152+
* ```ts
153+
* const hydrater = createProductHydrater(client);
154+
* const products = await hydrater.byPaths(['/shop/my-product'], 'en');
155+
* const productsBySkus = await hydrater.bySkus(['SKU-001', 'SKU-002'], 'en');
156+
* ```
157+
*/
143158
export function createProductHydrater(client: ClientInterface, options?: ProductHydraterOptions) {
144159
return {
145160
byPaths: byPaths(client, options),

0 commit comments

Comments
 (0)