diff --git a/components/js-api-client/.claude/settings.local.json b/components/js-api-client/.claude/settings.local.json new file mode 100644 index 00000000..47ae10ef --- /dev/null +++ b/components/js-api-client/.claude/settings.local.json @@ -0,0 +1,3 @@ +{ + "enabledMcpjsonServers": ["crystallize"] +} diff --git a/components/js-api-client/.env.example b/components/js-api-client/.env.example new file mode 100644 index 00000000..c180243a --- /dev/null +++ b/components/js-api-client/.env.example @@ -0,0 +1,12 @@ +# PROD +CRYSTALLIZE_TENANT_ID=your-tenant-id-here +CRYSTALLIZE_TENANT_IDENTIFIER=your-tenant-identifier-here +CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here +CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-token-secret-here + +# # DEV +# CRYSTALLIZE_TENANT_ID=your-dev-tenant-id-here +# CRYSTALLIZE_TENANT_IDENTIFIER=your-dev-tenant-identifier-here +# CRYSTALLIZE_ACCESS_TOKEN_ID=your-dev-token-id-here +# CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-dev-token-secret-here +# CRYSTALLIZE_ORIGIN=-dev.crystallize.digital diff --git a/components/js-api-client/.github/workflows/ci.yaml b/components/js-api-client/.github/workflows/ci.yaml new file mode 100644 index 00000000..80b76017 --- /dev/null +++ b/components/js-api-client/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + name: Build & Unit Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v5 + + - name: โŽ” Setup node + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + + - name: โŽ” Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install + + - name: ๐Ÿ”จ Build + run: pnpm build + + - name: ๐Ÿงช Run unit tests + run: pnpm vitest run tests/unit/ diff --git a/components/js-api-client/.gitignore b/components/js-api-client/.gitignore index 70e72faa..b820f4d7 100644 --- a/components/js-api-client/.gitignore +++ b/components/js-api-client/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.env yarn.lock package-lock.json diff --git a/components/js-api-client/.mcp.json b/components/js-api-client/.mcp.json new file mode 100644 index 00000000..7828dbae --- /dev/null +++ b/components/js-api-client/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "crystallize": { + "type": "http", + "url": "https://mcp.crystallize.com/mcp", + "headers": { + "X-Crystallize-Access-Token-Id": "558f95141de1c4112f34", + "X-Crystallize-Access-Token-Secret": "8f56c78c874ce55b2629139b9061cefacaff7d17" + } + } + } +} diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json new file mode 100644 index 00000000..f1015d48 --- /dev/null +++ b/components/js-api-client/PRD.json @@ -0,0 +1,427 @@ +{ + "project": "@crystallize/js-api-client", + "version": "5.3.0", + "goal": "Improve developer experience, code quality, type safety, and reliability of the JS API client", + "tasks": [ + { + "id": "DX-001", + "status": "done", + "title": "Remove dead try/catch block in API caller", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/client/create-api-caller.ts` lines 187-188, there is a catch block that simply rethrows the exception. This is a no-op that adds visual noise and misleads readers into thinking error handling is happening.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Remove the outer `try { ... } catch (exception) { throw exception; }` wrapper from the `post` function", + "Keep all the inner logic intact โ€” only the wrapping try/catch is removed", + "The function behavior must remain identical" + ], + "verification": [ + "Run `pnpm test` โ€” all tests must pass", + "Verify the `post` function no longer has a useless catch block" + ] + }, + { + "id": "DX-002", + "status": "done", + "title": "Delete commented-out subscription.ts file", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "The file `src/core/subscription.ts` contains 543 lines of entirely commented-out code. This functionality has been replaced by the modules in `src/core/pim/subscriptions/`. The dead code creates confusion about what's active. Git history preserves it if ever needed.", + "files": ["src/core/subscription.ts"], + "requirements": [ + "Delete the file `src/core/subscription.ts` entirely", + "Verify it is not imported or referenced anywhere in the codebase (it currently is not exported from `src/index.ts`)", + "No other files should be modified" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass", + "Grep the codebase for any imports of `subscription.ts` from the core directory (not the pim/subscriptions/ path)" + ] + }, + { + "id": "DX-003", + "status": "done", + "title": "Fix 'chidlren' typo in catalogue fetcher", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-catalogue-fetcher.ts` around line 67, there is a typo: `chidlren` instead of `children`. This is in a GraphQL query builder, so it likely produces an incorrect field name in queries. This is a bug.", + "files": ["src/core/catalogue/create-catalogue-fetcher.ts"], + "requirements": [ + "Find and replace `chidlren` with `children` in the catalogue fetcher", + "Check if the same typo appears anywhere else in the codebase and fix those too" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass", + "Grep the entire codebase for `chidlren` to ensure no remaining instances" + ] + }, + { + "id": "DX-004", + "status": "done", + "title": "Remove unnecessary double spread in product hydrater", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-product-hydrater.ts` around line 90, there is `{ ...{ ...productListQuery } }` โ€” a double spread that is functionally identical to `{ ...productListQuery }`. This is confusing to read.", + "files": ["src/core/catalogue/create-product-hydrater.ts"], + "requirements": [ + "Simplify `{ ...{ ...productListQuery } }` to `{ ...productListQuery }`", + "No behavioral change" + ], + "verification": ["Run `pnpm build` โ€” build must succeed", "Run `pnpm test` โ€” all tests must pass"] + }, + { + "id": "DX-005", + "status": "done", + "title": "Fix results variable initialization in mass call client", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/create-mass-call-client.ts` line 101, `results` is declared as `let results: { [key: string]: any } = []`. It's typed as an object but initialized as an array. It's then used as an object (`results[result.key] = ...`). While JavaScript allows this (arrays are objects), it's misleading and technically wrong.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Change `let results: { [key: string]: any } = []` to `let results: { [key: string]: any } = {}`", + "No behavioral change expected" + ], + "verification": ["Run `pnpm build` โ€” build must succeed", "Run `pnpm test` โ€” all tests must pass"] + }, + { + "id": "DX-006", + "status": "done", + "title": "Fix stale 'module' field in package.json", + "priority": "P0", + "category": "build", + "effort": "5min", + "context": "The `package.json` has `\"module\": \"./dist/index.mjs\"` but the build (tsup) outputs `./dist/index.js` for ESM. The `.mjs` file does not exist. Some bundlers (Webpack, Rollup) use the `module` field to resolve ESM entry points, so this can cause import failures in certain setups.", + "files": ["package.json"], + "requirements": [ + "Either change `\"module\": \"./dist/index.mjs\"` to `\"module\": \"./dist/index.js\"` to match the actual build output", + "Or remove the `module` field entirely since the `exports` map already handles ESM resolution correctly", + "Verify the `exports` field is correct and consistent with the chosen approach" + ], + "verification": [ + "Run `pnpm build` and verify the referenced file exists in `dist/`", + "Check that `ls dist/index.js` exists (ESM output)", + "Check that `ls dist/index.cjs` exists (CJS output)" + ] + }, + { + "id": "DX-007", + "status": "done", + "title": "Set error name on JSApiClientCallError", + "priority": "P1", + "category": "error-handling", + "effort": "5min", + "context": "The custom error class `JSApiClientCallError` in `src/core/client/create-api-caller.ts` extends `Error` but never sets `this.name`. This means stack traces and `error.name` show generic `Error` instead of `JSApiClientCallError`, making debugging harder. All well-designed custom error classes should set their name.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Add `this.name = 'JSApiClientCallError';` in the constructor, after the `super()` call", + "No other changes needed" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass", + "Manually verify: `new JSApiClientCallError({...}).name === 'JSApiClientCallError'`" + ] + }, + { + "id": "DX-008", + "status": "done", + "title": "Remove 'any' from union types in grabber and API caller signatures", + "priority": "P1", + "category": "type-safety", + "effort": "30min", + "context": "In `src/core/client/create-grabber.ts` line 14 and `src/core/client/create-api-caller.ts` line 95, the parameter type is `RequestInit | any | undefined`. The `| any` makes the entire union collapse to `any`, completely defeating TypeScript's type checking. This means any caller can pass anything without type errors.", + "files": ["src/core/client/create-grabber.ts", "src/core/client/create-api-caller.ts"], + "requirements": [ + "Define a proper `GrabOptions` type that extends or replaces the `RequestInit | any` pattern", + "The type should cover what the grabber actually uses: `method`, `headers`, `body`", + "Update the `Grab` type, `grab` function signature, and `post` function signature", + "Ensure HTTP/2 code path still compiles (it uses `headers` with `:method` and `:path` pseudo-headers)", + "No behavioral changes" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed with no type errors", + "Run `pnpm test` โ€” all tests must pass", + "Verify that passing an invalid option to `grab()` now produces a TypeScript error" + ] + }, + { + "id": "DX-009", + "status": "done", + "title": "Replace tracked .env with .env.example", + "priority": "P1", + "category": "security", + "effort": "10min", + "context": "There is a `.env` file (519 bytes) tracked in the repository root. Even if it only contains test tenant credentials, tracking `.env` files in git is bad practice โ€” it trains contributors to commit secrets. The file should be replaced with a `.env.example` template.", + "files": [".env", ".env.example", ".gitignore"], + "requirements": [ + "Create a `.env.example` file with the same keys but placeholder values (e.g., `CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here`)", + "Add `.env` to `.gitignore` if not already present", + "Remove `.env` from git tracking (but don't delete the local file): `git rm --cached .env`", + "Update any documentation that references the `.env` file" + ], + "verification": [ + "Verify `.env` is in `.gitignore`", + "Verify `.env.example` exists with placeholder values", + "Run `pnpm test` โ€” tests should still work (they use `dotenv/config` which reads the local `.env`)" + ] + }, + { + "id": "DX-010", + "status": "done", + "title": "Improve authentication error messaging", + "priority": "P1", + "category": "error-handling", + "effort": "30min", + "context": "In `src/core/client/create-api-caller.ts` lines 71-74, when no authentication is configured (no sessionId, no staticAuthToken, no accessToken), the `authenticationHeaders` function silently sends empty string values for `X-Crystallize-Access-Token-Id` and `X-Crystallize-Access-Token-Secret`. This results in a confusing 401/403 error from the server instead of a clear local error message telling the developer they forgot to configure authentication.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "When the fallback case is reached (no sessionId, no staticAuthToken) AND both `accessTokenId` and `accessTokenSecret` are empty/undefined, log a warning or throw a descriptive error", + "The warning should say something like: 'No authentication credentials configured. Set accessTokenId/accessTokenSecret, staticAuthToken, or sessionId in the client configuration.'", + "Consider using `console.warn` rather than throwing, since some catalogue endpoints may work without auth", + "Only warn once per client instance to avoid spam" + ], + "verification": [ + "Run `pnpm test` โ€” all tests must pass", + "Manually test: create a client with no auth config, call `pimApi()`, verify a warning is logged" + ] + }, + { + "id": "DX-011", + "status": "done", + "title": "Improve generic parameter naming in fetchers", + "priority": "P2", + "category": "type-safety", + "effort": "30min", + "context": "Across order, customer, and subscription fetchers/managers, generic type parameters use cryptic abbreviations: `OO`, `OOI`, `OC`. These are mixed with clearer names like `OnOrder`, `OnCustomer` in the same files. This inconsistency makes the generics hard to understand for library consumers who see them in IDE tooltips.", + "files": [ + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/customers/create-customer-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts" + ], + "requirements": [ + "Rename cryptic generic parameters to descriptive names consistently across all fetchers/managers", + "Suggested mapping: `OO` โ†’ `OrderExtra`, `OOI` โ†’ `OrderItemExtra`, `OC` โ†’ `CustomerExtra`", + "Ensure the public API types (visible to consumers) use the new names", + "This is a non-breaking change since generic names are not part of the runtime API" + ], + "verification": ["Run `pnpm build` โ€” build must succeed", "Run `pnpm test` โ€” all tests must pass"] + }, + { + "id": "DX-012", + "status": "done", + "title": "Add Symbol.dispose support for automatic cleanup", + "priority": "P2", + "category": "api-design", + "effort": "1h", + "context": "The `ClientInterface` requires calling `close()` to clean up HTTP/2 connections. If a developer forgets, connections leak. TypeScript 5.2+ supports the `using` declaration with `Symbol.dispose` / `Symbol.asyncDispose`, which provides automatic cleanup. This is a modern ergonomic improvement that makes the library safer to use.", + "files": ["src/core/client/create-client.ts"], + "requirements": [ + "Add `[Symbol.dispose]` to the `ClientInterface` type that calls `close()`", + "Implement it in the `createClient` return value", + "Keep the existing `close()` method for backward compatibility", + "This allows: `using client = createClient({...})` โ€” auto-closes when scope exits", + "Ensure the TypeScript target/lib settings support `Symbol.dispose` (may need to add `esnext.disposable` to `lib`)", + "Do NOT break existing usage patterns" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass", + "Write a quick TypeScript snippet using `using client = createClient({...})` and verify it compiles" + ] + }, + { + "id": "DX-013", + "status": "done", + "title": "Add request timeout support via AbortController", + "priority": "P2", + "category": "reliability", + "effort": "1h", + "context": "There is no way to set a timeout on API calls. A hanging request will hang forever, which is especially problematic in serverless environments with execution time limits. The library should support an optional timeout that automatically aborts requests.", + "files": [ + "src/core/client/create-client.ts", + "src/core/client/create-api-caller.ts", + "src/core/client/create-grabber.ts" + ], + "requirements": [ + "Add an optional `timeout` field to `CreateClientOptions` (in milliseconds)", + "In the `post` function, create an `AbortController` with `AbortSignal.timeout(ms)` when timeout is configured", + "Pass the signal to the `grab` function (for fetch path: pass as part of RequestInit; for HTTP/2 path: use `req.close()` on timeout)", + "Throw a descriptive error when a timeout occurs (e.g., `JSApiClientCallError` with a clear message)", + "Allow per-request timeout override (optional, can be deferred)", + "Default: no timeout (backward compatible)" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass", + "Manually test: create a client with `timeout: 1` (1ms) and verify it times out on a real request" + ] + }, + { + "id": "DX-014", + "status": "done", + "title": "Make Server-Timing header parsing robust", + "priority": "P2", + "category": "reliability", + "effort": "15min", + "context": "In `src/core/client/create-api-caller.ts` lines 127-131, the Server-Timing header is parsed with `split(';')[1]?.split('=')[1]`. This assumes a specific format and will produce garbage values with non-standard headers. The profiling feature should not break or report wrong values due to header format variations.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Parse the Server-Timing header according to the spec: `;dur=`", + "Use a regex like `/dur=([\\d.]+)/` to extract the duration value reliably", + "If parsing fails, fall back to `-1` (current fallback) without throwing", + "Handle the case where the header is missing entirely (already handled with `?? undefined`)" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Run `pnpm test` โ€” all tests must pass (especially `catalogue-profiled.test.ts`)" + ] + }, + { + "id": "DX-015", + "status": "done", + "title": "Reduce boilerplate in mass call client enqueue methods", + "priority": "P3", + "category": "code-quality", + "effort": "15min", + "context": "In `src/core/create-mass-call-client.ts` lines 209-234, the five `enqueue` methods (`catalogueApi`, `discoveryApi`, `pimApi`, `nextPimApi`, `shopCartApi`) are identical except for the key prefix and the caller they reference. This is ~25 lines of repetitive code that can be generated programmatically.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace the five repetitive `enqueue` method definitions with a generated approach", + "Use a helper function or `Object.fromEntries` pattern to create the enqueue methods dynamically", + "The public API and types must remain identical โ€” this is an internal refactor only", + "Ensure type safety is preserved (the `QueuedApiCaller` type must still apply)" + ], + "verification": ["Run `pnpm build` โ€” build must succeed", "Run `pnpm test` โ€” all tests must pass"] + }, + { + "id": "DX-016", + "status": "done", + "title": "Reduce any usage in mass call client types", + "priority": "P2", + "category": "type-safety", + "effort": "1h", + "context": "The `create-mass-call-client.ts` file has 10+ usages of `any` in its types: `execute()` returns `Promise`, `afterRequest` callback receives `any`, `onFailure` receives `any` for the exception, `results` is `{ [key: string]: any }`. This defeats TypeScript's ability to help consumers of the mass call client.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace `Promise` return type of `execute()` with `Promise>` or a generic", + "Replace `any` in `afterRequest` callback with the actual result shape", + "Replace `any` for `exception` in `onFailure` with `unknown` (TypeScript best practice for caught errors)", + "Replace `results` type with `Record` or a generic keyed type", + "Keep the `experimental` nature โ€” don't over-engineer, just remove the `any` holes", + "Fix the typo `situaion` โ†’ `situation` in the `changeIncrementFor` callback parameter (line 85)" + ], + "verification": ["Run `pnpm build` โ€” build must succeed", "Run `pnpm test` โ€” all tests must pass"] + }, + { + "id": "DX-017", + "status": "done", + "title": "Add unit tests with mocked HTTP for core modules", + "priority": "P3", + "category": "testing", + "effort": "4h", + "context": "Currently all 15 test files are integration tests that hit real Crystallize APIs. There are zero unit tests. Critical modules like `create-grabber.ts`, `create-mass-call-client.ts`, and `create-binary-file-manager.ts` have no tests at all. Unit tests with mocked HTTP would: (1) allow tests to run without credentials, (2) test error paths, (3) run fast enough for PR CI.", + "files": [ + "tests/unit/create-api-caller.test.ts", + "tests/unit/create-grabber.test.ts", + "tests/unit/create-mass-call-client.test.ts" + ], + "requirements": [ + "Create a `tests/unit/` directory for unit tests", + "Add unit tests for `create-api-caller.ts` covering: successful response, GraphQL errors in 200 response, HTTP error responses, Core Next wrapped errors, 204 No Content handling", + "Add unit tests for `create-mass-call-client.ts` covering: basic enqueue and execute, retry logic, batch size adaptation (increment/decrement), Fibonacci backoff", + "Mock the `grab` function (it's a simple function signature, easy to mock)", + "Do not require API credentials or network access", + "Test error paths: network failures, malformed JSON responses, timeout scenarios" + ], + "verification": [ + "Run `pnpm test` โ€” all tests (integration + unit) must pass", + "Unit tests must pass without any `.env` credentials" + ] + }, + { + "id": "DX-018", + "status": "done", + "title": "Add error-path tests for API caller", + "priority": "P3", + "category": "testing", + "effort": "2h", + "context": "The current test suite only covers happy paths. There are no tests for: authentication failures (401/403), rate limiting (429), server errors (500/502/503), network timeouts, malformed JSON responses, or missing required fields. These are common real-world scenarios that should be covered.", + "files": ["tests/unit/error-handling.test.ts"], + "requirements": [ + "Create tests that verify `JSApiClientCallError` is thrown with correct properties for various HTTP error codes", + "Test that GraphQL errors in 200 responses are properly detected and thrown", + "Test that Core Next wrapped errors are detected via `getCoreNextError`", + "Test that the error includes the query and variables for debugging", + "Test 204 No Content returns empty object", + "Mock the grab function for all tests โ€” no network required" + ], + "verification": ["Run `pnpm test` โ€” all tests must pass", "Tests must pass without credentials"] + }, + { + "id": "DX-019", + "status": "done", + "title": "Add CI workflow for pull requests", + "priority": "P3", + "category": "ci-cd", + "effort": "30min", + "context": "Currently `.github/workflows/release.yaml` only runs on git tags (releases). There is no CI that runs on pull requests. This means broken code can be merged without any automated checks. A PR workflow should at minimum run build and unit tests.", + "files": [".github/workflows/ci.yaml"], + "requirements": [ + "Create a new workflow file `.github/workflows/ci.yaml`", + "Trigger on: pull requests to main, pushes to main", + "Steps: checkout, setup Node.js (same version as release.yaml), install deps with pnpm, run `pnpm build`, run `pnpm test` (unit tests only โ€” integration tests need credentials)", + "Consider testing on multiple Node.js versions (20, 22, 24)", + "Keep it fast โ€” unit tests should not require API credentials" + ], + "verification": [ + "Push a branch and create a PR to verify the workflow triggers", + "Verify the workflow passes with build + unit tests" + ] + }, + { + "id": "DX-020", + "status": "done", + "title": "Add JSDoc comments to main exported functions", + "priority": "P3", + "category": "documentation", + "effort": "2h", + "context": "Most exported factory functions have no JSDoc comments. IDE users (the primary consumers of this library) get no inline documentation when hovering over `createClient`, `createCatalogueFetcher`, `createOrderManager`, etc. Good JSDoc with `@param`, `@returns`, and `@example` tags significantly improves discoverability.", + "files": [ + "src/core/client/create-client.ts", + "src/core/catalogue/create-catalogue-fetcher.ts", + "src/core/catalogue/create-navigation-fetcher.ts", + "src/core/catalogue/create-product-hydrater.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts", + "src/core/shop/create-cart-manager.ts", + "src/core/create-signature-verifier.ts", + "src/core/pim/create-binary-file-manager.ts", + "src/core/create-mass-call-client.ts" + ], + "requirements": [ + "Add JSDoc to all exported factory functions with: description, @param tags, @returns description, and at least one @example", + "Focus on the main entry points that library consumers use directly", + "Keep descriptions concise โ€” one sentence for what it does, one for when to use it", + "Include authentication requirements in the description where relevant (e.g., 'Requires PIM API credentials')", + "Do not add JSDoc to internal/helper functions" + ], + "verification": [ + "Run `pnpm build` โ€” build must succeed", + "Open the source files in an IDE and verify JSDoc appears on hover for the exported functions" + ] + } + ] +} diff --git a/components/js-api-client/package.json b/components/js-api-client/package.json index 65715f32..0b21259d 100644 --- a/components/js-api-client/package.json +++ b/components/js-api-client/package.json @@ -26,7 +26,7 @@ }, "types": "./dist/index.d.ts", "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/node": "^24.2.0", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt new file mode 100644 index 00000000..09ab74a7 --- /dev/null +++ b/components/js-api-client/progress.txt @@ -0,0 +1,20 @@ +DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE +DX-001: Remove dead try/catch block in API caller - DONE +DX-002: Delete commented-out subscription.ts file - DONE +DX-004: Remove unnecessary double spread in product hydrater - DONE +DX-005: Fix results variable initialization in mass call client - DONE +DX-006: Fix stale 'module' field in package.json - DONE +DX-007: Set error name on JSApiClientCallError - DONE +DX-008: Remove 'any' from union types in grabber and API caller signatures - DONE +DX-009: Replace tracked .env with .env.example - DONE +DX-010: Improve authentication error messaging - DONE +DX-014: Make Server-Timing header parsing robust - DONE +DX-011: Improve generic parameter naming in fetchers - DONE +DX-012: Add Symbol.dispose support for automatic cleanup - DONE +DX-013: Add request timeout support via AbortController - DONE +DX-016: Reduce any usage in mass call client types - DONE +DX-015: Reduce boilerplate in mass call client enqueue methods - DONE +DX-017: Add unit tests with mocked HTTP for core modules - DONE +DX-018: Add error-path tests for API caller - DONE +DX-019: Add CI workflow for pull requests - DONE +DX-020: Add JSDoc comments to main exported functions - DONE diff --git a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 2d049afa..da500ed1 100644 --- a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts @@ -22,6 +22,25 @@ export type CatalogueFetcherGrapqhqlOnFolder = { onChildren?: OC; }; +/** + * Creates a catalogue fetcher that executes queries against the Crystallize Catalogue API using JSON-based query objects. + * Use this when you want to build catalogue queries programmatically instead of writing raw GraphQL strings. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns A function that accepts a JSON query object and optional variables, and returns the catalogue data. + * + * @example + * ```ts + * const fetcher = createCatalogueFetcher(client); + * const data = await fetcher({ + * catalogue: { + * __args: { path: '/my-product', language: 'en' }, + * name: true, + * path: true, + * }, + * }); + * ``` + */ export const createCatalogueFetcher = (client: ClientInterface) => { return (query: object, variables?: VariablesType): Promise => { return client.catalogueApi(jsonToGraphQLQuery({ query }), variables); @@ -64,7 +83,7 @@ function onFolder(onFolder?: OF, c?: CatalogueFetcherGrapqhqlOnFol const children = () => { if (c?.onChildren) { return { - chidlren: { + children: { ...c.onChildren, }, }; diff --git a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts index eb2b76a3..1018eb37 100644 --- a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts @@ -112,6 +112,20 @@ function buildNestedNavigationQuery( return jsonToGraphQLQuery({ query }); } +/** + * Creates a navigation fetcher that builds nested tree queries for folder-based or topic-based navigation. + * Use this to retrieve hierarchical navigation structures from the Crystallize catalogue. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns An object with `byFolders` and `byTopics` methods for fetching navigation trees at a given depth. + * + * @example + * ```ts + * const nav = createNavigationFetcher(client); + * const folderTree = await nav.byFolders('/', 'en', 3); + * const topicTree = await nav.byTopics('/', 'en', 2); + * ``` + */ export function createNavigationFetcher(client: ClientInterface): { byFolders: TreeFetcher; byTopics: TreeFetcher; diff --git a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts index abca70e8..285e3c23 100644 --- a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts +++ b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts @@ -87,7 +87,7 @@ function byPaths(client: ClientInterface, options?: ProductHydraterOptions): Pro }, {} as any); const query = { - ...{ ...productListQuery }, + ...productListQuery, ...(extraQuery !== undefined ? extraQuery : {}), }; @@ -140,6 +140,21 @@ function bySkus(client: ClientInterface, options?: ProductHydraterOptions): Prod }; } +/** + * Creates a product hydrater that fetches full product data from the catalogue by paths or SKUs. + * Use this to enrich a list of product references with complete variant, pricing, and attribute data. + * + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Optional settings for market identifiers, price lists, and price-for-everyone inclusion. + * @returns An object with `byPaths` and `bySkus` methods for hydrating products. + * + * @example + * ```ts + * const hydrater = createProductHydrater(client); + * const products = await hydrater.byPaths(['/shop/my-product'], 'en'); + * const productsBySkus = await hydrater.bySkus(['SKU-001', 'SKU-002'], 'en'); + * ``` + */ export function createProductHydrater(client: ClientInterface, options?: ProductHydraterOptions) { return { byPaths: byPaths(client, options), diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 4e864f70..98988ea9 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -1,5 +1,5 @@ import { CreateClientOptions, ClientConfiguration } from './create-client.js'; -import { Grab } from './create-grabber.js'; +import { Grab, GrabOptions } from './create-grabber.js'; export type VariablesType = Record; export type ApiCaller = (query: string, variables?: VariablesType) => Promise; @@ -27,6 +27,7 @@ export class JSApiClientCallError extends Error { variables: VariablesType; }) { super(message); + this.name = 'JSApiClientCallError'; this.code = code; this.statusText = statusText; this.errors = errors; @@ -57,6 +58,8 @@ export const createApiCaller = ( }; }; +const warnedConfigs = new WeakSet(); + export const authenticationHeaders = (config: ClientConfiguration): Record => { if (config.sessionId) { return { @@ -68,6 +71,13 @@ export const authenticationHeaders = (config: ClientConfiguration): Record( config: ClientConfiguration, query: string, variables?: VariablesType, - init?: RequestInit | any | undefined, + init?: GrabOptions, options?: CreateClientOptions, ): Promise => { - try { - const { headers: initHeaders, ...initRest } = init || {}; - const profiling = options?.profiling; + const { headers: initHeaders, ...initRest } = init || {}; + const profiling = options?.profiling; - const headers = { - 'Content-type': 'application/json; charset=UTF-8', - Accept: 'application/json', - ...authenticationHeaders(config), - ...initHeaders, - }; + const headers = { + 'Content-type': 'application/json; charset=UTF-8', + Accept: 'application/json', + ...authenticationHeaders(config), + ...initHeaders, + }; - const body = JSON.stringify({ query, variables }); - let start: number = 0; - if (profiling) { - start = Date.now(); - if (profiling.onRequest) { - profiling.onRequest(query, variables); - } + const body = JSON.stringify({ query, variables }); + let start: number = 0; + if (profiling) { + start = Date.now(); + if (profiling.onRequest) { + profiling.onRequest(query, variables); } + } - const response = await grab(path, { - ...initRest, - method: 'POST', - headers, - body, - }); + const timeout = options?.timeout; + const signal = timeout ? AbortSignal.timeout(timeout) : undefined; - if (profiling) { - const ms = Date.now() - start; - let serverTiming = response.headers.get('server-timing') ?? undefined; - if (Array.isArray(serverTiming)) { - serverTiming = serverTiming[0]; - } - const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; - profiling.onRequestResolved( - { - resolutionTimeMs: ms, - serverTimeMs: Number(duration), - }, - query, - variables, - ); - } - if (response.ok && 204 === response.status) { - return {}; - } - if (!response.ok) { - const json = await response.json<{ - message: string; - errors: unknown; - }>(); - throw new JSApiClientCallError({ - code: response.status, - statusText: response.statusText, - message: json.message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); + const response = await grab(path, { + ...initRest, + method: 'POST', + headers, + body, + signal, + }); + + if (profiling) { + const ms = Date.now() - start; + let serverTiming = response.headers.get('server-timing') ?? undefined; + if (Array.isArray(serverTiming)) { + serverTiming = serverTiming[0]; } - // we still need to check for error as the API can return 200 with errors + const durMatch = serverTiming?.match(/dur=([\d.]+)/); + const duration = durMatch ? durMatch[1] : -1; + profiling.onRequestResolved( + { + resolutionTimeMs: ms, + serverTimeMs: Number(duration), + }, + query, + variables, + ); + } + if (response.ok && 204 === response.status) { + return {}; + } + if (!response.ok) { const json = await response.json<{ - errors: { - message: string; - }[]; - data: T; + message: string; + errors: unknown; }>(); - if (json.errors) { - throw new JSApiClientCallError({ - code: 400, - statusText: 'Error was returned from the API', - message: json.errors[0].message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); - } - // let's try to find `errorName` at the second level to handle Core Next errors more gracefully - const err = getCoreNextError(json.data); - if (err) { - throw new JSApiClientCallError({ - code: 400, - query, - variables: variables || {}, - statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', - message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, - }); - } - return json.data; - } catch (exception) { - throw exception; + throw new JSApiClientCallError({ + code: response.status, + statusText: response.statusText, + message: json.message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // we still need to check for error as the API can return 200 with errors + const json = await response.json<{ + errors: { + message: string; + }[]; + data: T; + }>(); + if (json.errors) { + throw new JSApiClientCallError({ + code: 400, + statusText: 'Error was returned from the API', + message: json.errors[0].message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // let's try to find `errorName` at the second level to handle Core Next errors more gracefully + const err = getCoreNextError(json.data); + if (err) { + throw new JSApiClientCallError({ + code: 400, + query, + variables: variables || {}, + statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', + message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, + }); } + return json.data; }; diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 18958b37..43bfddc9 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -13,6 +13,7 @@ export type ClientInterface = { shopCartApi: ApiCaller; config: Pick; close: () => void; + [Symbol.dispose]: () => void; }; export type ClientConfiguration = { tenantIdentifier: string; @@ -29,7 +30,9 @@ export type ClientConfiguration = { export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; - extraHeaders?: RequestInit['headers']; + extraHeaders?: Record; + /** Request timeout in milliseconds. When set, requests that take longer will be aborted. */ + timeout?: number; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; @@ -47,6 +50,24 @@ export const apiHost = (configuration: ClientConfiguration) => { }; }; +/** + * Creates a Crystallize API client that provides access to catalogue, discovery, PIM, and shop cart APIs. + * Use this as the main entry point for all interactions with the Crystallize APIs. + * + * @param configuration - The tenant configuration including identifier and authentication credentials. + * @param options - Optional settings for HTTP/2, profiling, timeouts, and extra headers. + * @returns A client interface with pre-configured API callers for each Crystallize endpoint. + * + * @example + * ```ts + * const client = createClient({ + * tenantIdentifier: 'my-tenant', + * accessTokenId: 'my-token-id', + * accessTokenSecret: 'my-token-secret', + * }); + * const data = await client.catalogueApi(query); + * ``` + */ export const createClient = (configuration: ClientConfiguration, options?: CreateClientOptions): ClientInterface => { const identifier = configuration.tenantIdentifier; const { grab, close: grabClose } = createGrabber({ @@ -100,5 +121,6 @@ export const createClient = (configuration: ClientConfiguration, options?: Creat origin: configuration.origin, }, close: grabClose, + [Symbol.dispose]: grabClose, }; }; diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 81490987..7912be84 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -10,8 +10,14 @@ export type GrabResponse = { json: () => Promise; text: () => Promise; }; +export type GrabOptions = { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +}; export type Grab = { - grab: (url: string, options?: RequestInit | any | undefined) => Promise; + grab: (url: string, options?: GrabOptions) => Promise; close: () => void; }; @@ -21,9 +27,10 @@ type Options = { export const createGrabber = (options?: Options): Grab => { const clients = new Map(); const IDLE_TIMEOUT = 300000; // 5 min idle timeout - const grab = async (url: string, grabOptions?: RequestInit | any): Promise => { + const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { - return fetch(url, grabOptions); + const { signal, ...fetchOptions } = grabOptions || {}; + return fetch(url, { ...fetchOptions, signal }); } const closeAndDeleteClient = (origin: string) => { const clientObj = clients.get(origin); @@ -62,12 +69,29 @@ export const createGrabber = (options?: Options): Grab => { const client = getClient(origin); resetIdleTimeout(origin); const headers = { - ':method': grabOptions.method || 'GET', + ':method': grabOptions?.method || 'GET', ':path': urlObj.pathname + urlObj.search, - ...grabOptions.headers, + ...grabOptions?.headers, }; const req = client.request(headers); - if (grabOptions.body) { + + if (grabOptions?.signal) { + const signal = grabOptions.signal; + if (signal.aborted) { + req.close(); + reject(signal.reason); + return; + } + const onAbort = () => { + req.close(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + req.on('end', () => signal.removeEventListener('abort', onAbort)); + req.on('error', () => signal.removeEventListener('abort', onAbort)); + } + + if (grabOptions?.body) { req.write(grabOptions.body); } req.setEncoding('utf8'); diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 22d0af65..e49f338d 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -14,12 +14,14 @@ export type MassCallClientBatch = { }; export type QueuedApiCaller = (query: string, variables?: VariablesType) => string; +export type MassCallResults = Record; + export type MassClientInterface = ClientInterface & { - execute: () => Promise; + execute: () => Promise; reset: () => void; hasFailed: () => boolean; failureCount: () => number; - retry: () => Promise; + retry: () => Promise; catalogueApi: ApiCaller; discoveryApi: ApiCaller; pimApi: ApiCaller; @@ -60,12 +62,23 @@ const createFibonacciSleeper = (): Sleeper => { }; /** - * Note: MassCallClient is experimental and may not work as expected. - * Creates a mass call client based on an existing ClientInterface. + * Creates a mass call client that batches and throttles multiple API requests with automatic retry and concurrency control. + * Use this when you need to execute many API calls efficiently, such as bulk imports or migrations. Note: this feature is experimental. + * + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Configuration for concurrency, batching callbacks, failure handling, and sleep strategy. + * @returns A mass client interface that extends `ClientInterface` with `enqueue`, `execute`, `retry`, `reset`, `hasFailed`, and `failureCount` capabilities. * - * @param client ClientInterface - * @param options Object - * @returns MassClientInterface + * @example + * ```ts + * const massClient = createMassCallClient(client, { initialSpawn: 2, maxSpawn: 5 }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '1' }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '2' }); + * const results = await massClient.execute(); + * if (massClient.hasFailed()) { + * const retryResults = await massClient.retry(); + * } + * ``` */ export function createMassCallClient( client: ClientInterface, @@ -74,15 +87,19 @@ export function createMassCallClient( maxSpawn?: number; onBatchDone?: (batch: MassCallClientBatch) => Promise; beforeRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise) => Promise; - afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: any) => Promise; + afterRequest?: ( + batch: MassCallClientBatch, + promise: CrystallizePromise, + results: Record, + ) => Promise; onFailure?: ( batch: { from: number; to: number }, - exception: any, + exception: unknown, promise: CrystallizePromise, ) => Promise; sleeper?: Sleeper; changeIncrementFor?: ( - situaion: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', + situation: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', currentIncrement: number, ) => number; }, @@ -97,16 +114,16 @@ export function createMassCallClient( const execute = async () => { failedPromises = []; let batch = []; - let results: { - [key: string]: any; - } = []; + let results: MassCallResults = {}; do { let batchErrorCount = 0; const to = seek + increment; batch = promises.slice(seek, to); const batchResults = await Promise.all( batch.map(async (promise: CrystallizePromise) => { - const buildStandardPromise = async (promise: CrystallizePromise): Promise => { + const buildStandardPromise = async ( + promise: CrystallizePromise, + ): Promise<{ key: string; result: unknown } | undefined> => { try { return { key: promise.key, @@ -129,7 +146,7 @@ export function createMassCallClient( } // otherwise we wrap it - return new Promise(async (resolve) => { + return new Promise<{ key: string; result: unknown } | undefined>(async (resolve) => { let alteredPromise; if (options.beforeRequest) { alteredPromise = await options.beforeRequest({ from: seek, to: to }, promise); @@ -206,32 +223,16 @@ export function createMassCallClient( nextPimApi: client.nextPimApi, config: client.config, close: client.close, - enqueue: { - catalogueApi: (query: string, variables?: VariablesType): string => { - const key = `catalogueApi-${counter++}`; - promises.push({ key, caller: client.catalogueApi, query, variables }); - return key; - }, - discoveryApi: (query: string, variables?: VariablesType): string => { - const key = `discoveryApi-${counter++}`; - promises.push({ key, caller: client.discoveryApi, query, variables }); - return key; - }, - pimApi: (query: string, variables?: VariablesType): string => { - const key = `pimApi-${counter++}`; - promises.push({ key, caller: client.pimApi, query, variables }); - return key; - }, - nextPimApi: (query: string, variables?: VariablesType): string => { - const key = `nextPimApi-${counter++}`; - promises.push({ key, caller: client.nextPimApi, query, variables }); - return key; - }, - shopCartApi: (query: string, variables?: VariablesType): string => { - const key = `shopCartApi-${counter++}`; - promises.push({ key, caller: client.shopCartApi, query, variables }); - return key; - }, - }, + [Symbol.dispose]: client[Symbol.dispose], + enqueue: Object.fromEntries( + (['catalogueApi', 'discoveryApi', 'pimApi', 'nextPimApi', 'shopCartApi'] as const).map((apiName) => [ + apiName, + (query: string, variables?: VariablesType): string => { + const key = `${apiName}-${counter++}`; + promises.push({ key, caller: client[apiName], query, variables }); + return key; + }, + ]), + ) as MassClientInterface['enqueue'], }; } diff --git a/components/js-api-client/src/core/create-signature-verifier.ts b/components/js-api-client/src/core/create-signature-verifier.ts index 10f0ca85..ac7fd73b 100644 --- a/components/js-api-client/src/core/create-signature-verifier.ts +++ b/components/js-api-client/src/core/create-signature-verifier.ts @@ -65,6 +65,23 @@ const buildGETSituationChallenge = (request: SimplifiedRequest) => { return null; }; +/** + * Creates a signature verifier for validating Crystallize webhook and app signatures. + * Use this to verify that incoming requests genuinely originate from Crystallize. + * + * @param params - An object containing a `sha256` hash function, a `jwtVerify` function, and the webhook `secret`. + * @returns An async function that takes a signature string and a simplified request, and resolves to the verified payload or throws on invalid signatures. + * + * @example + * ```ts + * const verifier = createSignatureVerifier({ + * sha256: async (data) => createHash('sha256').update(data).digest('hex'), + * jwtVerify: async (token, secret) => jwt.verify(token, secret), + * secret: process.env.CRYSTALLIZE_WEBHOOK_SECRET, + * }); + * const payload = await verifier(signatureHeader, { url, method, body }); + * ``` + */ export const createSignatureVerifier = ({ sha256, jwtVerify, secret }: CreateAsyncSignatureVerifierParams) => { return async (signature: string, request: SimplifiedRequest): Promise => { try { diff --git a/components/js-api-client/src/core/pim/create-binary-file-manager.ts b/components/js-api-client/src/core/pim/create-binary-file-manager.ts index 666248f5..c70fb7b3 100644 --- a/components/js-api-client/src/core/pim/create-binary-file-manager.ts +++ b/components/js-api-client/src/core/pim/create-binary-file-manager.ts @@ -34,6 +34,20 @@ const generatePresignedUploadRequest = `#graphql } }`; +/** + * Creates a binary file manager for uploading images, static files, and mass operation files to a Crystallize tenant. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `uploadToTenant`, `uploadImage`, `uploadFile`, and `uploadMassOperationFile`. + * + * @example + * ```ts + * const fileManager = createBinaryFileManager(client); + * const imageKey = await fileManager.uploadImage('/path/to/image.png'); + * const fileKey = await fileManager.uploadFile('/path/to/document.pdf'); + * ``` + */ export const createBinaryFileManager = (apiClient: ClientInterface) => { // this function returns the key of the uploaded file const uploadToTenant = async ({ type = 'MEDIA', mimeType, filename, buffer }: BinaryHandler): Promise => { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts index 79794a43..879a2edd 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts @@ -4,7 +4,7 @@ import { Customer } from '@crystallize/schema/pim'; export type DefaultCustomerType = R & Required>; -const buildBaseQuery = (onCustomer?: OC) => { +const buildBaseQuery = (onCustomer?: CustomerExtra) => { return { identifier: true, email: true, @@ -15,9 +15,9 @@ const buildBaseQuery = (onCustomer?: OC) => { }; export const createCustomerFetcher = (apiClient: ClientInterface) => { - const fetchByIdentifier = async ( + const fetchByIdentifier = async ( identifier: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { @@ -42,9 +42,9 @@ export const createCustomerFetcher = (apiClient: ClientInterface) => { ).customer; }; - const fetchByExternalReference = async ( + const fetchByExternalReference = async ( { key, value }: { key: string; value?: string }, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts index 5ada9bfe..b72ebd25 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts @@ -12,10 +12,27 @@ import { createCustomerFetcher } from './create-customer-fetcher.js'; type WithIdentifier = R & { identifier: string }; +/** + * Creates a customer manager for creating, updating, and managing customer records via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `create`, `update`, `setMeta`, `setMetaKey`, `setExternalReference`, and `setExternalReferenceKey`. + * + * @example + * ```ts + * const customerManager = createCustomerManager(client); + * const customer = await customerManager.create({ + * identifier: 'customer@example.com', + * firstName: 'Jane', + * lastName: 'Doe', + * }); + * ``` + */ export const createCustomerManager = (apiClient: ClientInterface) => { - const create = async ( + const create = async ( intentCustomer: CreateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const input = CreateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -43,9 +60,9 @@ export const createCustomerManager = (apiClient: ClientInterface) => { return confirmation.createCustomer; }; - const update = async ( + const update = async ( intentCustomer: UpdateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const { identifier, ...input } = UpdateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -75,30 +92,30 @@ export const createCustomerManager = (apiClient: ClientInterface) => { }; // this is overriding completely the previous meta (there is no merge method yes on the API) - const setMeta = async ( + const setMeta = async ( identifier: string, intentMeta: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const meta = UpdateCustomerInputSchema.shape.meta.parse(intentMeta); - return await update({ identifier, meta }, onCustomer); + return await update({ identifier, meta }, onCustomer); }; // this is overriding completely the previous references (there is no merge method yes on the API) - const setExternalReference = async ( + const setExternalReference = async ( identifier: string, intentReferences: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const references = UpdateCustomerInputSchema.shape.meta.parse(intentReferences); - return await update({ identifier, externalReferences: references }, onCustomer); + return await update({ identifier, externalReferences: references }, onCustomer); }; - const setMetaKey = async ( + const setMetaKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ meta: Customer['meta'] }>(identifier, { @@ -114,14 +131,14 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newMeta = existingMeta.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['meta'] >; - return await setMeta(identifier, newMeta, onCustomer); + return await setMeta(identifier, newMeta, onCustomer); }; - const setExternalReferenceKey = async ( + const setExternalReferenceKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ externalReferences: Customer['externalReferences'] }>( @@ -140,7 +157,7 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newReferences = existingReferences.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['externalReferences'] >; - return await setExternalReference(identifier, newReferences, onCustomer); + return await setExternalReference(identifier, newReferences, onCustomer); }; return { create, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index bdc81c30..52cad9df 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -20,7 +20,11 @@ export type DefaultOrderType; } & OnOrder; -const buildBaseQuery = (onOrder?: OO, onOrderItem?: OOI, onCustomer?: OC) => { +const buildBaseQuery = ( + onOrder?: OrderExtra, + onOrderItem?: OrderItemExtra, + onCustomer?: CustomerExtra, +) => { const priceQuery = { gross: true, net: true, @@ -62,25 +66,39 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onOrder?: OO; - onOrderItem?: OOI; - onCustomer?: OC; +type EnhanceQuery = { + onOrder?: OrderExtra; + onOrderItem?: OrderItemExtra; + onCustomer?: CustomerExtra; }; +/** + * Creates an order fetcher for retrieving orders from the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with `byId` and `byCustomerIdentifier` methods for fetching orders. + * + * @example + * ```ts + * const orderFetcher = createOrderFetcher(client); + * const order = await orderFetcher.byId('order-id-123'); + * const { orders, pageInfo } = await orderFetcher.byCustomerIdentifier('customer@example.com'); + * ``` + */ export function createOrderFetcher(apiClient: ClientInterface) { const fetchPaginatedByCustomerIdentifier = async < OnOrder = unknown, OnOrderItem = unknown, OnCustomer = unknown, EA extends Record = Record, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record & { customer?: Record } }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; orders: Array>; @@ -142,12 +160,12 @@ export function createOrderFetcher(apiClient: ClientInterface) { OnOrder = unknown, OnOrderItem = unknown, OnCustomer = unknown, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { order: { diff --git a/components/js-api-client/src/core/pim/orders/create-order-manager.ts b/components/js-api-client/src/core/pim/orders/create-order-manager.ts index 65ea1dfb..8d880d7d 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-manager.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-manager.ts @@ -9,7 +9,7 @@ import { ClientInterface } from '../../client/create-client.js'; import { jsonToGraphQLQuery } from 'json-to-graphql-query'; import { transformOrderInput } from './helpers.js'; -const baseQuery = (enhancements?: { onCustomer?: OC; onOrder?: OO }) => ({ +const baseQuery = (enhancements?: { onCustomer?: CustomerExtra; onOrder?: OrderExtra }) => ({ __on: [ { __typeName: 'Order', @@ -28,6 +28,23 @@ const baseQuery = (enhancements?: { onCustomer?: OC; onOrder?: OO }) => ], }); +/** + * Creates an order manager for registering, updating, and managing orders via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `register`, `update`, `setPayments`, `putInPipelineStage`, and `removeFromPipeline`. + * + * @example + * ```ts + * const orderManager = createOrderManager(client); + * const { id, createdAt } = await orderManager.register({ + * customer: { identifier: 'customer@example.com' }, + * cart: [{ sku: 'SKU-001', name: 'My Product', quantity: 1 }], + * total: { gross: 100, net: 80, currency: 'USD' }, + * }); + * ``` + */ export const createOrderManager = (apiClient: ClientInterface) => { const register = async (intentOrder: RegisterOrderInput) => { const intent = RegisterOrderInputSchema.parse(intentOrder); @@ -63,9 +80,9 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - const update = async ( + const update = async ( intentOrder: UpdateOrderInput, - onOrder?: OO, + onOrder?: OrderExtra, ): Promise> & OnOrder> => { const { id, ...input } = UpdateOrderInputSchema.parse(intentOrder); const mutation = { @@ -101,16 +118,21 @@ export const createOrderManager = (apiClient: ClientInterface) => { pipelineId: string; stageId: string; }; - type PutInPipelineStageEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type PutInPipelineStageEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type PutInPipelineStageDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const putInPipelineStage = async ( + const putInPipelineStage = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId, stageId }: PutInPipelineStageArgs, - enhancements?: PutInPipelineStageEnhancedQuery, + enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { const mutation = { updateOrderPipelineStage: { @@ -133,16 +155,21 @@ export const createOrderManager = (apiClient: ClientInterface) => { id: string; pipelineId: string; }; - type RemoveFromPipelineEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type RemoveFromPipelineEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type RemoveFromPipelineDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const removeFromPipeline = async ( + const removeFromPipeline = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId }: RemoveFromPipelineArgs, - enhancements?: RemoveFromPipelineEnhancedQuery, + enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { const mutation = { deleteOrderPipeline: { @@ -160,10 +187,10 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - type SetPaymentsEnhancedQuery = { - onCustomer?: OC; - onPayment?: OP; - onOrder?: OO; + type SetPaymentsEnhancedQuery = { + onCustomer?: CustomerExtra; + onPayment?: PaymentExtra; + onOrder?: OrderExtra; }; type SetPaymentsDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; @@ -173,13 +200,13 @@ export const createOrderManager = (apiClient: ClientInterface) => { OnCustomer = unknown, OnPayment = unknown, OnOrder = unknown, - OC = unknown, - OP = unknown, - OO = unknown, + CustomerExtra = unknown, + PaymentExtra = unknown, + OrderExtra = unknown, >( id: string, payments: UpdateOrderInput['payment'], - enhancements?: SetPaymentsEnhancedQuery, + enhancements?: SetPaymentsEnhancedQuery, ): Promise> => { const paymentSchema = UpdateOrderInputSchema.shape.payment; const input = paymentSchema.parse(payments); diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts index 8cef4889..195167e4 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts @@ -19,7 +19,10 @@ type DefaultSubscriptionContractType = Requi customer: Required, 'identifier'>> & OnCustomer; } & OnSubscriptionContract; -const buildBaseQuery = (onSubscriptionContract?: OSC, onCustomer?: OC) => { +const buildBaseQuery = ( + onSubscriptionContract?: SubscriptionContractExtra, + onCustomer?: CustomerExtra, +) => { const phaseQuery = { period: true, unit: true, @@ -108,15 +111,20 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onSubscriptionContract?: OSC; - onCustomer?: OC; +type EnhanceQuery = { + onSubscriptionContract?: SubscriptionContractExtra; + onCustomer?: CustomerExtra; }; export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => { - const fetchById = async ( + const fetchById = async < + OnSubscriptionContract = unknown, + OnCustomer = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, + >( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { subscriptionContract: { @@ -147,12 +155,12 @@ export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => OnSubscriptionContract = unknown, OnCustomer = unknown, EA extends Record = Record, - OSC = unknown, - OC = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; subscriptionContracts: Array>; diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index aaa6b1aa..3ca2ad50 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -25,7 +25,9 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: OSC) => ({ +const baseQuery = }>( + onSubscriptionContract?: SubscriptionContractExtra, +) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -45,10 +47,31 @@ const baseQuery = }>(onSubscript ], }); +/** + * Creates a subscription contract manager for creating, updating, and managing subscription lifecycle via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `create`, `update`, `cancel`, `pause`, `resume`, `renew`, `createTemplateBasedOnVariant`, and `createTemplateBasedOnVariantIdentity`. + * + * @example + * ```ts + * const subscriptionManager = createSubscriptionContractManager(client); + * const contract = await subscriptionManager.create({ + * customerIdentifier: 'customer@example.com', + * subscriptionPlan: { identifier: 'monthly', periodId: 'period-1' }, + * item: { sku: 'SKU-001', name: 'My Subscription', quantity: 1 }, + * recurring: { price: 9.99, currency: 'USD', period: 1, unit: 'month' }, + * }); + * ``` + */ export const createSubscriptionContractManager = (apiClient: ClientInterface) => { - const create = async } = {}>( + const create = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: CreateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const input = CreateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -82,9 +105,12 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.createSubscriptionContract; }; - const update = async } = {}>( + const update = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: UpdateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const { id, ...input } = UpdateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -103,10 +129,13 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.updateSubscriptionContract; }; - const cancel = async } = {}>( + const cancel = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], deactivate = false, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -126,9 +155,12 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.cancelSubscriptionContract; }; - const pause = async } = {}>( + const pause = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -145,9 +177,12 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.pauseSubscriptionContract; }; - const resume = async } = {}>( + const resume = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -164,9 +199,12 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.resumeSubscriptionContract; }; - const renew = async } = {}>( + const renew = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index bfabc40e..8729a501 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -13,6 +13,23 @@ import { transformCartCustomerInput, transformCartInput } from './helpers.js'; type WithId = R & { id: string }; +/** + * Creates a cart manager for hydrating, placing, and managing shopping carts via the Crystallize Shop Cart API. + * Requires a shop API token or appropriate credentials in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient`. + * @returns An object with methods to `hydrate`, `fetch`, `place`, `fulfill`, `abandon`, `addSkuItem`, `removeItem`, `setMeta`, and `setCustomer`. + * + * @example + * ```ts + * const cartManager = createCartManager(client); + * const cart = await cartManager.hydrate({ + * items: [{ sku: 'SKU-001', quantity: 2 }], + * locale: { displayName: 'English', language: 'en' }, + * }); + * const placed = await cartManager.place(cart.id); + * ``` + */ export const createCartManager = (apiClient: ClientInterface) => { const fetch = async (id: string, onCart?: OC) => { const query = { diff --git a/components/js-api-client/src/core/subscription.ts b/components/js-api-client/src/core/subscription.ts deleted file mode 100644 index e6b6589b..00000000 --- a/components/js-api-client/src/core/subscription.ts +++ /dev/null @@ -1,543 +0,0 @@ -// import { EnumType, jsonToGraphQLQuery } from 'json-to-graphql-query'; -// import { -// ProductPriceVariant, -// ProductVariant, -// ProductVariantSubscriptionPlan, -// ProductVariantSubscriptionPlanPeriod, -// ProductVariantSubscriptionMeteredVariable, -// ProductVariantSubscriptionPlanTier, -// ProductVariantSubscriptionPlanPricing, -// } from '../types/product.js'; -// import { -// createSubscriptionContractInputRequest, -// CreateSubscriptionContractInputRequest, -// SubscriptionContract, -// SubscriptionContractMeteredVariableReferenceInputRequest, -// SubscriptionContractMeteredVariableTierInputRequest, -// SubscriptionContractPhaseInput, -// updateSubscriptionContractInputRequest, -// UpdateSubscriptionContractInputRequest, -// } from '../types/subscription.js'; -// import { catalogueFetcherGraphqlBuilder, createCatalogueFetcher } from './catalogue/create-catalogue-fetcher.js'; -// import { ClientInterface } from './client/create-client.js'; - -// function convertDates(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// if (!intent.status) { -// return { -// ...intent, -// }; -// } - -// let results: any = { -// ...intent, -// }; - -// if (intent.status.renewAt) { -// results = { -// ...results, -// status: { -// ...results.status, -// renewAt: intent.status.renewAt.toISOString(), -// }, -// }; -// } - -// if (intent.status.activeUntil) { -// results = { -// ...results, -// status: { -// ...results.status, -// activeUntil: intent.status.activeUntil.toISOString(), -// }, -// }; -// } -// return results; -// } - -// function convertEnums(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// let results: any = { -// ...intent, -// }; - -// if (intent.initial && intent.initial.meteredVariables) { -// results = { -// ...results, -// initial: { -// ...intent.initial, -// meteredVariables: intent.initial.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// if (intent.recurring && intent.recurring.meteredVariables) { -// results = { -// ...results, -// recurring: { -// ...intent.recurring, -// meteredVariables: intent.recurring.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// return results; -// } - -// export function createSubscriptionContractManager(apiClient: ClientInterface) { -// const create = async ( -// intentSubsctiptionContract: CreateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = createSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// create: { -// __args: { -// input: convertDates(intent), -// }, -// id: true, -// createdAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.create; -// }; - -// const update = async ( -// id: string, -// intentSubsctiptionContract: UpdateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = updateSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// update: { -// __args: { -// id, -// input: convertDates(intent), -// }, -// id: true, -// updatedAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.update; -// }; - -// /** -// * This function assumes that the variant contains the subscriptions plans -// */ -// const createSubscriptionContractTemplateBasedOnVariant = async ( -// variant: ProductVariant, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// ) => { -// const matchingPlan: ProductVariantSubscriptionPlan | undefined = variant?.subscriptionPlans?.find( -// (plan: ProductVariantSubscriptionPlan) => plan.identifier === planIdentifier, -// ); -// const matchingPeriod: ProductVariantSubscriptionPlanPeriod | undefined = matchingPlan?.periods?.find( -// (period: ProductVariantSubscriptionPlanPeriod) => period.id === periodId, -// ); -// if (!matchingPlan || !matchingPeriod) { -// throw new Error( -// `Impossible to find the Subscription Plans for SKU ${variant.sku}, plan: ${planIdentifier}, period: ${periodId}`, -// ); -// } - -// const getPriceVariant = ( -// priceVariants: ProductPriceVariant[], -// identifier: string, -// ): ProductPriceVariant | undefined => { -// return priceVariants.find((priceVariant: ProductPriceVariant) => priceVariant.identifier === identifier); -// }; - -// const transformPeriod = (period: ProductVariantSubscriptionPlanPricing): SubscriptionContractPhaseInput => { -// return { -// currency: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.currency || 'USD', -// price: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.price || 0.0, -// meteredVariables: (period.meteredVariables || []).map( -// ( -// meteredVariable: ProductVariantSubscriptionMeteredVariable, -// ): SubscriptionContractMeteredVariableReferenceInputRequest => { -// return { -// id: meteredVariable.id, -// tierType: new EnumType(meteredVariable.tierType), -// tiers: meteredVariable.tiers.map( -// ( -// tier: ProductVariantSubscriptionPlanTier, -// ): SubscriptionContractMeteredVariableTierInputRequest => { -// return { -// threshold: tier.threshold, -// currency: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier) -// ?.currency || 'USD', -// price: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier)?.price || -// 0.0, -// }; -// }, -// ), -// }; -// }, -// ), -// }; -// }; -// const contract: Omit< -// CreateSubscriptionContractInputRequest, -// 'customerIdentifier' | 'payment' | 'addresses' | 'tenantId' | 'status' -// > = { -// item: { -// sku: variant.sku, -// name: variant.name || '', -// quantity: 1, -// imageUrl: variant.firstImage?.url || '', -// }, -// subscriptionPlan: { -// identifier: matchingPlan.identifier, -// periodId: matchingPeriod.id, -// }, -// initial: !matchingPeriod.initial ? undefined : transformPeriod(matchingPeriod.initial), -// recurring: !matchingPeriod.recurring ? undefined : transformPeriod(matchingPeriod.recurring), -// }; - -// return contract; -// }; - -// const createSubscriptionContractTemplateBasedOnVariantIdentity = async ( -// path: string, -// productVariantIdentifier: { sku?: string; id?: string }, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// language: string = 'en', -// ) => { -// if (!productVariantIdentifier.sku && !productVariantIdentifier.id) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} with and empty Variant Identity`, -// ); -// } - -// // let's ask the catalog for the data we need to create the subscription contract template -// const fetcher = createCatalogueFetcher(apiClient); -// const builder = catalogueFetcherGraphqlBuilder; -// const data: any = await fetcher({ -// catalogue: { -// __args: { -// path, -// language, -// }, -// __on: [ -// builder.onProduct( -// {}, -// { -// onVariant: { -// id: true, -// name: true, -// sku: true, -// ...builder.onSubscriptionPlan(), -// }, -// }, -// ), -// ], -// }, -// }); - -// const matchingVariant: ProductVariant | undefined = data.catalogue?.variants?.find( -// (variant: ProductVariant) => { -// if (productVariantIdentifier.sku && variant.sku === productVariantIdentifier.sku) { -// return true; -// } -// if (productVariantIdentifier.id && variant.id === productVariantIdentifier.id) { -// return true; -// } -// return false; -// }, -// ); - -// if (!matchingVariant) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} and Variant: (sku: ${productVariantIdentifier.sku} id: ${productVariantIdentifier.id}), plan: ${planIdentifier}, period: ${periodId} in lang: ${language}`, -// ); -// } - -// return createSubscriptionContractTemplateBasedOnVariant( -// matchingVariant, -// planIdentifier, -// periodId, -// priceVariantIdentifier, -// ); -// }; - -// const fetchById = async (id: string, onCustomer?: any, extraQuery?: any): Promise => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// ...SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }; -// const data = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return data.subscriptionContract.get; -// }; - -// const fetchByCustomerIdentifier = async ( -// customerIdentifier: string, -// extraQueryArgs?: any, -// onCustomer?: any, -// extraQuery?: any, -// ): Promise<{ -// pageInfo: { -// hasNextPage: boolean; -// hasPreviousPage: boolean; -// startCursor: string; -// endCursor: string; -// totalNodes: number; -// }; -// contracts: SubscriptionContract[]; -// }> => { -// const query = { -// subscriptionContract: { -// getMany: { -// __args: { -// customerIdentifier: customerIdentifier, -// tenantId: apiClient.config.tenantId, -// ...(extraQueryArgs !== undefined ? extraQueryArgs : {}), -// }, -// pageInfo: { -// hasPreviousPage: true, -// hasNextPage: true, -// startCursor: true, -// endCursor: true, -// totalNodes: true, -// }, -// edges: { -// cursor: true, -// node: SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }, -// }; -// const response = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return { -// pageInfo: response.subscriptionContract.getMany.pageInfo, -// contracts: response.subscriptionContract.getMany?.edges?.map((edge: any) => edge.node) || [], -// }; -// }; - -// const getCurrentPhase = async (id: string): Promise<'initial' | 'recurring'> => { -// const query = { -// subscriptionContractEvent: { -// getMany: { -// __args: { -// subscriptionContractId: id, -// tenantId: apiClient.config.tenantId, -// sort: new EnumType('asc'), -// first: 1, -// eventTypes: new EnumType('renewed'), -// }, -// edges: { -// node: { -// id: true, -// }, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContractEvent.getMany.edges.length > 0 ? 'recurring' : 'initial'; -// }; - -// const getUsageForPeriod = async ( -// id: string, -// from: Date, -// to: Date, -// ): Promise< -// { -// meteredVariableId: string; -// quantity: number; -// }[] -// > => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// id: true, -// usage: { -// __args: { -// start: from.toISOString(), -// end: to.toISOString(), -// }, -// meteredVariableId: true, -// quantity: true, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContract.get.usage; -// }; - -// return { -// create, -// update, -// fetchById, -// fetchByCustomerIdentifier, -// getCurrentPhase, -// getUsageForPeriod, -// createSubscriptionContractTemplateBasedOnVariantIdentity, -// createSubscriptionContractTemplateBasedOnVariant, -// }; -// } - -// const buildGenericSubscriptionContractQuery = (onCustomer?: any, extraQuery?: any) => { -// return { -// id: true, -// tenantId: true, -// subscriptionPlan: { -// name: true, -// identifier: true, -// meteredVariables: { -// id: true, -// identifier: true, -// name: true, -// unit: true, -// }, -// }, -// item: { -// name: true, -// sku: true, -// quantity: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// initial: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// recurring: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// status: { -// renewAt: true, -// activeUntil: true, -// price: true, -// currency: true, -// }, -// meta: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// }, -// customerIdentifier: true, -// customer: { -// identifier: true, -// email: true, -// firstName: true, -// lastName: true, -// companyName: true, -// phone: true, -// taxNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// externalReferences: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// ...(onCustomer !== undefined ? onCustomer : {}), -// }, -// ...(extraQuery !== undefined ? extraQuery : {}), -// }; -// }; diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts new file mode 100644 index 00000000..3d7b8dcf --- /dev/null +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect, vi } from 'vitest'; +import { + createApiCaller, + post, + authenticationHeaders, + JSApiClientCallError, +} from '../../src/core/client/create-api-caller.js'; +import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: { test: true } }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const mockGrab = (response: GrabResponse): Grab['grab'] => { + return vi.fn().mockResolvedValue(response); +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +describe('authenticationHeaders', () => { + test('returns session cookie when sessionId is set', () => { + const headers = authenticationHeaders({ ...defaultConfig, sessionId: 'sess123' }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns static auth token when set (and no sessionId)', () => { + const headers = authenticationHeaders({ ...defaultConfig, staticAuthToken: 'static-tok' }); + expect(headers).toEqual({ 'X-Crystallize-Static-Auth-Token': 'static-tok' }); + }); + + test('sessionId takes priority over staticAuthToken', () => { + const headers = authenticationHeaders({ + ...defaultConfig, + sessionId: 'sess123', + staticAuthToken: 'static-tok', + }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns access token headers when no session or static token', () => { + const headers = authenticationHeaders(defaultConfig); + expect(headers).toEqual({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + }); + }); + + test('warns when no auth is configured', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test' }; + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No authentication credentials configured')); + warnSpy.mockRestore(); + }); + + test('warns only once per config object', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test-once' }; + authenticationHeaders(config); + authenticationHeaders(config); + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); + +describe('post', () => { + test('successful response returns data', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { items: [1, 2, 3] } } })); + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + + test('passes query and variables in request body', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', { limit: 10 }); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), + }), + ); + }); + + test('includes authentication headers', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + 'Content-type': 'application/json; charset=UTF-8', + }), + }), + ); + }); + + test('204 No Content returns empty object', async () => { + const grab = mockGrab(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('throws JSApiClientCallError on HTTP error', async () => { + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, + }), + ); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(401); + expect(err.statusText).toBe('Unauthorized'); + expect(err.query).toBe('{ items }'); + } + }); + + test('throws on GraphQL errors in 200 response', async () => { + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Field "foo" not found' }], + data: null, + }, + }), + ); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('Field "foo" not found'); + expect(err.statusText).toBe('Error was returned from the API'); + } + }); + + test('detects Core Next wrapped errors', async () => { + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + someOperation: { + errorName: 'ItemNotFound', + message: 'The item does not exist', + }, + }, + }, + }), + ); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ someOperation }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ItemNotFound] The item does not exist'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('Core Next error without message uses fallback', async () => { + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + op: { errorName: 'GenericError' }, + }, + }, + }), + ); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ op }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[GenericError] An error occurred'); + } + }); + + test('includes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', undefined, { + headers: { 'X-Custom': 'value' }, + }); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + }), + ); + }); + + test('error includes query and variables for debugging', async () => { + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'Server error', errors: [] }, + }), + ); + const variables = { id: '123' }; + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ item(id: $id) }', variables); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('{ item(id: $id) }'); + expect(err.variables).toEqual({ id: '123' }); + } + }); +}); + +describe('createApiCaller', () => { + test('returns a callable function', () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig); + expect(typeof caller).toBe('function'); + }); + + test('caller delegates to post with correct URL', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { result: 42 } } })); + const caller = createApiCaller(grab, 'https://api.test.com/graphql', defaultConfig); + const result = await caller('{ result }'); + expect(result).toEqual({ result: 42 }); + expect(grab).toHaveBeenCalledWith('https://api.test.com/graphql', expect.anything()); + }); + + test('passes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + extraHeaders: { 'X-Tenant': 'test' }, + }); + await caller('{ items }'); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Tenant': 'test' }), + }), + ); + }); +}); + +describe('profiling', () => { + test('calls onRequest and onRequestResolved', async () => { + const onRequest = vi.fn(); + const onRequestResolved = vi.fn(); + const grab = mockGrab( + mockGrabResponse({ + headers: { get: (name: string) => (name === 'server-timing' ? 'total;dur=42.5' : null) }, + }), + ); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequest, onRequestResolved }, + }); + await caller('{ items }', { limit: 5 }); + expect(onRequest).toHaveBeenCalledWith('{ items }', { limit: 5 }); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ + serverTimeMs: 42.5, + resolutionTimeMs: expect.any(Number), + }), + '{ items }', + { limit: 5 }, + ); + }); + + test('handles missing server-timing header', async () => { + const onRequestResolved = vi.fn(); + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequestResolved }, + }); + await caller('{ items }'); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ serverTimeMs: -1 }), + '{ items }', + undefined, + ); + }); +}); diff --git a/components/js-api-client/tests/unit/create-grabber.test.ts b/components/js-api-client/tests/unit/create-grabber.test.ts new file mode 100644 index 00000000..1bb97708 --- /dev/null +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createGrabber } from '../../src/core/client/create-grabber.js'; + +describe('createGrabber (fetch mode)', () => { + test('returns grab and close functions', () => { + const grabber = createGrabber(); + expect(typeof grabber.grab).toBe('function'); + expect(typeof grabber.close).toBe('function'); + }); + + test('grab delegates to fetch with correct options', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ data: 'test' }), + text: () => Promise.resolve('{"data":"test"}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + + const { grab } = createGrabber(); + const response = await grab('https://api.test.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com/graphql', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }), + ); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toEqual({ data: 'test' }); + + fetchSpy.mockRestore(); + }); + + test('passes signal to fetch for abort support', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({}), + text: () => Promise.resolve('{}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + const controller = new AbortController(); + + const { grab } = createGrabber(); + await grab('https://api.test.com', { signal: controller.signal }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: controller.signal, + }), + ); + + fetchSpy.mockRestore(); + }); + + test('close is callable without error', () => { + const { close } = createGrabber(); + expect(() => close()).not.toThrow(); + }); + + test('propagates fetch errors', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure')); + + const { grab } = createGrabber(); + await expect(grab('https://api.test.com')).rejects.toThrow('Network failure'); + + fetchSpy.mockRestore(); + }); +}); diff --git a/components/js-api-client/tests/unit/create-mass-call-client.test.ts b/components/js-api-client/tests/unit/create-mass-call-client.test.ts new file mode 100644 index 00000000..acad1082 --- /dev/null +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -0,0 +1,230 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createMassCallClient } from '../../src/core/create-mass-call-client.js'; +import type { ClientInterface } from '../../src/core/client/create-client.js'; +import type { ApiCaller } from '../../src/core/client/create-api-caller.js'; + +const createMockCaller = (results?: Record): ApiCaller => { + let callCount = 0; + return vi.fn(async (query: string) => { + callCount++; + return results?.[query] ?? { success: true, call: callCount }; + }) as unknown as ApiCaller; +}; + +const createMockClient = (overrides?: Partial>): ClientInterface => { + return { + catalogueApi: overrides?.catalogueApi ?? createMockCaller(), + discoveryApi: overrides?.discoveryApi ?? createMockCaller(), + pimApi: overrides?.pimApi ?? createMockCaller(), + nextPimApi: overrides?.nextPimApi ?? createMockCaller(), + shopCartApi: overrides?.shopCartApi ?? createMockCaller(), + config: { tenantIdentifier: 'test', tenantId: 'test-id' }, + close: vi.fn(), + [Symbol.dispose]: vi.fn(), + }; +}; + +const noopSleeper = () => ({ + wait: () => Promise.resolve(), + reset: () => {}, +}); + +describe('createMassCallClient', () => { + test('enqueue returns a unique key', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + expect(key1).not.toBe(key2); + expect(key1).toContain('pimApi'); + expect(key2).toContain('pimApi'); + }); + + test('execute runs all enqueued requests and returns results', async () => { + const pimCaller = createMockCaller(); + const client = createMockClient({ pimApi: pimCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + const results = await mass.execute(); + + expect(results[key1]).toBeDefined(); + expect(results[key2]).toBeDefined(); + expect(pimCaller).toHaveBeenCalledTimes(2); + }); + + test('execute with different API types', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const k1 = mass.enqueue.catalogueApi('{ catalogue }'); + const k2 = mass.enqueue.pimApi('{ pim }'); + const k3 = mass.enqueue.discoveryApi('{ discovery }'); + const results = await mass.execute(); + + expect(results[k1]).toBeDefined(); + expect(results[k2]).toBeDefined(); + expect(results[k3]).toBeDefined(); + }); + + test('reset clears queue and state', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ query1 }'); + mass.reset(); + + const results = await mass.execute(); + expect(Object.keys(results)).toHaveLength(0); + }); + + test('hasFailed and failureCount track failures', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ fail1 }'); + mass.enqueue.pimApi('{ fail2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + expect(mass.failureCount()).toBe(2); + }); + + test('retry re-executes failed requests', async () => { + let callCount = 0; + const sometimesFails = vi.fn(async () => { + callCount++; + if (callCount <= 2) throw new Error('temporary failure'); + return { recovered: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: sometimesFails }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ q1 }'); + mass.enqueue.pimApi('{ q2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + const retryResults = await mass.retry(); + expect(mass.hasFailed()).toBe(false); + expect(Object.values(retryResults).every((r: any) => r.recovered)).toBe(true); + }); + + test('onFailure callback controls retry queuing', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const onFailure = vi.fn().mockResolvedValue(false); // don't retry + + const mass = createMassCallClient(client, { onFailure, sleeper: noopSleeper() }); + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(onFailure).toHaveBeenCalled(); + expect(mass.hasFailed()).toBe(false); // not queued for retry + }); + + test('batch size adapts: increases on success', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const batches: Array<{ from: number; to: number }> = []; + const mass = createMassCallClient(client, { + initialSpawn: 1, + maxSpawn: 5, + sleeper: noopSleeper(), + onBatchDone: async (batch) => { + batches.push(batch); + }, + }); + + for (let i = 0; i < 6; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + // First batch: 1 item, second: 2 items, third: 3 items = 6 total + expect(batches[0]).toEqual({ from: 0, to: 1 }); + expect(batches[1]).toEqual({ from: 1, to: 3 }); + expect(batches[2]).toEqual({ from: 3, to: 6 }); + }); + + test('batch size does not exceed maxSpawn', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 3, + sleeper: noopSleeper(), + }); + + for (let i = 0; i < 9; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + // All batches should be size 3 + expect(caller).toHaveBeenCalledTimes(9); + }); + + test('batch size decreases on errors', async () => { + let callNum = 0; + const mixedCaller = vi.fn(async () => { + callNum++; + // First batch (3 items): 2 fail = more than half + if (callNum <= 2) throw new Error('fail'); + return { ok: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: mixedCaller }); + const changeIncrementFor = vi.fn((situation: string, current: number) => { + if (situation === 'more-than-half-have-failed') return 1; + if (situation === 'some-have-failed') return current - 1; + return current + 1; + }); + + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 5, + sleeper: noopSleeper(), + changeIncrementFor, + }); + + for (let i = 0; i < 5; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + expect(changeIncrementFor).toHaveBeenCalled(); + }); + + test('beforeRequest and afterRequest hooks are called', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const beforeRequest = vi.fn().mockResolvedValue(undefined); + const afterRequest = vi.fn().mockResolvedValue(undefined); + + const mass = createMassCallClient(client, { + beforeRequest, + afterRequest, + sleeper: noopSleeper(), + }); + + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(beforeRequest).toHaveBeenCalledTimes(1); + expect(afterRequest).toHaveBeenCalledTimes(1); + }); + + test('passes through API callers from original client', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + + expect(mass.catalogueApi).toBe(client.catalogueApi); + expect(mass.pimApi).toBe(client.pimApi); + expect(mass.discoveryApi).toBe(client.discoveryApi); + expect(mass.nextPimApi).toBe(client.nextPimApi); + expect(mass.shopCartApi).toBe(client.shopCartApi); + }); +}); diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts new file mode 100644 index 00000000..4cd0cec3 --- /dev/null +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -0,0 +1,435 @@ +import { describe, test, expect, vi } from 'vitest'; +import { post, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import type { GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: {} }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +const query = '{ items { id name } }'; +const variables = { lang: 'en' }; + +describe('HTTP error codes', () => { + const errorCases = [ + { status: 400, statusText: 'Bad Request', message: 'Invalid query syntax' }, + { status: 401, statusText: 'Unauthorized', message: 'Invalid credentials' }, + { status: 403, statusText: 'Forbidden', message: 'Access denied' }, + { status: 404, statusText: 'Not Found', message: 'Endpoint not found' }, + { status: 429, statusText: 'Too Many Requests', message: 'Rate limit exceeded' }, + { status: 500, statusText: 'Internal Server Error', message: 'Server error' }, + { status: 502, statusText: 'Bad Gateway', message: 'Upstream failure' }, + { status: 503, statusText: 'Service Unavailable', message: 'Service down' }, + ]; + + test.each(errorCases)( + 'throws JSApiClientCallError for HTTP $status ($statusText)', + async ({ status, statusText, message }) => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status, + statusText, + jsonData: { message, errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query, variables); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(status); + expect(err.statusText).toBe(statusText); + expect(err.message).toBe(message); + expect(err.query).toBe(query); + expect(err.variables).toEqual(variables); + } + }, + ); + + test('error includes errors array from response', async () => { + const errors = [ + { field: 'token', message: 'expired' }, + { field: 'scope', message: 'insufficient' }, + ]; + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 403, + statusText: 'Forbidden', + jsonData: { message: 'Forbidden', errors }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.errors).toEqual(errors); + } + }); + + test('error defaults variables to empty object when undefined', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'fail', errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.variables).toEqual({}); + } + }); +}); + +describe('GraphQL errors in 200 response', () => { + test('throws on single GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Cannot query field "foo" on type "Query"' }], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err).toBeInstanceOf(JSApiClientCallError); + expect(err.code).toBe(400); + expect(err.message).toBe('Cannot query field "foo" on type "Query"'); + expect(err.statusText).toBe('Error was returned from the API'); + expect(err.errors).toEqual([{ message: 'Cannot query field "foo" on type "Query"' }]); + } + }); + + test('uses first error message when multiple GraphQL errors', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'First error' }, { message: 'Second error' }, { message: 'Third error' }], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ bad }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('First error'); + expect(err.errors).toHaveLength(3); + } + }); + + test('preserves query and variables in GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Validation error' }], + data: null, + }, + }), + ); + + const vars = { id: 'abc', limit: 5 }; + try { + await post(grab, 'https://api.test.com', defaultConfig, 'query Q($id: ID!) { item(id: $id) }', vars); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('query Q($id: ID!) { item(id: $id) }'); + expect(err.variables).toEqual(vars); + } + }); +}); + +describe('Core Next wrapped errors', () => { + test('detects errorName at second level of data', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + createItem: { + errorName: 'ValidationError', + message: 'Name is required', + }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { createItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ValidationError] Name is required'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('uses fallback message when errorName has no message', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + deleteItem: { errorName: 'InternalError' }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { deleteItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[InternalError] An error occurred'); + } + }); + + test('does not trigger on normal data without errorName', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { id: '123', name: 'Test' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { id: '123', name: 'Test' } }); + }); + + test('does not trigger when errorName is not a string', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { errorName: 42, message: 'not a real error' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { errorName: 42, message: 'not a real error' } }); + }); +}); + +describe('network failures', () => { + test('propagates network error from grab', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('fetch failed'); + }); + + test('propagates DNS resolution failure', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test.com')); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ENOTFOUND'); + }); + + test('propagates connection refused', async () => { + const grab = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNREFUSED'); + }); + + test('propagates connection reset', async () => { + const grab = vi.fn().mockRejectedValue(new Error('read ECONNRESET')); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNRESET'); + }); +}); + +describe('timeout scenarios', () => { + test('passes abort signal when timeout is configured', async () => { + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); + + await post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 5000, + }); + + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + test('does not pass signal when no timeout configured', async () => { + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); + + await post(grab, 'https://api.test.com', defaultConfig, query); + + const callArgs = grab.mock.calls[0][1]; + expect(callArgs.signal).toBeUndefined(); + }); + + test('propagates abort error on timeout', async () => { + const grab = vi.fn().mockRejectedValue(new DOMException('The operation was aborted', 'TimeoutError')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 1, + }), + ).rejects.toThrow('The operation was aborted'); + }); +}); + +describe('malformed responses', () => { + test('propagates JSON parse error on HTTP error with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + text: () => Promise.resolve('Server Error'), + }); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); + }); + + test('propagates JSON parse error on 200 with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + text: () => Promise.resolve(''), + }); + + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); + }); +}); + +describe('204 No Content', () => { + test('returns empty object', async () => { + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); + + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('does not attempt to parse JSON body', async () => { + const jsonFn = vi.fn(); + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + headers: { get: () => null }, + json: jsonFn, + text: () => Promise.resolve(''), + }); + + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(jsonFn).not.toHaveBeenCalled(); + }); +}); + +describe('JSApiClientCallError properties', () => { + test('is an instance of Error', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '{ q }', + variables: {}, + }); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(JSApiClientCallError); + }); + + test('has correct name property', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 400, + statusText: 'Bad Request', + query: '{ q }', + variables: {}, + }); + expect(err.name).toBe('JSApiClientCallError'); + }); + + test('stores all constructor properties', () => { + const errors = [{ field: 'x' }]; + const err = new JSApiClientCallError({ + message: 'Something broke', + code: 422, + statusText: 'Unprocessable Entity', + query: 'mutation M { m }', + variables: { id: '1' }, + errors, + }); + expect(err.message).toBe('Something broke'); + expect(err.code).toBe(422); + expect(err.statusText).toBe('Unprocessable Entity'); + expect(err.query).toBe('mutation M { m }'); + expect(err.variables).toEqual({ id: '1' }); + expect(err.errors).toEqual(errors); + }); + + test('has a stack trace', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '', + variables: {}, + }); + expect(err.stack).toBeDefined(); + expect(err.stack).toContain('JSApiClientCallError'); + }); + + test('uses default values when provided', () => { + const err = new JSApiClientCallError({ + message: 'An error occurred while calling the API', + code: 500, + statusText: 'Internal Server Error', + query: '', + variables: {}, + }); + expect(err.message).toBe('An error occurred while calling the API'); + expect(err.code).toBe(500); + expect(err.errors).toBeUndefined(); + }); +}); diff --git a/components/js-api-client/tsconfig.json b/components/js-api-client/tsconfig.json index 0c1b91d2..baddaa4d 100644 --- a/components/js-api-client/tsconfig.json +++ b/components/js-api-client/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "declaration": true, - "lib": ["es2021", "DOM"], + "lib": ["es2021", "DOM", "esnext.disposable"], "sourceMap": true }, "include": ["./src/**/*"]