diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c918d93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +## Project purpose + +This repository is the documentation website for `@dfsync/client`. + +`@dfsync/client` is a Node.js and TypeScript HTTP client for service-to-service communication. It focuses on reliable backend HTTP calls with retry, auth, lifecycle hooks, request metadata, response validation, and structured errors. + +Package source: + +- https://github.com/dfsyncjs/dfsync/tree/main/packages/client + +Documentation website: + +- https://github.com/dfsyncjs/dfsyncjs.github.io + +## Source of truth + +Documentation must match the current release branch or PR in the `dfsync` monorepo. + +Before updating docs for a release, inspect the package source and tests instead of guessing behavior: + +- `packages/client/src/index.ts` +- `packages/client/src/types/*` +- `packages/client/src/errors/*` +- `packages/client/src/core/request.ts` +- `packages/client/src/core/should-retry.ts` +- `packages/client/tests` + +If behavior is unclear, check the release branch or ask before documenting it. + +## Documentation structure + +Markdown docs live in: + +- `docs///*.md` + +Current `@dfsync/client` docs live in: + +- `docs/client/v1/*.md` + +Docs are loaded by: + +- `src/content/docsContent.ts` + +Default docs constants are defined in: + +- `src/content/docsNavigation.ts` + +Package docs navigation and markdown imports are defined in `docsPackages` inside `src/content/docsContent.ts`. + +When adding a new markdown page, add it to the relevant package/version entry in `docsPackages` unless the page is intentionally hidden. + +The default docs package is `client`, so `#/docs` is treated as `@dfsync/client` documentation. + +Canonical docs URLs use: + +- `#/docs///` + +For example: + +- `#/docs/client/v1/getting-started` + +## Current docs pages + +- Getting Started +- Installation +- Create Client +- Response Handling +- Auth +- Hooks +- Observability +- Retry +- Errors +- Examples +- API Reference + +## Important API concepts + +Keep these names and behaviors consistent across the docs: + +- `baseUrl`, never `baseURL` +- `createClient` +- `get`, `delete`, `post`, `put`, `patch`, `request` +- auth strategies: `bearer`, `apiKey`, `custom` +- retry config: `attempts`, `retryOn`, `retryMethods`, `backoff`, `baseDelayMs` +- request metadata: `requestId`, `x-request-id` +- idempotency: `idempotencyKey`, `idempotency-key` +- response validation: `validateResponse`, `ResponseValidator`, `ValidationError` +- hooks: `beforeRequest`, `afterResponse`, `onError`, `onRetry` +- errors: `DfsyncError`, `HttpError`, `NetworkError`, `TimeoutError`, `ValidationError`, `RequestAbortedError` + +## Release docs checklist + +When preparing docs for a new `@dfsync/client` release: + +1. Compare the release branch against `main` in `packages/client`. +2. Check public exports from `packages/client/src/index.ts`. +3. Check public config, request, hook, and auth types. +4. Check new or changed error classes. +5. Check tests for behavioral rules. +6. Update relevant docs pages. +7. Update `docsPackages` in `docsContent.ts` if adding pages or packages. +8. Run verification commands. + +## Adding another package + +When adding docs for another package: + +1. Create `docs//`. +2. Add the package to `docsPackages` in `src/content/docsContent.ts`. +3. Define package label, default version, default page slug, navigation, and markdown imports. +4. Keep `client` as the default docs package unless the product direction changes. +5. Verify package routes under `#/docs///`. + +## Local commands + +```bash +npm run dev +npm run build +npm run typecheck +npm run lint +npm run format:check +``` + +## Writing rules + +- Keep docs factual and implementation-aligned. +- Prefer examples that compile against the real public API. +- Do not document behavior unless it is confirmed by source or tests. +- Keep examples focused on backend and service-to-service use cases. +- Keep release notes and docs versioned around `@dfsync/client`, not the full monorepo. diff --git a/README.md b/README.md index c987b94..d8bd23d 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,133 @@ -# React + TypeScript + Vite +# dfsyncjs.github.io -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Documentation website for [`@dfsync/client`](https://github.com/dfsyncjs/dfsync/tree/main/packages/client). -Currently, two official plugins are available: +`@dfsync/client` is a Node.js and TypeScript HTTP client for service-to-service communication. It provides retries, auth, lifecycle hooks, request metadata, response validation, and structured errors. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +Website repository: -## React Compiler +- https://github.com/dfsyncjs/dfsyncjs.github.io -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +Package monorepo: -## Expanding the ESLint configuration +- https://github.com/dfsyncjs/dfsync -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +## Tech stack -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +- React +- TypeScript +- Vite +- Material UI +- React Router +- `react-markdown` with `remark-gfm` - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## Project structure - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); +```text +docs/// Markdown documentation content +docs/client/v1/ @dfsync/client v1 documentation +src/content/docsContent.ts Package docs registry and markdown import map +src/content/docsNavigation.ts Default docs package/version constants +src/pages/Docs/ Documentation page renderer +src/components/ Shared UI components +src/services/analytics/ Analytics helpers ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x'; -import reactDom from 'eslint-plugin-react-dom'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); +## Documentation pages + +The current `v1` docs include: + +- Getting Started +- Installation +- Create Client +- Response Handling +- Auth +- Hooks +- Observability +- Retry +- Errors +- Examples +- API Reference + +`#/docs` is the default documentation entry point for `@dfsync/client`. + +Canonical documentation URLs use: + +```text +#/docs/// +``` + +For example: + +```text +#/docs/client/v1/getting-started +``` + +When adding a new markdown file under `docs//`, also add it to the relevant package/version entry in: + +- `src/content/docsContent.ts` + +## Adding package docs + +To add documentation for another package: + +1. Create `docs//`. +2. Add the package to `docsPackages` in `src/content/docsContent.ts`. +3. Define the package label, default version, default page, navigation, and markdown imports. +4. Verify routes under `#/docs///`. + +## Source of truth + +Docs should match the current `@dfsync/client` source in the monorepo: + +- `packages/client/src/index.ts` +- `packages/client/src/types/*` +- `packages/client/src/errors/*` +- `packages/client/src/core/*` +- `packages/client/tests` + +For release documentation, compare the release branch or PR against `main` before editing this site. + +## Development + +Install dependencies: + +```bash +npm install +``` + +Start the dev server: + +```bash +npm run dev +``` + +Build for production: + +```bash +npm run build ``` + +Preview the production build: + +```bash +npm run preview +``` + +## Verification + +Run these before opening or merging documentation changes: + +```bash +npm run format:check +npm run typecheck +npm run lint +npm run build +``` + +## Notes for docs updates + +- Use `baseUrl`, not `baseURL`. +- Keep examples aligned with the public `@dfsync/client` API. +- Do not document behavior unless it is confirmed by source or tests. +- If a behavior is release-specific, check the active release branch or PR. diff --git a/docs/client/v1/api-reference.md b/docs/client/v1/api-reference.md new file mode 100644 index 0000000..70b7986 --- /dev/null +++ b/docs/client/v1/api-reference.md @@ -0,0 +1,90 @@ +# API Reference + +## createClient + +Creates a configured HTTP client. + +```ts +import { createClient } from '@dfsync/client'; +``` + +## Client methods + +```text +client.get(path, options?) +client.delete(path, options?) + +client.post(path, body?, options?) +client.put(path, body?, options?) +client.patch(path, body?, options?) + +client.request(config) +``` + +## Configuration + +- `baseUrl` +- `timeout` +- `headers` +- `fetch` +- `retry` +- `auth` +- `hooks` +- `validateResponse` + +## Request options + +- `query` +- `headers` +- `timeout` +- `retry` +- `signal` +- `requestId` +- `idempotencyKey` +- `validateResponse` + +## Retry + +- `attempts` +- `backoff` +- `baseDelayMs` +- `retryOn` +- `retryMethods` + +## Response validation + +```ts +type ResponseValidator = (data: TData) => boolean | void | Promise; +``` + +## Hooks + +- `beforeRequest` +- `afterResponse` +- `onError` +- `onRetry` + +## Errors + +- `HttpError` +- `NetworkError` +- `TimeoutError` +- `ValidationError` +- `RequestAbortedError` + +## Exported types + +- `AuthConfig` +- `Client` +- `ClientConfig` +- `RetryConfig` +- `RetryCondition` +- `RetryBackoff` +- `ResponseValidator` +- `BeforeRequestContext` +- `AfterResponseContext` +- `ErrorContext` +- `RetryContext` +- `HooksConfig` +- `RequestConfig` +- `RequestOptions` diff --git a/docs/v1/auth.md b/docs/client/v1/auth.md similarity index 100% rename from docs/v1/auth.md rename to docs/client/v1/auth.md diff --git a/docs/v1/create-client.md b/docs/client/v1/create-client.md similarity index 81% rename from docs/v1/create-client.md rename to docs/client/v1/create-client.md index 0516cd1..212a1a0 100644 --- a/docs/v1/create-client.md +++ b/docs/client/v1/create-client.md @@ -9,6 +9,7 @@ It provides a consistent way to configure: - auth - lifecycle hooks - request observability metadata +- response validation ## Basic client @@ -64,6 +65,7 @@ type ClientConfig = { // see Hooks section }; retry?: RetryConfig; + validateResponse?: ResponseValidator; }; ``` @@ -100,7 +102,7 @@ client.request(config) - `get` and `delete` do not accept body - `post`, `put`, and `patch` accept request body as the second argument -- `options` is used for headers, query, timeout, retry, and other settings +- `options` is used for headers, query, timeout, retry, validation, idempotency keys, and other settings ## GET request @@ -165,6 +167,8 @@ type RequestOptions = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + idempotencyKey?: string; + validateResponse?: ResponseValidator; }; ``` @@ -172,6 +176,8 @@ type RequestOptions = { Request-level `retry` overrides client-level retry settings. +Request-level `validateResponse` overrides client-level response validation. + ## Low-level request ```ts @@ -201,6 +207,8 @@ type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + idempotencyKey?: string; + validateResponse?: ResponseValidator; }; ``` @@ -258,6 +266,55 @@ await client.get('/users', { }); ``` +## Idempotency key + +Use `idempotencyKey` for non-idempotent operations that may be retried safely. + +```ts +await client.post( + '/payments', + { amount: 100 }, + { + idempotencyKey: 'payment-123', + }, +); +``` + +This adds: + +```http +idempotency-key: payment-123 +``` + +If both `idempotencyKey` and an explicit `idempotency-key` header are provided, the explicit header takes precedence. + +`POST` and `PATCH` requests are retried only when the method is included in `retry.retryMethods` and the request provides `idempotencyKey`. + +## Response validation + +Use `validateResponse` to validate parsed response data before it is returned. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, +}); +``` + +You can override validation per request: + +```ts +await client.get('/users/1', { + validateResponse(data) { + return typeof data === 'object' && data !== null && 'email' in data; + }, +}); +``` + +Returning `false` throws `ValidationError`. Returning `true` or `undefined` passes validation. + ## Request cancellation Requests can be cancelled using `AbortSignal`: @@ -361,5 +418,6 @@ If the header value is invalid, `@dfsync/client` falls back to normal retry back ## Related guides - See **Hooks** for lifecycle hooks and observability metadata +- See **Response Handling** for parsing and response validation - See **Retry** for retry conditions, backoff, and `Retry-After` - See **Errors** for failure behavior and error types diff --git a/docs/v1/errors.md b/docs/client/v1/errors.md similarity index 70% rename from docs/v1/errors.md rename to docs/client/v1/errors.md index 3577e8b..2d14ac0 100644 --- a/docs/v1/errors.md +++ b/docs/client/v1/errors.md @@ -8,9 +8,10 @@ - `HttpError` — non-2xx responses - `NetworkError` — network failures - `TimeoutError` — request timed out +- `ValidationError` — response validation failed - `RequestAbortedError` — request was cancelled -- This allows you to handle failures more precisely. +This allows you to handle failures more precisely. ## Base error @@ -114,14 +115,68 @@ try { Properties: -- `code` → `"NETWORK_ERROR"` +- `code` → `"TIMEOUT_ERROR"` - `timeout` - optional `cause` +## ValidationError + +Thrown when a successful response fails `validateResponse`. + +```ts +import { ValidationError } from '@dfsync/client'; + +try { + await client.get('/users/1'); +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.data); + console.error(error.response.status); + } +} +``` + +Properties: + +- `code` → `"VALIDATION_ERROR"` +- `data` +- `response` + +Validation failures are not retried. + +## RequestAbortedError + +Thrown when the request is cancelled by an external `AbortSignal`. + +```ts +import { RequestAbortedError } from '@dfsync/client'; + +const controller = new AbortController(); + +const promise = client.get('/users', { + signal: controller.signal, +}); + +controller.abort(); + +try { + await promise; +} catch (error) { + if (error instanceof RequestAbortedError) { + console.error('Request was cancelled'); + } +} +``` + +Properties: + +- `code` → `"REQUEST_ABORTED"` +- optional `cause` + ## Error handling example ```ts -import { HttpError, NetworkError, TimeoutError } from '@dfsync/client'; +import { HttpError, NetworkError, TimeoutError, ValidationError } from '@dfsync/client'; try { const result = await client.get('/users/1'); @@ -143,6 +198,11 @@ try { throw error; } + if (error instanceof ValidationError) { + console.error('Unexpected response payload:', error.data); + throw error; + } + throw error; } ``` diff --git a/docs/client/v1/examples.md b/docs/client/v1/examples.md new file mode 100644 index 0000000..4422778 --- /dev/null +++ b/docs/client/v1/examples.md @@ -0,0 +1,103 @@ +# Examples + +## Basic client + +```ts +import { createClient } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', +}); +``` + +## GET method + +```ts +const users = await client.get('/users'); +``` + +## POST method + +```ts +const createdUser = await client.post('/users', { + name: 'Tom', +}); +``` + +## PATCH method + +```ts +const updatedUser = await client.patch('/users/1', { + name: 'Jane', +}); +``` + +## DELETE method + +```ts +const deletedUser = await client.delete('/users/1'); +``` + +## Low-level request + +```ts +const singleUser = await client.request({ + method: 'GET', + path: '/users/2', +}); +``` + +## Client with auth + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + auth: { + type: 'bearer', + token: 'TOKEN', + }, +}); +``` + +## Response validation + +```ts +import { ValidationError } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, +}); + +try { + const user = await client.get('/users/1'); + console.log(user); +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.data); + } +} +``` + +## Safe POST retry + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 2, + retryMethods: ['POST'], + retryOn: ['5xx'], + }, +}); + +const payment = await client.post( + '/payments', + { amount: 100 }, + { + idempotencyKey: 'payment-123', + }, +); +``` diff --git a/docs/v1/getting-started.md b/docs/client/v1/getting-started.md similarity index 88% rename from docs/v1/getting-started.md rename to docs/client/v1/getting-started.md index 757ef4b..aa3f103 100644 --- a/docs/v1/getting-started.md +++ b/docs/client/v1/getting-started.md @@ -17,13 +17,15 @@ The client focuses on predictable behavior, extensibility, and a clean developer - request ID propagation (`x-request-id`) - request cancellation via `AbortSignal` - built-in retry with configurable policies -- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError` +- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`, `onRetry` - typed responses - automatic JSON parsing +- response validation with `ValidationError` - consistent error handling - auth support: bearer, API key, custom +- idempotency key support for safer retries - support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` ## Quick example diff --git a/docs/v1/hooks.md b/docs/client/v1/hooks.md similarity index 84% rename from docs/v1/hooks.md rename to docs/client/v1/hooks.md index 5f7726b..0a32ede 100644 --- a/docs/v1/hooks.md +++ b/docs/client/v1/hooks.md @@ -35,6 +35,8 @@ Retry-specific hooks also expose: - `retryReason` - `retrySource` +`afterResponse` also exposes validation metadata when response validation is configured and passes. + ```ts const client = createClient({ baseUrl: 'https://api.example.com', @@ -90,7 +92,7 @@ const client = createClient({ }); ``` -### Multiple beforeRequest hooks +### Multiple afterResponse hooks ```ts const client = createClient({ @@ -152,10 +154,31 @@ const client = createClient({ If an `afterResponse` hook throws, that hook error is rethrown. +### Validation metadata + +When `validateResponse` is configured and passes, `afterResponse` receives `validation`. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + hooks: { + afterResponse: ({ validation }) => { + console.log(validation); + // { enabled: true, passed: true } + }, + }, +}); +``` + +If validation is not configured, `validation` is not present. + ### afterResponse context ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, response, data +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, response, data, validation? ``` ## onError @@ -178,6 +201,8 @@ onError runs for: - HttpError - NetworkError - TimeoutError +- ValidationError +- RequestAbortedError ### Important behavior @@ -234,7 +259,7 @@ request, url, headers, signal, attempt, maxAttempts, requestId, startedAt Additional fields: -- `afterResponse` → `endedAt`, `durationMs`, `response`,`data` +- `afterResponse` → `endedAt`, `durationMs`, `response`, `data`, optional `validation` - `onError` → `endedAt`, `durationMs`, `error` - `onRetry` → `endedAt`, `durationMs`, `error`, `retryDelayMs`, `retryReason`, `retrySource` @@ -246,16 +271,19 @@ Request lifecycle order is: 2. `beforeRequest` 3. fetch execution 4. response parsing -5. `afterResponse` on success +5. response validation, if configured +6. `afterResponse` on success Retry flow: 1. auth 2. `beforeRequest` 3. fetch execution -4. retry decision -5. `onRetry` before the next attempt -6. next retry attempt +4. response parsing +5. response validation, if configured +6. retry decision +7. `onRetry` before the next attempt +8. next retry attempt Failure flow: diff --git a/docs/v1/installation.md b/docs/client/v1/installation.md similarity index 100% rename from docs/v1/installation.md rename to docs/client/v1/installation.md diff --git a/docs/v1/observability.md b/docs/client/v1/observability.md similarity index 87% rename from docs/v1/observability.md rename to docs/client/v1/observability.md index cb78d63..057ced2 100644 --- a/docs/v1/observability.md +++ b/docs/client/v1/observability.md @@ -7,6 +7,7 @@ Each request exposes: - **requestId** — stable identifier across retries - **attempt / maxAttempts** — retry progress - **startedAt / endedAt / durationMs** — timing information +- **validation** — response validation metadata in `afterResponse`, when validation is configured - **retryReason** — why a retry happened (`network-error`, `5xx`, `429`) - **retryDelayMs** — delay before the next retry - **retrySource** — delay source (`backoff` or `retry-after`) @@ -38,5 +39,6 @@ const client = createClient({ This makes it easier to understand: - what happened during a request +- whether response validation ran and passed - how retries behaved - how long requests actually took diff --git a/docs/client/v1/response-handling.md b/docs/client/v1/response-handling.md new file mode 100644 index 0000000..6c74081 --- /dev/null +++ b/docs/client/v1/response-handling.md @@ -0,0 +1,145 @@ +# Response Handling + +`@dfsync/client` parses successful and failed HTTP responses before returning data or throwing an error. + +Response handling has three steps: + +1. parse the response body +2. throw `HttpError` for non-2xx responses +3. validate successful response data when `validateResponse` is configured + +## Response parsing + +Responses are parsed automatically: + +- `application/json` responses are parsed with `response.json()` +- other response types are returned as text +- `204 No Content` returns `undefined` + +```ts +const user = await client.get('/users/1'); +``` + +The generic type controls the TypeScript return type. Runtime validation is separate and only runs when you configure `validateResponse`. + +## Failed HTTP responses + +For non-2xx responses, the client parses the response body first and throws `HttpError`. + +```ts +import { HttpError } from '@dfsync/client'; + +try { + await client.get('/users/unknown'); +} catch (error) { + if (error instanceof HttpError) { + console.log(error.status); + console.log(error.data); + } +} +``` + +`validateResponse` does not run for non-2xx responses. + +## Response validation + +Use `validateResponse` when a successful HTTP response still needs a runtime shape check before your application uses it. + +```ts +import { createClient } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, +}); + +const user = await client.get('/users/1'); +``` + +The validator receives parsed response data. + +Validation passes when the validator: + +- returns `true` +- returns `undefined` + +Validation fails when the validator returns `false`. + +Return `false` for expected validation failures. If the validator itself throws, that error follows the normal request error path instead of becoming `ValidationError`. + +## Async validation + +Validators can be async. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + async validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, +}); +``` + +## Request-level validation + +You can override client-level validation for one request. + +```ts +await client.get('/users/1', { + validateResponse(data) { + return typeof data === 'object' && data !== null && 'email' in data; + }, +}); +``` + +Request-level `validateResponse` takes precedence over client-level `validateResponse`. + +## ValidationError + +When validation returns `false`, the client throws `ValidationError`. + +```ts +import { ValidationError } from '@dfsync/client'; + +try { + await client.get('/users/1'); +} catch (error) { + if (error instanceof ValidationError) { + console.log(error.data); + console.log(error.response.status); + } +} +``` + +`ValidationError` includes: + +- `code` -> `"VALIDATION_ERROR"` +- `data` -> parsed response data +- `response` -> original `Response` + +Validation failures are not retried. + +## Hooks and validation + +When response validation is configured and passes, `afterResponse` receives validation metadata. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + hooks: { + afterResponse(ctx) { + console.log(ctx.validation); + // { enabled: true, passed: true } + }, + }, +}); +``` + +If validation is not configured, `ctx.validation` is not present. + +If validation returns `false`, `afterResponse` is not called. The request fails with `ValidationError`, and `onError` runs after the final failure. diff --git a/docs/v1/retry.md b/docs/client/v1/retry.md similarity index 72% rename from docs/v1/retry.md rename to docs/client/v1/retry.md index 3273806..edcf8b7 100644 --- a/docs/v1/retry.md +++ b/docs/client/v1/retry.md @@ -121,9 +121,14 @@ By default retries apply to: - `PUT` - `DELETE` -POST requests are **not retried by default**. +`POST` and `PATCH` requests are **not retried by default**. -Example enabling POST retries: +To retry `POST` or `PATCH`, both conditions must be true: + +- the method is explicitly included in `retry.retryMethods` +- the request provides `idempotencyKey` + +Example enabling safe POST retries: ```ts const client = createClient({ @@ -131,19 +136,35 @@ const client = createClient({ retry: { attempts: 2, retryMethods: ['GET', 'POST'], + retryOn: ['5xx'], }, }); + +await client.post( + '/payments', + { amount: 100 }, + { + idempotencyKey: 'payment-123', + }, +); ``` +The idempotency key is propagated as the `idempotency-key` header. + +If `POST` or `PATCH` is included in `retryMethods` without `idempotencyKey`, the request is not retried. + +Validation failures are not retried. + ## Retry and hooks Hooks behave as follows when retries are enabled: -| Hook | Behavior | -| --------------- | ------------------------------------- | -| `beforeRequest` | executed on every retry attempt | -| `afterResponse` | executed only on successful response | -| `onError` | executed once after the final failure | +| Hook | Behavior | +| --------------- | -------------------------------------- | +| `beforeRequest` | executed on every retry attempt | +| `afterResponse` | executed only on successful response | +| `onRetry` | executed before the next retry attempt | +| `onError` | executed once after the final failure | Example: diff --git a/docs/v1/api-reference.md b/docs/v1/api-reference.md deleted file mode 100644 index cb56230..0000000 --- a/docs/v1/api-reference.md +++ /dev/null @@ -1,35 +0,0 @@ -# API Reference - -## createClient - -Creates a configured HTTP client. - -## Client methods - -- `get` -- `post` -- `put` -- `patch` -- `delete` - -## Configuration - -- `baseUrl` -- `timeout` -- `retry` -- `auth` -- `hooks` - -## Hooks - -- `beforeRequest` -- `afterResponse` -- `onError` -- `onRetry` - -## Errors - -- `HttpError` -- `NetworkError` -- `TimeoutError` -- `RequestAbortedError` diff --git a/docs/v1/examples.md b/docs/v1/examples.md deleted file mode 100644 index cfa6867..0000000 --- a/docs/v1/examples.md +++ /dev/null @@ -1,58 +0,0 @@ -# Examples - -## Basic client - -```ts -const client = createClient({ - baseURL: 'https://api.example.com', -}); -``` - -## GET method - -```ts -const users = await client.get('/users'); -``` - -## POST method - -```ts -const createdUser = await client.post('/users', { - name: 'Tom', -}); -``` - -## PATCH method - -```ts -const updatedUser = await client.patch('/users/1', { - name: 'Jane', -}); -``` - -## DELETE method - -```ts -const deletedUser = await client.delete('/users/1'); -``` - -## Low-level request - -```ts -const singleUser = await client.request({ - method: 'GET', - path: '/users/2', -}); -``` - -## Client with auth - -```ts -const client = createClient({ - baseURL: 'https://api.example.com', - - auth: async ({ request }) => { - request.headers.set('Authorization', 'Bearer TOKEN'); - }, -}); -``` diff --git a/docs/v1/response-handling.md b/docs/v1/response-handling.md deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/layout.ts b/src/app/layout.ts new file mode 100644 index 0000000..7bb83f6 --- /dev/null +++ b/src/app/layout.ts @@ -0,0 +1,3 @@ +export const APP_HEADER_HEIGHT = 72; +export const DOCS_SIDEBAR_GAP = 24; +export const DOCS_SIDEBAR_OFFSET = APP_HEADER_HEIGHT + DOCS_SIDEBAR_GAP; diff --git a/src/app/router.tsx b/src/app/router.tsx index a6e2fd0..f269625 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,7 +1,11 @@ import { lazy } from 'react'; import { Navigate } from 'react-router-dom'; import type { RouteObject } from 'react-router-dom'; -import { defaultDocsSlug, defaultDocsVersion } from '../content/docsNavigation.ts'; +import { + defaultDocsPackage, + defaultDocsSlug, + defaultDocsVersion, +} from '../content/docsNavigation.ts'; const HomePage = lazy(() => import('../pages/Home/HomePage')); const DocsPage = lazy(() => @@ -18,10 +22,19 @@ export const routes: RouteObject[] = [ }, { path: '/docs', - element: , + element: ( + + ), }, { - path: '/docs/:version/:slug', + path: '/docs/:packageSlug', + element: , + }, + { + path: '/docs/:packageSlug/:version/:slug', element: , }, { diff --git a/src/components/Features/Features.tsx b/src/components/Features/Features.tsx index 7e380b7..28f0819 100644 --- a/src/components/Features/Features.tsx +++ b/src/components/Features/Features.tsx @@ -5,6 +5,8 @@ import ReplayIcon from '@mui/icons-material/Replay'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import InsightsIcon from '@mui/icons-material/Insights'; import BoltIcon from '@mui/icons-material/Bolt'; +import FactCheckIcon from '@mui/icons-material/FactCheck'; +import KeyIcon from '@mui/icons-material/Key'; import { Card, CardContent, Container, Grid, Stack, Typography } from '@mui/material'; const items = [ @@ -28,7 +30,19 @@ const items = [ { icon: , title: 'Retry support', - description: 'Built-in retry policies with Retry-After support and full retry visibility..', + description: 'Built-in retry policies with Retry-After support and full retry visibility.', + }, + { + icon: , + title: 'Response validation', + description: + 'Validate successful responses and fail fast with ValidationError when payloads drift.', + }, + { + icon: , + title: 'Idempotency keys', + description: + 'Attach idempotency keys for safer retries of non-idempotent POST and PATCH requests.', }, { icon: , @@ -56,8 +70,9 @@ export const Features = () => { Why @dfsync/client - A lightweight HTTP client with a predictable request lifecycle, built-in retries, and - request observability for service-to-service communication. + A lightweight HTTP client with a predictable request lifecycle, built-in retries, response + validation, idempotency keys, and request observability for service-to-service + communication. @@ -77,7 +92,8 @@ export const Features = () => { ))} - Includes request timing, retry reasons, and stable request IDs across retries. + Includes request timing, retry reasons, validation metadata, and stable request IDs across + retries. ); diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 7c89558..290c997 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -4,6 +4,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { Brand } from '../Brand/Brand'; import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; import { createTrackedLinkHandler } from '../../services/analytics'; +import { APP_HEADER_HEIGHT } from '../../app/layout'; export const Header = () => { return ( @@ -18,7 +19,10 @@ export const Header = () => { }} > - +