diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 17a6942fc433..16cc73bbd4a4 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,304 +6,99 @@ disable-model-invocation: true # REST Protocol Setup & Migration -This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it once the Data Client provider is set up and the project calls REST APIs. +Setting up `@data-client/rest` only defines endpoints and schemas, so it needs no Data Client provider: leave how the app loads and stores data alone unless the user asks to change it. -The [RestEndpoint](references/RestEndpoint.md) and [resource](references/resource.md) references cover the APIs this guide uses. If the skill "data-client-rest" is installed, also apply it for resource and endpoint patterns beyond setup. +APIs: [RestEndpoint](references/RestEndpoint.md), [resource](references/resource.md). Vue projects: read `.vue.md` instead of `.md` when it exists. -## Step 1: Installation +## Step 1: Install -Install the REST package alongside the core package: +Install `@data-client/rest` with the project's package manager. It ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`); link them from the project root: ```bash -# npm -npm install @data-client/rest - -# yarn -yarn add @data-client/rest - -# pnpm -pnpm add @data-client/rest +npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -## Step 2: Detect Existing HTTP Patterns - -Scan the codebase to determine what's currently used. **Multiple patterns may coexist** — run each applicable migration sub-procedure independently on the relevant files. - -### Detection Checklist - -Check `package.json` dependencies and scan source files: - -| Check | Pattern | Action | -|-------|---------|--------| -| `"axios"` in dependencies, or `import.*from ['"]axios['"]` in source | **Axios** | Follow [references/axios-migration.md](references/axios-migration.md) | -| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [references/fetch-migration.md](references/fetch-migration.md) | -| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [references/ky-migration.md](references/ky-migration.md) | -| `"superagent"` in dependencies | **SuperAgent** | Follow [references/superagent-migration.md](references/superagent-migration.md) | -| `"got"` in dependencies (rare in browser code) | **Got** | Follow [references/got-migration.md](references/got-migration.md) | -| No existing HTTP library detected | **Fresh project** | Skip to [Step 3: Custom RestEndpoint Base Class](#step-3-custom-restendpoint-base-class) | - -### Ambiguous Detection - -If you cannot confidently determine which patterns are used (e.g., no clear imports but HTTP calls exist), **ask the user**: - -> I found HTTP calls in your codebase but couldn't determine the library. Are you migrating from: -> 1. axios -> 2. Raw fetch / custom fetch wrapper -> 3. ky -> 4. superagent -> 5. Something else (please describe) -> 6. Starting fresh (no migration needed) - -### Mixed Codebases - -When multiple HTTP libraries are detected, run each sub-procedure on the relevant files. The sub-procedures are independent and don't conflict: +- If a skill isn't available by name afterwards, read `.claude/skills//SKILL.md`. +- If the command isn't found, the installed version predates 0.19: run `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. +- Add this line to `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), with the project's package manager in the command, and commit `.agents/skills` and `.claude/skills`: -1. Identify which files use which library (group by import statements) -2. Run each applicable migration sub-procedure on its file group -3. After all migrations, proceed to the base class setup - -## Migration References - -Each migration is a self-contained reference. Read only the relevant one(s) based on detection results above. After completing migrations, return here for base class setup. - -- **Axios** → [references/axios-migration.md](references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress - - Run its codemod before any manual edits: `npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/`. -- **Raw fetch** → [references/fetch-migration.md](references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling -- **Ky** → [references/ky-migration.md](references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config -- **SuperAgent** → [references/superagent-migration.md](references/superagent-migration.md) — chained API, plugins, agents, file uploads -- **Got** → [references/got-migration.md](references/got-migration.md) — Node.js patterns, hooks, pagination, retry +```md +After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. +``` -## Step 3: Custom RestEndpoint Base Class +## Step 2: Migrate each HTTP library -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. +Check `package.json` and imports. Several libraries can coexist: migrate each one's files with its reference. If HTTP calls exist but the library is unclear, ask the user. -### Detection Checklist +| Detected | Follow | +|----------|--------| +| `axios` | [Axios migration](references/axios-migration.md), with the steps below | +| `fetch(` calls or wrappers around fetch | [Fetch migration](references/fetch-migration.md) | +| `ky` | [Ky migration](references/ky-migration.md) | +| `superagent` | [SuperAgent migration](references/superagent-migration.md) | +| `got` | [Got migration](references/got-migration.md) | +| none | Step 3 | -Scan the existing codebase for common REST patterns to include: +Axios: -1. **Base URL / API prefix**: Look for hardcoded URLs like `https://api.example.com` or env vars like `process.env.API_URL` -2. **Authentication**: Look for `Authorization` headers, tokens in localStorage/cookies, auth interceptors -3. **Content-Type handling**: Check if API uses JSON, form-data, or custom content types -4. **Error handling**: Look for error response patterns, status code handling -5. **Request/Response transforms**: Data transformations, date parsing, case conversion -6. **Query string format**: Simple params vs nested objects (may need qs library) +1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. +2. Run the codemod before any manual edits, on the source folders (never the repo root): `npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/`. It turns each axios call into an endpoint call, turns each `axios.create()` instance into a base endpoint class, and drops `.data` unless a response interceptor may have unwrapped it. +3. Search the source for `TODO(data-client)`. Each comment names one thing to finish and links its guide section, which is in the reference under the same heading. Resolve it, then delete the comment. +4. Follow the reference's remaining steps. Callers keep loading data the way they did; only the call changes. +5. Run the type checker and tests. When no `axios` import or `TODO(data-client)` comment is left, uninstall axios. -### Base Class Template +## Step 3: Base endpoint class -Create a file at `src/api/BaseEndpoint.ts` (or similar location based on project structure): +Put the project's shared request behavior in one `RestEndpoint` subclass and build every endpoint from it. If a migration made one (the axios codemod does, from `axios.create()`), add to that class instead of creating another. ```ts import { RestEndpoint, RestGenerics } from '@data-client/rest'; -/** - * Base RestEndpoint with project-specific defaults. - * Extend this for all REST API endpoints. - */ export class BaseEndpoint extends RestEndpoint { - // API base URL - adjust based on detected patterns - urlPrefix = process.env.REACT_APP_API_URL ?? 'https://api.example.com'; + urlPrefix = 'https://api.example.com'; - // Add authentication headers getHeaders(headers: HeadersInit): HeadersInit { const token = localStorage.getItem('authToken'); - return { - ...headers, - ...(token && { Authorization: `Bearer ${token}` }), - }; + return { ...headers, ...(token && { Authorization: `Bearer ${token}` }) }; } } ``` -## Common Lifecycle Overrides +Override only what the app does today: -Include these based on what's detected in the codebase. See [RestEndpoint](references/RestEndpoint.md) for full API documentation. +| Behavior | Override | +|----------|----------| +| Auth or other headers (may be `async`, e.g. token refresh) | `getHeaders(headers)` | +| Auth token from React context (Okta, Auth0) | [`hookifyResource()`](references/hookifyResource.md); a base class can't read context | +| Cookies, CSRF, other `RequestInit` | `getRequestInit(body)`, spreading `super.getRequestInit(body)` | +| Transform the response: unwrap an envelope like `{ "data": {...}, "meta": {...} }`, convert case | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Not axios's `response.data`: that is axios's wrapper, which the codemod already removed | +| Date fields | Not `process()`: the Entity's `static schema`, e.g. `createdAt: Temporal.Instant.from` (skill "data-client-schema") | +| Status handling, like a 401 redirect | `async fetchResponse(input, init)`: `super.fetchResponse()` throws a `NetworkError` for any non-2xx status, so wrap it in `try`, check `error.status` in `catch`, and rethrow ([Server error messages](references/axios-migration.md#server-error-messages) shows it) | +| Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | +| Non-JSON responses (files, text) | The [`content`](references/RestEndpoint.md#content) option, e.g. `content: 'blob'`; otherwise `parseResponse(response)` | -### Authentication (async token refresh) +Request bodies need no override: a plain object or array is sent as JSON, and anything else (`FormData`, `URLSearchParams`, a string) as-is ([getRequestInit](references/RestEndpoint.md#getRequestInit)). -```ts -async getHeaders(headers: HeadersInit): Promise { - const token = await getValidToken(); // handles refresh - return { - ...headers, - Authorization: `Bearer ${token}`, - }; -} -``` - -### Authentication from React context (Okta, Auth0) +## Defining endpoints -When auth tokens live in React context (not localStorage), `getHeaders()` on a base class **cannot** access them. Use [`hookifyResource()`](references/hookifyResource.md) to inject context-derived headers into every endpoint: +**`resource()`** for standard CRUD on one path (`get`, `getList`, `create`, `update`, `partialUpdate`, `delete`): ```ts -import { hookifyResource, resource } from '@data-client/rest'; - -const ArticleResourceBase = resource({ - path: '/articles/:id', - schema: Article, - Endpoint: BaseEndpoint, -}); - -export const ArticleResource = hookifyResource( - ArticleResourceBase, - function useInit() { - const accessToken = useContext(AuthContext); - return { - headers: { Authorization: `Bearer ${accessToken}` }, - }; - }, -); -``` - -Usage: `useSuspense(ArticleResource.useGet(), { id })` — the hook calls `useInit()` on every render, so the token is always fresh from context. - -### Custom Request Init (CSRF, credentials) - -```ts -getRequestInit(body?: RequestInit['body'] | Record): RequestInit { - return { - ...super.getRequestInit(body), - credentials: 'include', // for cookies - headers: { - 'X-CSRF-Token': getCsrfToken(), - }, - }; -} -``` - -### Custom Response Parsing (unwrap data envelope) - -```ts -process(value: any, ...args: any[]) { - // If API wraps responses in { data: ... } - return value.data ?? value; -} -``` - -### Custom Error Handling - -```ts -async fetchResponse(input: RequestInfo, init: RequestInit): Promise { - const response = await super.fetchResponse(input, init); - - if (response.status === 401) { - window.dispatchEvent(new CustomEvent('auth:expired')); - } - - return response; -} -``` - -### Custom Search Params (using qs library) - -```ts -searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); -} -``` - -### Custom parseResponse (handle non-JSON) - -```ts -async parseResponse(response: Response): Promise { - const contentType = response.headers.get('content-type'); - - if (contentType?.includes('text/csv')) { - return parseCSV(await response.text()); - } - - return super.parseResponse(response); -} -``` - -## Full Example with Multiple Overrides - -```ts -import { RestEndpoint, RestGenerics } from '@data-client/rest'; -import qs from 'qs'; - -export class BaseEndpoint extends RestEndpoint { - urlPrefix = process.env.API_URL ?? 'http://localhost:3001/api'; - - async getHeaders(headers: HeadersInit): Promise { - const token = await getAuthToken(); - return { - ...headers, - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - }; - } - - getRequestInit(body?: RequestInit['body'] | Record): RequestInit { - return { - ...super.getRequestInit(body), - credentials: 'include', - }; - } - - searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); - } - - process(value: any, ...args: any[]) { - return value?.data ?? value; - } -} - -async function getAuthToken(): Promise { - return localStorage.getItem('token'); -} -``` - -## Usage After Setup - -Once the base class is created, use it instead of [RestEndpoint](references/RestEndpoint.md) directly. - -### Choosing `resource()` vs individual endpoints - -**Use `resource()`** when an API module has standard CRUD on a single path (list, get, create, update, delete). This is the common case: - -```ts -import { resource } from '@data-client/rest'; -import { BaseEndpoint } from './BaseEndpoint'; -import { Todo } from '../schemas/Todo'; - export const TodoResource = resource({ path: '/todos/:id', schema: Todo, Endpoint: BaseEndpoint, }); -// Provides: TodoResource.get, .getList, .create, .update, .delete, .partialUpdate -``` - -**Use standalone `new BaseEndpoint()`** for non-CRUD operations (search, auth, custom actions) or when the path doesn't match `resource()` conventions: - -```ts -export const loginEndpoint = new BaseEndpoint({ - path: '/auth/login', - method: 'POST' as const, - body: {} as { email: string; password: string }, - schema: undefined, -}); ``` -**Body typing**: Use `body: {} as BodyType` (truthy value) — not `undefined as unknown as BodyType`. The truthy value is needed so the endpoint correctly sends a request body for POST/PUT/PATCH. - -### Coexisting with existing validation (Zod, Yup) - -If the codebase already validates responses with Zod/Yup, prefer **Entity as the source of truth** for types that benefit from caching/normalization. Keep Zod only for types that don't need normalization (auth tokens, form validation types, one-off responses). See the migration reference files for detailed options. - -## Next Steps +**`new BaseEndpoint({ ... })`** for anything else (search, auth, custom actions). Type a body with a truthy value, `body: {} as { email: string; password: string }`, never `undefined as unknown as T`: `body: undefined` means the endpoint sends no body. A `POST`, `PUT` or `PATCH` that sends none needs that `body: undefined`, or its only argument is taken as the body and the path params go missing. -1. **Define Entity classes** (skill "data-client-schema") and **wire them to endpoints via `schema:`** — this is essential, not optional. Endpoints with `schema: undefined` bypass normalization and caching. -2. Apply skill "data-client-rest" for resource and endpoint patterns -3. Apply skill "data-client-react" or "data-client-vue" for hook-based usage +If the codebase validates responses with Zod or Yup, keep that by parsing in `process()` and still add an Entity `schema`; see "Coexisting with Zod or Yup" in the [Axios migration](references/axios-migration.md). -## References +## Next steps -Vue projects: read `.vue.md` instead of `.md` when it exists. +1. Define Entity classes (skill "data-client-schema") and set each endpoint's `schema:`. Without one, responses aren't normalized or cached. +2. Apply skill "data-client-rest" for resource and endpoint patterns. +3. Only when the user asks to move components onto the store: apply skill "data-client-react" or "data-client-vue". -- [RestEndpoint](references/RestEndpoint.md) - Full RestEndpoint API -- [resource](references/resource.md) - Resource factory function -- [Authentication Guide](references/auth.md) - Auth patterns and examples -- [Django Integration](references/django.md) - Django REST Framework patterns -- [Axios Migration Guide](https://dataclient.io/rest/guides/axios-migration) - Full axios migration documentation +More: [Authentication](references/auth.md), [Django REST Framework](references/django.md). diff --git a/.agents/skills/data-client-rest-setup/references/axios-migration.md b/.agents/skills/data-client-rest-setup/references/axios-migration.md index 12b4a2c8af1c..09ca8f5c6279 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -2,119 +2,280 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios with a declarative, type-safe approach to REST APIs. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components still call them directly, +so how the app loads and stores data doesn't change; only the call shape does. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? -### Type-safe paths +The URL is declared once: path parameters are typed and encoded from the `path` template, and the rest become the query string. Calls don't change. + +```ts title="Before" +const getComments = ({ + userId, + postId, + sort, +}: { + userId: string; + postId: string; + sort?: 'newest' | 'top'; +}) => + // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type. + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +```ts title="After" +const getComments = new RestEndpoint({ + // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error. + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, +}); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: +- **Typed end to end**: path parameters, bodies and responses are typed from each definition. +- **Less code**: [`resource()`](./resource.md) makes a full CRUD API from a `path` and a `schema`. +- **One place for request behavior**: a base endpoint's methods replace interceptors and per-call config. +- **Ready for the store**: the same endpoints and schemas work with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the + normalized cache, whenever you adopt them. -```ts -// axios: no type checking — typo silently produces wrong URL -axios.get(`/users/${usrId}`); +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest ``` -With [`RestEndpoint`](./RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: +### 2. Run the codemod {#codemod} -```ts -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -// TypeScript enforces { id: string } — typos are compile errors -getUser({ id: '1' }); +A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on +your source folders, never the repo root: + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -This also means IDE autocomplete works for every path parameter. +Each axios call becomes a call to an endpoint. Path params, `params` and the body become its arguments, and it resolves to the body, so `.data` goes away. -### Additional benefits +```ts title="Before" +import axios from 'axios'; -- **Normalized cache** — shared entities are deduplicated and updated everywhere automatically -- **Declarative data dependencies** — components declare what data they need via [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to fetch it -- **Optimistic updates** — instant UI feedback before the server responds -- **Zero boilerplate** — [`resource()`](./resource.md) generates a full CRUD API from a `path` and `schema` +const api = axios.create({ baseURL: 'https://api.example.com' }); -## Quick reference +export async function getPost(id: string) { + const res = await api.get(`/posts/${id}`); + return res.data; +} + +export async function createPost(body: { title: string }) { + const { data } = await api.post('/posts', body); + return data; +} +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export async function getPost(id: string) { + const res = await new ApiEndpoint({ path: '/posts/:id' })({ id }); + return res; +} + +export async function createPost(body: { title: string }) { + const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })( + body, + ); + return data; +} +``` -| Axios | @data-client/rest | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseURL` | [`urlPrefix`](./RestEndpoint.md#urlPrefix) | -| `headers` config | [`getHeaders()`](./RestEndpoint.md#getHeaders) | -| `interceptors.request` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) / [`getHeaders()`](./RestEndpoint.md#getHeaders) | -| `interceptors.response` | [`parseResponse()`](./RestEndpoint.md#parseResponse) / [`process()`](./RestEndpoint.md#process) | -| `timeout` | [`AbortSignal.timeout()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) via `signal` | -| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.md#searchParams) / [`searchToString()`](./RestEndpoint.md#searchToString) | -| `cancelToken` / `signal` | `signal` ([AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)) | -| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](./RestEndpoint.md#content) / `'arrayBuffer'` — see [file download](https://dataclient.io/rest/guides/network-transform#file-download) | -| `auth: { username, password }` | [`getHeaders()`](./RestEndpoint.md#getHeaders) with `btoa()` | -| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](./RestEndpoint.md#getHeaders) — see [Django Integration](./django.md) | -| `transformRequest` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | -| `transformResponse` | [`process()`](./RestEndpoint.md#process) | -| `validateStatus` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) | -| `onUploadProgress` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) | -| `isAxiosError` / `error.response` | [`NetworkError`](./RestEndpoint.md#fetchResponse) with `.status` and `.response` | +What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios +response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for +`TODO(data-client)` and resolve each one as you go through the next steps. -## Migration examples +If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only +calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint). -### Basic GET +### 3. Move interceptors into a base endpoint {#base-endpoint} -**Before (axios)** +Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The +codemod creates this class from `axios.create()`; move the interceptors into it. -```ts title="api.ts" +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. + +```ts title="Before" import axios from 'axios'; -export const getUser = (id: string) => - axios.get(`https://api.example.com/users/${id}`); +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +api.interceptors.response.use(response => response.data); + +export default api; ``` -```ts title="usage.ts" -const { data } = await getUser('1'); +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // callout: Runs before every request, like a request interceptor. It can be `async`, to refresh a token first. + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + Authorization: `Bearer ${getToken()}`, + }; + } +} ``` -**After (data-client)** +Response interceptors that change the body, such as converting `snake_case` keys, go in +[`process()`](./RestEndpoint.md#process); see [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels). -```ts title="User" -import { Entity } from '@data-client/rest'; +When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response, +so keep it. The codemod marks these calls when the interceptor is in the same file; if a global +`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there. -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +#### Server error messages + +Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once, +in the base class's [`fetchResponse()`](./RestEndpoint.md#fetchResponse), so call sites get it from +`error.message`: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } } ``` -```ts title="api" -import { RestEndpoint } from '@data-client/rest'; -import User from './User'; +#### Existing endpoint abstractions + +A custom endpoint class that wraps axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend +`RestEndpoint` instead of being replaced. It keeps its backward-compatible methods and gains +[`url()`](./RestEndpoint.md#url), [`getRequestInit()`](./RestEndpoint.md#getRequestInit), +[`fetchResponse()`](./RestEndpoint.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; + + /** @deprecated use url() */ + toDynamicUrl = this.url; +} -export const getUser = new RestEndpoint({ - urlPrefix: 'https://api.example.com', +const getUser = new LegacyEndpoint({ path: '/users/:id', + queryKey: 'user', schema: User, }); ``` -```ts title="Usage" column -import { getUser } from './api'; -getUser({ id: '1' }); -``` +Pass extra members like `queryKey` as options rather than through a custom constructor, so +[`extend()`](./RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps +working. -### Instance with base URL and headers +#### Context-based auth -**Before (axios)** +When tokens come from React context (Okta, Auth0) rather than storage, `getHeaders()` can't +read them. Use [`hookifyResource()`](./hookifyResource.md) to add the headers through a +hook. See the +[authentication guide](./auth.md) for this and other patterns. -```ts title="api.ts" -import axios from 'axios'; +### 4. Define entities and resources {#entities} -const api = axios.create({ - baseURL: 'https://api.example.com', - headers: { 'X-API-Key': 'my-key' }, -}); +The codemod makes endpoints without a `schema`. Define an [Entity](https://dataclient.io/rest/api/Entity) for each type the API returns: its +fields, their types and its primary key. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.md): + +One `resource()` replaces a module of CRUD functions, and `schema` types every response. + +```ts title="Before" +import api from './api'; + +export interface Post { + id: string; + title: string; + body: string; +} + +export const getPosts = () => api.get('/posts'); +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: Omit) => + api.post('/posts', data); +export const updatePost = (id: string, data: Partial) => + api.patch(`/posts/${id}`, data); +export const deletePost = (id: string) => api.delete(`/posts/${id}`); +``` + +```ts title="After" +import { Entity, resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; + +export class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} -export const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +// callout: Gives `getList`, `get`, `getList.push`, `update`, `partialUpdate` and `delete`, all typed. +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); ``` -**After (data-client)** +Try the result against a mock API: ```ts title="Post" import { Entity } from '@data-client/rest'; @@ -161,115 +322,110 @@ import { PostResource } from './PostResource'; PostResource.get({ id: '1' }); ``` -### POST mutation - -**Before (axios)** - -```ts title="api.ts" -import axios from 'axios'; +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for +operations that aren't CRUD, like search, custom actions and auth. -const api = axios.create({ baseURL: 'https://api.example.com' }); +> **Warning** +> +> Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their +> types, and the store can't normalize them when you adopt it. -export const createPost = (data: { title: string; body: string }) => - api.post('/posts', data); -``` +#### Non-standard primary keys -**After (data-client)** +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): -```ts title="Post" +```ts import { Entity } from '@data-client/rest'; -export default class Post extends Entity { - id = ''; - title = ''; - body = ''; - static key = 'Post'; +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } } ``` -```ts title="PostResource" -import { resource } from '@data-client/rest'; -import Post from './Post'; +#### Body typing -export const PostResource = resource({ - urlPrefix: 'https://api.example.com', - path: '/posts/:id', - schema: Post, -}); -``` +Type the body of standalone `POST`, `PUT` and `PATCH` endpoints with `body: {} as BodyType`. Don't use +`undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.md#body)`: undefined` as having +no body argument. [`resource()`](./resource.md) types its endpoints' bodies itself. -```ts title="Usage" column -import { PostResource } from './PostResource'; -PostResource.getList.push({ - title: 'New Post', - body: 'Content', +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, }); ``` -### Interceptors → lifecycle methods - -Axios interceptors map to [RestEndpoint](./RestEndpoint.md) lifecycle methods: +#### Coexisting with Zod or Yup -**Before (axios)** +If the codebase already validates responses with Zod or Yup, choose one approach per type: -```ts title="api.ts" -import axios from 'axios'; +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: -const api = axios.create({ baseURL: 'https://api.example.com' }); + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields give + types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might + send malformed. -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); -``` +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from + normalization (auth tokens, one-off responses). -**After (data-client)** +### 5. Update call sites {#call-sites} -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +Components and services keep their own loading and state; only the call changes. -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = 'https://api.example.com'; +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. - // Equivalent to request interceptor - getHeaders(headers: HeadersInit) { - return { - ...headers, - Authorization: `Bearer ${getToken()}`, - }; - } +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; - // Equivalent to response interceptor (unwrap/transform) - process(value: any, ...args: any) { - return value; - } -} +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); ``` -> **Tip** -> -> `RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +```ts title="After" +import { PostResource } from './api/posts'; -Response interceptors that transform the body, such as converting `snake_case` keys, belong in [`process()`](./RestEndpoint.md#process). See [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels) for a complete example. +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); +``` -### Error handling +Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the normalized cache, is a +separate step you can take later; see [data dependencies](https://dataclient.io/docs/getting-started/data-dependency). -**Before (axios)** +### 6. Migrate error handling {#error-handling} -```ts +`NetworkError` has the `status` and the raw `Response`, in place of axios's `error.response`. + +```ts title="Before" import axios from 'axios'; +import { getPost } from './api/posts'; try { - const { data } = await axios.get('/users/1'); + const { data } = await getPost('1'); } catch (err) { if (axios.isAxiosError(err)) { console.log(err.response?.status); @@ -278,13 +434,12 @@ try { } ``` -**After (data-client)** - -```ts +```ts title="After" import { NetworkError } from '@data-client/rest'; +import { PostResource } from './api/posts'; try { - const user = await getUser({ id: '1' }); + const post = await PostResource.get({ id: '1' }); } catch (err) { if (err instanceof NetworkError) { console.log(err.status); @@ -293,84 +448,67 @@ try { } ``` -[`NetworkError`](./RestEndpoint.md#fetchResponse) provides `.status` and `.response` (the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object). For soft retries on server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). - -#### Server error messages - -Axios codebases commonly surface `error.response.data.error` or `.message` to the user. Read it from the `Response` body instead, once, in the base class's [`fetchResponse()`](./RestEndpoint.md#fetchResponse), so call sites get it from `error.message` without parsing the body: +Read a server's error message from the body once, in the base endpoint; see +[server error messages](#server-error-messages). -```ts title="ApiEndpoint.ts" -import { - NetworkError, - RestEndpoint, - RestGenerics, -} from '@data-client/rest'; +### 7. Remove axios {#remove-axios} -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - async fetchResponse(input: RequestInfo, init: RequestInit) { - try { - return await super.fetchResponse(input, init); - } catch (error) { - if (error instanceof NetworkError) { - const body = await error.response - .clone() - .json() - .catch(() => null); - // keep the NetworkError so `status` and `errorPolicy()` still work - error.message = body?.error ?? body?.message ?? error.message; - } - throw error; - } - } -} -``` +Search for what still uses axios: -### Cancellation +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `TODO\(data-client\)` | what the codemod left | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | -**Before (axios)** +Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the +type checker and tests: -```ts -import axios from 'axios'; +```bash +npm uninstall axios +``` + +## Axios options reference {#quick-reference} + +| Axios | @data-client/rest | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](./RestEndpoint.md#urlPrefix) | +| `headers` config | [`getHeaders()`](./RestEndpoint.md#getHeaders) | +| `interceptors.request` | [`getHeaders()`](./RestEndpoint.md#getHeaders) / [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | +| `interceptors.response` | [`parseResponse()`](./RestEndpoint.md#parseResponse) / [`process()`](./RestEndpoint.md#process) | +| `transformRequest` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | +| `transformResponse` | [`process()`](./RestEndpoint.md#process) | +| `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | +| `cancelToken` / `signal` | [`signal`](#cancellation) | +| `timeout` | [`AbortSignal.timeout()`](#timeout) in `getRequestInit()` | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](#binary-responses) / `'arrayBuffer'` | +| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.md#searchParams) / [`searchToString()`](#query-serialization) | +| `auth: { username, password }` | [`getHeaders()` with `btoa()`](#basic-auth) | +| `validateStatus` | [Custom `fetchResponse()`](#accepting-error-statuses) | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](#csrf-headers) | +| `onUploadProgress` | [Custom `fetchResponse()` with XMLHttpRequest](#upload-progress) | -const controller = new AbortController(); -axios.get('/users', { signal: controller.signal }); -controller.abort(); -``` +### Cancellation -Or with the deprecated `CancelToken`: +Both axios's `signal` and the deprecated `CancelToken` map to an +[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`: -```ts +```ts title="Before" const source = axios.CancelToken.source(); -axios.get('/users', { cancelToken: source.token }); +axios.get('/users/1', { cancelToken: source.token }); source.cancel(); ``` -**After (data-client)** - -Both map to an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`. The [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) hook automatically cancels in-flight requests when parameters change: - -```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; -import { searchEndpoint } from './api/search'; -import ResultsList from './ResultsList'; - -function SearchResults({ query }: { query: string }) { - const results = useSuspense(useCancelling(searchEndpoint), { q: query }); - return ; -} -``` - -For manual cancellation, pass `signal` directly: - -```ts +```ts title="After" +const getUser = new ApiEndpoint({ path: '/users/:id' }); const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); +// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests. +getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` @@ -378,28 +516,48 @@ See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns ### Timeout -```ts title="Before (axios)" +```ts title="Before" axios.get('/users', { timeout: 5000 }); ``` -```ts title="After (data-client)" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // callout: Runs for each request, so each one gets its own 5 seconds. `AbortSignal.timeout()` passed to the constructor would start counting once, when the endpoint is created. + async getRequestInit( + body?: RequestInit['body'] | Record, + ) { + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; + return { ...init, signal }; + } +} ``` ### Binary responses -```ts title="Before (axios)" +```ts title="Before" axios.get('/files/1', { responseType: 'blob' }); ``` -Set [`content`](./RestEndpoint.md#content) to `'blob'`, `'arrayBuffer'` or `'text'`. See [file download](https://dataclient.io/rest/guides/network-transform#file-download) for the full endpoint and triggering a browser download. +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', +}); +``` + +[`content`](./RestEndpoint.md#content) also takes `'arrayBuffer'` and `'text'`. See +[file download](https://dataclient.io/rest/guides/network-transform#file-download) to trigger a browser download. ### Query serialization -```ts title="Before (axios)" +```ts title="Before" axios.get('/users', { params: { ids: [1, 2, 3] }, paramsSerializer: params => @@ -407,15 +565,30 @@ axios.get('/users', { }); ``` -Override [`searchToString()`](./RestEndpoint.md#searchToString) to serialize with `qs`; see [using the `qs` library](./RestEndpoint.md#searchToString). +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams: Record) { + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); + } +} +``` + +axios leaves out `null` and `undefined` params, but the default `searchToString()` uses +[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth -```ts title="Before (axios)" +```ts title="Before" axios.get('/api', { auth: { username: 'user', password: 'pass' } }); ``` -```ts title="After (data-client)" +```ts title="After" export default class BasicAuthEndpoint< O extends RestGenerics = any, > extends RestEndpoint { @@ -430,13 +603,14 @@ export default class BasicAuthEndpoint< ### Accepting error statuses -[`fetchResponse()`](./RestEndpoint.md#fetchResponse) throws [`NetworkError`](./RestEndpoint.md#fetchResponse) for any non-`ok` status. Override it to change what counts as an error: +[`fetchResponse()`](./RestEndpoint.md#fetchResponse) throws [`NetworkError`](#error-handling) for any status that +isn't `ok`. Override it to change what counts as an error: -```ts title="Before (axios)" +```ts title="Before" axios.get('/api', { validateStatus: status => status < 500 }); ``` -```ts title="After (data-client)" +```ts title="After" import { NetworkError, RestEndpoint, @@ -456,26 +630,30 @@ export default class LenientEndpoint< ### CSRF headers -```ts title="Before (axios)" +```ts title="Before" axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken', }); ``` -Read the cookie in [`getHeaders()`](./RestEndpoint.md#getHeaders) for non-`GET` requests. See [Django Integration](./django.md) for the complete endpoint class. +Read the cookie in [`getHeaders()`](./RestEndpoint.md#getHeaders) for requests that aren't `GET`. See +[Django Integration](./django.md) for the complete endpoint class. ### Upload progress -`fetch` cannot report upload progress, so use [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside [`fetchResponse()`](./RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like any other member. +`fetch` can't report upload progress, so use +[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside +[`fetchResponse()`](./RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like +any other member. -```ts title="Before (axios)" +```ts title="Before" axios.post('/upload', formData, { onUploadProgress: e => console.log(e.loaded / e.total), }); ``` -```ts title="After (data-client)" +```ts title="UploadEndpoint.ts" import { NetworkError, RestEndpoint, @@ -544,218 +722,9 @@ const uploadFile = new UploadEndpoint({ }); ``` -## Codemod {#codemod} - -A standalone [jscodeshift](https://github.com/facebook/jscodeshift) codemod handles the mechanical parts of migration. - -```bash -npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ -``` - -The codemod automatically: - -- Replaces `import axios from 'axios'` with `import { RestEndpoint } from '@data-client/rest'` -- Converts `axios.create({ baseURL, headers })` into a base `RestEndpoint` subclass with `urlPrefix` and `getHeaders()` -- Transforms `axios.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into `new CreatedClassName({ path, method })` - -The codemod has little to do when the project wraps axios in its own class or function and never calls `axios.get()`/`.post()` directly, or only calls `axios(config)` without a method name. In those cases, skip it and start with [the manual steps](#after-the-codemod). - -The codemod does **not** handle: - -- Interceptors — see [lifecycle methods](#interceptors--lifecycle-methods) -- Error handling (`isAxiosError`, `error.response`) — see [error handling](#error-handling) -- The rest of the [quick reference](#quick-reference) — see the [migration examples](#migration-examples) above -- [Entity](https://dataclient.io/rest/api/Entity) schema definitions and converting call sites to hooks — see [below](#after-the-codemod) - -### Finding remaining axios usage - -Search patterns for locating what still needs migrating: - -| Pattern | Finds | -| ------------------------------------------ | ------------------------- | -| `import.*from ['"]axios['"]` | import statements | -| `axios\.create` | instance creation | -| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | -| `\.interceptors\.(request\|response)\.use` | interceptors | -| `isAxiosError` | error handling | -| `cancelToken\|CancelToken` | cancellation (deprecated) | -| `onUploadProgress\|onDownloadProgress` | progress callbacks | - -## After the codemod {#after-the-codemod} - -The codemod produces endpoints without schemas. Defining [Entity](https://dataclient.io/rest/api/Entity) schemas and wiring them to endpoints enables normalization and caching — the core value of Reactive Data Client. - -### Non-standard primary keys - -Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): - -```ts -import { Entity } from '@data-client/rest'; - -export class User extends Entity { - _id = ''; - name = ''; - email = ''; - static key = 'User'; - - pk() { - return this._id; - } -} -``` - -### Group CRUD endpoints with resource() - -When an axios module has separate `getUsers`, `getUser`, `createUser`, `updateUser` and `deleteUser` functions for one path, replace them with a single [`resource()`](./resource.md): - -```ts -import { resource } from '@data-client/rest'; -import ApiEndpoint from './ApiEndpoint'; -import { User } from './User'; - -export const UserResource = resource({ - path: '/users/:id', - schema: User, - Endpoint: ApiEndpoint, -}); -// UserResource.getList, .get, .getList.push, .update, .partialUpdate, .delete -``` - -Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Reserve standalone `new ApiEndpoint()` for non-CRUD operations (search, custom actions, auth). - -### Coexisting with Zod or Yup - -If the codebase already validates responses with Zod or Yup, choose one approach per type: - -- **Zod in `process()`** (recommended): keep runtime validation by parsing in [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: - - ```ts - const getUser = new ApiEndpoint({ - path: '/users/:id', - schema: User, - process(value: any) { - return userSchema.parse(value); - }, - }); - ``` - -- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields provide types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might send malformed. - -- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't benefit from normalization (auth tokens, one-off responses). - -> **Warning** -> -> Don't define Entity classes and then leave `schema` unset on every endpoint — without `schema`, nothing is normalized and the migration gains little over axios. - -### Body typing - -Type the body of standalone `POST`/`PUT`/`PATCH` endpoints with `body: {} as BodyType`. Don't use `undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.md#body)`: undefined` as having no body argument. - -```ts -const createUser = new ApiEndpoint({ - path: '/users', - method: 'POST', - body: {} as { name: string; email: string }, - schema: User, -}); -``` - -[`resource()`](./resource.md) types its CRUD endpoints automatically. - -### Convert call sites to hooks - -**Before (axios)** - -```tsx -import { useEffect, useState } from 'react'; -import api from './lib/api'; -import { Spinner } from './Spinner'; -import type { User } from './User'; - -function UserProfile({ id }: { id: string }) { - const [user, setUser] = useState(null); - useEffect(() => { - api.get(`/users/${id}`).then(({ data }) => setUser(data)); - }, [id]); - if (!user) return ; - return

{user.name}

; -} -``` - -**After (data-client)** - -```tsx -import { useSuspense } from '@data-client/react'; -import { UserResource } from './UserResource'; - -function UserProfile({ id }: { id: string }) { - const user = useSuspense(UserResource.get, { id }); - return

{user.name}

; -} -``` - -Loading and error states move to [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary). See [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) for details. - -### Context-based auth - -When tokens come from React context (Okta, Auth0) rather than storage, use [`hookifyResource()`](./hookifyResource.md) to inject headers through a hook. See the [authentication guide](./auth.md) for this and other patterns. - -### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks temporarily but fetch through [`controller.fetch()`](https://dataclient.io/docs/api/Controller#fetch). Calling an endpoint directly only runs its fetch; going through the Controller also normalizes the response into the shared cache, so data is consistent from day one: - -```ts -import { useController } from '@data-client/react'; -import { useQuery } from '@tanstack/react-query'; -import ApiEndpoint from './ApiEndpoint'; -import { Project } from './Project'; - -export const getProject = new ApiEndpoint({ - path: '/projects/:id', - schema: Project, -}); - -export function useProject(id: string) { - const ctrl = useController(); - return useQuery({ - queryKey: ['project', id], - queryFn: () => ctrl.fetch(getProject, { id }), - }); -} -``` - -Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. - -### Existing endpoint abstractions - -Codebases that already have a custom endpoint class wrapping axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend `RestEndpoint` instead of replacing it, keeping backward-compatible methods while gaining [`url()`](./RestEndpoint.md#url), [`getRequestInit()`](./RestEndpoint.md#getRequestInit), [`fetchResponse()`](./RestEndpoint.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.md#parseResponse): - -```ts -import { RestEndpoint, RestGenerics } from '@data-client/rest'; - -export class LegacyEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = API_ROOT; - declare queryKey?: string; - - /** @deprecated use url() */ - toDynamicUrl = this.url; -} - -const getUser = new LegacyEndpoint({ - path: '/users/:id', - queryKey: 'user', - schema: User, -}); -``` - -Pass extra members like `queryKey` as options rather than through a custom constructor, so [`extend()`](./RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps working. - ## Related guides -- [Authentication](./auth.md) — token and cookie auth patterns -- [Aborting Fetch](https://dataclient.io/rest/guides/abort) — cancellation and debouncing -- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform) — response transforms, field renaming, file downloads -- [Django Integration](./django.md) — CSRF and cookie auth for Django +- [Authentication](./auth.md): token and cookie auth patterns +- [Aborting Fetch](https://dataclient.io/rest/guides/abort): cancellation and debouncing +- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform): response transforms, field renaming, file downloads +- [Django Integration](./django.md): CSRF and cookie auth for Django diff --git a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md new file mode 100644 index 000000000000..2965666de76e --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -0,0 +1,730 @@ + + +# Migrating from Axios + +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components still call them directly, +so how the app loads and stores data doesn't change; only the call shape does. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. + +## Why migrate? + +The URL is declared once: path parameters are typed and encoded from the `path` template, and the rest become the query string. Calls don't change. + +```ts title="Before" +const getComments = ({ + userId, + postId, + sort, +}: { + userId: string; + postId: string; + sort?: 'newest' | 'top'; +}) => + // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type. + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +```ts title="After" +const getComments = new RestEndpoint({ + // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error. + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, +}); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +- **Typed end to end**: path parameters, bodies and responses are typed from each definition. +- **Less code**: [`resource()`](./resource.vue.md) makes a full CRUD API from a `path` and a `schema`. +- **One place for request behavior**: a base endpoint's methods replace interceptors and per-call config. +- **Ready for the store**: the same endpoints and schemas work with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense) and the + normalized cache, whenever you adopt them. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +### 2. Run the codemod {#codemod} + +A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on +your source folders, never the repo root: + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ +``` + +Each axios call becomes a call to an endpoint. Path params, `params` and the body become its arguments, and it resolves to the body, so `.data` goes away. + +```ts title="Before" +import axios from 'axios'; + +const api = axios.create({ baseURL: 'https://api.example.com' }); + +export async function getPost(id: string) { + const res = await api.get(`/posts/${id}`); + return res.data; +} + +export async function createPost(body: { title: string }) { + const { data } = await api.post('/posts', body); + return data; +} +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export async function getPost(id: string) { + const res = await new ApiEndpoint({ path: '/posts/:id' })({ id }); + return res; +} + +export async function createPost(body: { title: string }) { + const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })( + body, + ); + return data; +} +``` + +What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios +response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for +`TODO(data-client)` and resolve each one as you go through the next steps. + +If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only +calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint). + +### 3. Move interceptors into a base endpoint {#base-endpoint} + +Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The +codemod creates this class from `axios.create()`; move the interceptors into it. + +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. + +```ts title="Before" +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +api.interceptors.response.use(response => response.data); + +export default api; +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // callout: Runs before every request, like a request interceptor. It can be `async`, to refresh a token first. + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + Authorization: `Bearer ${getToken()}`, + }; + } +} +``` + +Response interceptors that change the body, such as converting `snake_case` keys, go in +[`process()`](./RestEndpoint.vue.md#process); see [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels). + +When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response, +so keep it. The codemod marks these calls when the interceptor is in the same file; if a global +`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there. + +#### Server error messages + +Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once, +in the base class's [`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse), so call sites get it from +`error.message`: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } +} +``` + +#### Existing endpoint abstractions + +A custom endpoint class that wraps axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend +`RestEndpoint` instead of being replaced. It keeps its backward-compatible methods and gains +[`url()`](./RestEndpoint.vue.md#url), [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit), +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.vue.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; + + /** @deprecated use url() */ + toDynamicUrl = this.url; +} + +const getUser = new LegacyEndpoint({ + path: '/users/:id', + queryKey: 'user', + schema: User, +}); +``` + +Pass extra members like `queryKey` as options rather than through a custom constructor, so +[`extend()`](./RestEndpoint.vue.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps +working. + +#### Context-based auth + +When tokens come from `inject()` (Okta, Auth0) rather than storage, `getHeaders()` can't +read them. Use [`hookifyResource()`](./hookifyResource.vue.md) to add the headers through a +composable. See the +[authentication guide](./auth.vue.md) for this and other patterns. + +### 4. Define entities and resources {#entities} + +The codemod makes endpoints without a `schema`. Define an [Entity](https://dataclient.io/rest/api/Entity) for each type the API returns: its +fields, their types and its primary key. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.vue.md): + +One `resource()` replaces a module of CRUD functions, and `schema` types every response. + +```ts title="Before" +import api from './api'; + +export interface Post { + id: string; + title: string; + body: string; +} + +export const getPosts = () => api.get('/posts'); +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: Omit) => + api.post('/posts', data); +export const updatePost = (id: string, data: Partial) => + api.patch(`/posts/${id}`, data); +export const deletePost = (id: string) => api.delete(`/posts/${id}`); +``` + +```ts title="After" +import { Entity, resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; + +export class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} + +// callout: Gives `getList`, `get`, `getList.push`, `update`, `partialUpdate` and `delete`, all typed. +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); +``` + +Try the result against a mock API: + +```ts title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} +``` + +```ts title="ApiEndpoint" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + }; + } +} +``` + +```ts title="PostResource" +import { resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; +import Post from './Post'; + +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); +``` + +```ts title="Usage" column +import { PostResource } from './PostResource'; +PostResource.get({ id: '1' }); +``` + +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for +operations that aren't CRUD, like search, custom actions and auth. + +> **Warning** +> +> Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their +> types, and the store can't normalize them when you adopt it. + +#### Non-standard primary keys + +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): + +```ts +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } +} +``` + +#### Body typing + +Type the body of standalone `POST`, `PUT` and `PATCH` endpoints with `body: {} as BodyType`. Don't use +`undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.vue.md#body)`: undefined` as having +no body argument. [`resource()`](./resource.vue.md) types its endpoints' bodies itself. + +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, +}); +``` + +#### Coexisting with Zod or Yup + +If the codebase already validates responses with Zod or Yup, choose one approach per type: + +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.vue.md#process), and let the Entity handle normalization: + + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` + +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields give + types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might + send malformed. + +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from + normalization (auth tokens, one-off responses). + +### 5. Update call sites {#call-sites} + +Components and services keep their own loading and state; only the call changes. + +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. + +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; + +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); +``` + +```ts title="After" +import { PostResource } from './api/posts'; + +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); +``` + +Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense) and the normalized cache, is a +separate step you can take later; see [data dependencies](https://dataclient.io/vue/getting-started/data-dependency). + +### 6. Migrate error handling {#error-handling} + +`NetworkError` has the `status` and the raw `Response`, in place of axios's `error.response`. + +```ts title="Before" +import axios from 'axios'; +import { getPost } from './api/posts'; + +try { + const { data } = await getPost('1'); +} catch (err) { + if (axios.isAxiosError(err)) { + console.log(err.response?.status); + console.log(err.response?.data); + } +} +``` + +```ts title="After" +import { NetworkError } from '@data-client/rest'; +import { PostResource } from './api/posts'; + +try { + const post = await PostResource.get({ id: '1' }); +} catch (err) { + if (err instanceof NetworkError) { + console.log(err.status); + console.log(err.response); + } +} +``` + +Read a server's error message from the body once, in the base endpoint; see +[server error messages](#server-error-messages). + +### 7. Remove axios {#remove-axios} + +Search for what still uses axios: + +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `TODO\(data-client\)` | what the codemod left | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | + +Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the +type checker and tests: + +```bash +npm uninstall axios +``` + +## Axios options reference {#quick-reference} + +| Axios | @data-client/rest | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](./RestEndpoint.vue.md#urlPrefix) | +| `headers` config | [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) | +| `interceptors.request` | [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) / [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit) | +| `interceptors.response` | [`parseResponse()`](./RestEndpoint.vue.md#parseResponse) / [`process()`](./RestEndpoint.vue.md#process) | +| `transformRequest` | [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit) | +| `transformResponse` | [`process()`](./RestEndpoint.vue.md#process) | +| `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | +| `cancelToken` / `signal` | [`signal`](#cancellation) | +| `timeout` | [`AbortSignal.timeout()`](#timeout) in `getRequestInit()` | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](#binary-responses) / `'arrayBuffer'` | +| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.vue.md#searchParams) / [`searchToString()`](#query-serialization) | +| `auth: { username, password }` | [`getHeaders()` with `btoa()`](#basic-auth) | +| `validateStatus` | [Custom `fetchResponse()`](#accepting-error-statuses) | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](#csrf-headers) | +| `onUploadProgress` | [Custom `fetchResponse()` with XMLHttpRequest](#upload-progress) | + +### Cancellation + +Both axios's `signal` and the deprecated `CancelToken` map to an +[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`: + +```ts title="Before" +const source = axios.CancelToken.source(); +axios.get('/users/1', { cancelToken: source.token }); +source.cancel(); +``` + +```ts title="After" +const getUser = new ApiEndpoint({ path: '/users/:id' }); +const controller = new AbortController(); +// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests. +getUser.extend({ signal: controller.signal })({ id: '1' }); +controller.abort(); +``` + +See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. + +### Timeout + +```ts title="Before" +axios.get('/users', { timeout: 5000 }); +``` + +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // callout: Runs for each request, so each one gets its own 5 seconds. `AbortSignal.timeout()` passed to the constructor would start counting once, when the endpoint is created. + async getRequestInit( + body?: RequestInit['body'] | Record, + ) { + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; + return { ...init, signal }; + } +} +``` + +### Binary responses + +```ts title="Before" +axios.get('/files/1', { responseType: 'blob' }); +``` + +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', +}); +``` + +[`content`](./RestEndpoint.vue.md#content) also takes `'arrayBuffer'` and `'text'`. See +[file download](https://dataclient.io/rest/guides/network-transform#file-download) to trigger a browser download. + +### Query serialization + +```ts title="Before" +axios.get('/users', { + params: { ids: [1, 2, 3] }, + paramsSerializer: params => + qs.stringify(params, { arrayFormat: 'repeat' }), +}); +``` + +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams: Record) { + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); + } +} +``` + +axios leaves out `null` and `undefined` params, but the default `searchToString()` uses +[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.vue.md#searchToString). + +### Basic auth + +```ts title="Before" +axios.get('/api', { auth: { username: 'user', password: 'pass' } }); +``` + +```ts title="After" +export default class BasicAuthEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Basic ${btoa('user:pass')}`, + }; + } +} +``` + +### Accepting error statuses + +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse) throws [`NetworkError`](#error-handling) for any status that +isn't `ok`. Override it to change what counts as an error: + +```ts title="Before" +axios.get('/api', { validateStatus: status => status < 500 }); +``` + +```ts title="After" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class LenientEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + const response = await fetch(input, init); + if (response.status >= 500) throw new NetworkError(response); + return response; + } +} +``` + +### CSRF headers + +```ts title="Before" +axios.create({ + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', +}); +``` + +Read the cookie in [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) for requests that aren't `GET`. See +[Django Integration](./django.md) for the complete endpoint class. + +### Upload progress + +`fetch` can't report upload progress, so use +[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like +any other member. + +```ts title="Before" +axios.post('/upload', formData, { + onUploadProgress: e => console.log(e.loaded / e.total), +}); +``` + +```ts title="UploadEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class UploadEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare onProgress?: (progress: number) => void; + + fetchResponse(input: RequestInfo, init: RequestInit) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const abort = () => xhr.abort(); + const abortError = () => + new DOMException('The operation was aborted.', 'AbortError'); + if (init.signal?.aborted) return reject(abortError()); + init.signal?.addEventListener('abort', abort, { once: true }); + + xhr.open( + init.method ?? 'POST', + typeof input === 'string' ? input : input.url, + ); + new Headers(init.headers).forEach((value, key) => + xhr.setRequestHeader(key, value), + ); + xhr.onloadend = () => + init.signal?.removeEventListener('abort', abort); + xhr.upload.onprogress = e => { + if (e.lengthComputable) this.onProgress?.(e.loaded / e.total); + }; + xhr.onload = () => { + const headers = new Headers(); + for (const line of xhr + .getAllResponseHeaders() + .trim() + .split(/\r?\n/)) { + const [key, ...rest] = line.split(': '); + if (key) headers.append(key, rest.join(': ')); + } + // 204, 205 and 304 responses can't have a body + const body = [204, 205, 304].includes(xhr.status) + ? null + : xhr.response; + const response = new Response(body, { + status: xhr.status, + statusText: xhr.statusText, + headers, + }); + if (response.ok) resolve(response); + else reject(new NetworkError(response)); + }; + xhr.onerror = () => reject(new TypeError('Network request failed')); + xhr.onabort = () => reject(abortError()); + xhr.send(init.body as XMLHttpRequestBodyInit | null); + }); + } +} + +const uploadFile = new UploadEndpoint({ + path: '/upload', + method: 'POST', + body: {} as FormData, + onProgress: (progress: number) => console.log(progress), +}); +``` + +## Related guides + +- [Authentication](./auth.vue.md): token and cookie auth patterns +- [Aborting Fetch](https://dataclient.io/rest/guides/abort): cancellation and debouncing +- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform): response transforms, field renaming, file downloads +- [Django Integration](./django.md): CSRF and cookie auth for Django diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup.md b/.agents/skills/data-client-setup/references/data-client-rest-setup.md index 6734332385f2..e9c69ef420fa 100644 --- a/.agents/skills/data-client-setup/references/data-client-rest-setup.md +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup.md @@ -2,304 +2,99 @@ # REST Protocol Setup & Migration -This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it once the Data Client provider is set up and the project calls REST APIs. +Setting up `@data-client/rest` only defines endpoints and schemas, so it needs no Data Client provider: leave how the app loads and stores data alone unless the user asks to change it. -The [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) and [resource](./data-client-rest-setup/references/resource.md) references cover the APIs this guide uses. If the skill "data-client-rest" is installed, also apply it for resource and endpoint patterns beyond setup. +APIs: [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md), [resource](./data-client-rest-setup/references/resource.md). Vue projects: read `.vue.md` instead of `.md` when it exists. -## Step 1: Installation +## Step 1: Install -Install the REST package alongside the core package: +Install `@data-client/rest` with the project's package manager. It ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`); link them from the project root: ```bash -# npm -npm install @data-client/rest - -# yarn -yarn add @data-client/rest - -# pnpm -pnpm add @data-client/rest +npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -## Step 2: Detect Existing HTTP Patterns - -Scan the codebase to determine what's currently used. **Multiple patterns may coexist** — run each applicable migration sub-procedure independently on the relevant files. - -### Detection Checklist - -Check `package.json` dependencies and scan source files: - -| Check | Pattern | Action | -|-------|---------|--------| -| `"axios"` in dependencies, or `import.*from ['"]axios['"]` in source | **Axios** | Follow [references/axios-migration.md](./data-client-rest-setup/references/axios-migration.md) | -| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [references/fetch-migration.md](./data-client-rest-setup/references/fetch-migration.md) | -| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [references/ky-migration.md](./data-client-rest-setup/references/ky-migration.md) | -| `"superagent"` in dependencies | **SuperAgent** | Follow [references/superagent-migration.md](./data-client-rest-setup/references/superagent-migration.md) | -| `"got"` in dependencies (rare in browser code) | **Got** | Follow [references/got-migration.md](./data-client-rest-setup/references/got-migration.md) | -| No existing HTTP library detected | **Fresh project** | Skip to [Step 3: Custom RestEndpoint Base Class](#step-3-custom-restendpoint-base-class) | - -### Ambiguous Detection - -If you cannot confidently determine which patterns are used (e.g., no clear imports but HTTP calls exist), **ask the user**: - -> I found HTTP calls in your codebase but couldn't determine the library. Are you migrating from: -> 1. axios -> 2. Raw fetch / custom fetch wrapper -> 3. ky -> 4. superagent -> 5. Something else (please describe) -> 6. Starting fresh (no migration needed) - -### Mixed Codebases - -When multiple HTTP libraries are detected, run each sub-procedure on the relevant files. The sub-procedures are independent and don't conflict: +- If a skill isn't available by name afterwards, read `.claude/skills//SKILL.md`. +- If the command isn't found, the installed version predates 0.19: run `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. +- Add this line to `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), with the project's package manager in the command, and commit `.agents/skills` and `.claude/skills`: -1. Identify which files use which library (group by import statements) -2. Run each applicable migration sub-procedure on its file group -3. After all migrations, proceed to the base class setup - -## Migration References - -Each migration is a self-contained reference. Read only the relevant one(s) based on detection results above. After completing migrations, return here for base class setup. - -- **Axios** → [references/axios-migration.md](./data-client-rest-setup/references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress - - Run its codemod before any manual edits: `npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/`. -- **Raw fetch** → [references/fetch-migration.md](./data-client-rest-setup/references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling -- **Ky** → [references/ky-migration.md](./data-client-rest-setup/references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config -- **SuperAgent** → [references/superagent-migration.md](./data-client-rest-setup/references/superagent-migration.md) — chained API, plugins, agents, file uploads -- **Got** → [references/got-migration.md](./data-client-rest-setup/references/got-migration.md) — Node.js patterns, hooks, pagination, retry +```md +After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. +``` -## Step 3: Custom RestEndpoint Base Class +## Step 2: Migrate each HTTP library -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. +Check `package.json` and imports. Several libraries can coexist: migrate each one's files with its reference. If HTTP calls exist but the library is unclear, ask the user. -### Detection Checklist +| Detected | Follow | +|----------|--------| +| `axios` | [Axios migration](./data-client-rest-setup/references/axios-migration.md), with the steps below | +| `fetch(` calls or wrappers around fetch | [Fetch migration](./data-client-rest-setup/references/fetch-migration.md) | +| `ky` | [Ky migration](./data-client-rest-setup/references/ky-migration.md) | +| `superagent` | [SuperAgent migration](./data-client-rest-setup/references/superagent-migration.md) | +| `got` | [Got migration](./data-client-rest-setup/references/got-migration.md) | +| none | Step 3 | -Scan the existing codebase for common REST patterns to include: +Axios: -1. **Base URL / API prefix**: Look for hardcoded URLs like `https://api.example.com` or env vars like `process.env.API_URL` -2. **Authentication**: Look for `Authorization` headers, tokens in localStorage/cookies, auth interceptors -3. **Content-Type handling**: Check if API uses JSON, form-data, or custom content types -4. **Error handling**: Look for error response patterns, status code handling -5. **Request/Response transforms**: Data transformations, date parsing, case conversion -6. **Query string format**: Simple params vs nested objects (may need qs library) +1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. +2. Run the codemod before any manual edits, on the source folders (never the repo root): `npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/`. It turns each axios call into an endpoint call, turns each `axios.create()` instance into a base endpoint class, and drops `.data` unless a response interceptor may have unwrapped it. +3. Search the source for `TODO(data-client)`. Each comment names one thing to finish and links its guide section, which is in the reference under the same heading. Resolve it, then delete the comment. +4. Follow the reference's remaining steps. Callers keep loading data the way they did; only the call changes. +5. Run the type checker and tests. When no `axios` import or `TODO(data-client)` comment is left, uninstall axios. -### Base Class Template +## Step 3: Base endpoint class -Create a file at `src/api/BaseEndpoint.ts` (or similar location based on project structure): +Put the project's shared request behavior in one `RestEndpoint` subclass and build every endpoint from it. If a migration made one (the axios codemod does, from `axios.create()`), add to that class instead of creating another. ```ts import { RestEndpoint, RestGenerics } from '@data-client/rest'; -/** - * Base RestEndpoint with project-specific defaults. - * Extend this for all REST API endpoints. - */ export class BaseEndpoint extends RestEndpoint { - // API base URL - adjust based on detected patterns - urlPrefix = process.env.REACT_APP_API_URL ?? 'https://api.example.com'; + urlPrefix = 'https://api.example.com'; - // Add authentication headers getHeaders(headers: HeadersInit): HeadersInit { const token = localStorage.getItem('authToken'); - return { - ...headers, - ...(token && { Authorization: `Bearer ${token}` }), - }; + return { ...headers, ...(token && { Authorization: `Bearer ${token}` }) }; } } ``` -## Common Lifecycle Overrides +Override only what the app does today: -Include these based on what's detected in the codebase. See [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) for full API documentation. +| Behavior | Override | +|----------|----------| +| Auth or other headers (may be `async`, e.g. token refresh) | `getHeaders(headers)` | +| Auth token from React context (Okta, Auth0) | [`hookifyResource()`](./data-client-rest-setup/references/hookifyResource.md); a base class can't read context | +| Cookies, CSRF, other `RequestInit` | `getRequestInit(body)`, spreading `super.getRequestInit(body)` | +| Transform the response: unwrap an envelope like `{ "data": {...}, "meta": {...} }`, convert case | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Not axios's `response.data`: that is axios's wrapper, which the codemod already removed | +| Date fields | Not `process()`: the Entity's `static schema`, e.g. `createdAt: Temporal.Instant.from` (skill "data-client-schema") | +| Status handling, like a 401 redirect | `async fetchResponse(input, init)`: `super.fetchResponse()` throws a `NetworkError` for any non-2xx status, so wrap it in `try`, check `error.status` in `catch`, and rethrow ([Server error messages](./data-client-rest-setup/references/axios-migration.md#server-error-messages) shows it) | +| Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | +| Non-JSON responses (files, text) | The [`content`](./data-client-rest-setup/references/RestEndpoint.md#content) option, e.g. `content: 'blob'`; otherwise `parseResponse(response)` | -### Authentication (async token refresh) +Request bodies need no override: a plain object or array is sent as JSON, and anything else (`FormData`, `URLSearchParams`, a string) as-is ([getRequestInit](./data-client-rest-setup/references/RestEndpoint.md#getRequestInit)). -```ts -async getHeaders(headers: HeadersInit): Promise { - const token = await getValidToken(); // handles refresh - return { - ...headers, - Authorization: `Bearer ${token}`, - }; -} -``` - -### Authentication from React context (Okta, Auth0) +## Defining endpoints -When auth tokens live in React context (not localStorage), `getHeaders()` on a base class **cannot** access them. Use [`hookifyResource()`](./data-client-rest-setup/references/hookifyResource.md) to inject context-derived headers into every endpoint: +**`resource()`** for standard CRUD on one path (`get`, `getList`, `create`, `update`, `partialUpdate`, `delete`): ```ts -import { hookifyResource, resource } from '@data-client/rest'; - -const ArticleResourceBase = resource({ - path: '/articles/:id', - schema: Article, - Endpoint: BaseEndpoint, -}); - -export const ArticleResource = hookifyResource( - ArticleResourceBase, - function useInit() { - const accessToken = useContext(AuthContext); - return { - headers: { Authorization: `Bearer ${accessToken}` }, - }; - }, -); -``` - -Usage: `useSuspense(ArticleResource.useGet(), { id })` — the hook calls `useInit()` on every render, so the token is always fresh from context. - -### Custom Request Init (CSRF, credentials) - -```ts -getRequestInit(body?: RequestInit['body'] | Record): RequestInit { - return { - ...super.getRequestInit(body), - credentials: 'include', // for cookies - headers: { - 'X-CSRF-Token': getCsrfToken(), - }, - }; -} -``` - -### Custom Response Parsing (unwrap data envelope) - -```ts -process(value: any, ...args: any[]) { - // If API wraps responses in { data: ... } - return value.data ?? value; -} -``` - -### Custom Error Handling - -```ts -async fetchResponse(input: RequestInfo, init: RequestInit): Promise { - const response = await super.fetchResponse(input, init); - - if (response.status === 401) { - window.dispatchEvent(new CustomEvent('auth:expired')); - } - - return response; -} -``` - -### Custom Search Params (using qs library) - -```ts -searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); -} -``` - -### Custom parseResponse (handle non-JSON) - -```ts -async parseResponse(response: Response): Promise { - const contentType = response.headers.get('content-type'); - - if (contentType?.includes('text/csv')) { - return parseCSV(await response.text()); - } - - return super.parseResponse(response); -} -``` - -## Full Example with Multiple Overrides - -```ts -import { RestEndpoint, RestGenerics } from '@data-client/rest'; -import qs from 'qs'; - -export class BaseEndpoint extends RestEndpoint { - urlPrefix = process.env.API_URL ?? 'http://localhost:3001/api'; - - async getHeaders(headers: HeadersInit): Promise { - const token = await getAuthToken(); - return { - ...headers, - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - }; - } - - getRequestInit(body?: RequestInit['body'] | Record): RequestInit { - return { - ...super.getRequestInit(body), - credentials: 'include', - }; - } - - searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); - } - - process(value: any, ...args: any[]) { - return value?.data ?? value; - } -} - -async function getAuthToken(): Promise { - return localStorage.getItem('token'); -} -``` - -## Usage After Setup - -Once the base class is created, use it instead of [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) directly. - -### Choosing `resource()` vs individual endpoints - -**Use `resource()`** when an API module has standard CRUD on a single path (list, get, create, update, delete). This is the common case: - -```ts -import { resource } from '@data-client/rest'; -import { BaseEndpoint } from './BaseEndpoint'; -import { Todo } from '../schemas/Todo'; - export const TodoResource = resource({ path: '/todos/:id', schema: Todo, Endpoint: BaseEndpoint, }); -// Provides: TodoResource.get, .getList, .create, .update, .delete, .partialUpdate -``` - -**Use standalone `new BaseEndpoint()`** for non-CRUD operations (search, auth, custom actions) or when the path doesn't match `resource()` conventions: - -```ts -export const loginEndpoint = new BaseEndpoint({ - path: '/auth/login', - method: 'POST' as const, - body: {} as { email: string; password: string }, - schema: undefined, -}); ``` -**Body typing**: Use `body: {} as BodyType` (truthy value) — not `undefined as unknown as BodyType`. The truthy value is needed so the endpoint correctly sends a request body for POST/PUT/PATCH. - -### Coexisting with existing validation (Zod, Yup) - -If the codebase already validates responses with Zod/Yup, prefer **Entity as the source of truth** for types that benefit from caching/normalization. Keep Zod only for types that don't need normalization (auth tokens, form validation types, one-off responses). See the migration reference files for detailed options. - -## Next Steps +**`new BaseEndpoint({ ... })`** for anything else (search, auth, custom actions). Type a body with a truthy value, `body: {} as { email: string; password: string }`, never `undefined as unknown as T`: `body: undefined` means the endpoint sends no body. A `POST`, `PUT` or `PATCH` that sends none needs that `body: undefined`, or its only argument is taken as the body and the path params go missing. -1. **Define Entity classes** (skill "data-client-schema") and **wire them to endpoints via `schema:`** — this is essential, not optional. Endpoints with `schema: undefined` bypass normalization and caching. -2. Apply skill "data-client-rest" for resource and endpoint patterns -3. Apply skill "data-client-react" or "data-client-vue" for hook-based usage +If the codebase validates responses with Zod or Yup, keep that by parsing in `process()` and still add an Entity `schema`; see "Coexisting with Zod or Yup" in the [Axios migration](./data-client-rest-setup/references/axios-migration.md). -## References +## Next steps -Vue projects: read `.vue.md` instead of `.md` when it exists. +1. Define Entity classes (skill "data-client-schema") and set each endpoint's `schema:`. Without one, responses aren't normalized or cached. +2. Apply skill "data-client-rest" for resource and endpoint patterns. +3. Only when the user asks to move components onto the store: apply skill "data-client-react" or "data-client-vue". -- [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) - Full RestEndpoint API -- [resource](./data-client-rest-setup/references/resource.md) - Resource factory function -- [Authentication Guide](./data-client-rest-setup/references/auth.md) - Auth patterns and examples -- [Django Integration](./data-client-rest-setup/references/django.md) - Django REST Framework patterns -- [Axios Migration Guide](https://dataclient.io/rest/guides/axios-migration) - Full axios migration documentation +More: [Authentication](./data-client-rest-setup/references/auth.md), [Django REST Framework](./data-client-rest-setup/references/django.md). diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md index 12b4a2c8af1c..09ca8f5c6279 100644 --- a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md @@ -2,119 +2,280 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios with a declarative, type-safe approach to REST APIs. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components still call them directly, +so how the app loads and stores data doesn't change; only the call shape does. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? -### Type-safe paths +The URL is declared once: path parameters are typed and encoded from the `path` template, and the rest become the query string. Calls don't change. + +```ts title="Before" +const getComments = ({ + userId, + postId, + sort, +}: { + userId: string; + postId: string; + sort?: 'newest' | 'top'; +}) => + // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type. + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +```ts title="After" +const getComments = new RestEndpoint({ + // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error. + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, +}); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: +- **Typed end to end**: path parameters, bodies and responses are typed from each definition. +- **Less code**: [`resource()`](./resource.md) makes a full CRUD API from a `path` and a `schema`. +- **One place for request behavior**: a base endpoint's methods replace interceptors and per-call config. +- **Ready for the store**: the same endpoints and schemas work with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the + normalized cache, whenever you adopt them. -```ts -// axios: no type checking — typo silently produces wrong URL -axios.get(`/users/${usrId}`); +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest ``` -With [`RestEndpoint`](./RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: +### 2. Run the codemod {#codemod} -```ts -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -// TypeScript enforces { id: string } — typos are compile errors -getUser({ id: '1' }); +A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on +your source folders, never the repo root: + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -This also means IDE autocomplete works for every path parameter. +Each axios call becomes a call to an endpoint. Path params, `params` and the body become its arguments, and it resolves to the body, so `.data` goes away. -### Additional benefits +```ts title="Before" +import axios from 'axios'; -- **Normalized cache** — shared entities are deduplicated and updated everywhere automatically -- **Declarative data dependencies** — components declare what data they need via [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to fetch it -- **Optimistic updates** — instant UI feedback before the server responds -- **Zero boilerplate** — [`resource()`](./resource.md) generates a full CRUD API from a `path` and `schema` +const api = axios.create({ baseURL: 'https://api.example.com' }); -## Quick reference +export async function getPost(id: string) { + const res = await api.get(`/posts/${id}`); + return res.data; +} + +export async function createPost(body: { title: string }) { + const { data } = await api.post('/posts', body); + return data; +} +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export async function getPost(id: string) { + const res = await new ApiEndpoint({ path: '/posts/:id' })({ id }); + return res; +} + +export async function createPost(body: { title: string }) { + const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })( + body, + ); + return data; +} +``` -| Axios | @data-client/rest | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseURL` | [`urlPrefix`](./RestEndpoint.md#urlPrefix) | -| `headers` config | [`getHeaders()`](./RestEndpoint.md#getHeaders) | -| `interceptors.request` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) / [`getHeaders()`](./RestEndpoint.md#getHeaders) | -| `interceptors.response` | [`parseResponse()`](./RestEndpoint.md#parseResponse) / [`process()`](./RestEndpoint.md#process) | -| `timeout` | [`AbortSignal.timeout()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) via `signal` | -| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.md#searchParams) / [`searchToString()`](./RestEndpoint.md#searchToString) | -| `cancelToken` / `signal` | `signal` ([AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)) | -| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](./RestEndpoint.md#content) / `'arrayBuffer'` — see [file download](https://dataclient.io/rest/guides/network-transform#file-download) | -| `auth: { username, password }` | [`getHeaders()`](./RestEndpoint.md#getHeaders) with `btoa()` | -| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](./RestEndpoint.md#getHeaders) — see [Django Integration](./django.md) | -| `transformRequest` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | -| `transformResponse` | [`process()`](./RestEndpoint.md#process) | -| `validateStatus` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) | -| `onUploadProgress` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) | -| `isAxiosError` / `error.response` | [`NetworkError`](./RestEndpoint.md#fetchResponse) with `.status` and `.response` | +What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios +response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for +`TODO(data-client)` and resolve each one as you go through the next steps. -## Migration examples +If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only +calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint). -### Basic GET +### 3. Move interceptors into a base endpoint {#base-endpoint} -**Before (axios)** +Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The +codemod creates this class from `axios.create()`; move the interceptors into it. -```ts title="api.ts" +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. + +```ts title="Before" import axios from 'axios'; -export const getUser = (id: string) => - axios.get(`https://api.example.com/users/${id}`); +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +api.interceptors.response.use(response => response.data); + +export default api; ``` -```ts title="usage.ts" -const { data } = await getUser('1'); +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // callout: Runs before every request, like a request interceptor. It can be `async`, to refresh a token first. + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + Authorization: `Bearer ${getToken()}`, + }; + } +} ``` -**After (data-client)** +Response interceptors that change the body, such as converting `snake_case` keys, go in +[`process()`](./RestEndpoint.md#process); see [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels). -```ts title="User" -import { Entity } from '@data-client/rest'; +When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response, +so keep it. The codemod marks these calls when the interceptor is in the same file; if a global +`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there. -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +#### Server error messages + +Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once, +in the base class's [`fetchResponse()`](./RestEndpoint.md#fetchResponse), so call sites get it from +`error.message`: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } } ``` -```ts title="api" -import { RestEndpoint } from '@data-client/rest'; -import User from './User'; +#### Existing endpoint abstractions + +A custom endpoint class that wraps axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend +`RestEndpoint` instead of being replaced. It keeps its backward-compatible methods and gains +[`url()`](./RestEndpoint.md#url), [`getRequestInit()`](./RestEndpoint.md#getRequestInit), +[`fetchResponse()`](./RestEndpoint.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; + + /** @deprecated use url() */ + toDynamicUrl = this.url; +} -export const getUser = new RestEndpoint({ - urlPrefix: 'https://api.example.com', +const getUser = new LegacyEndpoint({ path: '/users/:id', + queryKey: 'user', schema: User, }); ``` -```ts title="Usage" column -import { getUser } from './api'; -getUser({ id: '1' }); -``` +Pass extra members like `queryKey` as options rather than through a custom constructor, so +[`extend()`](./RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps +working. -### Instance with base URL and headers +#### Context-based auth -**Before (axios)** +When tokens come from React context (Okta, Auth0) rather than storage, `getHeaders()` can't +read them. Use [`hookifyResource()`](./hookifyResource.md) to add the headers through a +hook. See the +[authentication guide](./auth.md) for this and other patterns. -```ts title="api.ts" -import axios from 'axios'; +### 4. Define entities and resources {#entities} -const api = axios.create({ - baseURL: 'https://api.example.com', - headers: { 'X-API-Key': 'my-key' }, -}); +The codemod makes endpoints without a `schema`. Define an [Entity](https://dataclient.io/rest/api/Entity) for each type the API returns: its +fields, their types and its primary key. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.md): + +One `resource()` replaces a module of CRUD functions, and `schema` types every response. + +```ts title="Before" +import api from './api'; + +export interface Post { + id: string; + title: string; + body: string; +} + +export const getPosts = () => api.get('/posts'); +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: Omit) => + api.post('/posts', data); +export const updatePost = (id: string, data: Partial) => + api.patch(`/posts/${id}`, data); +export const deletePost = (id: string) => api.delete(`/posts/${id}`); +``` + +```ts title="After" +import { Entity, resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; + +export class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} -export const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +// callout: Gives `getList`, `get`, `getList.push`, `update`, `partialUpdate` and `delete`, all typed. +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); ``` -**After (data-client)** +Try the result against a mock API: ```ts title="Post" import { Entity } from '@data-client/rest'; @@ -161,115 +322,110 @@ import { PostResource } from './PostResource'; PostResource.get({ id: '1' }); ``` -### POST mutation - -**Before (axios)** - -```ts title="api.ts" -import axios from 'axios'; +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for +operations that aren't CRUD, like search, custom actions and auth. -const api = axios.create({ baseURL: 'https://api.example.com' }); +> **Warning** +> +> Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their +> types, and the store can't normalize them when you adopt it. -export const createPost = (data: { title: string; body: string }) => - api.post('/posts', data); -``` +#### Non-standard primary keys -**After (data-client)** +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): -```ts title="Post" +```ts import { Entity } from '@data-client/rest'; -export default class Post extends Entity { - id = ''; - title = ''; - body = ''; - static key = 'Post'; +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } } ``` -```ts title="PostResource" -import { resource } from '@data-client/rest'; -import Post from './Post'; +#### Body typing -export const PostResource = resource({ - urlPrefix: 'https://api.example.com', - path: '/posts/:id', - schema: Post, -}); -``` +Type the body of standalone `POST`, `PUT` and `PATCH` endpoints with `body: {} as BodyType`. Don't use +`undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.md#body)`: undefined` as having +no body argument. [`resource()`](./resource.md) types its endpoints' bodies itself. -```ts title="Usage" column -import { PostResource } from './PostResource'; -PostResource.getList.push({ - title: 'New Post', - body: 'Content', +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, }); ``` -### Interceptors → lifecycle methods - -Axios interceptors map to [RestEndpoint](./RestEndpoint.md) lifecycle methods: +#### Coexisting with Zod or Yup -**Before (axios)** +If the codebase already validates responses with Zod or Yup, choose one approach per type: -```ts title="api.ts" -import axios from 'axios'; +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: -const api = axios.create({ baseURL: 'https://api.example.com' }); + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields give + types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might + send malformed. -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); -``` +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from + normalization (auth tokens, one-off responses). -**After (data-client)** +### 5. Update call sites {#call-sites} -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +Components and services keep their own loading and state; only the call changes. -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = 'https://api.example.com'; +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. - // Equivalent to request interceptor - getHeaders(headers: HeadersInit) { - return { - ...headers, - Authorization: `Bearer ${getToken()}`, - }; - } +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; - // Equivalent to response interceptor (unwrap/transform) - process(value: any, ...args: any) { - return value; - } -} +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); ``` -> **Tip** -> -> `RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +```ts title="After" +import { PostResource } from './api/posts'; -Response interceptors that transform the body, such as converting `snake_case` keys, belong in [`process()`](./RestEndpoint.md#process). See [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels) for a complete example. +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); +``` -### Error handling +Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the normalized cache, is a +separate step you can take later; see [data dependencies](https://dataclient.io/docs/getting-started/data-dependency). -**Before (axios)** +### 6. Migrate error handling {#error-handling} -```ts +`NetworkError` has the `status` and the raw `Response`, in place of axios's `error.response`. + +```ts title="Before" import axios from 'axios'; +import { getPost } from './api/posts'; try { - const { data } = await axios.get('/users/1'); + const { data } = await getPost('1'); } catch (err) { if (axios.isAxiosError(err)) { console.log(err.response?.status); @@ -278,13 +434,12 @@ try { } ``` -**After (data-client)** - -```ts +```ts title="After" import { NetworkError } from '@data-client/rest'; +import { PostResource } from './api/posts'; try { - const user = await getUser({ id: '1' }); + const post = await PostResource.get({ id: '1' }); } catch (err) { if (err instanceof NetworkError) { console.log(err.status); @@ -293,84 +448,67 @@ try { } ``` -[`NetworkError`](./RestEndpoint.md#fetchResponse) provides `.status` and `.response` (the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object). For soft retries on server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). - -#### Server error messages - -Axios codebases commonly surface `error.response.data.error` or `.message` to the user. Read it from the `Response` body instead, once, in the base class's [`fetchResponse()`](./RestEndpoint.md#fetchResponse), so call sites get it from `error.message` without parsing the body: +Read a server's error message from the body once, in the base endpoint; see +[server error messages](#server-error-messages). -```ts title="ApiEndpoint.ts" -import { - NetworkError, - RestEndpoint, - RestGenerics, -} from '@data-client/rest'; +### 7. Remove axios {#remove-axios} -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - async fetchResponse(input: RequestInfo, init: RequestInit) { - try { - return await super.fetchResponse(input, init); - } catch (error) { - if (error instanceof NetworkError) { - const body = await error.response - .clone() - .json() - .catch(() => null); - // keep the NetworkError so `status` and `errorPolicy()` still work - error.message = body?.error ?? body?.message ?? error.message; - } - throw error; - } - } -} -``` +Search for what still uses axios: -### Cancellation +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `TODO\(data-client\)` | what the codemod left | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | -**Before (axios)** +Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the +type checker and tests: -```ts -import axios from 'axios'; +```bash +npm uninstall axios +``` + +## Axios options reference {#quick-reference} + +| Axios | @data-client/rest | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](./RestEndpoint.md#urlPrefix) | +| `headers` config | [`getHeaders()`](./RestEndpoint.md#getHeaders) | +| `interceptors.request` | [`getHeaders()`](./RestEndpoint.md#getHeaders) / [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | +| `interceptors.response` | [`parseResponse()`](./RestEndpoint.md#parseResponse) / [`process()`](./RestEndpoint.md#process) | +| `transformRequest` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | +| `transformResponse` | [`process()`](./RestEndpoint.md#process) | +| `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | +| `cancelToken` / `signal` | [`signal`](#cancellation) | +| `timeout` | [`AbortSignal.timeout()`](#timeout) in `getRequestInit()` | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](#binary-responses) / `'arrayBuffer'` | +| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.md#searchParams) / [`searchToString()`](#query-serialization) | +| `auth: { username, password }` | [`getHeaders()` with `btoa()`](#basic-auth) | +| `validateStatus` | [Custom `fetchResponse()`](#accepting-error-statuses) | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](#csrf-headers) | +| `onUploadProgress` | [Custom `fetchResponse()` with XMLHttpRequest](#upload-progress) | -const controller = new AbortController(); -axios.get('/users', { signal: controller.signal }); -controller.abort(); -``` +### Cancellation -Or with the deprecated `CancelToken`: +Both axios's `signal` and the deprecated `CancelToken` map to an +[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`: -```ts +```ts title="Before" const source = axios.CancelToken.source(); -axios.get('/users', { cancelToken: source.token }); +axios.get('/users/1', { cancelToken: source.token }); source.cancel(); ``` -**After (data-client)** - -Both map to an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`. The [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) hook automatically cancels in-flight requests when parameters change: - -```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; -import { searchEndpoint } from './api/search'; -import ResultsList from './ResultsList'; - -function SearchResults({ query }: { query: string }) { - const results = useSuspense(useCancelling(searchEndpoint), { q: query }); - return ; -} -``` - -For manual cancellation, pass `signal` directly: - -```ts +```ts title="After" +const getUser = new ApiEndpoint({ path: '/users/:id' }); const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); +// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests. +getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` @@ -378,28 +516,48 @@ See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns ### Timeout -```ts title="Before (axios)" +```ts title="Before" axios.get('/users', { timeout: 5000 }); ``` -```ts title="After (data-client)" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // callout: Runs for each request, so each one gets its own 5 seconds. `AbortSignal.timeout()` passed to the constructor would start counting once, when the endpoint is created. + async getRequestInit( + body?: RequestInit['body'] | Record, + ) { + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; + return { ...init, signal }; + } +} ``` ### Binary responses -```ts title="Before (axios)" +```ts title="Before" axios.get('/files/1', { responseType: 'blob' }); ``` -Set [`content`](./RestEndpoint.md#content) to `'blob'`, `'arrayBuffer'` or `'text'`. See [file download](https://dataclient.io/rest/guides/network-transform#file-download) for the full endpoint and triggering a browser download. +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', +}); +``` + +[`content`](./RestEndpoint.md#content) also takes `'arrayBuffer'` and `'text'`. See +[file download](https://dataclient.io/rest/guides/network-transform#file-download) to trigger a browser download. ### Query serialization -```ts title="Before (axios)" +```ts title="Before" axios.get('/users', { params: { ids: [1, 2, 3] }, paramsSerializer: params => @@ -407,15 +565,30 @@ axios.get('/users', { }); ``` -Override [`searchToString()`](./RestEndpoint.md#searchToString) to serialize with `qs`; see [using the `qs` library](./RestEndpoint.md#searchToString). +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams: Record) { + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); + } +} +``` + +axios leaves out `null` and `undefined` params, but the default `searchToString()` uses +[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth -```ts title="Before (axios)" +```ts title="Before" axios.get('/api', { auth: { username: 'user', password: 'pass' } }); ``` -```ts title="After (data-client)" +```ts title="After" export default class BasicAuthEndpoint< O extends RestGenerics = any, > extends RestEndpoint { @@ -430,13 +603,14 @@ export default class BasicAuthEndpoint< ### Accepting error statuses -[`fetchResponse()`](./RestEndpoint.md#fetchResponse) throws [`NetworkError`](./RestEndpoint.md#fetchResponse) for any non-`ok` status. Override it to change what counts as an error: +[`fetchResponse()`](./RestEndpoint.md#fetchResponse) throws [`NetworkError`](#error-handling) for any status that +isn't `ok`. Override it to change what counts as an error: -```ts title="Before (axios)" +```ts title="Before" axios.get('/api', { validateStatus: status => status < 500 }); ``` -```ts title="After (data-client)" +```ts title="After" import { NetworkError, RestEndpoint, @@ -456,26 +630,30 @@ export default class LenientEndpoint< ### CSRF headers -```ts title="Before (axios)" +```ts title="Before" axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken', }); ``` -Read the cookie in [`getHeaders()`](./RestEndpoint.md#getHeaders) for non-`GET` requests. See [Django Integration](./django.md) for the complete endpoint class. +Read the cookie in [`getHeaders()`](./RestEndpoint.md#getHeaders) for requests that aren't `GET`. See +[Django Integration](./django.md) for the complete endpoint class. ### Upload progress -`fetch` cannot report upload progress, so use [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside [`fetchResponse()`](./RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like any other member. +`fetch` can't report upload progress, so use +[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside +[`fetchResponse()`](./RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like +any other member. -```ts title="Before (axios)" +```ts title="Before" axios.post('/upload', formData, { onUploadProgress: e => console.log(e.loaded / e.total), }); ``` -```ts title="After (data-client)" +```ts title="UploadEndpoint.ts" import { NetworkError, RestEndpoint, @@ -544,218 +722,9 @@ const uploadFile = new UploadEndpoint({ }); ``` -## Codemod {#codemod} - -A standalone [jscodeshift](https://github.com/facebook/jscodeshift) codemod handles the mechanical parts of migration. - -```bash -npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ -``` - -The codemod automatically: - -- Replaces `import axios from 'axios'` with `import { RestEndpoint } from '@data-client/rest'` -- Converts `axios.create({ baseURL, headers })` into a base `RestEndpoint` subclass with `urlPrefix` and `getHeaders()` -- Transforms `axios.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into `new CreatedClassName({ path, method })` - -The codemod has little to do when the project wraps axios in its own class or function and never calls `axios.get()`/`.post()` directly, or only calls `axios(config)` without a method name. In those cases, skip it and start with [the manual steps](#after-the-codemod). - -The codemod does **not** handle: - -- Interceptors — see [lifecycle methods](#interceptors--lifecycle-methods) -- Error handling (`isAxiosError`, `error.response`) — see [error handling](#error-handling) -- The rest of the [quick reference](#quick-reference) — see the [migration examples](#migration-examples) above -- [Entity](https://dataclient.io/rest/api/Entity) schema definitions and converting call sites to hooks — see [below](#after-the-codemod) - -### Finding remaining axios usage - -Search patterns for locating what still needs migrating: - -| Pattern | Finds | -| ------------------------------------------ | ------------------------- | -| `import.*from ['"]axios['"]` | import statements | -| `axios\.create` | instance creation | -| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | -| `\.interceptors\.(request\|response)\.use` | interceptors | -| `isAxiosError` | error handling | -| `cancelToken\|CancelToken` | cancellation (deprecated) | -| `onUploadProgress\|onDownloadProgress` | progress callbacks | - -## After the codemod {#after-the-codemod} - -The codemod produces endpoints without schemas. Defining [Entity](https://dataclient.io/rest/api/Entity) schemas and wiring them to endpoints enables normalization and caching — the core value of Reactive Data Client. - -### Non-standard primary keys - -Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): - -```ts -import { Entity } from '@data-client/rest'; - -export class User extends Entity { - _id = ''; - name = ''; - email = ''; - static key = 'User'; - - pk() { - return this._id; - } -} -``` - -### Group CRUD endpoints with resource() - -When an axios module has separate `getUsers`, `getUser`, `createUser`, `updateUser` and `deleteUser` functions for one path, replace them with a single [`resource()`](./resource.md): - -```ts -import { resource } from '@data-client/rest'; -import ApiEndpoint from './ApiEndpoint'; -import { User } from './User'; - -export const UserResource = resource({ - path: '/users/:id', - schema: User, - Endpoint: ApiEndpoint, -}); -// UserResource.getList, .get, .getList.push, .update, .partialUpdate, .delete -``` - -Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Reserve standalone `new ApiEndpoint()` for non-CRUD operations (search, custom actions, auth). - -### Coexisting with Zod or Yup - -If the codebase already validates responses with Zod or Yup, choose one approach per type: - -- **Zod in `process()`** (recommended): keep runtime validation by parsing in [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: - - ```ts - const getUser = new ApiEndpoint({ - path: '/users/:id', - schema: User, - process(value: any) { - return userSchema.parse(value); - }, - }); - ``` - -- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields provide types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might send malformed. - -- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't benefit from normalization (auth tokens, one-off responses). - -> **Warning** -> -> Don't define Entity classes and then leave `schema` unset on every endpoint — without `schema`, nothing is normalized and the migration gains little over axios. - -### Body typing - -Type the body of standalone `POST`/`PUT`/`PATCH` endpoints with `body: {} as BodyType`. Don't use `undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.md#body)`: undefined` as having no body argument. - -```ts -const createUser = new ApiEndpoint({ - path: '/users', - method: 'POST', - body: {} as { name: string; email: string }, - schema: User, -}); -``` - -[`resource()`](./resource.md) types its CRUD endpoints automatically. - -### Convert call sites to hooks - -**Before (axios)** - -```tsx -import { useEffect, useState } from 'react'; -import api from './lib/api'; -import { Spinner } from './Spinner'; -import type { User } from './User'; - -function UserProfile({ id }: { id: string }) { - const [user, setUser] = useState(null); - useEffect(() => { - api.get(`/users/${id}`).then(({ data }) => setUser(data)); - }, [id]); - if (!user) return ; - return

{user.name}

; -} -``` - -**After (data-client)** - -```tsx -import { useSuspense } from '@data-client/react'; -import { UserResource } from './UserResource'; - -function UserProfile({ id }: { id: string }) { - const user = useSuspense(UserResource.get, { id }); - return

{user.name}

; -} -``` - -Loading and error states move to [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary). See [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) for details. - -### Context-based auth - -When tokens come from React context (Okta, Auth0) rather than storage, use [`hookifyResource()`](./hookifyResource.md) to inject headers through a hook. See the [authentication guide](./auth.md) for this and other patterns. - -### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks temporarily but fetch through [`controller.fetch()`](https://dataclient.io/docs/api/Controller#fetch). Calling an endpoint directly only runs its fetch; going through the Controller also normalizes the response into the shared cache, so data is consistent from day one: - -```ts -import { useController } from '@data-client/react'; -import { useQuery } from '@tanstack/react-query'; -import ApiEndpoint from './ApiEndpoint'; -import { Project } from './Project'; - -export const getProject = new ApiEndpoint({ - path: '/projects/:id', - schema: Project, -}); - -export function useProject(id: string) { - const ctrl = useController(); - return useQuery({ - queryKey: ['project', id], - queryFn: () => ctrl.fetch(getProject, { id }), - }); -} -``` - -Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. - -### Existing endpoint abstractions - -Codebases that already have a custom endpoint class wrapping axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend `RestEndpoint` instead of replacing it, keeping backward-compatible methods while gaining [`url()`](./RestEndpoint.md#url), [`getRequestInit()`](./RestEndpoint.md#getRequestInit), [`fetchResponse()`](./RestEndpoint.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.md#parseResponse): - -```ts -import { RestEndpoint, RestGenerics } from '@data-client/rest'; - -export class LegacyEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = API_ROOT; - declare queryKey?: string; - - /** @deprecated use url() */ - toDynamicUrl = this.url; -} - -const getUser = new LegacyEndpoint({ - path: '/users/:id', - queryKey: 'user', - schema: User, -}); -``` - -Pass extra members like `queryKey` as options rather than through a custom constructor, so [`extend()`](./RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps working. - ## Related guides -- [Authentication](./auth.md) — token and cookie auth patterns -- [Aborting Fetch](https://dataclient.io/rest/guides/abort) — cancellation and debouncing -- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform) — response transforms, field renaming, file downloads -- [Django Integration](./django.md) — CSRF and cookie auth for Django +- [Authentication](./auth.md): token and cookie auth patterns +- [Aborting Fetch](https://dataclient.io/rest/guides/abort): cancellation and debouncing +- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform): response transforms, field renaming, file downloads +- [Django Integration](./django.md): CSRF and cookie auth for Django diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.vue.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.vue.md new file mode 100644 index 000000000000..2965666de76e --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.vue.md @@ -0,0 +1,730 @@ + + +# Migrating from Axios + +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components still call them directly, +so how the app loads and stores data doesn't change; only the call shape does. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. + +## Why migrate? + +The URL is declared once: path parameters are typed and encoded from the `path` template, and the rest become the query string. Calls don't change. + +```ts title="Before" +const getComments = ({ + userId, + postId, + sort, +}: { + userId: string; + postId: string; + sort?: 'newest' | 'top'; +}) => + // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type. + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +```ts title="After" +const getComments = new RestEndpoint({ + // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error. + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, +}); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +- **Typed end to end**: path parameters, bodies and responses are typed from each definition. +- **Less code**: [`resource()`](./resource.vue.md) makes a full CRUD API from a `path` and a `schema`. +- **One place for request behavior**: a base endpoint's methods replace interceptors and per-call config. +- **Ready for the store**: the same endpoints and schemas work with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense) and the + normalized cache, whenever you adopt them. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +### 2. Run the codemod {#codemod} + +A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on +your source folders, never the repo root: + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ +``` + +Each axios call becomes a call to an endpoint. Path params, `params` and the body become its arguments, and it resolves to the body, so `.data` goes away. + +```ts title="Before" +import axios from 'axios'; + +const api = axios.create({ baseURL: 'https://api.example.com' }); + +export async function getPost(id: string) { + const res = await api.get(`/posts/${id}`); + return res.data; +} + +export async function createPost(body: { title: string }) { + const { data } = await api.post('/posts', body); + return data; +} +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export async function getPost(id: string) { + const res = await new ApiEndpoint({ path: '/posts/:id' })({ id }); + return res; +} + +export async function createPost(body: { title: string }) { + const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })( + body, + ); + return data; +} +``` + +What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios +response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for +`TODO(data-client)` and resolve each one as you go through the next steps. + +If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only +calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint). + +### 3. Move interceptors into a base endpoint {#base-endpoint} + +Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The +codemod creates this class from `axios.create()`; move the interceptors into it. + +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. + +```ts title="Before" +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +api.interceptors.response.use(response => response.data); + +export default api; +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // callout: Runs before every request, like a request interceptor. It can be `async`, to refresh a token first. + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + Authorization: `Bearer ${getToken()}`, + }; + } +} +``` + +Response interceptors that change the body, such as converting `snake_case` keys, go in +[`process()`](./RestEndpoint.vue.md#process); see [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels). + +When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response, +so keep it. The codemod marks these calls when the interceptor is in the same file; if a global +`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there. + +#### Server error messages + +Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once, +in the base class's [`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse), so call sites get it from +`error.message`: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } +} +``` + +#### Existing endpoint abstractions + +A custom endpoint class that wraps axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend +`RestEndpoint` instead of being replaced. It keeps its backward-compatible methods and gains +[`url()`](./RestEndpoint.vue.md#url), [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit), +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.vue.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; + + /** @deprecated use url() */ + toDynamicUrl = this.url; +} + +const getUser = new LegacyEndpoint({ + path: '/users/:id', + queryKey: 'user', + schema: User, +}); +``` + +Pass extra members like `queryKey` as options rather than through a custom constructor, so +[`extend()`](./RestEndpoint.vue.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps +working. + +#### Context-based auth + +When tokens come from `inject()` (Okta, Auth0) rather than storage, `getHeaders()` can't +read them. Use [`hookifyResource()`](./hookifyResource.vue.md) to add the headers through a +composable. See the +[authentication guide](./auth.vue.md) for this and other patterns. + +### 4. Define entities and resources {#entities} + +The codemod makes endpoints without a `schema`. Define an [Entity](https://dataclient.io/rest/api/Entity) for each type the API returns: its +fields, their types and its primary key. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.vue.md): + +One `resource()` replaces a module of CRUD functions, and `schema` types every response. + +```ts title="Before" +import api from './api'; + +export interface Post { + id: string; + title: string; + body: string; +} + +export const getPosts = () => api.get('/posts'); +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: Omit) => + api.post('/posts', data); +export const updatePost = (id: string, data: Partial) => + api.patch(`/posts/${id}`, data); +export const deletePost = (id: string) => api.delete(`/posts/${id}`); +``` + +```ts title="After" +import { Entity, resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; + +export class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} + +// callout: Gives `getList`, `get`, `getList.push`, `update`, `partialUpdate` and `delete`, all typed. +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); +``` + +Try the result against a mock API: + +```ts title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} +``` + +```ts title="ApiEndpoint" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + }; + } +} +``` + +```ts title="PostResource" +import { resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; +import Post from './Post'; + +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); +``` + +```ts title="Usage" column +import { PostResource } from './PostResource'; +PostResource.get({ id: '1' }); +``` + +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for +operations that aren't CRUD, like search, custom actions and auth. + +> **Warning** +> +> Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their +> types, and the store can't normalize them when you adopt it. + +#### Non-standard primary keys + +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): + +```ts +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } +} +``` + +#### Body typing + +Type the body of standalone `POST`, `PUT` and `PATCH` endpoints with `body: {} as BodyType`. Don't use +`undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.vue.md#body)`: undefined` as having +no body argument. [`resource()`](./resource.vue.md) types its endpoints' bodies itself. + +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, +}); +``` + +#### Coexisting with Zod or Yup + +If the codebase already validates responses with Zod or Yup, choose one approach per type: + +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.vue.md#process), and let the Entity handle normalization: + + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` + +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields give + types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might + send malformed. + +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from + normalization (auth tokens, one-off responses). + +### 5. Update call sites {#call-sites} + +Components and services keep their own loading and state; only the call changes. + +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. + +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; + +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); +``` + +```ts title="After" +import { PostResource } from './api/posts'; + +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); +``` + +Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense) and the normalized cache, is a +separate step you can take later; see [data dependencies](https://dataclient.io/vue/getting-started/data-dependency). + +### 6. Migrate error handling {#error-handling} + +`NetworkError` has the `status` and the raw `Response`, in place of axios's `error.response`. + +```ts title="Before" +import axios from 'axios'; +import { getPost } from './api/posts'; + +try { + const { data } = await getPost('1'); +} catch (err) { + if (axios.isAxiosError(err)) { + console.log(err.response?.status); + console.log(err.response?.data); + } +} +``` + +```ts title="After" +import { NetworkError } from '@data-client/rest'; +import { PostResource } from './api/posts'; + +try { + const post = await PostResource.get({ id: '1' }); +} catch (err) { + if (err instanceof NetworkError) { + console.log(err.status); + console.log(err.response); + } +} +``` + +Read a server's error message from the body once, in the base endpoint; see +[server error messages](#server-error-messages). + +### 7. Remove axios {#remove-axios} + +Search for what still uses axios: + +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `TODO\(data-client\)` | what the codemod left | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | + +Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the +type checker and tests: + +```bash +npm uninstall axios +``` + +## Axios options reference {#quick-reference} + +| Axios | @data-client/rest | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](./RestEndpoint.vue.md#urlPrefix) | +| `headers` config | [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) | +| `interceptors.request` | [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) / [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit) | +| `interceptors.response` | [`parseResponse()`](./RestEndpoint.vue.md#parseResponse) / [`process()`](./RestEndpoint.vue.md#process) | +| `transformRequest` | [`getRequestInit()`](./RestEndpoint.vue.md#getRequestInit) | +| `transformResponse` | [`process()`](./RestEndpoint.vue.md#process) | +| `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | +| `cancelToken` / `signal` | [`signal`](#cancellation) | +| `timeout` | [`AbortSignal.timeout()`](#timeout) in `getRequestInit()` | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](#binary-responses) / `'arrayBuffer'` | +| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.vue.md#searchParams) / [`searchToString()`](#query-serialization) | +| `auth: { username, password }` | [`getHeaders()` with `btoa()`](#basic-auth) | +| `validateStatus` | [Custom `fetchResponse()`](#accepting-error-statuses) | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](#csrf-headers) | +| `onUploadProgress` | [Custom `fetchResponse()` with XMLHttpRequest](#upload-progress) | + +### Cancellation + +Both axios's `signal` and the deprecated `CancelToken` map to an +[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`: + +```ts title="Before" +const source = axios.CancelToken.source(); +axios.get('/users/1', { cancelToken: source.token }); +source.cancel(); +``` + +```ts title="After" +const getUser = new ApiEndpoint({ path: '/users/:id' }); +const controller = new AbortController(); +// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests. +getUser.extend({ signal: controller.signal })({ id: '1' }); +controller.abort(); +``` + +See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. + +### Timeout + +```ts title="Before" +axios.get('/users', { timeout: 5000 }); +``` + +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // callout: Runs for each request, so each one gets its own 5 seconds. `AbortSignal.timeout()` passed to the constructor would start counting once, when the endpoint is created. + async getRequestInit( + body?: RequestInit['body'] | Record, + ) { + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; + return { ...init, signal }; + } +} +``` + +### Binary responses + +```ts title="Before" +axios.get('/files/1', { responseType: 'blob' }); +``` + +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', +}); +``` + +[`content`](./RestEndpoint.vue.md#content) also takes `'arrayBuffer'` and `'text'`. See +[file download](https://dataclient.io/rest/guides/network-transform#file-download) to trigger a browser download. + +### Query serialization + +```ts title="Before" +axios.get('/users', { + params: { ids: [1, 2, 3] }, + paramsSerializer: params => + qs.stringify(params, { arrayFormat: 'repeat' }), +}); +``` + +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams: Record) { + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); + } +} +``` + +axios leaves out `null` and `undefined` params, but the default `searchToString()` uses +[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.vue.md#searchToString). + +### Basic auth + +```ts title="Before" +axios.get('/api', { auth: { username: 'user', password: 'pass' } }); +``` + +```ts title="After" +export default class BasicAuthEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Basic ${btoa('user:pass')}`, + }; + } +} +``` + +### Accepting error statuses + +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse) throws [`NetworkError`](#error-handling) for any status that +isn't `ok`. Override it to change what counts as an error: + +```ts title="Before" +axios.get('/api', { validateStatus: status => status < 500 }); +``` + +```ts title="After" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class LenientEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + const response = await fetch(input, init); + if (response.status >= 500) throw new NetworkError(response); + return response; + } +} +``` + +### CSRF headers + +```ts title="Before" +axios.create({ + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', +}); +``` + +Read the cookie in [`getHeaders()`](./RestEndpoint.vue.md#getHeaders) for requests that aren't `GET`. See +[Django Integration](./django.md) for the complete endpoint class. + +### Upload progress + +`fetch` can't report upload progress, so use +[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside +[`fetchResponse()`](./RestEndpoint.vue.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like +any other member. + +```ts title="Before" +axios.post('/upload', formData, { + onUploadProgress: e => console.log(e.loaded / e.total), +}); +``` + +```ts title="UploadEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class UploadEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare onProgress?: (progress: number) => void; + + fetchResponse(input: RequestInfo, init: RequestInit) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const abort = () => xhr.abort(); + const abortError = () => + new DOMException('The operation was aborted.', 'AbortError'); + if (init.signal?.aborted) return reject(abortError()); + init.signal?.addEventListener('abort', abort, { once: true }); + + xhr.open( + init.method ?? 'POST', + typeof input === 'string' ? input : input.url, + ); + new Headers(init.headers).forEach((value, key) => + xhr.setRequestHeader(key, value), + ); + xhr.onloadend = () => + init.signal?.removeEventListener('abort', abort); + xhr.upload.onprogress = e => { + if (e.lengthComputable) this.onProgress?.(e.loaded / e.total); + }; + xhr.onload = () => { + const headers = new Headers(); + for (const line of xhr + .getAllResponseHeaders() + .trim() + .split(/\r?\n/)) { + const [key, ...rest] = line.split(': '); + if (key) headers.append(key, rest.join(': ')); + } + // 204, 205 and 304 responses can't have a body + const body = [204, 205, 304].includes(xhr.status) + ? null + : xhr.response; + const response = new Response(body, { + status: xhr.status, + statusText: xhr.statusText, + headers, + }); + if (response.ok) resolve(response); + else reject(new NetworkError(response)); + }; + xhr.onerror = () => reject(new TypeError('Network request failed')); + xhr.onabort = () => reject(abortError()); + xhr.send(init.body as XMLHttpRequestBodyInit | null); + }); + } +} + +const uploadFile = new UploadEndpoint({ + path: '/upload', + method: 'POST', + body: {} as FormData, + onProgress: (progress: number) => console.log(progress), +}); +``` + +## Related guides + +- [Authentication](./auth.vue.md): token and cookie auth patterns +- [Aborting Fetch](https://dataclient.io/rest/guides/abort): cancellation and debouncing +- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform): response transforms, field renaming, file downloads +- [Django Integration](./django.md): CSRF and cookie auth for Django diff --git a/.agents/skills/data-client/SKILL.md b/.agents/skills/data-client/SKILL.md index fd62a77efeca..854af3505d2c 100644 --- a/.agents/skills/data-client/SKILL.md +++ b/.agents/skills/data-client/SKILL.md @@ -15,6 +15,9 @@ Read `../data-client-setup/SKILL.md` (next to this one) and follow it. It isn't installs; the user asking to add or use Data Client in this app is the go-ahead. If they only asked about Data Client, answer and offer to set it up instead. +To only swap an HTTP client (axios, fetch, ky, superagent, got) for `@data-client/rest` endpoints, without a provider +or store changes, follow `../data-client-setup/references/data-client-rest-setup.md` instead. + ## Set up Load the skill for the work. If it isn't available by name but its package is installed, run @@ -30,9 +33,10 @@ ship into the project's skill folder, then read `.claude/skills//SKILL.md | Tests | `data-client-react-testing` or `data-client-vue-testing` | `react` or `vue` | | WebSockets, SSE, polling, global side effects | `data-client-manager` | `core` | -To add a protocol the app doesn't depend on yet, or to write GraphQL queries or wrap async functions, follow its guide -in `data-client-setup` (`../data-client-setup/references/`): `data-client-rest-setup.md`, -`data-client-graphql-setup.md` or `data-client-endpoint-setup.md`, installing only packages the app lacks. They +To add a protocol the app doesn't depend on yet, move axios or fetch calls to REST endpoints, write GraphQL queries or +wrap async functions, follow its guide in `data-client-setup` (`../data-client-setup/references/`): +`data-client-rest-setup.md`, `data-client-graphql-setup.md` or `data-client-endpoint-setup.md`, installing only packages +the app lacks. They aren't loaded by name. -If a skill named here isn't installed, follow [references/install.md](references/install.md). +If a skill named here isn't installed, follow [Installing a missing skill](references/install.md). diff --git a/.changeset/axios-codemod-calls.md b/.changeset/axios-codemod-calls.md new file mode 100644 index 000000000000..efe75665d6ce --- /dev/null +++ b/.changeset/axios-codemod-calls.md @@ -0,0 +1,31 @@ +--- +'@data-client/codemods': patch +--- + +`axios-to-rest` now turns axios calls into working endpoint calls + +It used to swap `api.get(url)` for `new ApiEndpoint({ path })` without calling it, so `await` returned the endpoint +instead of data, and POST bodies and `params` were dropped. Now it calls the endpoint, passes path params, `params`, +query strings and bodies as arguments, and reads the body where the code read `.data`: + +```ts +// before the codemod +const res = await api.get(`/posts/${id}`, { params: { expand: 'author' } }); +return res.data; +``` + +```ts +// after the codemod +const res = await (new ApiEndpoint({ + path: '/posts/:id', + searchParams: {} as Record, +})({ id, expand: 'author' }) as Promise); +return res; +``` + +Calls through an `axios.create()` instance that another file exports, like `import { api } from './client'`, are +converted too, and their import becomes the instance's new endpoint class. + +Anything it can't convert, such as interceptors, `timeout` or `isAxiosError()`, gets a `// TODO(data-client):` comment +that links the [axios migration guide](https://dataclient.io/rest/guides/axios-migration) section covering it. Search +for `TODO(data-client)` after running it. diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 8ce9d3cba992..5566272812c0 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -3,164 +3,329 @@ title: Migrating from Axios to Reactive Data Client sidebar_label: Axios Migration --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; -import SkillTabs from '@site/src/components/SkillTabs'; +import PkgTabs from '@site/src/components/PkgTabs'; +import DiffEditor from '@site/src/components/DiffEditor'; import SiteOnly from '@site/src/components/SiteOnly'; +import AgentOnly from '@site/src/components/AgentOnly'; # Migrating from Axios -[`@data-client/rest`](/rest) replaces axios with a declarative, type-safe approach to REST APIs. +[`@data-client/rest`](/rest) replaces axios calls with typed endpoint definitions. Components still call them directly, +so how the app loads and stores data doesn't change; only the call shape does. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. -## AI-assisted migration {#skill} +## Migrate with your coding agent {#agent} -Install the REST setup skill to automate the migration with your AI coding assistant. It auto-detects axios in your project and runs the [codemod](#codemod) for deterministic transforms, then guides you through the manual steps that require judgment (interceptors, error handling, schema definitions, etc.). - - - -Then start the migration in your agent's chat. It will detect axios and apply the appropriate migration sub-procedure automatically. +Paste this into your agent's chat: ```prompt -/data-client-rest-setup +Replace this app's axios calls with `@data-client/rest` endpoints, without changing how components load or store data. Install the REST setup skill first: `npx skills add reactive/data-client --skill data-client-rest-setup -y`, then read `.agents/skills/data-client-rest-setup/SKILL.md` and follow it. ``` +The REST setup [skill](/docs/getting-started/agent-skills) follows this guide: it runs the codemod, then works through +the [steps](#steps) the codemod can't do. Review its diff like a teammate's. + + + +To migrate an app from axios, install the REST setup skill from the project root: + +```bash +npx skills add reactive/data-client --skill data-client-rest-setup -y +``` + +Then read `.agents/skills/data-client-rest-setup/SKILL.md` and follow it; it detects axios and follows the +[steps](#steps) below. Only replace the axios calls and their definitions: leave components, data loading and state +management as they are, unless the user asks to change them. + + + ## Why migrate? -### Type-safe paths + + +```ts title="Before" +const getComments = ({ + userId, + postId, + sort, +}: { + userId: string; + postId: string; + sort?: 'newest' | 'top'; +}) => + // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type. + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); + +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` + +```ts title="After" +const getComments = new RestEndpoint({ + // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error. + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, +}); -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); +``` -```ts -// axios: no type checking — typo silently produces wrong URL -axios.get(`/users/${usrId}`); + + +- **Typed end to end**: path parameters, bodies and responses are typed from each definition. +- **Less code**: [`resource()`](../api/resource.md) makes a full CRUD API from a `path` and a `schema`. +- **One place for request behavior**: a base endpoint's methods replace interceptors and per-call config. +- **Ready for the store**: the same endpoints and schemas work with [`useSuspense()`](/docs/api/useSuspense) and the + normalized cache, whenever you adopt them. + +## Migrate step by step {#steps} + + + +These are the steps your agent follows. Commit before each one, so you can review its diff. + + + +### 1. Install {#install} + + + +### 2. Run the codemod {#codemod} + +A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on +your source folders, never the repo root: + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -With [`RestEndpoint`](../api/RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: + -```ts -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -// TypeScript enforces { id: string } — typos are compile errors -getUser({ id: '1' }); -``` - -This also means IDE autocomplete works for every path parameter. - -### Additional benefits - -- **Normalized cache** — shared entities are deduplicated and updated everywhere automatically -- **Declarative data dependencies** — components declare what data they need via [`useSuspense()`](/docs/api/useSuspense), not how to fetch it -- **Optimistic updates** — instant UI feedback before the server responds -- **Zero boilerplate** — [`resource()`](../api/resource.md) generates a full CRUD API from a `path` and `schema` - -## Quick reference - -| Axios | @data-client/rest | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseURL` | [`urlPrefix`](../api/RestEndpoint.md#urlPrefix) | -| `headers` config | [`getHeaders()`](../api/RestEndpoint.md#getHeaders) | -| `interceptors.request` | [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit) / [`getHeaders()`](../api/RestEndpoint.md#getHeaders) | -| `interceptors.response` | [`parseResponse()`](../api/RestEndpoint.md#parseResponse) / [`process()`](../api/RestEndpoint.md#process) | -| `timeout` | [`AbortSignal.timeout()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) via `signal` | -| `params` / `paramsSerializer` | [`searchParams`](../api/RestEndpoint.md#searchParams) / [`searchToString()`](../api/RestEndpoint.md#searchToString) | -| `cancelToken` / `signal` | `signal` ([AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)) | -| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](../api/RestEndpoint.md#content) / `'arrayBuffer'` — see [file download](./network-transform.md#file-download) | -| `auth: { username, password }` | [`getHeaders()`](../api/RestEndpoint.md#getHeaders) with `btoa()` | -| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](../api/RestEndpoint.md#getHeaders) — see [Django Integration](./django.md) | -| `transformRequest` | [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit) | -| `transformResponse` | [`process()`](../api/RestEndpoint.md#process) | -| `validateStatus` | Custom [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) | -| `onUploadProgress` | Custom [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) | -| `isAxiosError` / `error.response` | [`NetworkError`](../api/RestEndpoint.md#fetchResponse) with `.status` and `.response` | - -## Migration examples - -### Basic GET - - - - -```ts title="api.ts" +```ts title="Before" import axios from 'axios'; -export const getUser = (id: string) => - axios.get(`https://api.example.com/users/${id}`); +const api = axios.create({ baseURL: 'https://api.example.com' }); + +export async function getPost(id: string) { + const res = await api.get(`/posts/${id}`); + return res.data; +} + +export async function createPost(body: { title: string }) { + const { data } = await api.post('/posts', body); + return data; +} ``` -```ts title="usage.ts" -const { data } = await getUser('1'); +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export async function getPost(id: string) { + const res = await new ApiEndpoint({ path: '/posts/:id' })({ id }); + return res; +} + +export async function createPost(body: { title: string }) { + const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })( + body, + ); + return data; +} ``` - - + - +What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios +response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for +`TODO(data-client)` and resolve each one as you go through the next steps. -```ts title="User" collapsed -import { Entity } from '@data-client/rest'; +If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only +calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint). -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +### 3. Move interceptors into a base endpoint {#base-endpoint} + +Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The +codemod creates this class from `axios.create()`; move the interceptors into it. + + + +```ts title="Before" +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +api.interceptors.response.use(response => response.data); + +export default api; +``` + +```ts title="After" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // callout: Runs before every request, like a request interceptor. It can be `async`, to refresh a token first. + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + Authorization: `Bearer ${getToken()}`, + }; + } } ``` -```ts title="api" -import { RestEndpoint } from '@data-client/rest'; -import User from './User'; + + +Response interceptors that change the body, such as converting `snake_case` keys, go in +[`process()`](../api/RestEndpoint.md#process); see [snakes to camels](./network-transform.md#snakes-to-camels). + +When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response, +so keep it. The codemod marks these calls when the interceptor is in the same file; if a global +`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there. + +#### Server error messages + +Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once, +in the base class's [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse), so call sites get it from +`error.message`: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } +} +``` + +#### Existing endpoint abstractions + +A custom endpoint class that wraps axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend +`RestEndpoint` instead of being replaced. It keeps its backward-compatible methods and gains +[`url()`](../api/RestEndpoint.md#url), [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit), +[`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) and [`parseResponse()`](../api/RestEndpoint.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; -export const getUser = new RestEndpoint({ - urlPrefix: 'https://api.example.com', + /** @deprecated use url() */ + toDynamicUrl = this.url; +} + +const getUser = new LegacyEndpoint({ path: '/users/:id', + queryKey: 'user', schema: User, }); ``` -```ts title="Usage" column -import { getUser } from './api'; -getUser({ id: '1' }); -``` +Pass extra members like `queryKey` as options rather than through a custom constructor, so +[`extend()`](../api/RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps +working. - +#### Context-based auth - - +When tokens come from :react[React context]:vue[`inject()`] (Okta, Auth0) rather than storage, `getHeaders()` can't +read them. Use [`hookifyResource()`](../api/hookifyResource.md) to add the headers through a +:react[hook]:vue[composable]. See the +[authentication guide](./auth.md) for this and other patterns. -### Instance with base URL and headers +### 4. Define entities and resources {#entities} - - +The codemod makes endpoints without a `schema`. Define an [Entity](../api/Entity.md) for each type the API returns: its +fields, their types and its primary key. Replace each module's CRUD functions on one path with a +[`resource()`](../api/resource.md): -```ts title="api.ts" -import axios from 'axios'; + -const api = axios.create({ - baseURL: 'https://api.example.com', - headers: { 'X-API-Key': 'my-key' }, -}); +```ts title="Before" +import api from './api'; + +export interface Post { + id: string; + title: string; + body: string; +} + +export const getPosts = () => api.get('/posts'); +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: Omit) => + api.post('/posts', data); +export const updatePost = (id: string, data: Partial) => + api.patch(`/posts/${id}`, data); +export const deletePost = (id: string) => api.delete(`/posts/${id}`); +``` + +```ts title="After" +import { Entity, resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; + +export class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} -export const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +// callout: Gives `getList`, `get`, `getList.push`, `update`, `partialUpdate` and `delete`, all typed. +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); ``` - - + + +Try the result against a mock API: @@ -175,7 +340,7 @@ export default class Post extends Entity { } ``` -```ts title="ApiEndpoint" +```ts title="ApiEndpoint" collapsed import { RestEndpoint, RestGenerics } from '@data-client/rest'; export default class ApiEndpoint< @@ -192,7 +357,7 @@ export default class ApiEndpoint< } ``` -```ts title="PostResource" collapsed +```ts title="PostResource" import { resource } from '@data-client/rest'; import ApiEndpoint from './ApiEndpoint'; import Post from './Post'; @@ -211,150 +376,113 @@ PostResource.get({ id: '1' }); - - - -### POST mutation - - - +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for +operations that aren't CRUD, like search, custom actions and auth. -```ts title="api.ts" -import axios from 'axios'; +:::warning -const api = axios.create({ baseURL: 'https://api.example.com' }); +Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their +types, and the store can't normalize them when you adopt it. -export const createPost = (data: { title: string; body: string }) => - api.post('/posts', data); -``` +::: - - +#### Non-standard primary keys - +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](../api/Entity.md#pk): -```ts title="Post" collapsed +```ts import { Entity } from '@data-client/rest'; -export default class Post extends Entity { - id = ''; - title = ''; - body = ''; - static key = 'Post'; +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } } ``` -```ts title="PostResource" -import { resource } from '@data-client/rest'; -import Post from './Post'; +#### Body typing -export const PostResource = resource({ - urlPrefix: 'https://api.example.com', - path: '/posts/:id', - schema: Post, -}); -``` +Type the body of standalone `POST`, `PUT` and `PATCH` endpoints with `body: {} as BodyType`. Don't use +`undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](../api/RestEndpoint.md#body)`: undefined` as having +no body argument. [`resource()`](../api/resource.md) types its endpoints' bodies itself. -```ts title="Usage" column -import { PostResource } from './PostResource'; -PostResource.getList.push({ - title: 'New Post', - body: 'Content', +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, }); ``` - - - - - -### Interceptors → lifecycle methods - -Axios interceptors map to [RestEndpoint](../api/RestEndpoint.md) lifecycle methods: - - - +#### Coexisting with Zod or Yup -```ts title="api.ts" -import axios from 'axios'; +If the codebase already validates responses with Zod or Yup, choose one approach per type: -const api = axios.create({ baseURL: 'https://api.example.com' }); +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](../api/RestEndpoint.md#process), and let the Entity handle normalization: -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); -``` +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields give + types, not runtime checks, so add [`static validate()`](../api/Entity.md#validate) for any fields the server might + send malformed. +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from + normalization (auth tokens, one-off responses). - - +### 5. Update call sites {#call-sites} -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +Components and services keep their own loading and state; only the call changes. -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = 'https://api.example.com'; + - // Equivalent to request interceptor - getHeaders(headers: HeadersInit) { - return { - ...headers, - Authorization: `Bearer ${getToken()}`, - }; - } +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; - // Equivalent to response interceptor (unwrap/transform) - process(value: any, ...args: any) { - return value; - } -} +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); ``` - - +```ts title="After" +import { PostResource } from './api/posts'; -:::tip - -`RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); +``` -::: + -Response interceptors that transform the body, such as converting `snake_case` keys, belong in [`process()`](../api/RestEndpoint.md#process). See [snakes to camels](./network-transform.md#snakes-to-camels) for a complete example. +Reading these endpoints through the store, with [`useSuspense()`](/docs/api/useSuspense) and the normalized cache, is a +separate step you can take later; see [data dependencies](/docs/getting-started/data-dependency). -### Error handling +### 6. Migrate error handling {#error-handling} - - + -```ts +```ts title="Before" import axios from 'axios'; +import { getPost } from './api/posts'; try { - const { data } = await axios.get('/users/1'); + const { data } = await getPost('1'); } catch (err) { if (axios.isAxiosError(err)) { console.log(err.response?.status); @@ -363,14 +491,12 @@ try { } ``` - - - -```ts +```ts title="After" import { NetworkError } from '@data-client/rest'; +import { PostResource } from './api/posts'; try { - const user = await getUser({ id: '1' }); + const post = await PostResource.get({ id: '1' }); } catch (err) { if (err instanceof NetworkError) { console.log(err.status); @@ -379,126 +505,132 @@ try { } ``` -[`NetworkError`](../api/RestEndpoint.md#fetchResponse) provides `.status` and `.response` (the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object). For soft retries on server errors, see [`errorPolicy`](../api/RestEndpoint.md#errorpolicy). + - - +Read a server's error message from the body once, in the base endpoint; see +[server error messages](#server-error-messages). -#### Server error messages +### 7. Remove axios {#remove-axios} -Axios codebases commonly surface `error.response.data.error` or `.message` to the user. Read it from the `Response` body instead, once, in the base class's [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse), so call sites get it from `error.message` without parsing the body: - -```ts title="ApiEndpoint.ts" -import { - NetworkError, - RestEndpoint, - RestGenerics, -} from '@data-client/rest'; +Search for what still uses axios: -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - async fetchResponse(input: RequestInfo, init: RequestInit) { - try { - return await super.fetchResponse(input, init); - } catch (error) { - if (error instanceof NetworkError) { - const body = await error.response - .clone() - .json() - .catch(() => null); - // keep the NetworkError so `status` and `errorPolicy()` still work - error.message = body?.error ?? body?.message ?? error.message; - } - throw error; - } - } -} -``` +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `TODO\(data-client\)` | what the codemod left | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | -### Cancellation +Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the +type checker and tests: - - +```bash +npm uninstall axios +``` + +## Axios options reference {#quick-reference} + +| Axios | @data-client/rest | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](../api/RestEndpoint.md#urlPrefix) | +| `headers` config | [`getHeaders()`](../api/RestEndpoint.md#getHeaders) | +| `interceptors.request` | [`getHeaders()`](../api/RestEndpoint.md#getHeaders) / [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit) | +| `interceptors.response` | [`parseResponse()`](../api/RestEndpoint.md#parseResponse) / [`process()`](../api/RestEndpoint.md#process) | +| `transformRequest` | [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit) | +| `transformResponse` | [`process()`](../api/RestEndpoint.md#process) | +| `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | +| `cancelToken` / `signal` | [`signal`](#cancellation) | +| `timeout` | [`AbortSignal.timeout()`](#timeout) in `getRequestInit()` | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](#binary-responses) / `'arrayBuffer'` | +| `params` / `paramsSerializer` | [`searchParams`](../api/RestEndpoint.md#searchParams) / [`searchToString()`](#query-serialization) | +| `auth: { username, password }` | [`getHeaders()` with `btoa()`](#basic-auth) | +| `validateStatus` | [Custom `fetchResponse()`](#accepting-error-statuses) | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](#csrf-headers) | +| `onUploadProgress` | [Custom `fetchResponse()` with XMLHttpRequest](#upload-progress) | -```ts -import axios from 'axios'; +### Cancellation -const controller = new AbortController(); -axios.get('/users', { signal: controller.signal }); -controller.abort(); -``` +Both axios's `signal` and the deprecated `CancelToken` map to an +[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`: -Or with the deprecated `CancelToken`: + -```ts +```ts title="Before" const source = axios.CancelToken.source(); -axios.get('/users', { cancelToken: source.token }); +axios.get('/users/1', { cancelToken: source.token }); source.cancel(); ``` - - - -Both map to an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`. The [`useCancelling()`](/docs/api/useCancelling) hook automatically cancels in-flight requests when parameters change: - -```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; -import { searchEndpoint } from './api/search'; -import ResultsList from './ResultsList'; - -function SearchResults({ query }: { query: string }) { - const results = useSuspense(useCancelling(searchEndpoint), { q: query }); - return ; -} -``` - -For manual cancellation, pass `signal` directly: - -```ts +```ts title="After" +const getUser = new ApiEndpoint({ path: '/users/:id' }); const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); +// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests. +getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` - - + See the [abort guide](./abort.md) for more patterns. ### Timeout -```ts title="Before (axios)" + + +```ts title="Before" axios.get('/users', { timeout: 5000 }); ``` -```ts title="After (data-client)" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // callout: Runs for each request, so each one gets its own 5 seconds. `AbortSignal.timeout()` passed to the constructor would start counting once, when the endpoint is created. + async getRequestInit( + body?: RequestInit['body'] | Record, + ) { + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; + return { ...init, signal }; + } +} ``` + + ### Binary responses -```ts title="Before (axios)" + + +```ts title="Before" axios.get('/files/1', { responseType: 'blob' }); ``` -Set [`content`](../api/RestEndpoint.md#content) to `'blob'`, `'arrayBuffer'` or `'text'`. See [file download](./network-transform.md#file-download) for the full endpoint and triggering a browser download. +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', +}); +``` + + + +[`content`](../api/RestEndpoint.md#content) also takes `'arrayBuffer'` and `'text'`. See +[file download](./network-transform.md#file-download) to trigger a browser download. ### Query serialization -```ts title="Before (axios)" + + +```ts title="Before" axios.get('/users', { params: { ids: [1, 2, 3] }, paramsSerializer: params => @@ -506,15 +638,34 @@ axios.get('/users', { }); ``` -Override [`searchToString()`](../api/RestEndpoint.md#searchToString) to serialize with `qs`; see [using the `qs` library](../api/RestEndpoint.md#searchToString). +```ts title="After" +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams: Record) { + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); + } +} +``` + + + +axios leaves out `null` and `undefined` params, but the default `searchToString()` uses +[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](../api/RestEndpoint.md#searchToString). ### Basic auth -```ts title="Before (axios)" + + +```ts title="Before" axios.get('/api', { auth: { username: 'user', password: 'pass' } }); ``` -```ts title="After (data-client)" +```ts title="After" export default class BasicAuthEndpoint< O extends RestGenerics = any, > extends RestEndpoint { @@ -527,15 +678,20 @@ export default class BasicAuthEndpoint< } ``` + + ### Accepting error statuses -[`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) throws [`NetworkError`](../api/RestEndpoint.md#fetchResponse) for any non-`ok` status. Override it to change what counts as an error: +[`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) throws [`NetworkError`](#error-handling) for any status that +isn't `ok`. Override it to change what counts as an error: -```ts title="Before (axios)" + + +```ts title="Before" axios.get('/api', { validateStatus: status => status < 500 }); ``` -```ts title="After (data-client)" +```ts title="After" import { NetworkError, RestEndpoint, @@ -553,28 +709,34 @@ export default class LenientEndpoint< } ``` + + ### CSRF headers -```ts title="Before (axios)" +```ts title="Before" axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken', }); ``` -Read the cookie in [`getHeaders()`](../api/RestEndpoint.md#getHeaders) for non-`GET` requests. See [Django Integration](./django.md) for the complete endpoint class. +Read the cookie in [`getHeaders()`](../api/RestEndpoint.md#getHeaders) for requests that aren't `GET`. See +[Django Integration](./django.md) for the complete endpoint class. ### Upload progress -`fetch` cannot report upload progress, so use [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like any other member. +`fetch` can't report upload progress, so use +[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside +[`fetchResponse()`](../api/RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like +any other member. -```ts title="Before (axios)" +```ts title="Before" axios.post('/upload', formData, { onUploadProgress: e => console.log(e.loaded / e.total), }); ``` -```ts title="After (data-client)" +```ts title="UploadEndpoint.ts" import { NetworkError, RestEndpoint, @@ -643,229 +805,9 @@ const uploadFile = new UploadEndpoint({ }); ``` -## Codemod {#codemod} - -A standalone [jscodeshift](https://github.com/facebook/jscodeshift) codemod handles the mechanical parts of migration. Run it yourself for non-AI workflows; the [AI skill](#skill) above runs it automatically as its first step. - -```bash -npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ -``` - -The codemod automatically: - -- Replaces `import axios from 'axios'` with `import { RestEndpoint } from '@data-client/rest'` -- Converts `axios.create({ baseURL, headers })` into a base `RestEndpoint` subclass with `urlPrefix` and `getHeaders()` -- Transforms `axios.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into `new CreatedClassName({ path, method })` - -The codemod has little to do when the project wraps axios in its own class or function and never calls `axios.get()`/`.post()` directly, or only calls `axios(config)` without a method name. In those cases, skip it and start with [the manual steps](#after-the-codemod). - -The codemod does **not** handle: - -- Interceptors — see [lifecycle methods](#interceptors--lifecycle-methods) -- Error handling (`isAxiosError`, `error.response`) — see [error handling](#error-handling) -- The rest of the [quick reference](#quick-reference) — see the [migration examples](#migration-examples) above -- [Entity](../api/Entity.md) schema definitions and converting call sites to hooks — see [below](#after-the-codemod) - -### Finding remaining axios usage - -Search patterns for locating what still needs migrating: - -| Pattern | Finds | -| ------------------------------------------ | ------------------------- | -| `import.*from ['"]axios['"]` | import statements | -| `axios\.create` | instance creation | -| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | -| `\.interceptors\.(request\|response)\.use` | interceptors | -| `isAxiosError` | error handling | -| `cancelToken\|CancelToken` | cancellation (deprecated) | -| `onUploadProgress\|onDownloadProgress` | progress callbacks | - -## After the codemod {#after-the-codemod} - -The codemod produces endpoints without schemas. Defining [Entity](../api/Entity.md) schemas and wiring them to endpoints enables normalization and caching — the core value of Reactive Data Client. - -### Non-standard primary keys - -Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](../api/Entity.md#pk): - -```ts -import { Entity } from '@data-client/rest'; - -export class User extends Entity { - _id = ''; - name = ''; - email = ''; - static key = 'User'; - - pk() { - return this._id; - } -} -``` - -### Group CRUD endpoints with resource() - -When an axios module has separate `getUsers`, `getUser`, `createUser`, `updateUser` and `deleteUser` functions for one path, replace them with a single [`resource()`](../api/resource.md): - -```ts -import { resource } from '@data-client/rest'; -import ApiEndpoint from './ApiEndpoint'; -import { User } from './User'; - -export const UserResource = resource({ - path: '/users/:id', - schema: User, - Endpoint: ApiEndpoint, -}); -// UserResource.getList, .get, .getList.push, .update, .partialUpdate, .delete -``` - -Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Reserve standalone `new ApiEndpoint()` for non-CRUD operations (search, custom actions, auth). - -### Coexisting with Zod or Yup - -If the codebase already validates responses with Zod or Yup, choose one approach per type: - -- **Zod in `process()`** (recommended): keep runtime validation by parsing in [`process()`](../api/RestEndpoint.md#process), and let the Entity handle normalization: - - ```ts - const getUser = new ApiEndpoint({ - path: '/users/:id', - schema: User, - process(value: any) { - return userSchema.parse(value); - }, - }); - ``` - -- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields provide types, not runtime checks, so add [`static validate()`](../api/Entity.md#validate) for any fields the server might send malformed. -- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't benefit from normalization (auth tokens, one-off responses). - -:::warning - -Don't define Entity classes and then leave `schema` unset on every endpoint — without `schema`, nothing is normalized and the migration gains little over axios. - -::: - -### Body typing - -Type the body of standalone `POST`/`PUT`/`PATCH` endpoints with `body: {} as BodyType`. Don't use `undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](../api/RestEndpoint.md#body)`: undefined` as having no body argument. - -```ts -const createUser = new ApiEndpoint({ - path: '/users', - method: 'POST', - body: {} as { name: string; email: string }, - schema: User, -}); -``` - -[`resource()`](../api/resource.md) types its CRUD endpoints automatically. - -### Convert call sites to hooks - - - - -```tsx -import { useEffect, useState } from 'react'; -import api from './lib/api'; -import { Spinner } from './Spinner'; -import type { User } from './User'; - -function UserProfile({ id }: { id: string }) { - const [user, setUser] = useState(null); - useEffect(() => { - api.get(`/users/${id}`).then(({ data }) => setUser(data)); - }, [id]); - if (!user) return ; - return

{user.name}

; -} -``` - -
- - -```tsx -import { useSuspense } from '@data-client/react'; -import { UserResource } from './UserResource'; - -function UserProfile({ id }: { id: string }) { - const user = useSuspense(UserResource.get, { id }); - return

{user.name}

; -} -``` - -
-
- -Loading and error states move to [`AsyncBoundary`](/docs/api/AsyncBoundary). See [`useSuspense()`](/docs/api/useSuspense) for details. - -### Context-based auth - -When tokens come from React context (Okta, Auth0) rather than storage, use [`hookifyResource()`](../api/hookifyResource.md) to inject headers through a hook. See the [authentication guide](./auth.md) for this and other patterns. - -### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks temporarily but fetch through [`controller.fetch()`](/docs/api/Controller#fetch). Calling an endpoint directly only runs its fetch; going through the Controller also normalizes the response into the shared cache, so data is consistent from day one: - -```ts -import { useController } from '@data-client/react'; -import { useQuery } from '@tanstack/react-query'; -import ApiEndpoint from './ApiEndpoint'; -import { Project } from './Project'; - -export const getProject = new ApiEndpoint({ - path: '/projects/:id', - schema: Project, -}); - -export function useProject(id: string) { - const ctrl = useController(); - return useQuery({ - queryKey: ['project', id], - queryFn: () => ctrl.fetch(getProject, { id }), - }); -} -``` - -Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. - -### Existing endpoint abstractions - -Codebases that already have a custom endpoint class wrapping axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend `RestEndpoint` instead of replacing it, keeping backward-compatible methods while gaining [`url()`](../api/RestEndpoint.md#url), [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit), [`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) and [`parseResponse()`](../api/RestEndpoint.md#parseResponse): - -```ts -import { RestEndpoint, RestGenerics } from '@data-client/rest'; - -export class LegacyEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = API_ROOT; - declare queryKey?: string; - - /** @deprecated use url() */ - toDynamicUrl = this.url; -} - -const getUser = new LegacyEndpoint({ - path: '/users/:id', - queryKey: 'user', - schema: User, -}); -``` - -Pass extra members like `queryKey` as options rather than through a custom constructor, so [`extend()`](../api/RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps working. - ## Related guides -- [Authentication](./auth.md) — token and cookie auth patterns -- [Aborting Fetch](./abort.md) — cancellation and debouncing -- [Transforming data on fetch](./network-transform.md) — response transforms, field renaming, file downloads -- [Django Integration](./django.md) — CSRF and cookie auth for Django +- [Authentication](./auth.md): token and cookie auth patterns +- [Aborting Fetch](./abort.md): cancellation and debouncing +- [Transforming data on fetch](./network-transform.md): response transforms, field renaming, file downloads +- [Django Integration](./django.md): CSRF and cookie auth for Django diff --git a/packages/codemods/README.md b/packages/codemods/README.md index 6d40f0b76bf0..73b64de6eded 100644 --- a/packages/codemods/README.md +++ b/packages/codemods/README.md @@ -15,7 +15,7 @@ npx @data-client/codemods v0.18 src/ | `v0.18` | `Schema.denormalize()`/`normalize()` to the delegate signatures ([v0.18 post](https://dataclient.io/blog/2026/05/01/v0.18-scalar-typed-downloads)) | | `v0.16` | `path-to-regexp` v8 path syntax, `useFetch()` truthiness checks and `schema.X` imports ([v0.16 post](https://dataclient.io/blog/2026/04/01/v0.16-parallel-fetching-collection-move-lazy)) | | `v0.15` | `useDebounce()` results, `entityMeta`, `MemoCache` state and `INVALID` imports ([v0.15 post](https://dataclient.io/blog/2026/01/06/v0.15-vue-support-collection-remove)) | -| `axios-to-rest` | `axios` calls to `RestEndpoint` ([axios migration guide](https://dataclient.io/rest/guides/axios-migration)) | +| `axios-to-rest` | `axios` calls to `RestEndpoint` calls, with a `TODO(data-client)` comment on what's left ([axios migration guide](https://dataclient.io/rest/guides/axios-migration)) | It processes JavaScript and TypeScript files and skips `node_modules`. Other [jscodeshift options](https://github.com/facebook/jscodeshift#usage-cli) pass through, such as `--dry` and `--print` diff --git a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js index 9ce8c042fec1..892c2c81ad41 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1,6 +1,13 @@ 'use strict'; -const { defineInlineTest } = require('jscodeshift/dist/testUtils'); +const fs = require('fs'); +const { + applyTransform, + defineInlineTest, + runInlineTest, +} = require('jscodeshift/dist/testUtils'); +const os = require('os'); +const path = require('path'); const transform = require('../axios-to-rest'); @@ -46,6 +53,7 @@ const source = CancelToken.source(); ` import { RestEndpoint } from '@data-client/rest'; import { CancelToken } from 'axios'; +// TODO(data-client): use an \`AbortSignal\` instead of \`CancelToken\`: https://dataclient.io/rest/guides/axios-migration#cancellation const source = CancelToken.source(); `, 'preserves CancelToken import when used at runtime', @@ -70,6 +78,7 @@ import { AxiosError } from 'axios'; try { throw new Error('x'); } catch (err) { + // TODO(data-client): check \`NetworkError\` instead of \`AxiosError\`: https://dataclient.io/rest/guides/axios-migration#error-handling if (err instanceof AxiosError) { console.log(err); } @@ -121,7 +130,9 @@ if (err instanceof AxiosError) { ` import { RestEndpoint } from '@data-client/rest'; import { CancelToken, AxiosError } from 'axios'; +// TODO(data-client): use an \`AbortSignal\` instead of \`CancelToken\`: https://dataclient.io/rest/guides/axios-migration#cancellation const source = CancelToken.source(); +// TODO(data-client): check \`NetworkError\` instead of \`AxiosError\`: https://dataclient.io/rest/guides/axios-migration#error-handling if (err instanceof AxiosError) { log(err); } @@ -141,6 +152,7 @@ if (err instanceof Err) { ` import { RestEndpoint } from '@data-client/rest'; import { AxiosError as Err } from 'axios'; +// TODO(data-client): check \`NetworkError\` instead of \`AxiosError\`: https://dataclient.io/rest/guides/axios-migration#error-handling if (err instanceof Err) { log(err); } @@ -304,9 +316,10 @@ class ApiEndpoint extends RestEndpoint { } const other = 123; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users' -}); +})(); `, 'preserves sibling declarators when axios.create shares a declaration', ); @@ -346,9 +359,10 @@ export class ApiEndpoint extends RestEndpoint { } export const other = 123; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users' -}); +})(); `, 'preserves exported sibling declarators when axios.create shares a declaration', ); @@ -366,9 +380,10 @@ const result = axios.get('/users'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'converts axios.get to new RestEndpoint', ); @@ -382,10 +397,12 @@ const result = axios.post('/users'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users', - method: 'POST' -}); + method: 'POST', + body: undefined +})(); `, 'converts axios.post to new RestEndpoint with method POST', ); @@ -399,10 +416,12 @@ const result = axios.put('/users/1'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users/1', - method: 'PUT' -}); + method: 'PUT', + body: undefined +})(); `, 'converts axios.put to new RestEndpoint with method PUT', ); @@ -416,10 +435,12 @@ const result = axios.patch('/users/1'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users/1', - method: 'PATCH' -}); + method: 'PATCH', + body: undefined +})(); `, 'converts axios.patch to new RestEndpoint with method PATCH', ); @@ -433,10 +454,11 @@ const result = axios.delete('/users/1'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users/1', method: 'DELETE' -}); +})(); `, 'converts axios.delete to new RestEndpoint with method DELETE', ); @@ -452,9 +474,10 @@ const result = axios.get(url); ` import axios from 'axios'; const url = getUrl(); +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites const result = axios.get(url); `, - 'leaves non-literal URL calls unchanged without rewriting imports', + 'marks non-literal URL calls without rewriting imports', ); defineInlineTest( @@ -470,9 +493,11 @@ const dynamic = axios.get(dynamicUrl); import { RestEndpoint } from '@data-client/rest'; import axios from 'axios'; const dynamicUrl = getUrl(); +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const direct = new RestEndpoint({ path: '/users' -}); +})(); +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites const dynamic = axios.get(dynamicUrl); `, 'keeps axios import when dynamic axios calls remain after converting literal calls', @@ -497,9 +522,10 @@ class ApiEndpoint extends RestEndpoint { urlPrefix = 'https://api.example.com'; } +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users' -}); +})(); `, 'converts instance.get to new ClassName after axios.create transform', ); @@ -519,10 +545,12 @@ class ApiEndpoint extends RestEndpoint { urlPrefix = 'https://api.example.com'; } +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users', - method: 'POST' -}); + method: 'POST', + body: undefined +})(); `, 'converts instance.post to new ClassName with method POST', ); @@ -544,10 +572,11 @@ class ApiEndpoint extends RestEndpoint { } const payload = { name: 'Taylor' }; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users', method: 'POST' -}); +})(payload); `, 'converts instance.post with payload arg to avoid dangling instance references', ); @@ -567,10 +596,12 @@ class ApiEndpoint extends RestEndpoint { urlPrefix = 'https://api.example.com'; } +// TODO(data-client): move axios \`headers\` to the endpoint: https://dataclient.io/rest/guides/axios-migration#quick-reference +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users/1', method: 'PATCH' -}); +})({ enabled: true }); `, 'converts instance.patch with payload/config args to avoid dangling instance references', ); @@ -601,9 +632,10 @@ function fetchViaOtherClient() { return api.get('/shadowed'); } +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new ApiEndpoint({ path: '/users' -}); +})(); `, 'does not rewrite shadowed instance identifiers in nested scopes', ); @@ -621,9 +653,9 @@ const result: AxiosResponse = await axios.get('/users/1'); `, ` import { RestEndpoint } from '@data-client/rest'; -const result: AxiosResponse = await new RestEndpoint({ +const result: User = await new RestEndpoint({ path: '/users/1' -}); +})(); `, 'handles generic type parameters on axios method calls', ); @@ -637,9 +669,10 @@ const data = (await axios.get('/users')) as AxiosResponse; `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const data = (await new RestEndpoint({ path: '/users' -})) as AxiosResponse; +})()) as AxiosResponse; `, 'handles as casts with AxiosResponse', ); @@ -654,9 +687,10 @@ const result = axios.get('/users'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'removes import type declaration alongside default import', ); @@ -670,9 +704,10 @@ const result = axios.get('/users'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'removes inline type specifiers from import', ); @@ -687,9 +722,10 @@ const result = axios.get('/users'); ` import { RestEndpoint } from '@data-client/rest'; import { isAxiosError } from 'axios'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'removes inline type specifiers but preserves unknown named imports', ); @@ -743,9 +779,10 @@ const result = axios.get('/users'); import { RestEndpoint } from '@data-client/rest'; import axios from 'axios'; function foo(client: typeof axios) { return client; } +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'preserves axios import when typeof annotation coexists with method calls', ); @@ -792,9 +829,10 @@ const result = axios.get('/users'); `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); `, 'handles multiple import statements from axios without duplicate RestEndpoint imports', ); @@ -810,9 +848,10 @@ const x: AxiosResponse = result; `, ` import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ path: '/users' -}); +})(); const x: AxiosResponse = result; `, 'removes multiple axios imports with type references used as annotations', @@ -833,9 +872,9 @@ const users = axios.get('/users').then(res => res.data); import { RestEndpoint } from '@data-client/rest'; const users = new RestEndpoint({ path: '/users' -}).then(res => res.data); +})(); `, - 'chained .then() is preserved after transforming axios.get', + 'drops a .then() that only unwraps .data', ); defineInlineTest( @@ -851,9 +890,9 @@ async function getUsers() { ` import { RestEndpoint } from '@data-client/rest'; async function getUsers() { - const { data } = await new RestEndpoint({ + const data = await new RestEndpoint({ path: '/users' - }); + })(); return data; } `, @@ -869,9 +908,10 @@ const result = axios('/users'); `, ` import axios from 'axios'; +// TODO(data-client): replace this axios call with an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites const result = axios('/users'); `, - 'axios as default function call is left unchanged (no-op)', + 'marks axios(config) calls it cannot convert', ); defineInlineTest( @@ -882,10 +922,16 @@ import axios from 'axios'; const result = axios.get('/users', { headers: { Authorization: 'Bearer token' }, params: { page: 1 } }); `, ` -import axios from 'axios'; -const result = axios.get('/users', { headers: { Authorization: 'Bearer token' }, params: { page: 1 } }); +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): move axios \`headers\` to the endpoint: https://dataclient.io/rest/guides/axios-migration#quick-reference +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const result = new RestEndpoint({ + path: '/users' +})({ + page: 1 +}); `, - 'config object as second arg is left unchanged (no-op)', + 'passes config params as args and marks other config options', ); defineInlineTest( @@ -897,9 +943,10 @@ const result = axios.request({ url: '/users', method: 'get' }); `, ` import axios from 'axios'; +// TODO(data-client): replace this axios call with an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites const result = axios.request({ url: '/users', method: 'get' }); `, - 'axios.request() is left unchanged (no-op)', + 'marks axios.request() calls it cannot convert', ); defineInlineTest( @@ -913,11 +960,14 @@ const result = axios.get(\`/users/\${id}\`); ` import { RestEndpoint } from '@data-client/rest'; const id = 5; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites const result = new RestEndpoint({ - path: \`/users/\${id}\` + path: '/users/:id' +})({ + id }); `, - 'template literal URLs are transformed', + 'turns template literal expressions into path params', ); defineInlineTest( @@ -967,13 +1017,15 @@ function fetchData(condition) { import { RestEndpoint } from '@data-client/rest'; function fetchData(condition) { if (condition) { + // TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites return new RestEndpoint({ path: '/a' - }); + })(); } else { + // TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites return new RestEndpoint({ path: '/b' - }); + })(); } } `, @@ -990,16 +1042,885 @@ const result = axios.all([axios.get('/a'), axios.get('/b')]); ` import { RestEndpoint } from '@data-client/rest'; import axios from 'axios'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +// TODO(data-client): use \`Promise.all()\` instead of \`axios.all()\`: https://dataclient.io/rest/guides/axios-migration#call-sites const result = axios.all([new RestEndpoint({ path: '/a' -}), new RestEndpoint({ +})(), new RestEndpoint({ path: '/b' -})]); +})()]); `, 'axios.all with nested axios.get calls transforms the gets but preserves axios.all and import', ); }); + // ── Response bodies ───────────────────────────────────────────────── + + describe('reading the body instead of .data', () => { + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +async function getUser(id) { + const res = await axios.get(\`/users/\${id}\`); + console.log(res.data.name); + return res.data; +} + `, + ` +import { RestEndpoint } from '@data-client/rest'; +async function getUser(id) { + const res = await new RestEndpoint({ + path: '/users/:id' + })({ + id + }); + console.log(res.name); + return res; +} + `, + 'replaces res.data reads when the response is only read for its data', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +async function getUser() { + return (await axios.get('/me')).data; +} + `, + ` +import { RestEndpoint } from '@data-client/rest'; +async function getUser() { + return await new RestEndpoint({ + path: '/me' + })(); +} + `, + 'unwraps (await call).data', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +async function getUser() { + const { data: user } = await axios.get('/me'); + return user; +} + `, + ` +import { RestEndpoint } from '@data-client/rest'; +async function getUser() { + const user = await new RestEndpoint({ + path: '/me' + })(); + return user; +} + `, + 'unwraps a renamed data destructure', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +export const response = await axios.get('/config'); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +export const response = await new RestEndpoint({ + path: '/config' +})(); + `, + 'marks an exported response, since other modules may read its .data', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const search = () => axios.get('/search?q=hello+world'); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const search = () => new RestEndpoint({ + path: '/search' +})({ + q: 'hello world' +}); + `, + 'decodes + in a literal query as a space', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const a = () => axios.get('/search?q=hello#results'); +const b = queue => axios.post('/items', queue.shift(), { params: { id: queue.shift() } }); + `, + ` +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const a = () => axios.get('/search?q=hello#results'); +// TODO(data-client): convert this axios.post() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const b = queue => axios.post('/items', queue.shift(), { params: { id: queue.shift() } }); + `, + 'marks fragments and params that would run before an effectful body', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const search = q => axios.get(\`/search?q=\${q}&limit=5\`).then(({ data }) => data.items); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +const search = q => new RestEndpoint({ + path: '/search' +})({ + q, + limit: '5' +}).then(data => data.items); + `, + 'moves the query string into args and unwraps a .then() data destructure', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +async function save() { + const { data, status } = await axios.post('/users', { name: 'a' }); + return [data, status]; +} + `, + ` +import { RestEndpoint } from '@data-client/rest'; +async function save() { + // TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites + const { data, status } = await new RestEndpoint({ + path: '/users', + method: 'POST' + })({ name: 'a' }); + return [data, status]; +} + `, + 'marks reads of response fields other than data', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +async function remove(id) { + await axios.delete(\`/users/\${id}\`); +} + `, + ` +import { RestEndpoint } from '@data-client/rest'; +async function remove(id) { + await new RestEndpoint({ + path: '/users/:id', + method: 'DELETE' + })({ + id + }); +} + `, + 'leaves no .data note on a call whose result is unused', + ); + }); + + // ── Urls and config ───────────────────────────────────────────────── + + describe('urls and config', () => { + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const api = axios.create({ baseURL: '/api' }); +api.interceptors.response.use(response => response.data); +export const list = async () => (await api.get('/items')).data; + `, + ` +import { RestEndpoint } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = '/api'; +} + +// TODO(data-client): move this interceptor into \`ApiEndpoint\`: https://dataclient.io/rest/guides/axios-migration#base-endpoint +api.interceptors.response.use(response => response.data); +// TODO(data-client): \`api\` has a response interceptor, so check each \`.data\` read of this call: drop it if it read axios's response, keep it if it reads the body's own field: https://dataclient.io/rest/guides/axios-migration#base-endpoint +export const list = async () => (await new ApiEndpoint({ + path: '/items' +})()).data; + `, + 'keeps .data when a response interceptor may have unwrapped it', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const list = () => axios.get('https://user:pass@example.com/items'); +const api = axios.create({ baseURL: 'https://user:pass@example.com' }); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const list = () => axios.get('https://user:pass@example.com/items'); + +// TODO(data-client): fetch rejects credentials in a url: remove \`user:pass@\` from \`urlPrefix\` and send them as an \`Authorization\` header from \`getHeaders()\`: https://dataclient.io/rest/guides/axios-migration#basic-auth +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://user:pass@example.com'; +} + `, + 'marks urls with credentials, which fetch rejects', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const list = async () => (await axios.get('/users', { params: { role: null, page: undefined, sort: 'name' } })).data; + `, + ` +import { RestEndpoint } from '@data-client/rest'; +const list = async () => await new RestEndpoint({ + path: '/users' +})({ + sort: 'name' +}); + `, + 'drops null and undefined params as axios does', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = id => axios.get(\`/users/\${id}\`, { params: nextFilters() }); + `, + ` +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const get = id => axios.get(\`/users/\${id}\`, { params: nextFilters() }); + `, + 'marks calls whose params expression would now run before the url', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = () => axios.get('/users?sort=name', { params: nextFilters() }); + `, + ` +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const get = () => axios.get('/users?sort=name', { params: nextFilters() }); + `, + 'marks spread params next to a url query, since a shared key would collapse', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const search = async (user, q) => (await axios.get(\`/users/\${encodeURIComponent(user)}/search?q=\${encodeURIComponent(q)}\`)).data; + `, + ` +import { RestEndpoint } from '@data-client/rest'; +const search = async (user, q) => await new RestEndpoint({ + path: '/users/:user/search' +})({ + user, + q +}); + `, + 'unwraps encodeURIComponent() since the endpoint encodes path and query values', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const save = (post, body) => axios.put(\`https://api.example.com/posts/\${post.id}\`, body, { params: { notify: true } }); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const save = (post, body) => new RestEndpoint({ + urlPrefix: 'https://api.example.com', + path: '/posts/:id', + method: 'PUT' +})({ + id: post.id, + notify: true +}, body); + `, + 'splits an absolute url into urlPrefix and passes params with the body', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = (a, b) => axios.get(\`/files/v\${a}/\${b.id}/\${a}\`); + `, + ` +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const get = (a, b) => axios.get(\`/files/v\${a}/\${b.id}/\${a}\`); + `, + 'marks urls whose expressions do not fill a whole path segment', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = (a, b) => axios.get(\`/a/\${a.id}/b/\${b.id}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const get = (a, b) => new RestEndpoint({ + path: '/a/:id/b/:id2' +})({ + id: a.id, + id2: b.id +}); + `, + 'names repeated path params uniquely', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = id => axios.get(\`/pairs/\${id}/\${id++}/\${id}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const get = id => new RestEndpoint({ + path: '/pairs/:id/:param/:id2' +})({ + id, + param: id++, + id2: id +}); + `, + 'reads a repeated variable again after an effectful interpolation', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const get = (id, next) => axios.get(\`/pairs/\${id}/\${id}/\${next()}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const get = (id, next) => new RestEndpoint({ + path: '/pairs/:id/:id/:param' +})({ + id, + param: next() +}); + `, + 'still shares a param between repeated reads before an effectful interpolation', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const api = axios.create({ baseURL: 'https://api.example.com/v1/' }); +const getUser = id => api.get(\`/users/\${id}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com/v1'; +} + +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const getUser = id => new ApiEndpoint({ + path: '/users/:id' +})({ + id +}); + `, + 'drops a trailing slash from baseURL so it joins a leading-slash url once', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const api = axios.create({ baseURL: 'https://api.example.com/v1' }); +const getUser = id => api.get(\`users/\${id}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com/v1'; +} + +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const getUser = id => new ApiEndpoint({ + path: '/users/:id' +})({ + id +}); + `, + 'adds the slash axios puts between baseURL and a relative url', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const api = axios.create({ baseURL: process.env.API_URL }); +const other = () => api.get('https://other.com/x'); + `, + ` +import { RestEndpoint } from '@data-client/rest'; + +// TODO(data-client): make sure \`urlPrefix\` has no trailing \`/\`, since every path starts with one: https://dataclient.io/rest/guides/axios-migration#base-endpoint +class ApiEndpoint extends RestEndpoint { + urlPrefix = process.env.API_URL; +} + +// TODO(data-client): convert this api.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const other = () => api.get('https://other.com/x'); + `, + 'marks a non-literal baseURL and absolute urls through an instance', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const a = id => axios.get(\`/users/\${id}\`, { params: { id: 5 } }); +const b = () => axios.get('/tags?tag=red&tag=blue'); +const c = () => axios.get('/progress?done=100%'); +const d = ids => axios.get(\`/pairs/\${ids.shift()}/\${ids.shift()}\`); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +import axios from 'axios'; +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const a = id => axios.get(\`/users/\${id}\`, { params: { id: 5 } }); +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const b = () => axios.get('/tags?tag=red&tag=blue'); +// TODO(data-client): convert this axios.get() call to an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const c = () => axios.get('/progress?done=100%'); +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const d = ids => new RestEndpoint({ + path: '/pairs/:param/:param2' +})({ + param: ids.shift(), + param2: ids.shift() +}); + `, + 'marks query keys that clash or repeat and bad escapes, and evaluates each expression once', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const list = (id, filters) => axios.get(\`/users/\${id}/posts\`, { params: filters }); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +// TODO(data-client): check these query params serialize as before: axios leaves out null and undefined values and formats arrays and objects its own way; match it in \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +const list = (id, filters) => new RestEndpoint({ + path: '/users/:id/posts' +})({ + ...filters, + id +}); + `, + 'spreads non-literal params before path params and marks their serialization', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const rest = axios.create({ baseURL: '/api', ...defaults }); +const t = axios.toString(); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +import axios from 'axios'; + +// TODO(data-client): move axios \`...defaults\` to \`_RestEndpoint\`: https://dataclient.io/rest/guides/axios-migration#quick-reference +class _RestEndpoint extends RestEndpoint { + urlPrefix = '/api'; +} + +// TODO(data-client): replace this axios call with an endpoint: https://dataclient.io/rest/guides/axios-migration#call-sites +const t = axios.toString(); + `, + 'marks spread instance options, avoids the RestEndpoint name, and ignores inherited keys', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; +const api = axios.create({ baseURL: '/api', timeout: 5000, withCredentials: true }); +api.interceptors.request.use(config => config); + `, + ` +import { RestEndpoint } from '@data-client/rest'; + +// TODO(data-client): move axios \`timeout\`, \`withCredentials\` to \`ApiEndpoint\`: https://dataclient.io/rest/guides/axios-migration#quick-reference +class ApiEndpoint extends RestEndpoint { + urlPrefix = '/api'; +} + +// TODO(data-client): move this interceptor into \`ApiEndpoint\`: https://dataclient.io/rest/guides/axios-migration#base-endpoint +api.interceptors.request.use(config => config); + `, + 'marks instance options and interceptors it cannot move', + ); + }); + + // ── Instances from another file ──────────────────────────────────── + + describe('instances imported from another file', () => { + // each test gets its own folder, since the transform caches what it reads + const run = (files, file) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'axios-to-rest-')); + for (const [name, source] of Object.entries(files)) + fs.writeFileSync(path.join(dir, name), source); + return applyTransform( + transform, + {}, + { + path: path.join(dir, file), + source: files[file], + }, + ); + }; + const posts = ` +import { api } from './client'; +export async function getPost(id: number) { + const res = await api.get(\`/posts/\${id}\`); + return res.data; +} +`; + const converted = ` +import { ApiEndpoint } from './client'; +export async function getPost(id: number) { + const res = await (new ApiEndpoint({ + path: '/posts/:id' + })({ + id + }) as Promise); + return res; +}`.trim(); + + it('converts calls on an instance a client module exports', () => { + const client = ` +import axios from 'axios'; +export const api = axios.create({ baseURL: 'https://api.example.com' }); +`; + expect(run({ 'client.ts': client, 'posts.ts': posts }, 'posts.ts')).toBe( + converted, + ); + }); + + it('converts calls once the client module is rewritten', () => { + const client = ` +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +export class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} +`; + expect(run({ 'client.ts': client, 'posts.ts': posts }, 'posts.ts')).toBe( + converted, + ); + }); + + it('follows an aliased import', () => { + const client = ` +import axios from 'axios'; +export const api = axios.create(); +`; + const source = ` +import { api as http } from './client'; +export function listPosts() { + return http.get('/posts').then(res => res.data); +} +`; + expect(run({ 'client.ts': client, 'posts.ts': source }, 'posts.ts')).toBe( + ` +import { ApiEndpoint } from './client'; +export function listPosts() { + return new ApiEndpoint({ + path: '/posts' + })(); +}`.trim(), + ); + }); + + it('keeps .data when the client module has a response interceptor', () => { + const client = ` +import axios from 'axios'; +export const api = axios.create(); +api.interceptors.response.use(res => res.data); +`; + expect(run({ 'client.ts': client, 'posts.ts': posts }, 'posts.ts')).toBe( + ` +import { ApiEndpoint } from './client'; +export async function getPost(id: number) { + // TODO(data-client): \`api\` has a response interceptor, so check each \`.data\` read of this call: drop it if it read axios's response, keep it if it reads the body's own field: https://dataclient.io/rest/guides/axios-migration#base-endpoint + const res = await (new ApiEndpoint({ + path: '/posts/:id' + })({ + id + }) as Promise); + return res.data; +}`.trim(), + ); + }); + + it('exports the class where the module exported the instance', () => { + const client = ` +import axios from 'axios'; +const api = axios.create({ baseURL: 'https://api.example.com' }); +export { api }; +`; + expect(run({ 'client.ts': client }, 'client.ts')).toBe( + ` +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; +} + +export { ApiEndpoint };`.trim(), + ); + expect(run({ 'client.ts': client, 'posts.ts': posts }, 'posts.ts')).toBe( + converted, + ); + }); + + it('imports an aliased export under its alias', () => { + const client = ` +import axios from 'axios'; +const api = axios.create({ baseURL: 'https://api.example.com' }); +export { api as client }; +`; + const source = posts.replace('{ api }', '{ client as api }'); + const want = converted.replace( + '{ ApiEndpoint }', + '{ client as ApiEndpoint }', + ); + expect(run({ 'client.ts': client }, 'client.ts')).toMatch( + /export \{ ApiEndpoint as client \};$/, + ); + // before and after the client module is rewritten + expect(run({ 'client.ts': client, 'posts.ts': source }, 'posts.ts')).toBe( + want, + ); + const rewritten = run({ 'client.ts': client }, 'client.ts'); + expect( + run({ 'client.ts': rewritten, 'posts.ts': source }, 'posts.ts'), + ).toBe(want); + }); + + it('follows a default export', () => { + const client = ` +import axios from 'axios'; +const api = axios.create({ baseURL: 'https://api.example.com' }); +export default api; +`; + const source = posts.replace('{ api }', 'api'); + expect(run({ 'client.ts': client }, 'client.ts')).toMatch( + /export default ApiEndpoint;$/, + ); + expect(run({ 'client.ts': client, 'posts.ts': source }, 'posts.ts')).toBe( + converted.replace('{ ApiEndpoint }', 'ApiEndpoint'), + ); + }); + + it('marks what it leaves of an imported instance', () => { + const client = ` +import axios from 'axios'; +export const api = axios.create(); +`; + const source = ` +import { api } from './client'; +api.defaults.headers.common.X = '1'; +`; + expect(run({ 'client.ts': client, 'setup.ts': source }, 'setup.ts')).toBe( + ` +import { ApiEndpoint } from './client'; +// TODO(data-client): move these defaults into \`ApiEndpoint\`: https://dataclient.io/rest/guides/axios-migration#base-endpoint +api.defaults.headers.common.X = '1';`.trim(), + ); + }); + + it('resolves tsconfig path aliases', () => { + const client = ` +import axios from 'axios'; +export const api = axios.create({ baseURL: 'https://api.example.com' }); +`; + const tsconfig = `{ + // comments are allowed here + "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./*"], }, }, +}`; + expect( + run( + { + 'tsconfig.json': tsconfig, + 'client.ts': client, + 'posts.ts': posts.replace("'./client'", "'@/client'"), + }, + 'posts.ts', + ), + ).toBe(converted.replace("'./client'", "'@/client'")); + }); + + it('leaves other imports with a get() alone', () => { + const source = ` +import { cache } from './cache'; +export const hit = cache.get('posts'); +`; + expect( + run( + { 'cache.ts': 'export const cache = new Map();\n', 'use.ts': source }, + 'use.ts', + ), + ).toBe(source.trim()); + }); + }); + + // ── TypeScript ────────────────────────────────────────────────────── + + describe('TypeScript files', () => { + const tsTest = (input, output, name) => + it(name, () => { + runInlineTest(transform, {}, { source: input, path: 'api.ts' }, output); + }); + + tsTest( + ` +import axios, { AxiosResponse } from 'axios'; +const api = axios.create({ baseURL: '/api', headers: { 'X-App': 'web' } }); +interface User { id: string } +export async function getUser(id: string) { + const res: AxiosResponse = await api.get(\`/users/\${id}\`); + return res.data; +} +export async function listUsers(page: number) { + const { data } = await api.get('/users', { params: { page } }); + return data; +} + `, + ` +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class ApiEndpoint extends RestEndpoint { + urlPrefix = '/api'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'X-App': 'web' + }; + } +} + +interface User { id: string } +export async function getUser(id: string) { + const res: User = await new ApiEndpoint({ + path: '/users/:id' + })({ + id + }); + return res; +} +export async function listUsers(page: number) { + // TODO(data-client): check these query params serialize as before: axios leaves out null and undefined values and formats arrays and objects its own way; match it in \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization + const data = await (new ApiEndpoint({ + path: '/users', + searchParams: {} as Record + })({ + page + }) as Promise); + return data; +} + `, + 'keeps types: a generic base class, the response type as a cast and untyped query params', + ); + + tsTest( + ` +import type { RestGenerics } from '@data-client/rest'; +import axios from 'axios'; +export const save = (u: object) => axios.post('/users', u); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +import type { RestGenerics } from '@data-client/rest'; +// TODO(data-client): this resolves to the response body now, not an axios response, so drop \`.data\` (\`status\` and \`headers\` are gone too): https://dataclient.io/rest/guides/axios-migration#call-sites +export const save = (u: object) => new RestEndpoint({ + path: '/users', + method: 'POST' +})(u); + `, + 'adds a value import beside an import type', + ); + }); + // ── Edge cases ────────────────────────────────────────────────────── describe('edge cases', () => { diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index fbce9f5fcfdd..72d31d713163 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -1,12 +1,18 @@ 'use strict'; +const fs = require('fs'); +const nodePath = require('path'); + /** * Axios → @data-client/rest migration codemod * * Transforms: * 1. import axios from 'axios' → import { RestEndpoint } from '@data-client/rest' * 2. axios.create({ baseURL, headers }) → RestEndpoint subclass - * 3. axios.get/post/put/patch/delete(url) → new RestEndpoint({ path, method }) + * 3. axios.get/post/put/patch/delete(url, ...) → a call to + * new RestEndpoint({ path, method }), reading the body instead of `.data` + * 4. Anything it can't convert gets a `// TODO(data-client):` comment that + * links the guide section covering it * * Usage: * npx @data-client/codemods axios-to-rest src/ @@ -84,95 +90,84 @@ function extractHeaderProperties(j, node) { // --- 1. Transform imports --- -function hasReference(j, root, localName, importScope, excludeTypePositions) { - return ( - root - .find(j.Identifier, { name: localName }) - .filter(path => { - if (path.scope.lookup(localName) !== importScope) { - return false; - } - - const parent = path.parent.node; - - if ( - (parent.type === 'ImportDefaultSpecifier' || - parent.type === 'ImportSpecifier' || - parent.type === 'ImportNamespaceSpecifier') && - (parent.local === path.node || parent.imported === path.node) - ) { - return false; - } - - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === path.node && - !parent.computed - ) { - return false; - } - - if ( - parent.type === 'MemberExpression' && - parent.property === path.node && - !parent.computed - ) { - return false; - } +const TYPE_POSITIONS = new Set([ + 'TSTypeReference', + 'TSQualifiedName', + 'TSTypeQuery', + 'TSExpressionWithTypeArguments', + 'TSImportType', +]); - if (excludeTypePositions) { - // Ignore references used only in TS type positions. - if ( - parent.type === 'TSTypeReference' || - parent.type === 'TSQualifiedName' || - parent.type === 'TSTypeQuery' || - parent.type === 'TSExpressionWithTypeArguments' || - parent.type === 'TSImportType' - ) { - return false; - } - } +/** Whether an Identifier reads its binding, rather than naming an import, property or key */ +function isRead(path, { types = true } = {}) { + const parent = path.parent.node; + if (parent.type.startsWith('Import')) return false; + if ( + parent.type === 'MemberExpression' && + parent.property === path.node && + !parent.computed + ) + return false; + if ( + (parent.type === 'ObjectProperty' || parent.type === 'Property') && + parent.key === path.node && + !parent.computed && + !parent.shorthand + ) + return false; + if (!types && TYPE_POSITIONS.has(parent.type)) return false; + return true; +} - return true; - }) - .size() > 0 - ); +/** Reads of the `name` binding that `scope` declares, in the `within` collection */ +function readsOf(j, within, name, scope, options) { + return within + .find(j.Identifier, { name }) + .filter(p => p.scope.lookup(name) === scope && isRead(p, options)); } function hasLiveReference(j, root, localName, importScope) { - return hasReference(j, root, localName, importScope, false); + return readsOf(j, root, localName, importScope).size() > 0; } function hasRuntimeReference(j, root, localName, importScope) { - return hasReference(j, root, localName, importScope, true); + return readsOf(j, root, localName, importScope, { types: false }).size() > 0; } -function ensureRestEndpointImport(j, root) { - let dirty = false; +function ensureRestImports(j, root, names) { + // `import type` can't bring in the RestEndpoint value const restImports = root .find(j.ImportDeclaration) - .filter(p => p.node.source.value === '@data-client/rest'); + .filter( + p => + p.node.source.value === '@data-client/rest' && + p.node.importKind !== 'type', + ); if (restImports.length) { + const imported = new Set(); restImports.forEach(importPath => { - const specifiers = importPath.node.specifiers || []; - const hasRestEndpoint = specifiers.some( - s => + for (const s of importPath.node.specifiers || []) { + if ( s.type === 'ImportSpecifier' && s.imported && - (s.imported.name || s.imported.value) === 'RestEndpoint', - ); - if (hasRestEndpoint) return; - - specifiers.push(j.importSpecifier(j.identifier('RestEndpoint'))); - importPath.node.specifiers = specifiers; - dirty = true; + s.importKind !== 'type' + ) + imported.add(specName(s.imported)); + } }); - return dirty; + const missing = names.filter(name => !imported.has(name)); + if (!missing.length) return false; + const first = restImports.paths()[0].node; + first.specifiers = [ + ...(first.specifiers || []), + ...missing.map(name => j.importSpecifier(j.identifier(name))), + ]; + return true; } const newImport = j.importDeclaration( - [j.importSpecifier(j.identifier('RestEndpoint'))], + names.map(name => j.importSpecifier(j.identifier(name))), j.literal('@data-client/rest'), ); const firstImport = root.find(j.ImportDeclaration).at(0); @@ -184,7 +179,13 @@ function ensureRestEndpointImport(j, root) { return true; } -function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { +function transformImports( + j, + root, + axiosLocalName, + needsRestEndpointImport, + restImports, +) { let dirty = false; const axiosImportPaths = root .find(j.ImportDeclaration) @@ -205,7 +206,7 @@ function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { ); if (needsRestEndpointImport || (!axiosStillUsed && hasDefaultImport)) { - dirty = ensureRestEndpointImport(j, root) || dirty; + dirty = ensureRestImports(j, root, restImports) || dirty; } root.find(j.ImportDeclaration).forEach(importPath => { @@ -217,14 +218,29 @@ function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { return; } - if (axiosStillUsed) return; + if (axiosStillUsed) { + // axios stays, but drop the types nothing reads anymore + const used = importPath.node.specifiers.filter(s => { + if (s.type !== 'ImportSpecifier') return true; + const importedName = specName(s.imported); + if (s.importKind !== 'type' && !AXIOS_TYPE_IMPORTS.has(importedName)) + return true; + const localName = (s.local && s.local.name) || importedName; + return hasLiveReference(j, root, localName, importPath.scope); + }); + if (used.length !== importPath.node.specifiers.length) { + importPath.node.specifiers = used; + dirty = true; + } + return; + } const specs = importPath.node.specifiers; const remaining = specs.filter(s => { if (s.type === 'ImportDefaultSpecifier') return false; if (s.type === 'ImportSpecifier' && s.importKind === 'type') return false; if (s.type === 'ImportSpecifier' && s.imported) { - const importedName = s.imported.name || s.imported.value; + const importedName = specName(s.imported); const localName = (s.local && s.local.name) || importedName; if (AXIOS_RUNTIME_IMPORTS.has(importedName)) { @@ -252,10 +268,15 @@ function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { // --- 2. Transform axios.create() → class --- -function transformAxiosCreate(j, root, axiosLocalName) { +function transformAxiosCreate(j, root, axiosLocalName, isTS) { let dirty = false; + // names the generated class must not take const existingClasses = getExistingClassNames(j, root); + existingClasses.add('RestEndpoint'); + existingClasses.add('RestGenerics'); const createToClass = new Map(); + // classes whose urlPrefix came from a baseURL + const prefixedClasses = new Set(); root.find(j.VariableDeclarator).forEach(declPath => { const init = declPath.node.init; @@ -297,43 +318,73 @@ function transformAxiosCreate(j, root, axiosLocalName) { : null; const classBody = []; + const unknownOptions = []; + const unknownTodos = []; if (config) { for (const prop of config.properties) { - if (prop.type === 'SpreadElement' || prop.type === 'RestElement') + if (prop.type === 'SpreadElement' || prop.type === 'RestElement') { + unknownOptions.push('...' + j(prop.argument).toSource()); continue; - const key = - prop.key.type === 'Identifier' ? prop.key.name - : prop.key.type === 'StringLiteral' ? prop.key.value - : null; - if (!key) continue; + } + const key = propKey(prop); + if (!key) { + unknownOptions.push('...'); + continue; + } if (key === 'baseURL') { - const urlProp = j.classProperty( - j.identifier('urlPrefix'), - prop.value, - ); + // axios joins baseURL and url with one '/'; urlPrefix + path are + // joined as is, and every path starts with '/' + let prefix = prop.value; + const literal = + prefix.type === 'StringLiteral' || + (prefix.type === 'Literal' && typeof prefix.value === 'string'); + if (literal && prefix.value.endsWith('/')) { + prefix = j.stringLiteral(prefix.value.replace(/\/+$/, '')); + } else if (!literal) { + unknownTodos.push([ + 'make sure `urlPrefix` has no trailing `/`, since every path starts with one', + 'base-endpoint', + ]); + } + if (literal && ORIGIN.exec(prefix.value)?.[0].includes('@')) { + unknownTodos.push([ + 'fetch rejects credentials in a url: remove `user:pass@` from `urlPrefix` and send them as an `Authorization` header from `getHeaders()`', + 'basic-auth', + ]); + } + const urlProp = j.classProperty(j.identifier('urlPrefix'), prefix); urlProp.static = false; classBody.push(urlProp); + prefixedClasses.add(className); } else if (key === 'headers') { - const headersMethod = j.methodDefinition( - 'method', - j.identifier('getHeaders'), - j.functionExpression( - null, - [j.identifier('headers')], - j.blockStatement([ - j.returnStatement( - j.objectExpression([ - j.spreadElement(j.identifier('headers')), - ...extractHeaderProperties(j, prop.value), - ]), - ), - ]), - ), - false, + const headers = j.identifier('headers'); + const fn = j.functionExpression( + null, + [headers], + j.blockStatement([ + j.returnStatement( + j.objectExpression([ + j.spreadElement(j.identifier('headers')), + ...extractHeaderProperties(j, prop.value), + ]), + ), + ]), + ); + if (isTS) { + headers.typeAnnotation = j.tsTypeAnnotation( + j.tsTypeReference(j.identifier('HeadersInit')), + ); + fn.returnType = j.tsTypeAnnotation( + j.tsTypeReference(j.identifier('HeadersInit')), + ); + } + classBody.push( + j.methodDefinition('method', j.identifier('getHeaders'), fn, false), ); - classBody.push(headersMethod); + } else { + unknownOptions.push(key); } } } @@ -343,6 +394,23 @@ function transformAxiosCreate(j, root, axiosLocalName) { j.classBody(classBody), j.identifier('RestEndpoint'), ); + if (isTS) { + // keeps each endpoint's options typed: class X extends RestEndpoint + const generic = j.tsTypeParameter('O'); + generic.constraint = j.tsTypeReference(j.identifier('RestGenerics')); + generic.default = j.tsAnyKeyword(); + classDecl.typeParameters = j.tsTypeParameterDeclaration([generic]); + classDecl.superTypeParameters = j.tsTypeParameterInstantiation([ + j.tsTypeReference(j.identifier('O')), + ]); + } + const markOptions = node => { + for (const [message, anchor] of unknownTodos) + addTodo(j, node, message, anchor); + if (!unknownOptions.length) return; + const [message, anchor] = optionsTodo(unknownOptions, `\`${className}\``); + addTodo(j, node, message, anchor); + }; const parentPath = declPath.parent; if (parentPath.node.type === 'VariableDeclaration') { @@ -356,6 +424,7 @@ function transformAxiosCreate(j, root, axiosLocalName) { statementPath.node.type === 'ExportNamedDeclaration' ? j.exportNamedDeclaration(classDecl) : classDecl; + markOptions(classStatement); if (variableDecl.declarations.length === 1) { j(statementPath).replaceWith(classStatement); @@ -366,96 +435,1102 @@ function transformAxiosCreate(j, root, axiosLocalName) { j(statementPath).insertBefore(classStatement); } } else { + markOptions(classDecl); j(declPath).replaceWith(classDecl); } dirty = true; }); - return { dirty, createToClass }; + return { dirty, createToClass, prefixedClasses }; +} + +// --- 2b. Instances imported from another file --- + +// a client module usually exports `axios.create()` for the rest of the app; +// jscodeshift runs one file at a time, so read that module to know its class +const exportedInstanceCache = new Map(); +// an import and a member read that an instance could have +const INSTANCE_CALL = + /from\s*['"][\s\S]*\.\s*(?:get|post|put|patch|delete|interceptors|defaults)\b/; + +function findFile(base) { + const stem = base.replace(/\.[cm]?jsx?$/, ''); + const exts = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']; + const candidates = [ + base, + ...exts.map(e => stem + e), + ...exts.map(e => nodePath.join(base, 'index' + e)), + ]; + return candidates.find(f => { + try { + return fs.statSync(f).isFile(); + } catch { + return false; + } + }); +} + +// tsconfig/jsconfig `paths` and `baseUrl` of the nearest config, so aliased +// imports like `@/lib/api-client` resolve too +const pathsCache = new Map(); +function readConfig(file) { + const text = fs + .readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\/|("(?:\\.|[^"\\])*")|\/\/[^\n]*/g, (m, str) => + str ? m : '', + ) + .replace(/,(\s*[}\]])/g, '$1'); + const config = JSON.parse(text); + const options = config.compilerOptions || {}; + const dir = nodePath.dirname(file); + if (typeof config.extends === 'string' && config.extends.startsWith('.')) { + let parent = nodePath.resolve(dir, config.extends); + if (!parent.endsWith('.json')) parent += '.json'; + try { + const inherited = readConfig(parent); + return { + baseUrl: + options.baseUrl ? + nodePath.resolve(dir, options.baseUrl) + : inherited.baseUrl, + paths: options.paths || inherited.paths, + pathsDir: options.paths ? dir : inherited.pathsDir, + }; + } catch { + // fall through to this config alone + } + } + return { + baseUrl: options.baseUrl && nodePath.resolve(dir, options.baseUrl), + paths: options.paths, + pathsDir: dir, + }; +} + +function aliasConfig(fromFile) { + const dir = nodePath.dirname(nodePath.resolve(fromFile)); + if (pathsCache.has(dir)) return pathsCache.get(dir); + let config = null; + for (const name of ['tsconfig.json', 'jsconfig.json']) { + const file = nodePath.join(dir, name); + if (!fs.existsSync(file)) continue; + try { + config = readConfig(file); + } catch { + config = {}; + } + break; + } + const parent = nodePath.dirname(dir); + // a file in the parent folder, so the lookup continues from there + if (!config && parent !== dir) + config = aliasConfig(nodePath.join(parent, 'index.js')); + pathsCache.set(dir, config || {}); + return config || {}; +} + +// each folder's imports resolve the same way, so files there share lookups +const resolveCache = new Map(); +function resolveModule(fromFile, spec) { + const key = nodePath.dirname(nodePath.resolve(fromFile)) + '\0' + spec; + if (!resolveCache.has(key)) resolveCache.set(key, findModule(fromFile, spec)); + return resolveCache.get(key); +} + +function findModule(fromFile, spec) { + if (spec.startsWith('.')) + return findFile(nodePath.resolve(nodePath.dirname(fromFile), spec)); + const { baseUrl, paths, pathsDir } = aliasConfig(fromFile); + const root = baseUrl || pathsDir; + for (const [pattern, targets] of Object.entries(paths || {})) { + const star = pattern.indexOf('*'); + let rest; + if (star === -1) { + if (spec !== pattern) continue; + rest = ''; + } else { + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (!spec.startsWith(prefix) || !spec.endsWith(suffix)) continue; + rest = spec.slice(prefix.length, spec.length - suffix.length); + } + for (const target of targets) { + const file = findFile( + nodePath.resolve(root, target.split('*').join(rest)), + ); + if (file) return file; + } + } + // a bare import is a package unless baseUrl has a file by that name + return baseUrl ? findFile(nodePath.resolve(baseUrl, spec)) : undefined; +} + +/** Names of the instances whose `.interceptors.response` the module uses */ +function responseInterceptorTargets(j, root) { + return new Set( + root + .find(j.MemberExpression, { + object: { + type: 'MemberExpression', + object: { type: 'Identifier' }, + property: { name: 'interceptors' }, + }, + property: { name: 'response' }, + }) + .nodes() + .map(node => node.object.object.name), + ); +} + +// exported name ('default' for a default export) -> { className, prefixed, +// intercepted } for each axios instance a module exports, before or after +// this codemod has rewritten it +function exportedInstances(j, file) { + if (exportedInstanceCache.has(file)) return exportedInstanceCache.get(file); + const found = new Map(); + exportedInstanceCache.set(file, found); + let root; + try { + root = j(fs.readFileSync(file, 'utf8')); + } catch { + return found; + } + // exported name -> local binding + const exportsOf = new Map(); + root.find(j.ExportNamedDeclaration).forEach(p => { + const { declaration, specifiers, source } = p.node; + if (source) return; + if (declaration && declaration.type === 'VariableDeclaration') { + for (const d of declaration.declarations) + if (d.id.type === 'Identifier') exportsOf.set(d.id.name, d.id.name); + } else if (declaration && declaration.id) { + exportsOf.set(declaration.id.name, declaration.id.name); + } + for (const spec of specifiers || []) + if (spec.local && spec.exported) + exportsOf.set(specName(spec.exported), specName(spec.local)); + }); + root.find(j.ExportDefaultDeclaration).forEach(p => { + const decl = p.node.declaration; + if (decl.type === 'Identifier') exportsOf.set('default', decl.name); + else if (decl.type === 'ClassDeclaration' && decl.id) + exportsOf.set('default', decl.id.name); + }); + // a response interceptor can change what `.data` means at each call + const interceptedNames = responseInterceptorTargets(j, root); + const intercepts = name => interceptedNames.has(name); + + const axiosDefault = root + .find(j.ImportDeclaration, { source: { value: 'axios' } }) + .find(j.ImportDefaultSpecifier) + .nodes()[0]; + if (axiosDefault) { + // not rewritten yet: name the classes the way the rewrite will + const { createToClass, prefixedClasses } = transformAxiosCreate( + j, + root, + axiosDefault.local.name, + /\.[cm]?tsx?$/.test(file), + ); + for (const [exported, local] of exportsOf) { + const scopes = createToClass.get(local); + if (!scopes || scopes.size !== 1) continue; + const className = [...scopes.values()][0]; + found.set(exported, { + className, + // the rewrite keeps an alias, and gives the class's own name otherwise + exportName: exported === local ? className : exported, + prefixed: prefixedClasses.has(className), + intercepted: intercepts(local), + }); + } + } + // rewritten already: the instance's export now exports its class + const classes = new Map(); + root.find(j.ClassDeclaration).forEach(p => { + const { id, superClass, body } = p.node; + if (id && superClass && superClass.name === 'RestEndpoint') + classes.set( + id.name, + body.body.some(m => m.key && m.key.name === 'urlPrefix' && !m.static), + ); + }); + for (const [exported, local] of exportsOf) { + if (!classes.has(local)) continue; + const name = local.replace(/Endpoint$/, ''); + // the instance names the rewrite would derive this class's name from + const sources = [name, name.charAt(0).toLowerCase() + name.slice(1)].filter( + c => deriveClassName(c) === local, + ); + // an aliased or default export keeps its name; otherwise the class took + // the instance's place + const candidates = + exported !== local ? [exported] : sources.filter(c => !exportsOf.has(c)); + const intercepted = sources.some(intercepts); + for (const candidate of candidates) + if (!found.has(candidate)) + found.set(candidate, { + className: local, + exportName: exported, + prefixed: classes.get(local), + intercepted, + }); + } + return found; +} + +// imports of axios instances from the project's own modules; renames each +// import to the instance's class once the caller has converted the calls +function importedInstances(j, root, filePath) { + const createToClass = new Map(); + const prefixedClasses = new Set(); + const intercepted = new Set(); + const renames = []; + if (!filePath) + return { createToClass, prefixedClasses, intercepted, renames }; + const taken = getExistingClassNames(j, root); + root + .find(j.ImportDeclaration) + .filter( + p => + p.node.importKind !== 'type' && typeof p.node.source.value === 'string', + ) + .forEach(importPath => { + const file = resolveModule(filePath, importPath.node.source.value); + if (!file) return; + const instances = exportedInstances(j, file); + if (!instances.size) return; + for (const s of importPath.node.specifiers || []) { + if (s.importKind === 'type') continue; + const imported = + s.type === 'ImportDefaultSpecifier' ? 'default' + : s.type === 'ImportSpecifier' ? specName(s.imported) + : null; + const instance = imported && instances.get(imported); + if (!instance) continue; + const local = s.local.name; + const { className, prefixed } = instance; + // another binding already has the class's name + if ( + taken.has(className) || + root.find(j.Identifier, { name: className }).size() + ) + continue; + const scope = importPath.scope.lookup(local) || importPath.scope; + createToClass.set(local, new Map([[scope, className]])); + if (prefixed) prefixedClasses.add(className); + if (instance.intercepted) intercepted.add(local); + renames.push({ + declaration: importPath.node, + specifier: s, + className, + exportName: instance.exportName, + }); + } + }); + return { createToClass, prefixedClasses, intercepted, renames }; } // --- 3. Transform direct axios/instance method calls --- +const BODY_METHODS = new Set(['post', 'put', 'patch']); +// path-to-regexp v8 reserves these characters in a path +const RESERVED_PATH_CHARS = /[{}()[\]+?!*:\\]/; +const ORIGIN = /^(?:[a-z][a-z\d+.-]*:)?\/\/[^/?#]*/i; + +function isPure(node) { + if (node.type === 'Identifier' || node.type === 'ThisExpression') return true; + return ( + node.type === 'MemberExpression' && !node.computed && isPure(node.object) + ); +} + +function isLiteral(node) { + return ( + node.type === 'StringLiteral' || + node.type === 'NumericLiteral' || + node.type === 'BooleanLiteral' || + node.type === 'NullLiteral' || + node.type === 'Literal' || + (node.type === 'TemplateLiteral' && !node.expressions.length) + ); +} + +/** No side effects to reorder: a variable, member chain or literal */ +function isSimple(node) { + return isPure(node) || isLiteral(node); +} + +function isNullish(node) { + return ( + node.type === 'NullLiteral' || + (node.type === 'Literal' && node.value === null) || + (node.type === 'Identifier' && node.name === 'undefined') + ); +} + +/** + * Whether evaluating `moved` before `crossed`, instead of after, can change + * a value: one side has effects and the other reads state. + */ +function reordersEffects(moved, crossed) { + const effects = list => list.some(e => !isSimple(e)); + const reads = list => list.some(e => !isLiteral(e)); + return ( + (effects(moved) && reads(crossed)) || (effects(crossed) && reads(moved)) + ); +} + +/** A module export or import name, which can be a string literal */ +const specName = node => node.name || node.value; + +/** The property name when `node` reads a non-computed member of `object` */ +function memberOf(node, object) { + return node.type === 'MemberExpression' && node.object === object ? + memberName(node) + : null; +} + +function memberName(node) { + if (node.type === 'Identifier') return node.name; + if ( + node.type === 'MemberExpression' && + !node.computed && + node.property.type === 'Identifier' + ) + return node.property.name; + return null; +} + +/** + * RestEndpoint encodes path and query values itself, so a value the url + * already encoded passes unwrapped to avoid encoding it twice. + */ +function unencoded(expr) { + return ( + expr.type === 'CallExpression' && + expr.callee.type === 'Identifier' && + expr.callee.name === 'encodeURIComponent' && + expr.arguments.length === 1 && + expr.arguments[0].type !== 'SpreadElement' + ) ? + expr.arguments[0] + : expr; +} + +/** Parses `a=1&b=${x}` query text into args properties; null if it can't */ +function parseQuery(j, text, expressions) { + const props = []; + const pairs = text.split('&'); + let exprIndex = 0; + for (const pair of pairs) { + if (!pair) continue; + const eq = pair.indexOf('='); + if (eq <= 0) return null; + const key = pair.slice(0, eq); + const value = pair.slice(eq + 1); + if (!/^[A-Za-z_$][\w$]*$/.test(key)) return null; + // a repeated key would collapse into one arg + if (props.some(p => p.key.name === key)) return null; + if (value === '\0') { + const expr = expressions[exprIndex++]; + props.push( + j.objectProperty.from({ + key: j.identifier(key), + value: expr, + shorthand: expr.type === 'Identifier' && expr.name === key, + }), + ); + } else if (!value.includes('\0')) { + let decoded; + try { + decoded = decodeURIComponent(value.replace(/\+/g, ' ')); + } catch { + return null; + } + props.push(j.objectProperty(j.identifier(key), j.stringLiteral(decoded))); + } else { + return null; + } + } + return exprIndex === expressions.length ? props : null; +} + +/** + * Turns an axios url into a RestEndpoint path, with the values its + * template expressions (and query string) pass as args. + * Returns null when the url can't be expressed as a path. + */ +function toPath(j, urlArg) { + let quasis; + let expressions; + if (urlArg.type === 'StringLiteral' || urlArg.type === 'Literal') { + if (typeof urlArg.value !== 'string') return null; + quasis = [urlArg.value]; + expressions = []; + } else if (urlArg.type === 'TemplateLiteral') { + quasis = urlArg.quasis.map(q => q.value.cooked); + expressions = urlArg.expressions.map(unencoded); + if (quasis.some(q => q == null || q.includes('\0'))) return null; + } else { + return null; + } + + let urlPrefix; + const origin = quasis[0].match(ORIGIN); + if (origin) { + // fetch rejects credentials in a url + if (origin[0].includes('@')) return null; + urlPrefix = origin[0]; + quasis = [quasis[0].slice(urlPrefix.length), ...quasis.slice(1)]; + } + + // Join with \0 where each expression sits, then split off the query. + const joined = quasis.join('\0'); + // fragments aren't sent + if (joined.includes('#')) return null; + const queryStart = joined.indexOf('?'); + const pathPart = queryStart === -1 ? joined : joined.slice(0, queryStart); + const queryPart = queryStart === -1 ? '' : joined.slice(queryStart + 1); + + const args = []; + const taken = new Set(); + // the same variable can fill two segments: its source -> the param it got + const shared = new Map(); + let path = ''; + const segments = pathPart.split('\0'); + const pathExprCount = segments.length - 1; + for (let i = 0; i < segments.length; i++) { + const text = segments[i]; + if (RESERVED_PATH_CHARS.test(text)) return null; + path += text; + if (i === segments.length - 1) break; + // each expression must fill a whole path segment + const next = segments[i + 1]; + if (!path.endsWith('/') || (next && !next.startsWith('/'))) return null; + const expr = expressions[i]; + const source = isPure(expr) ? j(expr).toSource() : undefined; + let name = source && shared.get(source); + if (!name) { + const base = memberName(expr) || 'param'; + name = base; + for (let n = 2; taken.has(name); n++) name = base + n; + taken.add(name); + if (source) shared.set(source, name); + args.push( + j.objectProperty.from({ + key: j.identifier(name), + value: expr, + shorthand: expr.type === 'Identifier' && expr.name === name, + }), + ); + } + // an effect can change what a later read of an earlier variable sees + if (!isSimple(expr)) shared.clear(); + path += ':' + name; + } + if (!path) path = '/'; + + let query = []; + if (queryPart) { + query = parseQuery(j, queryPart, expressions.slice(pathExprCount)); + if (!query) return null; + } + + return { path, urlPrefix, args, query }; +} + +function propKey(prop) { + if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') return null; + if (prop.computed) return null; + if (prop.key.type === 'Identifier') return prop.key.name; + if (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') + return String(prop.key.value); + return null; +} + +/** + * For an `AxiosResponse` annotation, T; undefined for a bare + * `AxiosResponse`; null when it isn't an AxiosResponse at all + */ +function unwrapResponseType(annotation) { + const type = annotation && annotation.typeAnnotation; + if ( + type && + type.type === 'TSTypeReference' && + type.typeName.type === 'Identifier' && + type.typeName.name === 'AxiosResponse' + ) { + const params = type.typeParameters || type.typeArguments; + return params && params.params.length ? params.params[0] : undefined; + } + return null; +} + +function isDataMember(node, object) { + return memberOf(node, object) === 'data'; +} + +/** Replaces each `name.data` read with `name`; false if `name` is read any other way */ +function dropDataReads(j, within, name, scope, exclude) { + const reads = readsOf(j, j(within), name, scope) + .filter(p => p.node !== exclude) + .paths(); + if (!reads.every(p => isDataMember(p.parent.node, p.node))) return false; + reads.forEach(p => j(p.parent).replaceWith(p.node)); + return true; +} + +/** `{ data }` or `{ data: alias }` → the pattern data was bound to */ +function dataPattern(pattern) { + if (pattern.type !== 'ObjectPattern' || pattern.properties.length !== 1) + return null; + const prop = pattern.properties[0]; + if (propKey(prop) !== 'data') return null; + if (prop.value.type === 'AssignmentPattern') return null; + return prop.value; +} + +/** + * Axios resolves to a response whose `.data` is the body; endpoints resolve + * to the body. Rewrites how `callPath`'s result is read. + * Returns false when it can't tell, so the caller marks it. + */ +function unwrapData(j, callPath) { + const parent = callPath.parent; + + // `await api.delete(url);` reads nothing from the response + if (parent.node.type === 'ExpressionStatement') return true; + + if (parent.node.type === 'AwaitExpression') { + const awaitNode = parent.node; + const outer = parent.parent; + if (outer.node.type === 'ExpressionStatement') return true; + if (isDataMember(outer.node, awaitNode)) { + // (await x).data loses its parentheses with its .data + if (awaitNode.extra) awaitNode.extra.parenthesized = false; + j(outer).replaceWith(awaitNode); + return true; + } + if ( + outer.node.type === 'VariableDeclarator' && + outer.node.init === awaitNode + ) { + const id = outer.node.id; + const bodyType = unwrapResponseType(id.typeAnnotation); + if (id.type === 'Identifier') { + const scope = outer.scope.lookup(id.name); + if (!scope) return false; + // other modules may read `.data` from an exported response + if (outer.parent.parent.node.type === 'ExportNamedDeclaration') + return false; + if (!dropDataReads(j, scope.path, id.name, scope, id)) return false; + if (bodyType) id.typeAnnotation = j.tsTypeAnnotation(bodyType); + else if (bodyType === undefined) id.typeAnnotation = null; + return true; + } + const inner = dataPattern(id); + if (inner) { + if (bodyType && !inner.typeAnnotation) + inner.typeAnnotation = j.tsTypeAnnotation(bodyType); + outer.node.id = inner; + return true; + } + } + return false; + } + + // promise.then(res => res.data) + if ( + parent.node.type === 'MemberExpression' && + parent.node.object === callPath.node && + !parent.node.computed && + parent.node.property.type === 'Identifier' && + parent.node.property.name === 'then' && + parent.parent.node.type === 'CallExpression' && + parent.parent.node.callee === parent.node + ) { + const thenPath = parent.parent; + const cb = thenPath.node.arguments[0]; + if ( + !cb || + (cb.type !== 'ArrowFunctionExpression' && + cb.type !== 'FunctionExpression') || + !cb.params.length + ) + return false; + const param = cb.params[0]; + if (param.type === 'Identifier') { + const cbPath = thenPath.get('arguments', 0); + if (!dropDataReads(j, cbPath, param.name, cbPath.scope, param)) + return false; + const bodyType = unwrapResponseType(param.typeAnnotation); + param.typeAnnotation = bodyType ? j.tsTypeAnnotation(bodyType) : null; + } else { + const inner = dataPattern(param); + if (!inner) return false; + cb.params[0] = inner; + } + // .then(res => res) does nothing now + if ( + thenPath.node.arguments.length === 1 && + cb.type === 'ArrowFunctionExpression' && + cb.params.length === 1 && + cb.params[0].type === 'Identifier' && + cb.body.type === 'Identifier' && + cb.body.name === cb.params[0].name + ) { + j(thenPath).replaceWith(callPath.node); + } + return true; + } + + return false; +} + +const GUIDE = 'https://dataclient.io/rest/guides/axios-migration'; +const TODO = 'TODO(data-client):'; + +/** table[key], ignoring inherited keys like `toString` that source code can name */ +function own(table, key) { + return Object.hasOwn(table, key) ? table[key] : undefined; +} + +const OPTION_ANCHORS = { + timeout: 'timeout', + signal: 'cancellation', + cancelToken: 'cancellation', + responseType: 'binary-responses', + paramsSerializer: 'query-serialization', + auth: 'basic-auth', + validateStatus: 'accepting-error-statuses', + xsrfCookieName: 'csrf-headers', + xsrfHeaderName: 'csrf-headers', + withXSRFToken: 'csrf-headers', + onUploadProgress: 'upload-progress', + onDownloadProgress: 'upload-progress', +}; + +function optionsTodo(keys, target = 'the endpoint') { + const anchors = [...new Set(keys.map(k => own(OPTION_ANCHORS, k)))]; + const anchor = + anchors.length === 1 && anchors[0] ? anchors[0] : 'quick-reference'; + return [ + `move axios ${keys.map(k => `\`${k}\``).join(', ')} to ${target}`, + anchor, + ]; +} + +/** + * Adds a TODO comment above the nearest statement or class member holding + * `path` (outside any `export`), once per message + */ +function markTodo(j, path, message, anchor) { + let p = path; + while ( + p.parent && + !( + j.Statement.check(p.node) && + p.parent.node.type !== 'ExportNamedDeclaration' && + p.parent.node.type !== 'ExportDefaultDeclaration' + ) && + p.node.type !== 'ClassProperty' && + p.node.type !== 'ClassMethod' && + p.node.type !== 'MethodDefinition' + ) { + p = p.parent; + } + addTodo(j, p.node, message, anchor); +} + +function addTodo(j, node, message, anchor) { + const text = ` ${TODO} ${message}: ${GUIDE}#${anchor}`; + const comments = node.comments || (node.comments = []); + if (comments.some(c => c.value === text)) return; + comments.push(j.commentLine(text, true, false)); +} + +function buildCall(j, callPath, className, prefixed, isTS) { + const callee = callPath.node.callee; + const methodName = callee.property.name; + const args = callPath.node.arguments; + if (args.length === 0) return null; + + const url = toPath(j, args[0]); + if (!url) return null; + if (prefixed) { + // the class's urlPrefix field would override a urlPrefix option + if (url.urlPrefix) return null; + // axios puts a '/' between baseURL and a relative url + if (!url.path.startsWith('/')) url.path = '/' + url.path; + } + + const hasBody = BODY_METHODS.has(methodName); + const data = hasBody ? args[1] : undefined; + const config = hasBody ? args[2] : args[1]; + if (args.some(a => a.type === 'SpreadElement')) return null; + + const configParams = []; + const spreadParams = []; + const unknownOptions = []; + const todos = []; + if (config) { + if (config.type !== 'ObjectExpression') return null; + for (const prop of config.properties) { + const key = propKey(prop); + if (key === 'params') { + if (prop.value.type === 'ObjectExpression') { + configParams.push(...prop.value.properties); + } else { + // first, so it can't replace a path param + spreadParams.push(j.spreadElement(prop.value)); + } + } else if (key) { + unknownOptions.push(key); + } else { + return null; + } + } + } + + // axios evaluates the url, then the body, then params; the endpoint call + // takes spread params first and the body last + const spreadValues = spreadParams.map(p => p.argument); + const urlValues = [...url.args, ...url.query].map(p => p.value); + const paramValues = configParams.map(p => p.value).filter(Boolean); + if ( + reordersEffects(spreadValues, urlValues) || + (data && reordersEffects([...spreadValues, ...paramValues], [data])) + ) + return null; + + // axios sends both a url query key and the same key from spread params; + // an object keeps only one + if (spreadParams.length && url.query.length) return null; + // each query key must be distinct from the path params and each other + const keys = new Set(url.args.map(propKey)); + for (const prop of [...url.query, ...configParams]) { + const key = propKey(prop); + if (!key || keys.has(key)) return null; + keys.add(key); + } + // axios leaves out null and undefined params + const sentParams = configParams.filter(p => !isNullish(p.value)); + // and serializes them its own way, so only literal scalars are sure to match + if (spreadParams.length || sentParams.some(p => !isLiteral(p.value))) { + todos.push([ + 'check these query params serialize as before: axios leaves out null and undefined values and formats arrays and objects its own way; match it in `searchToString()`', + 'query-serialization', + ]); + } + const query = [...url.query, ...sentParams]; + const callArgs = [...spreadParams, ...url.args, ...query]; + + const options = []; + if (url.urlPrefix) { + options.push( + j.objectProperty( + j.identifier('urlPrefix'), + j.stringLiteral(url.urlPrefix), + ), + ); + } + options.push( + j.objectProperty(j.identifier('path'), j.stringLiteral(url.path)), + ); + if (methodName !== 'get') { + options.push( + j.objectProperty( + j.identifier('method'), + j.stringLiteral(methodName.toUpperCase()), + ), + ); + } + if (isTS && (query.length || spreadParams.length)) { + // axios params are untyped too; narrow this once you know the query + const searchParams = j.tsAsExpression( + j.objectExpression([]), + j.tsTypeReference( + j.identifier('Record'), + j.tsTypeParameterInstantiation([ + j.tsStringKeyword(), + j.tsUnknownKeyword(), + ]), + ), + ); + options.push(j.objectProperty(j.identifier('searchParams'), searchParams)); + } + // with no body, a single argument is params rather than the body + if (hasBody && !data) { + options.push( + j.objectProperty(j.identifier('body'), j.identifier('undefined')), + ); + } + const typeArgs = + callPath.node.typeParameters || callPath.node.typeArguments || null; + const endpoint = j.newExpression(j.identifier(className || 'RestEndpoint'), [ + j.objectExpression(options), + ]); + const params = callArgs.length ? j.objectExpression(callArgs) : null; + const invokeArgs = [params, data].filter(Boolean); + + return { + node: j.callExpression(endpoint, invokeArgs), + // the response type the axios call was given + responseType: isTS && typeArgs && typeArgs.params[0], + unknownOptions, + todos, + }; +} + function transformDirectCalls( j, root, axiosLocalName, axiosImportScope, - createToClass, + { createToClass, prefixedClasses, intercepted: importedIntercepted }, + isTS, ) { let dirty = false; + let marked = false; const callTargets = new Set([axiosLocalName, ...createToClass.keys()]); + // a response interceptor can change what `.data` refers to (often the + // body's own `data` field, after `response => response.data`) + const intercepted = new Set([ + ...[...responseInterceptorTargets(j, root)].filter(name => + callTargets.has(name), + ), + ...importedIntercepted, + ]); + + // innermost calls first, so a call nested in another call's args converts + // before its parent rebuilds around it + const calls = root + .find(j.CallExpression) + .filter(callPath => { + const callee = callPath.node.callee; + return ( + callee && + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callTargets.has(callee.object.name) && + callee.property.type === 'Identifier' && + HTTP_METHODS.has(callee.property.name) + ); + }) + .paths() + .reverse(); - root.find(j.CallExpression).forEach(callPath => { - const callee = callPath.node.callee; - if ( - !callee || - callee.type !== 'MemberExpression' || - callee.object.type !== 'Identifier' || - !callTargets.has(callee.object.name) || - callee.property.type !== 'Identifier' || - !HTTP_METHODS.has(callee.property.name) - ) { - return; - } - - const methodName = callee.property.name; - const args = callPath.node.arguments; - if (args.length === 0) return; - - const targetName = callee.object.name; + for (const callPath of calls) { + const targetName = callPath.node.callee.object.name; const callBindingScope = callPath.scope.lookup(targetName); let className; if (targetName === axiosLocalName) { // Only transform calls that still refer to the imported axios binding. - if (callBindingScope !== axiosImportScope) return; + if (callBindingScope !== axiosImportScope) continue; } else { const scopedClassNames = createToClass.get(targetName); - if (!scopedClassNames) return; + if (!scopedClassNames) continue; className = scopedClassNames.get(callBindingScope); // Avoid rewriting shadowed identifiers with the same name. - if (!className) return; + if (!className) continue; } - const urlArg = args[0]; - if (urlArg.type !== 'StringLiteral' && urlArg.type !== 'TemplateLiteral') - return; + const result = buildCall( + j, + callPath, + className, + prefixedClasses.has(className), + isTS, + ); + if (!result) { + markTodo( + j, + callPath, + `convert this ${targetName}.${callPath.node.callee.property.name}() call to an endpoint`, + 'call-sites', + ); + marked = true; + continue; + } - const isCreatedInstance = Boolean(className); + j(callPath).replaceWith(result.node); + if (result.unknownOptions.length) { + const [message, anchor] = optionsTodo(result.unknownOptions); + markTodo(j, callPath, message, anchor); + } + for (const [message, anchor] of result.todos) + markTodo(j, callPath, message, anchor); + if (intercepted.has(targetName)) { + markTodo( + j, + callPath, + `\`${targetName}\` has a response interceptor, so check each \`.data\` read of this call: drop it if it read axios's response, keep it if it reads the body's own field`, + 'base-endpoint', + ); + } else if (!unwrapData(j, callPath)) { + markTodo( + j, + callPath, + 'this resolves to the response body now, not an axios response, so drop `.data` (`status` and `headers` are gone too)', + 'call-sites', + ); + } + if (result.responseType) { + // cast where the call ends up; a process() override would skip the base class's + const castPath = root + .find(j.CallExpression) + .filter(p => p.node === result.node) + .paths()[0]; + if (castPath) { + const cast = j.tsAsExpression( + result.node, + j.tsTypeReference( + j.identifier('Promise'), + j.tsTypeParameterInstantiation([result.responseType]), + ), + ); + // recast won't add these itself, and `await x as T` casts the awaited value + cast.extra = { parenthesized: true }; + j(castPath).replaceWith(cast); + } + } + dirty = true; + } - // Keep existing no-op behavior for direct axios calls with extra args. - // However, created instances must still be transformed so we don't leave - // dangling references after axios.create() is replaced by a class. - if (args.length > 1 && !isCreatedInstance) return; + return { dirty, marked }; +} - const method = methodName.toUpperCase(); +// --- 4. Mark what's left for a person or agent --- + +const AXIOS_MEMBER_TODOS = { + isAxiosError: [ + 'check `NetworkError` instead of `isAxiosError()`', + 'error-handling', + ], + isCancel: [ + "check `error.name === 'AbortError'` instead of `isCancel()`", + 'cancellation', + ], + all: ['use `Promise.all()` instead of `axios.all()`', 'call-sites'], + spread: [ + 'destructure the `Promise.all()` result instead of `axios.spread()`', + 'call-sites', + ], + CancelToken: [ + 'use an `AbortSignal` instead of `CancelToken`', + 'cancellation', + ], + AxiosError: [ + 'check `NetworkError` instead of `AxiosError`', + 'error-handling', + ], +}; - const pathValue = - urlArg.type === 'StringLiteral' ? j.stringLiteral(urlArg.value) : urlArg; +function markLeftovers( + j, + root, + axiosLocalName, + axiosImportScope, + createToClass, +) { + let dirty = false; - const properties = [j.objectProperty(j.identifier('path'), pathValue)]; + // calls it couldn't convert are marked already + const isMarkedCall = p => HTTP_METHODS.has(memberOf(p.parent.node, p.node)); - if (method !== 'GET') { - properties.push( - j.objectProperty(j.identifier('method'), j.stringLiteral(method)), - ); - } + // axios instances are classes now; anything else reading them needs a hand + for (const [name, scopes] of createToClass) { + root + .find(j.Identifier, { name }) + .filter( + p => + scopes.has(p.scope.lookup(name)) && + isRead(p) && + p.parent.node.type !== 'ClassDeclaration' && + !isMarkedCall(p), + ) + .forEach(p => { + const className = scopes.get(p.scope.lookup(name)); + const parent = p.parent.node; + // the module exports the class now, which importers convert to + if (parent.type === 'ExportSpecifier') { + if (parent.local !== p.node) return; + const exported = specName(parent.exported); + parent.local = j.identifier(className); + if (exported === name) parent.exported = j.identifier(className); + dirty = true; + return; + } + if (parent.type === 'ExportDefaultDeclaration') { + parent.declaration = j.identifier(className); + dirty = true; + return; + } + const member = memberOf(parent, p.node); + if (member === 'interceptors') { + markTodo( + j, + p, + `move this interceptor into \`${className}\``, + 'base-endpoint', + ); + } else if (member === 'defaults') { + markTodo( + j, + p, + `move these defaults into \`${className}\``, + 'base-endpoint', + ); + } else { + markTodo( + j, + p, + `\`${name}\` is the \`${className}\` class now; call an endpoint instead`, + 'call-sites', + ); + } + dirty = true; + }); + } - let newExpr; - if (className) { - newExpr = j.newExpression(j.identifier(className), [ - j.objectExpression(properties), - ]); - } else { - newExpr = j.newExpression(j.identifier('RestEndpoint'), [ - j.objectExpression(properties), - ]); - } + if (axiosImportScope) { + readsOf(j, root, axiosLocalName, axiosImportScope, { types: false }) + .filter(p => !isMarkedCall(p)) + .forEach(p => { + const parent = p.parent.node; + const member = memberOf(parent, p.node); + const [message, anchor] = own(AXIOS_MEMBER_TODOS, member) || [ + 'replace this axios call with an endpoint', + 'call-sites', + ]; + markTodo(j, p, message, anchor); + dirty = true; + }); + } - j(callPath).replaceWith(newExpr); - dirty = true; - }); + // runtime uses of AxiosError / CancelToken imports + root + .find(j.ImportDeclaration) + .filter( + p => p.node.source.value === 'axios' && p.node.importKind !== 'type', + ) + .forEach(importPath => { + for (const s of importPath.node.specifiers || []) { + if (s.type !== 'ImportSpecifier' || s.importKind === 'type') continue; + const imported = specName(s.imported); + const local = (s.local && s.local.name) || imported; + const todo = own(AXIOS_MEMBER_TODOS, imported); + if (!todo) continue; + readsOf(j, root, local, importPath.scope, { types: false }).forEach( + p => { + markTodo(j, p, todo[0], todo[1]); + dirty = true; + }, + ); + } + }); return dirty; } @@ -463,15 +1538,19 @@ function transformDirectCalls( // --- Main --- module.exports = function transformer(fileInfo, api) { - // skip parsing files that can't import axios - if (!fileInfo.source.includes('axios')) return fileInfo.source; + // skip parsing files that neither import axios nor could call an instance + // imported from another module + const { source } = fileInfo; + if (!source.includes('axios') && !INSTANCE_CALL.test(source)) return source; const j = api.jscodeshift; - const root = j(fileInfo.source); + const root = j(source); + const isTS = /\.[cm]?tsx?$/.test(fileInfo.path || ''); const axiosImport = root .find(j.ImportDeclaration) .filter(p => p.node.source.value === 'axios'); - if (!axiosImport.length) return fileInfo.source; + const imported = importedInstances(j, root, fileInfo.path); + if (!axiosImport.length && !imported.renames.length) return source; let axiosLocalName = 'axios'; let axiosImportScope = null; @@ -487,27 +1566,64 @@ module.exports = function transformer(fileInfo, api) { let dirty = false; - const createResult = transformAxiosCreate(j, root, axiosLocalName); + const createResult = transformAxiosCreate(j, root, axiosLocalName, isTS); dirty = createResult.dirty || dirty; + for (const [name, scopes] of imported.createToClass) + createResult.createToClass.set(name, scopes); + for (const className of imported.prefixedClasses) + createResult.prefixedClasses.add(className); + createResult.intercepted = imported.intercepted; - const directCallsDirty = transformDirectCalls( + const calls = transformDirectCalls( j, root, axiosLocalName, axiosImportScope, - createResult.createToClass, + createResult, + isTS, ); - dirty = directCallsDirty || dirty; + dirty = calls.dirty || calls.marked || dirty; dirty = - transformImports( + markLeftovers( j, root, axiosLocalName, - createResult.dirty || directCallsDirty, + axiosImportScope, + createResult.createToClass, ) || dirty; - return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; + // after the calls, which look the instance up by its imported name + for (const { + declaration, + specifier, + className, + exportName, + } of imported.renames) { + declaration.specifiers = declaration.specifiers.map(s => + s !== specifier ? s + : s.type === 'ImportDefaultSpecifier' ? + j.importDefaultSpecifier(j.identifier(className)) + : exportName === className ? j.importSpecifier(j.identifier(className)) + : j.importSpecifier(j.identifier(exportName), j.identifier(className)), + ); + dirty = true; + } + + if (axiosImport.length) { + dirty = + transformImports( + j, + root, + axiosLocalName, + createResult.dirty || calls.dirty, + isTS && createResult.dirty ? + ['RestEndpoint', 'RestGenerics'] + : ['RestEndpoint'], + ) || dirty; + } + + return dirty ? root.toSource({ quote: 'single' }) : source; }; module.exports.parser = 'tsx'; diff --git a/website/framework-docs/docsToMarkdown.mjs b/website/framework-docs/docsToMarkdown.mjs index e7b888b60905..04f532cc3a06 100644 --- a/website/framework-docs/docsToMarkdown.mjs +++ b/website/framework-docs/docsToMarkdown.mjs @@ -166,6 +166,7 @@ function attributesOf(node, props, file) { const text = value => ({ type: 'text', value }); const paragraph = children => ({ type: 'paragraph', children }); +const code = value => ({ type: 'inlineCode', value }); const html = value => ({ type: 'html', value }); /** Fence meta flags: stripped from the output, and some read into `data` */ @@ -200,7 +201,6 @@ function codeBlock({ lang, value, title, meta = title && `title="${title}"` }) { } /** The glue a playground's router supplies, which its files don't show */ function routesNote(framework) { - const code = value => ({ type: 'inlineCode', value }); return paragraph([ text('Files with a '), code('route'), @@ -498,7 +498,20 @@ function render(file, framework, props = {}) { if (INLINE[name] && !flow) return [{ type: INLINE[name], children: convertAll(node.children) }]; // Playgrounds, layout and other wrappers: keep what's inside - const children = convertAll(node.children); + // a caption (DiffEditor's says why the code changes) leads; backticks mark + // code, as the components render them + const children = [ + ...(typeof attrs.caption === 'string' ? + [ + paragraph( + attrs.caption + .split('`') + .map((value, i) => (i % 2 ? code(value) : text(value))), + ), + ] + : []), + ...convertAll(node.children), + ]; if (!name.endsWith('Playground')) return children; // files of one playground make up one example app let routed = false; diff --git a/website/src/components/DiffEditor.tsx b/website/src/components/DiffEditor.tsx index 105761a9a427..636393c9494c 100644 --- a/website/src/components/DiffEditor.tsx +++ b/website/src/components/DiffEditor.tsx @@ -6,8 +6,11 @@ import DiffEditorChooser from './DiffEditorChooser'; import Grid from './Grid'; import { calloutMarker, parseCallouts } from './Playground/editor/callouts'; import { parseCodeDocuments } from './Playground/editor/codeModel'; +import { useHasIntersected } from './useHasIntersected'; export default function DiffEditor({ children, caption }: Props) { + // pages can hold many diffs: start each editor only as it nears the viewport + const [ref, nearViewport] = useHasIntersected(NEAR); // Display-only: documents never change after parsing const { sides, callouts } = useMemo(() => { const [before, after] = parseCodeDocuments(children, 'Before'); @@ -43,13 +46,15 @@ export default function DiffEditor({ children, caption }: Props) { // Caption and callouts render outside the editor so they are always in the // server-rendered HTML, whether or not Monaco loads. return ( -
+
{caption && (
{typeof caption === 'string' ? inlineCode(caption) : caption}
)} - + {nearViewport ? + + : fallback} {callouts.length > 0 && (
    {callouts.map(({ index, text }) => ( @@ -71,6 +76,8 @@ function inlineCode(text: string) { .map((part, i) => (i % 2 ? {part} : part)); } +const NEAR = { rootMargin: '50%', threshold: 0 }; + interface Props { children: React.ReactNode; /** One line on why this change is needed, shown above the diff */