From 13de7e79e3fbf4ef23172d0d4bb74962dc59dcc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:16:29 +0000 Subject: [PATCH 01/41] docs: Make the Axios migration guide agent-first One prompt hands the migration to a coding agent through the setup skill (SetupTabs takes a task), agents reading the page markdown get the steps directly, and the guide is now seven ordered steps with DiffEditor before/after, including a Vue call-site variant. DiffEditor mounts Monaco only near the viewport, and page markdown keeps component captions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 825 +++++++------ .../references/axios-migration.vue.md | 696 +++++++++++ .../references/axios-migration.md | 825 +++++++------ .../references/axios-migration.vue.md | 696 +++++++++++ .claude-plugin/readme-commands.test.mjs | 18 +- docs/rest/guides/axios-migration.md | 1025 ++++++++--------- website/framework-docs/docsToMarkdown.mjs | 17 +- website/framework-docs/skillCommands.mjs | 18 +- website/src/components/DiffEditor.tsx | 11 +- website/src/components/SetupTabs.tsx | 24 +- 10 files changed, 2739 insertions(+), 1416 deletions(-) create mode 100644 .agents/skills/data-client-rest-setup/references/axios-migration.vue.md create mode 100644 .agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.vue.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..6a09bf46d895 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,224 @@ # 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 endpoints whose responses go into one normalized cache. +A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. ## Why migrate? -### Type-safe paths +Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: - -```ts -// axios: no type checking — typo silently produces wrong URL +```ts title="Before" axios.get(`/users/${usrId}`); ``` -With [`RestEndpoint`](./RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: - -```ts +```ts title="After" 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. +- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. +- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to + fetch it, or track loading and error state. +- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the + change before the server answers. +- **Less code**: [`resource()`](./resource.md) makes a full CRUD API from a `path` and a `schema`. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/docs/getting-started/installation#manual-setup). + +### 2. Run the codemod {#codemod} -### Additional benefits +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/ +``` -- **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` +It: -## Quick reference +- 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()` and `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into + `new CreatedClassName({ path, method })` -| 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` | +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). -## Migration examples +### 3. Move interceptors into a base endpoint {#base-endpoint} -### Basic GET +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. -**Before (axios)** +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. -```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', + 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'; +#### Server error messages -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +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`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the +responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.md): -export const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +One `resource()` replaces a module of CRUD functions, and `schema` puts every response in the normalized cache. + +```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}`); ``` -**After (data-client)** +```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'; @@ -161,115 +266,155 @@ 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`, nothing is normalized, +> and the migration gains little over axios. -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 +#### Coexisting with Zod or Yup -Axios interceptors map to [RestEndpoint](./RestEndpoint.md) lifecycle methods: +If the codebase already validates responses with Zod or Yup, choose one approach per type: -**Before (axios)** +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: -```ts title="api.ts" -import axios from 'axios'; + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` -const api = axios.create({ baseURL: 'https://api.example.com' }); +- **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. -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); +- **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). -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); -``` +### 5. Convert call sites to hooks {#hooks} -**After (data-client)** - -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), and +mutate through the [Controller](https://dataclient.io/docs/api/Controller), which updates every component that shows the changed data. -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = 'https://api.example.com'; +Loading and error states move to an `AsyncBoundary` above the component. - // Equivalent to request interceptor - getHeaders(headers: HeadersInit) { - return { - ...headers, - Authorization: `Bearer ${getToken()}`, - }; - } +```tsx title="Before" +import { useEffect, useState } from 'react'; +import { createPost, getPosts, type Post } from './api/posts'; +import PostItems from './PostItems'; +import Spinner from './Spinner'; - // Equivalent to response interceptor (unwrap/transform) - process(value: any, ...args: any) { - return value; - } +function PostList() { + const [posts, setPosts] = useState(null); + useEffect(() => { + getPosts().then(({ data }) => setPosts(data)); + }, []); + const addPost = async (title: string) => { + const { data } = await createPost({ title, body: '' }); + setPosts(posts => [...(posts ?? []), data]); + }; + if (!posts) return ; + return ; } ``` -> **Tip** -> -> `RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +```tsx title="After" +import { useController, useSuspense } from '@data-client/react'; +import { PostResource } from './api/posts'; +import PostItems from './PostItems'; -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. +function PostList() { + const posts = useSuspense(PostResource.getList); + const ctrl = useController(); + // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. + const addPost = (title: string) => + ctrl.fetch(PostResource.getList.push, { title, body: '' }); + return ; +} +``` + +Put an [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading +and error states. -### Error handling +#### Gradual migration -**Before (axios)** +If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 })`. + +### 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 axios.get('/users/1'); + const { data } = await getPost('1'); } catch (err) { if (axios.isAxiosError(err)) { console.log(err.response?.status); @@ -278,13 +423,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,67 +437,75 @@ 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: +Components that read with `useSuspense()` get errors from their `AsyncBoundary` +instead of a `try`. For soft retries on +server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). -```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 | +| ------------------------------------------ | ------------------------- | +| `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) via `signal` | +| `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)** +```ts title="After" +const controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` -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: +In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: ```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; +import { useCancelling, useSuspense } from '@data-client/react'; import { searchEndpoint } from './api/search'; import ResultsList from './ResultsList'; @@ -363,26 +515,15 @@ function SearchResults({ query }: { query: string }) { } ``` -For manual cancellation, pass `signal` directly: - -```ts -const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); -controller.abort(); -``` - 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)" +```ts title="After" const getUsers = new RestEndpoint({ path: '/users', signal: AbortSignal.timeout(5000), @@ -391,15 +532,25 @@ const getUsers = new RestEndpoint({ ### 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', + // callout: Keeps large blobs out of the cache + dataExpiryLength: 0, +}); +``` + +[`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 +558,25 @@ 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' }); + } +} +``` + +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 +591,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 +618,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 +710,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..d96938fc067c --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -0,0 +1,696 @@ + + +# Migrating from Axios + +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. +A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. + +## Why migrate? + +Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. + +```ts title="Before" +axios.get(`/users/${usrId}`); +``` + +```ts title="After" +const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); +getUser({ id: '1' }); +``` + +- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. +- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), not how to + fetch it, or track loading and error state. +- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the + change before the server answers. +- **Less code**: [`resource()`](./resource.vue.md) makes a full CRUD API from a `path` and a `schema`. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/vue/getting-started/installation#manual-setup). + +### 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/ +``` + +It: + +- 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()` and `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into + `new CreatedClassName({ path, method })` + +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). + +#### 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`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the +responses, so define one for each type the API returns. 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` puts every response in the normalized cache. + +```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`, nothing is normalized, +> and the migration gains little over axios. + +#### 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. Convert call sites to hooks {#hooks} + +Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), and +mutate through the [Controller](https://dataclient.io/vue/api/Controller), which updates every component that shows the changed data. + +Loading and error states move to a `` above the component. + +```html title="Before" + + + +``` + +```html title="After" + + + +``` + +Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to +show their loading state, and handle their errors with +[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +### 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); + } +} +``` + +Components that read with `useSuspense()` get errors from `onErrorCaptured()` +instead of a `try`. For soft retries on +server errors, see [`errorPolicy`](./RestEndpoint.vue.md#errorpolicy). + +### 7. Remove axios {#remove-axios} + +Search for what still uses axios: + +| 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 | + +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) via `signal` | +| `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 controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` + +In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: + +```tsx +import { useCancelling, useSuspense } 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 ; +} +``` + +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" +const getUsers = new RestEndpoint({ + path: '/users', + signal: AbortSignal.timeout(5000), +}); +``` + +### Binary responses + +```ts title="Before" +axios.get('/files/1', { responseType: 'blob' }); +``` + +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', + // callout: Keeps large blobs out of the cache + dataExpiryLength: 0, +}); +``` + +[`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' }); + } +} +``` + +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/references/axios-migration.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md index 12b4a2c8af1c..6a09bf46d895 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,224 @@ # 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 endpoints whose responses go into one normalized cache. +A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. ## Why migrate? -### Type-safe paths +Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: - -```ts -// axios: no type checking — typo silently produces wrong URL +```ts title="Before" axios.get(`/users/${usrId}`); ``` -With [`RestEndpoint`](./RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: - -```ts +```ts title="After" 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. +- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. +- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to + fetch it, or track loading and error state. +- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the + change before the server answers. +- **Less code**: [`resource()`](./resource.md) makes a full CRUD API from a `path` and a `schema`. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/docs/getting-started/installation#manual-setup). + +### 2. Run the codemod {#codemod} -### Additional benefits +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/ +``` -- **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` +It: -## Quick reference +- 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()` and `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into + `new CreatedClassName({ path, method })` -| 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` | +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). -## Migration examples +### 3. Move interceptors into a base endpoint {#base-endpoint} -### Basic GET +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. -**Before (axios)** +`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do. -```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', + 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'; +#### Server error messages -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +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`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the +responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +[`resource()`](./resource.md): -export const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +One `resource()` replaces a module of CRUD functions, and `schema` puts every response in the normalized cache. + +```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}`); ``` -**After (data-client)** +```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'; @@ -161,115 +266,155 @@ 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`, nothing is normalized, +> and the migration gains little over axios. -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 +#### Coexisting with Zod or Yup -Axios interceptors map to [RestEndpoint](./RestEndpoint.md) lifecycle methods: +If the codebase already validates responses with Zod or Yup, choose one approach per type: -**Before (axios)** +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: -```ts title="api.ts" -import axios from 'axios'; + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` -const api = axios.create({ baseURL: 'https://api.example.com' }); +- **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. -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); +- **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). -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); -``` +### 5. Convert call sites to hooks {#hooks} -**After (data-client)** - -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), and +mutate through the [Controller](https://dataclient.io/docs/api/Controller), which updates every component that shows the changed data. -export default class ApiEndpoint< - O extends RestGenerics = any, -> extends RestEndpoint { - urlPrefix = 'https://api.example.com'; +Loading and error states move to an `AsyncBoundary` above the component. - // Equivalent to request interceptor - getHeaders(headers: HeadersInit) { - return { - ...headers, - Authorization: `Bearer ${getToken()}`, - }; - } +```tsx title="Before" +import { useEffect, useState } from 'react'; +import { createPost, getPosts, type Post } from './api/posts'; +import PostItems from './PostItems'; +import Spinner from './Spinner'; - // Equivalent to response interceptor (unwrap/transform) - process(value: any, ...args: any) { - return value; - } +function PostList() { + const [posts, setPosts] = useState(null); + useEffect(() => { + getPosts().then(({ data }) => setPosts(data)); + }, []); + const addPost = async (title: string) => { + const { data } = await createPost({ title, body: '' }); + setPosts(posts => [...(posts ?? []), data]); + }; + if (!posts) return ; + return ; } ``` -> **Tip** -> -> `RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +```tsx title="After" +import { useController, useSuspense } from '@data-client/react'; +import { PostResource } from './api/posts'; +import PostItems from './PostItems'; -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. +function PostList() { + const posts = useSuspense(PostResource.getList); + const ctrl = useController(); + // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. + const addPost = (title: string) => + ctrl.fetch(PostResource.getList.push, { title, body: '' }); + return ; +} +``` + +Put an [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading +and error states. -### Error handling +#### Gradual migration -**Before (axios)** +If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 })`. + +### 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 axios.get('/users/1'); + const { data } = await getPost('1'); } catch (err) { if (axios.isAxiosError(err)) { console.log(err.response?.status); @@ -278,13 +423,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,67 +437,75 @@ 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: +Components that read with `useSuspense()` get errors from their `AsyncBoundary` +instead of a `try`. For soft retries on +server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). -```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 | +| ------------------------------------------ | ------------------------- | +| `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) via `signal` | +| `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)** +```ts title="After" +const controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` -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: +In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: ```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; +import { useCancelling, useSuspense } from '@data-client/react'; import { searchEndpoint } from './api/search'; import ResultsList from './ResultsList'; @@ -363,26 +515,15 @@ function SearchResults({ query }: { query: string }) { } ``` -For manual cancellation, pass `signal` directly: - -```ts -const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); -controller.abort(); -``` - 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)" +```ts title="After" const getUsers = new RestEndpoint({ path: '/users', signal: AbortSignal.timeout(5000), @@ -391,15 +532,25 @@ const getUsers = new RestEndpoint({ ### 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', + // callout: Keeps large blobs out of the cache + dataExpiryLength: 0, +}); +``` + +[`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 +558,25 @@ 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' }); + } +} +``` + +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 +591,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 +618,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 +710,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..d96938fc067c --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.vue.md @@ -0,0 +1,696 @@ + + +# Migrating from Axios + +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. +A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. + +## Why migrate? + +Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. + +```ts title="Before" +axios.get(`/users/${usrId}`); +``` + +```ts title="After" +const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); +getUser({ id: '1' }); +``` + +- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. +- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), not how to + fetch it, or track loading and error state. +- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the + change before the server answers. +- **Less code**: [`resource()`](./resource.vue.md) makes a full CRUD API from a `path` and a `schema`. + +## Migrate step by step {#steps} + +### 1. Install {#install} + +```bash +npm install @data-client/rest +``` + +If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/vue/getting-started/installation#manual-setup). + +### 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/ +``` + +It: + +- 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()` and `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into + `new CreatedClassName({ path, method })` + +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). + +#### 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`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the +responses, so define one for each type the API returns. 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` puts every response in the normalized cache. + +```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`, nothing is normalized, +> and the migration gains little over axios. + +#### 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. Convert call sites to hooks {#hooks} + +Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), and +mutate through the [Controller](https://dataclient.io/vue/api/Controller), which updates every component that shows the changed data. + +Loading and error states move to a `` above the component. + +```html title="Before" + + + +``` + +```html title="After" + + + +``` + +Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to +show their loading state, and handle their errors with +[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +### 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); + } +} +``` + +Components that read with `useSuspense()` get errors from `onErrorCaptured()` +instead of a `try`. For soft retries on +server errors, see [`errorPolicy`](./RestEndpoint.vue.md#errorpolicy). + +### 7. Remove axios {#remove-axios} + +Search for what still uses axios: + +| 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 | + +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) via `signal` | +| `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 controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` + +In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: + +```tsx +import { useCancelling, useSuspense } 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 ; +} +``` + +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" +const getUsers = new RestEndpoint({ + path: '/users', + signal: AbortSignal.timeout(5000), +}); +``` + +### Binary responses + +```ts title="Before" +axios.get('/files/1', { responseType: 'blob' }); +``` + +```ts title="After" +const downloadFile = new RestEndpoint({ + path: '/files/:id', + content: 'blob', + // callout: Keeps large blobs out of the cache + dataExpiryLength: 0, +}); +``` + +[`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' }); + } +} +``` + +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/.claude-plugin/readme-commands.test.mjs b/.claude-plugin/readme-commands.test.mjs index a4a854cba039..944d4a143b33 100644 --- a/.claude-plugin/readme-commands.test.mjs +++ b/.claude-plugin/readme-commands.test.mjs @@ -15,6 +15,7 @@ import { cursorSetupPrompt, setupCommand, setupPrompt, + setupTask, } from '../website/framework-docs/skillCommands.mjs'; const read = file => fs.readFileSync(path.join(root, file), 'utf8'); @@ -28,13 +29,13 @@ for (const [file, framework] of Object.entries({ test(`${file} setup commands match skillCommands.mjs`, () => { const readme = read(file); for (const command of [ - setupPrompt(framework), + setupPrompt(setupTask(framework)), claudePluginCommand, - claudeSetupPrompt(framework), + claudeSetupPrompt(setupTask(framework)), cursorPluginCommand, - cursorSetupPrompt(framework), + cursorSetupPrompt(setupTask(framework)), codexPluginCommand, - codexSetupPrompt(framework), + codexSetupPrompt(setupTask(framework)), ]) assert.ok(readme.includes(command), command); }); @@ -43,13 +44,18 @@ for (const [file, framework] of Object.entries({ // protocol READMEs carry only the prompt for (const pkg of ['rest', 'graphql', 'endpoint']) { test(`packages/${pkg}/README.md setup prompt matches skillCommands.mjs`, () => { - assert.ok(read(`packages/${pkg}/README.md`).includes(setupPrompt(pkg))); + assert.ok( + read(`packages/${pkg}/README.md`).includes(setupPrompt(setupTask(pkg))), + ); }); } test("AgentPrompt's default prompt matches setupPrompt", () => { // its translate() literal can't import setupPrompt (i18n extraction needs a literal) - const message = setupPrompt('{framework}').replace(setupCommand, '{command}'); + const message = setupPrompt(setupTask('{framework}')).replace( + setupCommand, + '{command}', + ); assert.ok( read('website/src/components/AgentPrompt.tsx').includes(`'${message}'`), message, diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 8ce9d3cba992..ccea5d9ae183 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -3,164 +3,268 @@ 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 SetupTabs from '@site/src/components/SetupTabs'; 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 endpoints whose responses go into one normalized cache. +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.). + - +Your agent runs Data Client's setup [skill](/docs/getting-started/agent-skills), which follows this guide: it sets up +Data Client if the app doesn't have it yet, runs the codemod, then works through the [steps](#steps) the codemod can't +do. Review its diff like a teammate's. -Then start the migration in your agent's chat. It will detect axios and apply the appropriate migration sub-procedure automatically. + + + -```prompt -/data-client-rest-setup +To migrate an app from axios, install Data Client's setup skill from the project root: + +```bash +npx skills add reactive/data-client --skill data-client-setup -y ``` - +Then read `.agents/skills/data-client-setup/SKILL.md` and follow it. It sets up Data Client if the app doesn't have +it, detects axios, and follows the steps below. If the app already uses Data Client, do the [steps](#steps) in order. -## Why migrate? + -### Type-safe paths +## Why migrate? -With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: + -```ts -// axios: no type checking — typo silently produces wrong URL +```ts title="Before" axios.get(`/users/${usrId}`); ``` -With [`RestEndpoint`](../api/RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: - -```ts +```ts title="After" 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" + + +- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. +- **Declarative data**: components say what data they need with [`useSuspense()`](/docs/api/useSuspense), not how to + fetch it, or track loading and error state. +- **Mutations update the UI**: responses update the cache, and [optimistic updates](./optimistic-updates.md) show the + change before the server answers. +- **Less code**: [`resource()`](../api/resource.md) makes a full CRUD API from a `path` and a `schema`. + +## 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} + + + +If the app doesn't use Data Client yet, also [install it and add its provider](/docs/getting-started/installation#manual-setup). + +### 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/ +``` + +It: + +- 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()` and `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into + `new CreatedClassName({ path, method })` + +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. + + + +```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()}`, + }; + } +} ``` - - + - +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). -```ts title="User" collapsed -import { Entity } from '@data-client/rest'; +#### Server error messages -export default class User extends Entity { - id = ''; - username = ''; - email = ''; - static key = 'User'; +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; + } + } } ``` -```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()`](../api/RestEndpoint.md#url), [`getRequestInit()`](../api/RestEndpoint.md#getRequestInit), +[`fetchResponse()`](../api/RestEndpoint.md#fetchResponse) and [`parseResponse()`](../api/RestEndpoint.md#parseResponse): -export const getUser = new RestEndpoint({ - urlPrefix: 'https://api.example.com', +```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, }); ``` -```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`. An [Entity](../api/Entity.md) schema is what normalizes and caches the +responses, so define one for each type the API returns. 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 const getPost = (id: string) => api.get(`/posts/${id}`); -export const createPost = (data: any) => api.post('/posts', data); +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: @@ -175,7 +279,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 +296,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 +315,218 @@ 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`, nothing is normalized, +and the migration gains little over axios. -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, }); ``` - +#### Coexisting with Zod or Yup - - +If the codebase already validates responses with Zod or Yup, choose one approach per type: -### Interceptors → lifecycle methods +- **Zod in `process()`** (recommended): keep runtime validation by parsing in + [`process()`](../api/RestEndpoint.md#process), and let the Entity handle normalization: -Axios interceptors map to [RestEndpoint](../api/RestEndpoint.md) lifecycle methods: + ```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()`](../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). -```ts title="api.ts" -import axios from 'axios'; +### 5. Convert call sites to hooks {#hooks} -const api = axios.create({ baseURL: 'https://api.example.com' }); +Components stop storing responses in state. They read endpoints with [`useSuspense()`](/docs/api/useSuspense), and +mutate through the [Controller](/docs/api/Controller), which updates every component that shows the changed data. -// Request interceptor — add auth token -api.interceptors.request.use(config => { - config.headers.Authorization = `Bearer ${getToken()}`; - return config; -}); +:::react + + + +```tsx title="Before" +import { useEffect, useState } from 'react'; +import { createPost, getPosts, type Post } from './api/posts'; +import PostItems from './PostItems'; +import Spinner from './Spinner'; -// Response interceptor — unwrap .data -api.interceptors.response.use( - response => response.data, - error => Promise.reject(error), -); +function PostList() { + const [posts, setPosts] = useState(null); + useEffect(() => { + getPosts().then(({ data }) => setPosts(data)); + }, []); + const addPost = async (title: string) => { + const { data } = await createPost({ title, body: '' }); + setPosts(posts => [...(posts ?? []), data]); + }; + if (!posts) return ; + return ; +} ``` - - +```tsx title="After" +import { useController, useSuspense } from '@data-client/react'; +import { PostResource } from './api/posts'; +import PostItems from './PostItems'; -```ts title="ApiEndpoint.ts" -import { RestEndpoint, RestGenerics } from '@data-client/rest'; +function PostList() { + const posts = useSuspense(PostResource.getList); + const ctrl = useController(); + // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. + const addPost = (title: string) => + ctrl.fetch(PostResource.getList.push, { title, body: '' }); + return ; +} +``` -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()}`, - }; - } +Put an [`AsyncBoundary`](/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading +and error states. + +::: + +:::vue + + + +```html title="Before" + + + ``` - - +```html title="After" + + + +``` -:::tip + -`RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. +Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to +show their loading state, and handle their errors with +[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). ::: -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. +:::react -### Error handling +#### Gradual migration - - +If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 })`. + +::: + +### 6. Migrate error handling {#error-handling} + + + +```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 +535,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,77 +549,81 @@ 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). + - - +Components that read with `useSuspense()` get errors from :react[their `AsyncBoundary`]:vue[`onErrorCaptured()`] +instead of a `try`. For soft retries on +server errors, see [`errorPolicy`](../api/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()`](../api/RestEndpoint.md#fetchResponse), so call sites get it from `error.message` without parsing the body: +### 7. Remove axios {#remove-axios} -```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 | +| ------------------------------------------ | ------------------------- | +| `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) via `signal` | +| `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(); ``` - - +```ts title="After" +const controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` -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: + + +In components, [`useCancelling()`](/docs/api/useCancelling) cancels the in-flight request when its parameters change: ```tsx -import { useSuspense } from '@data-client/react'; -import { useCancelling } from '@data-client/react'; +import { useCancelling, useSuspense } from '@data-client/react'; import { searchEndpoint } from './api/search'; import ResultsList from './ResultsList'; @@ -459,46 +633,52 @@ function SearchResults({ query }: { query: string }) { } ``` -For manual cancellation, pass `signal` directly: - -```ts -const controller = new AbortController(); -const getUser = new RestEndpoint({ - path: '/users/:id', - signal: controller.signal, -}); -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)" +```ts title="After" const getUsers = new RestEndpoint({ path: '/users', signal: AbortSignal.timeout(5000), }); ``` + + ### 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', + // callout: Keeps large blobs out of the cache + dataExpiryLength: 0, +}); +``` + + + +[`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 +686,29 @@ 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' }); + } +} +``` + + + +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 +721,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 +752,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 +848,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/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/framework-docs/skillCommands.mjs b/website/framework-docs/skillCommands.mjs index 1cca01d60055..4157e9a2d6f8 100644 --- a/website/framework-docs/skillCommands.mjs +++ b/website/framework-docs/skillCommands.mjs @@ -24,18 +24,19 @@ export const claudePluginCommand = [ 'claude plugin install data-client@data-client --scope project', ].join('\n'); -const setupTask = framework => +/** What the setup prompts ask for; pages can ask the setup skill for another job it covers, like migrating from axios */ +export const setupTask = framework => `Set up \`@data-client/${framework}\` and use it for all API data`; /** The prompt any agent takes: installs the setup skill, then follows it. * AgentPrompt's translate() default repeats it as a literal for i18n extraction */ -export const setupPrompt = pkg => - `${setupTask(pkg)}. Install its setup skill first: \`${setupCommand}\`, then follow it.`; +export const setupPrompt = task => + `${task}. Install its setup skill first: \`${setupCommand}\`, then follow it.`; /** Starts the plugin's setup skill by name; through the router skill, Claude * would read it from the plugin cache, outside the project, and ask first */ -export const claudeSetupPrompt = framework => - `/data-client:data-client-setup ${setupTask(framework)}`; +export const claudeSetupPrompt = task => + `/data-client:data-client-setup ${task}`; /** Installs the Cursor plugin from .cursor-plugin, typed in Cursor's agent * chat; its skills load from the next chat */ @@ -44,8 +45,7 @@ const cursorPlugin = repo => export const cursorPluginCommand = cursorPlugin('reactive/data-client'); /** Cursor starts a plugin skill by its own name */ -export const cursorSetupPrompt = framework => - `/data-client-setup ${setupTask(framework)}`; +export const cursorSetupPrompt = task => `/data-client-setup ${task}`; /** * Codex reads the Claude Code marketplace; --sparse checks out only the @@ -59,8 +59,8 @@ const codexPlugin = repo => export const codexPluginCommand = codexPlugin('reactive/data-client'); /** Codex starts a skill when the prompt mentions it as `$plugin:skill` */ -export const codexSetupPrompt = framework => - `$data-client:data-client-setup ${setupTask(framework)}`; +export const codexSetupPrompt = task => + `$data-client:data-client-setup ${task}`; /** * @typedef {{ skillPackages: Record, setupSkills: string[], plugin: { plugins: { name: string, skills: string[] }[] } }} SkillSources 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 */ diff --git a/website/src/components/SetupTabs.tsx b/website/src/components/SetupTabs.tsx index 0695c4ccca47..9dc10aa15b4a 100644 --- a/website/src/components/SetupTabs.tsx +++ b/website/src/components/SetupTabs.tsx @@ -14,13 +14,22 @@ import { cursorPluginCommand, cursorSetupPrompt, codexSetupPrompt, + setupPrompt, + setupTask, } from '../../framework-docs/skillCommands.mjs'; +interface Props { + /** what the setup skill does, in place of setting up Data Client, like + * "Migrate this app from axios to `@data-client/rest`" */ + task?: string; +} + /** Ways to set up Data Client with an agent: agents' own plugins first * (install, then start setup in the agent's chat), then the * prompt other agents take in their chat */ -export default function SetupTabs() { +export default function SetupTabs({ task }: Props) { const framework = useFramework(); + const request = task ?? setupTask(framework); return ( @@ -53,7 +62,7 @@ export default function SetupTabs() {

    @@ -66,18 +75,15 @@ export default function SetupTabs() { {codexPluginCommand} - +
    - + {/* the default prompt is translated */} +
    ); From ed549fa0d8755cee3b4b821c83a4295b095d21c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:20:56 +0000 Subject: [PATCH 02/41] docs: Give each axios timeout and cancel its own request signal A signal set when the endpoint is created is shared by all of its requests, so the timeout started counting at import and one cancel aborted every later call. Set the timeout per request in getRequestInit() and cancel through extend(). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 23 +++++++++++-------- .../references/axios-migration.vue.md | 23 +++++++++++-------- .../references/axios-migration.md | 23 +++++++++++-------- .../references/axios-migration.vue.md | 23 +++++++++++-------- docs/rest/guides/axios-migration.md | 23 +++++++++++-------- website/src/components/SetupTabs.tsx | 2 +- 6 files changed, 71 insertions(+), 46 deletions(-) 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 6a09bf46d895..1f582edb71f3 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -474,7 +474,7 @@ npm uninstall axios | `transformResponse` | [`process()`](./RestEndpoint.md#process) | | `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | | `cancelToken` / `signal` | [`signal`](#cancellation) | -| `timeout` | [`AbortSignal.timeout()`](#timeout) via `signal` | +| `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) | @@ -495,10 +495,8 @@ source.cancel(); ```ts title="After" 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(); ``` @@ -524,10 +522,17 @@ axios.get('/users', { timeout: 5000 }); ``` ```ts title="After" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +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) { + return { + ...(await super.getRequestInit(body)), + signal: AbortSignal.timeout(5000), + }; + } +} ``` ### Binary responses 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 index d96938fc067c..ad6da96689d4 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -452,7 +452,7 @@ npm uninstall axios | `transformResponse` | [`process()`](./RestEndpoint.vue.md#process) | | `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | | `cancelToken` / `signal` | [`signal`](#cancellation) | -| `timeout` | [`AbortSignal.timeout()`](#timeout) via `signal` | +| `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) | @@ -473,10 +473,8 @@ source.cancel(); ```ts title="After" 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(); ``` @@ -502,10 +500,17 @@ axios.get('/users', { timeout: 5000 }); ``` ```ts title="After" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +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) { + return { + ...(await super.getRequestInit(body)), + signal: AbortSignal.timeout(5000), + }; + } +} ``` ### Binary responses 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 6a09bf46d895..1f582edb71f3 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 @@ -474,7 +474,7 @@ npm uninstall axios | `transformResponse` | [`process()`](./RestEndpoint.md#process) | | `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | | `cancelToken` / `signal` | [`signal`](#cancellation) | -| `timeout` | [`AbortSignal.timeout()`](#timeout) via `signal` | +| `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) | @@ -495,10 +495,8 @@ source.cancel(); ```ts title="After" 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(); ``` @@ -524,10 +522,17 @@ axios.get('/users', { timeout: 5000 }); ``` ```ts title="After" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +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) { + return { + ...(await super.getRequestInit(body)), + signal: AbortSignal.timeout(5000), + }; + } +} ``` ### Binary responses 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 index d96938fc067c..ad6da96689d4 100644 --- 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 @@ -452,7 +452,7 @@ npm uninstall axios | `transformResponse` | [`process()`](./RestEndpoint.vue.md#process) | | `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | | `cancelToken` / `signal` | [`signal`](#cancellation) | -| `timeout` | [`AbortSignal.timeout()`](#timeout) via `signal` | +| `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) | @@ -473,10 +473,8 @@ source.cancel(); ```ts title="After" 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(); ``` @@ -502,10 +500,17 @@ axios.get('/users', { timeout: 5000 }); ``` ```ts title="After" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +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) { + return { + ...(await super.getRequestInit(body)), + signal: AbortSignal.timeout(5000), + }; + } +} ``` ### Binary responses diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index ccea5d9ae183..67c6390291da 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -588,7 +588,7 @@ npm uninstall axios | `transformResponse` | [`process()`](../api/RestEndpoint.md#process) | | `isAxiosError` / `error.response` | [`NetworkError`](#error-handling) with `.status` and `.response` | | `cancelToken` / `signal` | [`signal`](#cancellation) | -| `timeout` | [`AbortSignal.timeout()`](#timeout) via `signal` | +| `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) | @@ -611,10 +611,8 @@ source.cancel(); ```ts title="After" 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(); ``` @@ -644,10 +642,17 @@ axios.get('/users', { timeout: 5000 }); ``` ```ts title="After" -const getUsers = new RestEndpoint({ - path: '/users', - signal: AbortSignal.timeout(5000), -}); +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) { + return { + ...(await super.getRequestInit(body)), + signal: AbortSignal.timeout(5000), + }; + } +} ``` diff --git a/website/src/components/SetupTabs.tsx b/website/src/components/SetupTabs.tsx index 9dc10aa15b4a..cdc09edc0d7f 100644 --- a/website/src/components/SetupTabs.tsx +++ b/website/src/components/SetupTabs.tsx @@ -29,7 +29,7 @@ interface Props { * prompt other agents take in their chat */ export default function SetupTabs({ task }: Props) { const framework = useFramework(); - const request = task ?? setupTask(framework); + const request = task || setupTask(framework); return ( Date: Sun, 25 Oct 2026 00:22:31 +0000 Subject: [PATCH 03/41] docs: Keep extend({ signal }) cancellation with the axios timeout The timeout override replaced the endpoint's own signal, so endpoints using it lost extend({ signal }) and useCancelling(). Combine both with AbortSignal.any(). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../data-client-rest-setup/references/axios-migration.md | 9 +++++---- .../references/axios-migration.vue.md | 9 +++++---- .../data-client-rest-setup/references/axios-migration.md | 9 +++++---- .../references/axios-migration.vue.md | 9 +++++---- docs/rest/guides/axios-migration.md | 9 +++++---- 5 files changed, 25 insertions(+), 20 deletions(-) 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 1f582edb71f3..6e68e4c595c3 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -527,10 +527,11 @@ export default class ApiEndpoint< > 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) { - return { - ...(await super.getRequestInit(body)), - signal: AbortSignal.timeout(5000), - }; + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + return { ...init, signal }; } } ``` 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 index ad6da96689d4..897a6606a958 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -505,10 +505,11 @@ export default class ApiEndpoint< > 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) { - return { - ...(await super.getRequestInit(body)), - signal: AbortSignal.timeout(5000), - }; + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + return { ...init, signal }; } } ``` 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 1f582edb71f3..6e68e4c595c3 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 @@ -527,10 +527,11 @@ export default class ApiEndpoint< > 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) { - return { - ...(await super.getRequestInit(body)), - signal: AbortSignal.timeout(5000), - }; + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + return { ...init, signal }; } } ``` 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 index ad6da96689d4..897a6606a958 100644 --- 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 @@ -505,10 +505,11 @@ export default class ApiEndpoint< > 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) { - return { - ...(await super.getRequestInit(body)), - signal: AbortSignal.timeout(5000), - }; + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + return { ...init, signal }; } } ``` diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 67c6390291da..711e708f9060 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -647,10 +647,11 @@ export default class ApiEndpoint< > 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) { - return { - ...(await super.getRequestInit(body)), - signal: AbortSignal.timeout(5000), - }; + const init = await super.getRequestInit(body); + const timeout = AbortSignal.timeout(5000); + // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + return { ...init, signal }; } } ``` From 8add77299e5c4da56182a3d28ac7b686a5afcab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:23:05 +0000 Subject: [PATCH 04/41] docs: Declare the endpoint in the axios cancellation example Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/references/axios-migration.md | 1 + .../data-client-rest-setup/references/axios-migration.vue.md | 1 + .../data-client-rest-setup/references/axios-migration.md | 1 + .../data-client-rest-setup/references/axios-migration.vue.md | 1 + docs/rest/guides/axios-migration.md | 1 + 5 files changed, 5 insertions(+) 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 6e68e4c595c3..801d7085415e 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -494,6 +494,7 @@ 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' }); 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 index 897a6606a958..f5748738ec1f 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -472,6 +472,7 @@ 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' }); 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 6e68e4c595c3..801d7085415e 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 @@ -494,6 +494,7 @@ 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' }); 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 index 897a6606a958..f5748738ec1f 100644 --- 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 @@ -472,6 +472,7 @@ 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' }); diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 711e708f9060..65de0d3dcb7f 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -610,6 +610,7 @@ 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' }); From 67ad89e6d956ca90ccd7935784c65da688426144 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:31:28 +0000 Subject: [PATCH 05/41] docs: Keep the axios migration to data definitions The guide moves axios calls to @data-client/rest endpoints and schemas, and leaves how components load and store data alone, as it did on master. Its agent prompt installs the REST setup skill instead of the setup skill, which also adds the provider, so SetupTabs and the prompt builders go back to master. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 2 +- .../references/axios-migration.md | 128 +++-------- .../references/axios-migration.vue.md | 104 +++------ .../references/data-client-rest-setup.md | 2 +- .../references/axios-migration.md | 128 +++-------- .../references/axios-migration.vue.md | 104 +++------ .claude-plugin/readme-commands.test.mjs | 18 +- docs/rest/guides/axios-migration.md | 205 ++++-------------- website/framework-docs/skillCommands.mjs | 18 +- website/src/components/SetupTabs.tsx | 24 +- 10 files changed, 189 insertions(+), 544 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 17a6942fc433..cc406deeb522 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,7 +6,7 @@ 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. +This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it when the project calls REST APIs. It only defines endpoints and schemas, so it doesn't need the 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. 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 801d7085415e..ddedd707d1c5 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -2,8 +2,9 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. -A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way +they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? @@ -18,12 +19,11 @@ const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); getUser({ id: '1' }); ``` -- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. -- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to - fetch it, or track loading and error state. -- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the - change before the server answers. +- **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. ## Migrate step by step {#steps} @@ -33,8 +33,6 @@ getUser({ id: '1' }); npm install @data-client/rest ``` -If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/docs/getting-started/installation#manual-setup). - ### 2. Run the codemod {#codemod} A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on @@ -176,11 +174,11 @@ hook. See the ### 4. Define entities and resources {#entities} -The codemod makes endpoints without a `schema`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the -responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +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` puts every response in the normalized cache. +One `resource()` replaces a module of CRUD functions, and `schema` types every response. ```ts title="Before" import api from './api'; @@ -271,8 +269,8 @@ 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`, nothing is normalized, -> and the migration gains little over axios. +> 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 @@ -332,78 +330,30 @@ If the codebase already validates responses with Zod or Yup, choose one approach - **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. Convert call sites to hooks {#hooks} - -Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), and -mutate through the [Controller](https://dataclient.io/docs/api/Controller), which updates every component that shows the changed data. - -Loading and error states move to an `AsyncBoundary` above the component. - -```tsx title="Before" -import { useEffect, useState } from 'react'; -import { createPost, getPosts, type Post } from './api/posts'; -import PostItems from './PostItems'; -import Spinner from './Spinner'; - -function PostList() { - const [posts, setPosts] = useState(null); - useEffect(() => { - getPosts().then(({ data }) => setPosts(data)); - }, []); - const addPost = async (title: string) => { - const { data } = await createPost({ title, body: '' }); - setPosts(posts => [...(posts ?? []), data]); - }; - if (!posts) return ; - return ; -} -``` +### 5. Update call sites {#call-sites} -```tsx title="After" -import { useController, useSuspense } from '@data-client/react'; -import { PostResource } from './api/posts'; -import PostItems from './PostItems'; - -function PostList() { - const posts = useSuspense(PostResource.getList); - const ctrl = useController(); - // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. - const addPost = (title: string) => - ctrl.fetch(PostResource.getList.push, { title, body: '' }); - return ; -} -``` +Components and services keep their own loading and state; only the call changes. -Put an [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading -and error states. +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. -#### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 title="Before" +import { createPost, getPost, getPosts } from './api/posts'; -```ts -import { useController } from '@data-client/react'; -import { useQuery } from '@tanstack/react-query'; -import ApiEndpoint from './ApiEndpoint'; -import { Project } from './Project'; +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); +``` -export const getProject = new ApiEndpoint({ - path: '/projects/:id', - schema: Project, -}); +```ts title="After" +import { PostResource } from './api/posts'; -export function useProject(id: string) { - const ctrl = useController(); - return useQuery({ - queryKey: ['project', id], - queryFn: () => ctrl.fetch(getProject, { id }), - }); -} +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ title: 'Hello', body: '' }); ``` -Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. +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). ### 6. Migrate error handling {#error-handling} @@ -437,9 +387,8 @@ try { } ``` -Components that read with `useSuspense()` get errors from their `AsyncBoundary` -instead of a `try`. For soft retries on -server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). +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} @@ -501,19 +450,6 @@ getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` -In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: - -```tsx -import { useCancelling, useSuspense } 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 ; -} -``` - See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. ### Timeout @@ -530,7 +466,7 @@ export default class ApiEndpoint< async getRequestInit(body?: RequestInit['body'] | Record) { const init = await super.getRequestInit(body); const timeout = AbortSignal.timeout(5000); - // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; return { ...init, signal }; } @@ -547,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache - dataExpiryLength: 0, }); ``` 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 index f5748738ec1f..9486b4ad77b2 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -2,8 +2,9 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. -A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way +they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? @@ -18,12 +19,11 @@ const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); getUser({ id: '1' }); ``` -- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. -- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), not how to - fetch it, or track loading and error state. -- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the - change before the server answers. +- **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} @@ -33,8 +33,6 @@ getUser({ id: '1' }); npm install @data-client/rest ``` -If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/vue/getting-started/installation#manual-setup). - ### 2. Run the codemod {#codemod} A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on @@ -176,11 +174,11 @@ composable. See the ### 4. Define entities and resources {#entities} -The codemod makes endpoints without a `schema`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the -responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +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` puts every response in the normalized cache. +One `resource()` replaces a module of CRUD functions, and `schema` types every response. ```ts title="Before" import api from './api'; @@ -271,8 +269,8 @@ 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`, nothing is normalized, -> and the migration gains little over axios. +> 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 @@ -332,56 +330,30 @@ If the codebase already validates responses with Zod or Yup, choose one approach - **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. Convert call sites to hooks {#hooks} - -Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), and -mutate through the [Controller](https://dataclient.io/vue/api/Controller), which updates every component that shows the changed data. +### 5. Update call sites {#call-sites} -Loading and error states move to a `` above the component. +Components and services keep their own loading and state; only the call changes. -```html title="Before" - +```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: '' }); ``` -```html title="After" - +```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: '' }); ``` -Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to -show their loading state, and handle their errors with -[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). +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} @@ -415,9 +387,8 @@ try { } ``` -Components that read with `useSuspense()` get errors from `onErrorCaptured()` -instead of a `try`. For soft retries on -server errors, see [`errorPolicy`](./RestEndpoint.vue.md#errorpolicy). +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} @@ -479,19 +450,6 @@ getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` -In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: - -```tsx -import { useCancelling, useSuspense } 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 ; -} -``` - See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. ### Timeout @@ -508,7 +466,7 @@ export default class ApiEndpoint< async getRequestInit(body?: RequestInit['body'] | Record) { const init = await super.getRequestInit(body); const timeout = AbortSignal.timeout(5000); - // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; return { ...init, signal }; } @@ -525,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache - dataExpiryLength: 0, }); ``` 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..9513870174bd 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,7 +2,7 @@ # 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. +This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it when the project calls REST APIs. It only defines endpoints and schemas, so it doesn't need the 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. 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 801d7085415e..ddedd707d1c5 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,8 +2,9 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. -A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way +they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? @@ -18,12 +19,11 @@ const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); getUser({ id: '1' }); ``` -- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. -- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to - fetch it, or track loading and error state. -- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the - change before the server answers. +- **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. ## Migrate step by step {#steps} @@ -33,8 +33,6 @@ getUser({ id: '1' }); npm install @data-client/rest ``` -If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/docs/getting-started/installation#manual-setup). - ### 2. Run the codemod {#codemod} A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on @@ -176,11 +174,11 @@ hook. See the ### 4. Define entities and resources {#entities} -The codemod makes endpoints without a `schema`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the -responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +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` puts every response in the normalized cache. +One `resource()` replaces a module of CRUD functions, and `schema` types every response. ```ts title="Before" import api from './api'; @@ -271,8 +269,8 @@ 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`, nothing is normalized, -> and the migration gains little over axios. +> 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 @@ -332,78 +330,30 @@ If the codebase already validates responses with Zod or Yup, choose one approach - **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. Convert call sites to hooks {#hooks} - -Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), and -mutate through the [Controller](https://dataclient.io/docs/api/Controller), which updates every component that shows the changed data. - -Loading and error states move to an `AsyncBoundary` above the component. - -```tsx title="Before" -import { useEffect, useState } from 'react'; -import { createPost, getPosts, type Post } from './api/posts'; -import PostItems from './PostItems'; -import Spinner from './Spinner'; - -function PostList() { - const [posts, setPosts] = useState(null); - useEffect(() => { - getPosts().then(({ data }) => setPosts(data)); - }, []); - const addPost = async (title: string) => { - const { data } = await createPost({ title, body: '' }); - setPosts(posts => [...(posts ?? []), data]); - }; - if (!posts) return ; - return ; -} -``` +### 5. Update call sites {#call-sites} -```tsx title="After" -import { useController, useSuspense } from '@data-client/react'; -import { PostResource } from './api/posts'; -import PostItems from './PostItems'; - -function PostList() { - const posts = useSuspense(PostResource.getList); - const ctrl = useController(); - // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. - const addPost = (title: string) => - ctrl.fetch(PostResource.getList.push, { title, body: '' }); - return ; -} -``` +Components and services keep their own loading and state; only the call changes. -Put an [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading -and error states. +Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object. -#### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 title="Before" +import { createPost, getPost, getPosts } from './api/posts'; -```ts -import { useController } from '@data-client/react'; -import { useQuery } from '@tanstack/react-query'; -import ApiEndpoint from './ApiEndpoint'; -import { Project } from './Project'; +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); +``` -export const getProject = new ApiEndpoint({ - path: '/projects/:id', - schema: Project, -}); +```ts title="After" +import { PostResource } from './api/posts'; -export function useProject(id: string) { - const ctrl = useController(); - return useQuery({ - queryKey: ['project', id], - queryFn: () => ctrl.fetch(getProject, { id }), - }); -} +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ title: 'Hello', body: '' }); ``` -Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. +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). ### 6. Migrate error handling {#error-handling} @@ -437,9 +387,8 @@ try { } ``` -Components that read with `useSuspense()` get errors from their `AsyncBoundary` -instead of a `try`. For soft retries on -server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). +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} @@ -501,19 +450,6 @@ getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` -In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: - -```tsx -import { useCancelling, useSuspense } 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 ; -} -``` - See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. ### Timeout @@ -530,7 +466,7 @@ export default class ApiEndpoint< async getRequestInit(body?: RequestInit['body'] | Record) { const init = await super.getRequestInit(body); const timeout = AbortSignal.timeout(5000); - // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; return { ...init, signal }; } @@ -547,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache - dataExpiryLength: 0, }); ``` 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 index f5748738ec1f..9486b4ad77b2 100644 --- 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 @@ -2,8 +2,9 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. -A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. +[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way +they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Why migrate? @@ -18,12 +19,11 @@ const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); getUser({ id: '1' }); ``` -- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. -- **Declarative data**: components say what data they need with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), not how to - fetch it, or track loading and error state. -- **Mutations update the UI**: responses update the cache, and [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) show the - change before the server answers. +- **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} @@ -33,8 +33,6 @@ getUser({ id: '1' }); npm install @data-client/rest ``` -If the app doesn't use Data Client yet, also [install it and add its provider](https://dataclient.io/vue/getting-started/installation#manual-setup). - ### 2. Run the codemod {#codemod} A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on @@ -176,11 +174,11 @@ composable. See the ### 4. Define entities and resources {#entities} -The codemod makes endpoints without a `schema`. An [Entity](https://dataclient.io/rest/api/Entity) schema is what normalizes and caches the -responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +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` puts every response in the normalized cache. +One `resource()` replaces a module of CRUD functions, and `schema` types every response. ```ts title="Before" import api from './api'; @@ -271,8 +269,8 @@ 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`, nothing is normalized, -> and the migration gains little over axios. +> 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 @@ -332,56 +330,30 @@ If the codebase already validates responses with Zod or Yup, choose one approach - **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. Convert call sites to hooks {#hooks} - -Components stop storing responses in state. They read endpoints with [`useSuspense()`](https://dataclient.io/vue/api/useSuspense), and -mutate through the [Controller](https://dataclient.io/vue/api/Controller), which updates every component that shows the changed data. +### 5. Update call sites {#call-sites} -Loading and error states move to a `` above the component. +Components and services keep their own loading and state; only the call changes. -```html title="Before" - +```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: '' }); ``` -```html title="After" - +```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: '' }); ``` -Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to -show their loading state, and handle their errors with -[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). +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} @@ -415,9 +387,8 @@ try { } ``` -Components that read with `useSuspense()` get errors from `onErrorCaptured()` -instead of a `try`. For soft retries on -server errors, see [`errorPolicy`](./RestEndpoint.vue.md#errorpolicy). +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} @@ -479,19 +450,6 @@ getUser.extend({ signal: controller.signal })({ id: '1' }); controller.abort(); ``` -In components, [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) cancels the in-flight request when its parameters change: - -```tsx -import { useCancelling, useSuspense } 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 ; -} -``` - See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. ### Timeout @@ -508,7 +466,7 @@ export default class ApiEndpoint< async getRequestInit(body?: RequestInit['body'] | Record) { const init = await super.getRequestInit(body); const timeout = AbortSignal.timeout(5000); - // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; return { ...init, signal }; } @@ -525,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache - dataExpiryLength: 0, }); ``` diff --git a/.claude-plugin/readme-commands.test.mjs b/.claude-plugin/readme-commands.test.mjs index 944d4a143b33..a4a854cba039 100644 --- a/.claude-plugin/readme-commands.test.mjs +++ b/.claude-plugin/readme-commands.test.mjs @@ -15,7 +15,6 @@ import { cursorSetupPrompt, setupCommand, setupPrompt, - setupTask, } from '../website/framework-docs/skillCommands.mjs'; const read = file => fs.readFileSync(path.join(root, file), 'utf8'); @@ -29,13 +28,13 @@ for (const [file, framework] of Object.entries({ test(`${file} setup commands match skillCommands.mjs`, () => { const readme = read(file); for (const command of [ - setupPrompt(setupTask(framework)), + setupPrompt(framework), claudePluginCommand, - claudeSetupPrompt(setupTask(framework)), + claudeSetupPrompt(framework), cursorPluginCommand, - cursorSetupPrompt(setupTask(framework)), + cursorSetupPrompt(framework), codexPluginCommand, - codexSetupPrompt(setupTask(framework)), + codexSetupPrompt(framework), ]) assert.ok(readme.includes(command), command); }); @@ -44,18 +43,13 @@ for (const [file, framework] of Object.entries({ // protocol READMEs carry only the prompt for (const pkg of ['rest', 'graphql', 'endpoint']) { test(`packages/${pkg}/README.md setup prompt matches skillCommands.mjs`, () => { - assert.ok( - read(`packages/${pkg}/README.md`).includes(setupPrompt(setupTask(pkg))), - ); + assert.ok(read(`packages/${pkg}/README.md`).includes(setupPrompt(pkg))); }); } test("AgentPrompt's default prompt matches setupPrompt", () => { // its translate() literal can't import setupPrompt (i18n extraction needs a literal) - const message = setupPrompt(setupTask('{framework}')).replace( - setupCommand, - '{command}', - ); + const message = setupPrompt('{framework}').replace(setupCommand, '{command}'); assert.ok( read('website/src/components/AgentPrompt.tsx').includes(`'${message}'`), message, diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 65de0d3dcb7f..9b298628bdd4 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -6,37 +6,41 @@ sidebar_label: Axios Migration import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; import PkgTabs from '@site/src/components/PkgTabs'; import DiffEditor from '@site/src/components/DiffEditor'; -import SetupTabs from '@site/src/components/SetupTabs'; import SiteOnly from '@site/src/components/SiteOnly'; import AgentOnly from '@site/src/components/AgentOnly'; # Migrating from Axios -[`@data-client/rest`](/rest) replaces axios calls with typed endpoints whose responses go into one normalized cache. -A [codemod](#codemod) converts the calls; the [steps](#steps) below cover the rest. +[`@data-client/rest`](/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way +they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[steps](#steps) below cover the rest. ## Migrate with your coding agent {#agent} - +Paste this into your agent's chat: -Your agent runs Data Client's setup [skill](/docs/getting-started/agent-skills), which follows this guide: it sets up -Data Client if the app doesn't have it yet, runs the codemod, then works through the [steps](#steps) the codemod can't -do. Review its diff like a teammate's. +```prompt +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 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 Data Client's setup skill from the project root: +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-setup -y +npx skills add reactive/data-client --skill data-client-rest-setup -y ``` -Then read `.agents/skills/data-client-setup/SKILL.md` and follow it. It sets up Data Client if the app doesn't have -it, detects axios, and follows the steps below. If the app already uses Data Client, do the [steps](#steps) in order. +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. @@ -55,12 +59,11 @@ getUser({ id: '1' }); -- **Normalized cache**: each entity is stored once, so an update shows everywhere it's used, without refetching. -- **Declarative data**: components say what data they need with [`useSuspense()`](/docs/api/useSuspense), not how to - fetch it, or track loading and error state. -- **Mutations update the UI**: responses update the cache, and [optimistic updates](./optimistic-updates.md) show the - change before the server answers. +- **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} @@ -74,8 +77,6 @@ These are the steps your agent follows. Commit before each one, so you can revie -If the app doesn't use Data Client yet, also [install it and add its provider](/docs/getting-started/installation#manual-setup). - ### 2. Run the codemod {#codemod} A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on @@ -219,11 +220,11 @@ read them. Use [`hookifyResource()`](../api/hookifyResource.md) to add the heade ### 4. Define entities and resources {#entities} -The codemod makes endpoints without a `schema`. An [Entity](../api/Entity.md) schema is what normalizes and caches the -responses, so define one for each type the API returns. Replace each module's CRUD functions on one path with a +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="Before" import api from './api'; @@ -320,8 +321,8 @@ 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`, nothing is normalized, -and the migration gains little over axios. +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. ::: @@ -382,140 +383,32 @@ If the codebase already validates responses with Zod or Yup, choose one approach - **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. Convert call sites to hooks {#hooks} +### 5. Update call sites {#call-sites} -Components stop storing responses in state. They read endpoints with [`useSuspense()`](/docs/api/useSuspense), and -mutate through the [Controller](/docs/api/Controller), which updates every component that shows the changed data. +Components and services keep their own loading and state; only the call changes. -:::react + - - -```tsx title="Before" -import { useEffect, useState } from 'react'; -import { createPost, getPosts, type Post } from './api/posts'; -import PostItems from './PostItems'; -import Spinner from './Spinner'; +```ts title="Before" +import { createPost, getPost, getPosts } from './api/posts'; -function PostList() { - const [posts, setPosts] = useState(null); - useEffect(() => { - getPosts().then(({ data }) => setPosts(data)); - }, []); - const addPost = async (title: string) => { - const { data } = await createPost({ title, body: '' }); - setPosts(posts => [...(posts ?? []), data]); - }; - if (!posts) return ; - return ; -} +const { data: posts } = await getPosts(); +const { data: post } = await getPost('1'); +const { data: created } = await createPost({ title: 'Hello', body: '' }); ``` -```tsx title="After" -import { useController, useSuspense } from '@data-client/react'; +```ts title="After" import { PostResource } from './api/posts'; -import PostItems from './PostItems'; - -function PostList() { - const posts = useSuspense(PostResource.getList); - const ctrl = useController(); - // callout: Adds the new post to the cached list, so every component showing the list updates without a refetch. - const addPost = (title: string) => - ctrl.fetch(PostResource.getList.push, { title, body: '' }); - return ; -} -``` - - - -Put an [`AsyncBoundary`](/docs/api/AsyncBoundary) around the routes or panels that load data, to show their loading -and error states. - -::: - -:::vue - - - -```html title="Before" - - - -``` - -```html title="After" - - - +const posts = await PostResource.getList(); +const post = await PostResource.get({ id: '1' }); +const created = await PostResource.getList.push({ title: 'Hello', body: '' }); ``` -Put a [``](https://vuejs.org/guide/built-ins/suspense.html) around the routes or panels that load data, to -show their loading state, and handle their errors with -[`onErrorCaptured()`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). - -::: - -:::react - -#### Gradual migration - -If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks for now 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 })`. - -::: +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). ### 6. Migrate error handling {#error-handling} @@ -551,9 +444,8 @@ try { -Components that read with `useSuspense()` get errors from :react[their `AsyncBoundary`]:vue[`onErrorCaptured()`] -instead of a `try`. 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). ### 7. Remove axios {#remove-axios} @@ -619,19 +511,6 @@ controller.abort(); -In components, [`useCancelling()`](/docs/api/useCancelling) cancels the in-flight request when its parameters change: - -```tsx -import { useCancelling, useSuspense } 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 ; -} -``` - See the [abort guide](./abort.md) for more patterns. ### Timeout @@ -650,7 +529,7 @@ export default class ApiEndpoint< async getRequestInit(body?: RequestInit['body'] | Record) { const init = await super.getRequestInit(body); const timeout = AbortSignal.timeout(5000); - // callout: Keeps `extend({ signal })` and `useCancelling()` working: either signal aborts the request. + // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request. const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; return { ...init, signal }; } @@ -671,8 +550,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache - dataExpiryLength: 0, }); ``` diff --git a/website/framework-docs/skillCommands.mjs b/website/framework-docs/skillCommands.mjs index 4157e9a2d6f8..1cca01d60055 100644 --- a/website/framework-docs/skillCommands.mjs +++ b/website/framework-docs/skillCommands.mjs @@ -24,19 +24,18 @@ export const claudePluginCommand = [ 'claude plugin install data-client@data-client --scope project', ].join('\n'); -/** What the setup prompts ask for; pages can ask the setup skill for another job it covers, like migrating from axios */ -export const setupTask = framework => +const setupTask = framework => `Set up \`@data-client/${framework}\` and use it for all API data`; /** The prompt any agent takes: installs the setup skill, then follows it. * AgentPrompt's translate() default repeats it as a literal for i18n extraction */ -export const setupPrompt = task => - `${task}. Install its setup skill first: \`${setupCommand}\`, then follow it.`; +export const setupPrompt = pkg => + `${setupTask(pkg)}. Install its setup skill first: \`${setupCommand}\`, then follow it.`; /** Starts the plugin's setup skill by name; through the router skill, Claude * would read it from the plugin cache, outside the project, and ask first */ -export const claudeSetupPrompt = task => - `/data-client:data-client-setup ${task}`; +export const claudeSetupPrompt = framework => + `/data-client:data-client-setup ${setupTask(framework)}`; /** Installs the Cursor plugin from .cursor-plugin, typed in Cursor's agent * chat; its skills load from the next chat */ @@ -45,7 +44,8 @@ const cursorPlugin = repo => export const cursorPluginCommand = cursorPlugin('reactive/data-client'); /** Cursor starts a plugin skill by its own name */ -export const cursorSetupPrompt = task => `/data-client-setup ${task}`; +export const cursorSetupPrompt = framework => + `/data-client-setup ${setupTask(framework)}`; /** * Codex reads the Claude Code marketplace; --sparse checks out only the @@ -59,8 +59,8 @@ const codexPlugin = repo => export const codexPluginCommand = codexPlugin('reactive/data-client'); /** Codex starts a skill when the prompt mentions it as `$plugin:skill` */ -export const codexSetupPrompt = task => - `$data-client:data-client-setup ${task}`; +export const codexSetupPrompt = framework => + `$data-client:data-client-setup ${setupTask(framework)}`; /** * @typedef {{ skillPackages: Record, setupSkills: string[], plugin: { plugins: { name: string, skills: string[] }[] } }} SkillSources diff --git a/website/src/components/SetupTabs.tsx b/website/src/components/SetupTabs.tsx index cdc09edc0d7f..0695c4ccca47 100644 --- a/website/src/components/SetupTabs.tsx +++ b/website/src/components/SetupTabs.tsx @@ -14,22 +14,13 @@ import { cursorPluginCommand, cursorSetupPrompt, codexSetupPrompt, - setupPrompt, - setupTask, } from '../../framework-docs/skillCommands.mjs'; -interface Props { - /** what the setup skill does, in place of setting up Data Client, like - * "Migrate this app from axios to `@data-client/rest`" */ - task?: string; -} - /** Ways to set up Data Client with an agent: agents' own plugins first * (install, then start setup in the agent's chat), then the * prompt other agents take in their chat */ -export default function SetupTabs({ task }: Props) { +export default function SetupTabs() { const framework = useFramework(); - const request = task || setupTask(framework); return ( @@ -62,7 +53,7 @@ export default function SetupTabs({ task }: Props) {

    @@ -75,15 +66,18 @@ export default function SetupTabs({ task }: Props) { {codexPluginCommand} - +
    - {/* the default prompt is translated */} - +
    ); From 62c32b3bf9fff594aae55915849df4b1a3519720 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:36:26 +0000 Subject: [PATCH 06/41] docs: Keep the REST setup skill's hook step opt-in for axios migrations The setup skill's next steps told agents to move components onto hooks, which the axios guide now leaves to the user. Also restore the blob endpoint's dataExpiryLength so it stays out of the cache once the store is adopted, and say plainly that only the call shape changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 2 +- .../data-client-rest-setup/references/axios-migration.md | 6 ++++-- .../references/axios-migration.vue.md | 6 ++++-- .../data-client-setup/references/data-client-rest-setup.md | 2 +- .../data-client-rest-setup/references/axios-migration.md | 6 ++++-- .../references/axios-migration.vue.md | 6 ++++-- docs/rest/guides/axios-migration.md | 6 ++++-- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index cc406deeb522..12113e297aea 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -296,7 +296,7 @@ If the codebase already validates responses with Zod/Yup, prefer **Entity as the 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 +3. Only when the user asks to move components onto the store: apply skill "data-client-react" or "data-client-vue" for hook-based usage ## References 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 ddedd707d1c5..9d49dec50be6 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -2,8 +2,8 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way -they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[`@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? @@ -483,6 +483,8 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', + // callout: Keeps large blobs out of the cache if you adopt the store later + dataExpiryLength: 0, }); ``` 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 index 9486b4ad77b2..91d936107a78 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -2,8 +2,8 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way -they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[`@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? @@ -483,6 +483,8 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', + // callout: Keeps large blobs out of the cache if you adopt the store later + dataExpiryLength: 0, }); ``` 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 9513870174bd..47a818b41f1f 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 @@ -292,7 +292,7 @@ If the codebase already validates responses with Zod/Yup, prefer **Entity as the 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 +3. Only when the user asks to move components onto the store: apply skill "data-client-react" or "data-client-vue" for hook-based usage ## References 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 ddedd707d1c5..9d49dec50be6 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,8 +2,8 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way -they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[`@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? @@ -483,6 +483,8 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', + // callout: Keeps large blobs out of the cache if you adopt the store later + dataExpiryLength: 0, }); ``` 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 index 9486b4ad77b2..91d936107a78 100644 --- 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 @@ -2,8 +2,8 @@ # Migrating from Axios -[`@data-client/rest`](https://dataclient.io/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way -they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[`@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? @@ -483,6 +483,8 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', + // callout: Keeps large blobs out of the cache if you adopt the store later + dataExpiryLength: 0, }); ``` diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 9b298628bdd4..549ce4b8fccd 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -11,8 +11,8 @@ import AgentOnly from '@site/src/components/AgentOnly'; # Migrating from Axios -[`@data-client/rest`](/rest) replaces axios calls with typed endpoint definitions. Components keep calling them the way -they called axios, so how the app loads and stores data doesn't change. A [codemod](#codemod) converts the calls; the +[`@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. @@ -550,6 +550,8 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', + // callout: Keeps large blobs out of the cache if you adopt the store later + dataExpiryLength: 0, }); ``` From 15a8396351a8dd7ab4c1edc61c6ab890655a6cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:38:57 +0000 Subject: [PATCH 07/41] docs: Drop the blob cache claim from the axios binary example dataExpiryLength: 0 only marks the response stale; the store still keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/references/axios-migration.md | 2 -- .../data-client-rest-setup/references/axios-migration.vue.md | 2 -- .../data-client-rest-setup/references/axios-migration.md | 2 -- .../data-client-rest-setup/references/axios-migration.vue.md | 2 -- docs/rest/guides/axios-migration.md | 2 -- 5 files changed, 10 deletions(-) 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 9d49dec50be6..08f8c4571583 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -483,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache if you adopt the store later - dataExpiryLength: 0, }); ``` 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 index 91d936107a78..efb1be263cb2 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -483,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache if you adopt the store later - dataExpiryLength: 0, }); ``` 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 9d49dec50be6..08f8c4571583 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 @@ -483,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache if you adopt the store later - dataExpiryLength: 0, }); ``` 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 index 91d936107a78..efb1be263cb2 100644 --- 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 @@ -483,8 +483,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache if you adopt the store later - dataExpiryLength: 0, }); ``` diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 549ce4b8fccd..df64ce6dd95d 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -550,8 +550,6 @@ axios.get('/files/1', { responseType: 'blob' }); const downloadFile = new RestEndpoint({ path: '/files/:id', content: 'blob', - // callout: Keeps large blobs out of the cache if you adopt the store later - dataExpiryLength: 0, }); ``` From a17086e6c0b2a855ea94449848ef2fc134c979b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:57:19 +0000 Subject: [PATCH 08/41] feat(codemods): Turn axios calls into working endpoint calls The axios-to-rest codemod replaced `api.get(url)` with an uncalled `new ApiEndpoint({ path })`, so awaiting it returned the endpoint, and it dropped bodies and params. It now calls the endpoint with path params, params, query strings and the body, reads the body where code read `.data`, keeps types, and marks anything left with a TODO(data-client) comment linking the guide. The rest-setup skill now runs the codemod, resolves those comments and removes axios, and the data-client router (the plugin's entry) sends "replace axios" to that definitions-only guide instead of provider setup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 6 +- .../references/axios-migration.md | 60 +- .../references/axios-migration.vue.md | 60 +- .../references/data-client-rest-setup.md | 6 +- .../references/axios-migration.md | 60 +- .../references/axios-migration.vue.md | 60 +- .agents/skills/data-client/SKILL.md | 11 +- .changeset/axios-codemod-calls.md | 31 + docs/rest/guides/axios-migration.md | 96 +- packages/codemods/README.md | 2 +- .../__tests__/axios-to-rest.node.js | 397 +++++++- .../codemods/src/transforms/axios-to-rest.js | 899 ++++++++++++++++-- 12 files changed, 1484 insertions(+), 204 deletions(-) create mode 100644 .changeset/axios-codemod-calls.md diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 12113e297aea..5944c10b9ab0 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -67,7 +67,11 @@ When multiple HTTP libraries are detected, run each sub-procedure on the relevan 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/`. + 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. + 2. Run its 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 and drops `.data`. + 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. - **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 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 08f8c4571583..7a9e180053c7 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -42,13 +42,47 @@ your source folders, never the repo root: npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -It: +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. -- 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()` and `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into - `new CreatedClassName({ path, method })` +```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). @@ -349,7 +383,10 @@ 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: '' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); ``` Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the normalized cache, is a @@ -396,6 +433,7 @@ 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 | @@ -463,11 +501,15 @@ 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) { + 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; + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; return { ...init, signal }; } } 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 index efb1be263cb2..99d8322558d6 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -42,13 +42,47 @@ your source folders, never the repo root: npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -It: +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. -- 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()` and `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into - `new CreatedClassName({ path, method })` +```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). @@ -349,7 +383,10 @@ 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: '' }); +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 @@ -396,6 +433,7 @@ 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 | @@ -463,11 +501,15 @@ 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) { + 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; + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; return { ...init, signal }; } } 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 47a818b41f1f..ce8aa88ea488 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 @@ -63,7 +63,11 @@ When multiple HTTP libraries are detected, run each sub-procedure on the relevan 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/`. + 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. + 2. Run its 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 and drops `.data`. + 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. - **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 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 08f8c4571583..7a9e180053c7 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 @@ -42,13 +42,47 @@ your source folders, never the repo root: npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -It: +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. -- 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()` and `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into - `new CreatedClassName({ path, method })` +```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). @@ -349,7 +383,10 @@ 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: '' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); ``` Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) and the normalized cache, is a @@ -396,6 +433,7 @@ 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 | @@ -463,11 +501,15 @@ 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) { + 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; + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; return { ...init, signal }; } } 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 index efb1be263cb2..99d8322558d6 100644 --- 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 @@ -42,13 +42,47 @@ your source folders, never the repo root: npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -It: +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. -- 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()` and `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into - `new CreatedClassName({ path, method })` +```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). @@ -349,7 +383,10 @@ 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: '' }); +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 @@ -396,6 +433,7 @@ 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 | @@ -463,11 +501,15 @@ 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) { + 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; + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; return { ...init, signal }; } } diff --git a/.agents/skills/data-client/SKILL.md b/.agents/skills/data-client/SKILL.md index fd62a77efeca..8677fd57f3be 100644 --- a/.agents/skills/data-client/SKILL.md +++ b/.agents/skills/data-client/SKILL.md @@ -15,6 +15,10 @@ 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 replace an HTTP client (axios, fetch, ky, superagent, got) with `@data-client/rest` endpoints, follow +`../data-client-setup/references/data-client-rest-setup.md` instead. It needs no provider and leaves how the app loads +and stores data alone. + ## Set up Load the skill for the work. If it isn't available by name but its package is installed, run @@ -30,9 +34,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). diff --git a/.changeset/axios-codemod-calls.md b/.changeset/axios-codemod-calls.md new file mode 100644 index 000000000000..cc20556340db --- /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, + process(value): Post { + return value; + }, +})({ id, expand: 'author' }); +return res; +``` + +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 df64ce6dd95d..4649dd08ce70 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -86,13 +86,49 @@ your source folders, never the repo root: npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ ``` -It: + -- 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()` and `.delete()` into `new RestEndpoint({ path, method })` -- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into - `new CreatedClassName({ path, method })` +```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). @@ -402,7 +438,10 @@ 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: '' }); +const created = await PostResource.getList.push({ + title: 'Hello', + body: '', +}); ```
    @@ -453,6 +492,7 @@ 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 | @@ -470,23 +510,23 @@ 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) | +| 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) | ### Cancellation @@ -526,11 +566,15 @@ 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) { + 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; + const signal = init.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout; return { ...init, signal }; } } 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..66a551b5f3d0 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,9 @@ 'use strict'; -const { defineInlineTest } = require('jscodeshift/dist/testUtils'); +const { + defineInlineTest, + runInlineTest, +} = require('jscodeshift/dist/testUtils'); const transform = require('../axios-to-rest'); @@ -46,6 +49,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 +74,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 +126,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 +148,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 +312,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 +355,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 +376,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 +393,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 +412,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 +431,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 +450,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 +470,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 +489,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 +518,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 +541,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 +568,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 +592,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 +628,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 +649,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 +665,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 +683,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 +700,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 +718,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 +775,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 +825,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 +844,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 +868,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 +886,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 +904,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 +918,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 +939,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 +956,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 +1013,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 +1038,279 @@ 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'; +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', + ); + }); + + // ── Urls and config ───────────────────────────────────────────────── + + describe('urls and config', () => { + 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 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', + ); + }); + + // ── 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) { + const data = await new ApiEndpoint({ + path: '/users', + searchParams: {} as Record, + + process(value): User[] { + return value; + } + })({ + page + }); + return data; +} + `, + 'keeps types: a generic base class, the response type and untyped query params', + ); + }); + // ── 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..e277d6a86485 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -6,7 +6,10 @@ * 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/ @@ -147,32 +150,31 @@ function hasRuntimeReference(j, root, localName, importScope) { return hasReference(j, root, localName, importScope, true); } -function ensureRestEndpointImport(j, root) { - let dirty = false; +function ensureRestImports(j, root, names) { const restImports = root .find(j.ImportDeclaration) .filter(p => p.node.source.value === '@data-client/rest'); if (restImports.length) { + const imported = new Set(); restImports.forEach(importPath => { - const specifiers = importPath.node.specifiers || []; - const hasRestEndpoint = specifiers.some( - s => - 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; + for (const s of importPath.node.specifiers || []) { + if (s.type === 'ImportSpecifier' && s.imported) + imported.add(s.imported.name || s.imported.value); + } }); - 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 +186,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 +213,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,7 +225,22 @@ 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 = s.imported.name || s.imported.value; + 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 => { @@ -252,7 +275,7 @@ 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; const existingClasses = getExistingClassNames(j, root); const createToClass = new Map(); @@ -297,6 +320,7 @@ function transformAxiosCreate(j, root, axiosLocalName) { : null; const classBody = []; + const unknownOptions = []; if (config) { for (const prop of config.properties) { @@ -306,7 +330,10 @@ function transformAxiosCreate(j, root, axiosLocalName) { prop.key.type === 'Identifier' ? prop.key.name : prop.key.type === 'StringLiteral' ? prop.key.value : null; - if (!key) continue; + if (!key) { + unknownOptions.push('...'); + continue; + } if (key === 'baseURL') { const urlProp = j.classProperty( @@ -316,24 +343,32 @@ function transformAxiosCreate(j, root, axiosLocalName) { urlProp.static = false; classBody.push(urlProp); } 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 +378,21 @@ 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 => { + 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 +406,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,6 +417,7 @@ function transformAxiosCreate(j, root, axiosLocalName) { j(statementPath).insertBefore(classStatement); } } else { + markOptions(classDecl); j(declPath).replaceWith(classDecl); } dirty = true; @@ -376,86 +428,732 @@ function transformAxiosCreate(j, root, axiosLocalName) { // --- 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 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; +} + +/** 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; + 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')) { + props.push( + j.objectProperty( + j.identifier(key), + j.stringLiteral(decodeURIComponent(value)), + ), + ); + } 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; + if (quasis.some(q => q == null || q.includes('\0'))) return null; + } else { + return null; + } + + let urlPrefix; + const origin = quasis[0].match(ORIGIN); + if (origin) { + 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'); + const queryStart = joined.indexOf('?'); + const pathPart = queryStart === -1 ? joined : joined.slice(0, queryStart); + const queryPart = queryStart === -1 ? '' : joined.slice(queryStart + 1); + const pathExprCount = (pathPart.match(/\0/g) || []).length; + + const args = []; + const names = new Map(); + let path = ''; + const segments = pathPart.split('\0'); + 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]; + let name = memberName(expr) || 'param'; + const source = j(expr).toSource(); + if (names.has(name) && names.get(name) !== source) { + let n = 2; + while (names.has(name + n)) n++; + name = name + n; + } + if (!names.has(name)) { + names.set(name, source); + args.push( + j.objectProperty.from({ + key: j.identifier(name), + value: expr, + shorthand: expr.type === 'Identifier' && expr.name === name, + }), + ); + } + path += ':' + name; + } + if (!path) path = '/'; + + const pathArgCount = args.length; + if (queryPart) { + const query = parseQuery(j, queryPart, expressions.slice(pathExprCount)); + if (!query) return null; + args.push(...query); + } + + return { path, urlPrefix, args, pathArgCount }; +} + +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; +} + +/** The type an AxiosResponse annotation wraps, if that's what it is */ +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 ( + node.type === 'MemberExpression' && + node.object === object && + !node.computed && + node.property.type === 'Identifier' && + node.property.name === 'data' + ); +} + +/** Every read of `name` bound by `scope`, under `within` */ +function bindingReads(j, within, name, scope, exclude) { + return j(within) + .find(j.Identifier, { name }) + .filter(p => { + if (p.node === exclude) return false; + if (p.scope.lookup(name) !== scope) return false; + const parent = p.parent.node; + if ( + parent.type === 'MemberExpression' && + parent.property === p.node && + !parent.computed + ) + return false; + if ( + (parent.type === 'ObjectProperty' || parent.type === 'Property') && + parent.key === p.node && + !parent.computed && + !parent.shorthand + ) + return false; + return true; + }) + .paths(); +} + +/** Replaces each `name.data` read with `name`; false if `name` is read any other way */ +function dropDataReads(j, within, name, scope, exclude) { + const reads = bindingReads(j, within, name, scope, exclude); + 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; + + if (parent.node.type === 'AwaitExpression') { + const awaitNode = parent.node; + const outer = parent.parent; + 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; + 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 = j(thenPath) + .find( + cb.type === 'ArrowFunctionExpression' ? + j.ArrowFunctionExpression + : j.FunctionExpression, + ) + .filter(p => p.node === cb) + .paths()[0]; + if (!dropDataReads(j, cbPath, param.name, cbPath.scope, param)) + return false; + param.typeAnnotation = 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):'; + +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 => 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 statement holding `path`, 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; + } + if ( + p.parent && + (p.parent.node.type === 'ExportNamedDeclaration' || + p.parent.node.type === 'ExportDefaultDeclaration') + ) { + 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, isTS) { + const callee = callPath.node.callee; + const methodName = callee.property.name; + const args = callPath.node.arguments; + if (args.length === 0) return { reason: 'no url' }; + + const url = toPath(j, args[0]); + if (!url) return { reason: 'url' }; + + 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 { reason: 'spread' }; + + const callArgs = [...url.args]; + const unknownOptions = []; + if (config) { + if (config.type !== 'ObjectExpression') return { reason: 'config' }; + for (const prop of config.properties) { + const key = propKey(prop); + if (key === 'params') { + if (prop.value.type === 'ObjectExpression') { + callArgs.push(...prop.value.properties); + } else { + callArgs.push(j.spreadElement(prop.value)); + } + } else if (key) { + unknownOptions.push(key); + } else { + return { reason: 'config' }; + } + } + } + + 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 && callArgs.length > url.pathArgCount) { + // 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)); + } + if (hasBody && !data) { + options.push( + j.objectProperty(j.identifier('body'), j.identifier('undefined')), + ); + } + const typeArgs = + callPath.node.typeParameters || callPath.node.typeArguments || null; + if (isTS && typeArgs && typeArgs.params.length) { + // keeps the response type the axios call was given + const process = j.objectMethod( + 'method', + j.identifier('process'), + [j.identifier('value')], + j.blockStatement([j.returnStatement(j.identifier('value'))]), + ); + process.returnType = j.tsTypeAnnotation(typeArgs.params[0]); + options.push(process); + } + + const endpoint = j.newExpression(j.identifier(className || 'RestEndpoint'), [ + j.objectExpression(options), + ]); + const params = callArgs.length ? j.objectExpression(callArgs) : null; + const invokeArgs = + data ? + params ? [params, data] + : [data] + : params ? [params] + : []; + + return { + node: j.callExpression(endpoint, invokeArgs), + unknownOptions, + }; +} + function transformDirectCalls( j, root, axiosLocalName, axiosImportScope, createToClass, + isTS, ) { let dirty = false; + let marked = false; const callTargets = new Set([axiosLocalName, ...createToClass.keys()]); - 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; + // 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(); - 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 isCreatedInstance = Boolean(className); + const result = buildCall(j, callPath, className, isTS); + if (!result.node) { + markTodo( + j, + callPath, + `convert this ${targetName}.${callPath.node.callee.property.name}() call to an endpoint`, + 'call-sites', + ); + marked = true; + continue; + } - // 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; + j(callPath).replaceWith(result.node); + const newPath = callPath; + if (result.unknownOptions.length) { + const [message, anchor] = optionsTodo(result.unknownOptions); + markTodo(j, newPath, message, anchor); + } + if (!unwrapData(j, newPath)) { + markTodo( + j, + newPath, + 'this resolves to the response body now, not an axios response, so drop `.data` (`status` and `headers` are gone too)', + 'call-sites', + ); + } + dirty = true; + } - const method = methodName.toUpperCase(); + return { dirty, marked }; +} - const pathValue = - urlArg.type === 'StringLiteral' ? j.stringLiteral(urlArg.value) : urlArg; +// --- 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', + ], +}; - const properties = [j.objectProperty(j.identifier('path'), pathValue)]; +function markLeftovers( + j, + root, + axiosLocalName, + axiosImportScope, + createToClass, +) { + let dirty = false; - 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 => { + const className = scopes.get(p.scope.lookup(name)); + if (!className) return false; + const parent = p.parent.node; + if ( + parent.type === 'MemberExpression' && + parent.property === p.node && + !parent.computed + ) + return false; + if ( + (parent.type === 'ObjectProperty' || parent.type === 'Property') && + parent.key === p.node && + !parent.computed && + !parent.shorthand + ) + return false; + if (parent.type === 'ClassDeclaration') return false; + // calls it couldn't convert are marked already + if ( + parent.type === 'MemberExpression' && + parent.object === p.node && + HTTP_METHODS.has(memberName(parent)) + ) + return false; + return true; + }) + .forEach(p => { + const className = scopes.get(p.scope.lookup(name)); + const parent = p.parent.node; + const member = + parent.type === 'MemberExpression' && parent.object === p.node ? + memberName(parent) + : null; + 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) { + root + .find(j.Identifier, { name: axiosLocalName }) + .filter(p => { + if (p.scope.lookup(axiosLocalName) !== axiosImportScope) return false; + const parent = p.parent.node; + if (parent.type.startsWith('Import')) return false; + if (parent.type.startsWith('TS')) return false; + if ( + parent.type === 'MemberExpression' && + parent.property === p.node && + !parent.computed + ) + return false; + if ( + (parent.type === 'ObjectProperty' || parent.type === 'Property') && + parent.key === p.node && + !parent.computed && + !parent.shorthand + ) + return false; + // calls it couldn't convert are marked already + if ( + parent.type === 'MemberExpression' && + parent.object === p.node && + HTTP_METHODS.has(memberName(parent)) + ) + return false; + return true; + }) + .forEach(p => { + const parent = p.parent.node; + const member = + parent.type === 'MemberExpression' && parent.object === p.node ? + memberName(parent) + : null; + const [message, anchor] = 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 = s.imported.name || s.imported.value; + const local = (s.local && s.local.name) || imported; + const todo = + imported === 'AxiosError' ? + ['check `NetworkError` instead of `AxiosError`', 'error-handling'] + : AXIOS_MEMBER_TODOS[imported]; + if (!todo) continue; + root + .find(j.Identifier, { name: local }) + .filter(p => { + if (p.scope.lookup(local) !== importPath.scope) return false; + const parent = p.parent.node; + if ( + parent.type.startsWith('Import') || + parent.type.startsWith('TS') + ) + return false; + if ( + parent.type === 'MemberExpression' && + parent.property === p.node && + !parent.computed + ) + return false; + return true; + }) + .forEach(p => { + markTodo(j, p, todo[0], todo[1]); + dirty = true; + }); + } + }); return dirty; } @@ -467,6 +1165,7 @@ module.exports = function transformer(fileInfo, api) { if (!fileInfo.source.includes('axios')) return fileInfo.source; const j = api.jscodeshift; const root = j(fileInfo.source); + const isTS = /\.[cm]?tsx?$/.test(fileInfo.path || ''); const axiosImport = root .find(j.ImportDeclaration) @@ -487,17 +1186,28 @@ 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; - const directCallsDirty = transformDirectCalls( + const calls = transformDirectCalls( j, root, axiosLocalName, axiosImportScope, createResult.createToClass, + isTS, ); - dirty = directCallsDirty || dirty; + const directCallsDirty = calls.dirty; + dirty = calls.dirty || calls.marked || dirty; + + dirty = + markLeftovers( + j, + root, + axiosLocalName, + axiosImportScope, + createResult.createToClass, + ) || dirty; dirty = transformImports( @@ -505,6 +1215,9 @@ module.exports = function transformer(fileInfo, api) { root, axiosLocalName, createResult.dirty || directCallsDirty, + isTS && createResult.dirty ? + ['RestEndpoint', 'RestGenerics'] + : ['RestEndpoint'], ) || dirty; return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; From 556d973f10b839fe5e59d0205dfb81667f2cb2b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:01:53 +0000 Subject: [PATCH 09/41] fix(codemods): Join axios baseURL and urls with one slash RestEndpoint joins urlPrefix and path as is, while axios puts exactly one '/' between baseURL and a relative url. Strip a literal baseURL's trailing slash, start every path through such a class with '/', mark non-literal baseURLs, and mark absolute urls through an instance, since the class's urlPrefix field would override a urlPrefix option. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 72 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 47 +++++++++--- 2 files changed, 110 insertions(+), 9 deletions(-) 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 66a551b5f3d0..70a88548f2c5 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1230,6 +1230,78 @@ const get = (a, b) => new RestEndpoint({ {}, ` 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 api = axios.create({ baseURL: '/api', timeout: 5000, withCredentials: true }); api.interceptors.request.use(config => config); `, diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index e277d6a86485..4fd8d0c9ef14 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -279,6 +279,8 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { let dirty = false; const existingClasses = getExistingClassNames(j, root); 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; @@ -321,6 +323,7 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { const classBody = []; const unknownOptions = []; + const unknownTodos = []; if (config) { for (const prop of config.properties) { @@ -336,12 +339,24 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { } 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', + ]); + } + const urlProp = j.classProperty(j.identifier('urlPrefix'), prefix); urlProp.static = false; classBody.push(urlProp); + prefixedClasses.add(className); } else if (key === 'headers') { const headers = j.identifier('headers'); const fn = j.functionExpression( @@ -389,6 +404,8 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { ]); } 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); @@ -423,7 +440,7 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { dirty = true; }); - return { dirty, createToClass }; + return { dirty, createToClass, prefixedClasses }; } // --- 3. Transform direct axios/instance method calls --- @@ -792,7 +809,7 @@ function addTodo(j, node, message, anchor) { comments.push(j.commentLine(text, true, false)); } -function buildCall(j, callPath, className, isTS) { +function buildCall(j, callPath, className, prefixed, isTS) { const callee = callPath.node.callee; const methodName = callee.property.name; const args = callPath.node.arguments; @@ -800,6 +817,12 @@ function buildCall(j, callPath, className, isTS) { const url = toPath(j, args[0]); if (!url) return { reason: 'url' }; + if (prefixed) { + // the class's urlPrefix field would override a urlPrefix option + if (url.urlPrefix) return { reason: 'absolute url' }; + // 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; @@ -901,7 +924,7 @@ function transformDirectCalls( root, axiosLocalName, axiosImportScope, - createToClass, + { createToClass, prefixedClasses }, isTS, ) { let dirty = false; @@ -942,7 +965,13 @@ function transformDirectCalls( if (!className) continue; } - const result = buildCall(j, callPath, className, isTS); + const result = buildCall( + j, + callPath, + className, + prefixedClasses.has(className), + isTS, + ); if (!result.node) { markTodo( j, @@ -1194,7 +1223,7 @@ module.exports = function transformer(fileInfo, api) { root, axiosLocalName, axiosImportScope, - createResult.createToClass, + createResult, isTS, ); const directCallsDirty = calls.dirty; From ba2982eb26271f94ab1dcd7c7cde055fd14b2da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:05:45 +0000 Subject: [PATCH 10/41] fix(codemods): Mark axios calls whose query or imports would change From review: mark query keys that clash with path params or repeat, and query values that fail to decode, instead of converting them; spread non-literal params before path params and mark their serialization; evaluate each non-trivial url expression once; mark spread instance options; never name the class RestEndpoint; add a value import beside an `import type` from @data-client/rest; ignore inherited keys like `toString` in lookup tables. One isRead() predicate now decides what counts as a read of a binding. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 92 +++++ .../codemods/src/transforms/axios-to-rest.js | 352 ++++++++---------- 2 files changed, 253 insertions(+), 191 deletions(-) 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 70a88548f2c5..a7d3d5743ddb 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1302,6 +1302,80 @@ const other = () => api.get('https://other.com/x'); {}, ` 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: arrays, nested objects and URLSearchParams need \`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); `, @@ -1381,6 +1455,24 @@ export async function listUsers(page: number) { `, 'keeps types: a generic base class, the response type 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 ────────────────────────────────────────────────────── diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 4fd8d0c9ef14..d8c2957f8248 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -87,58 +87,47 @@ 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; - } +const TYPE_POSITIONS = new Set([ + 'TSTypeReference', + 'TSQualifiedName', + 'TSTypeQuery', + 'TSExpressionWithTypeArguments', + 'TSImportType', +]); - if ( - parent.type === 'MemberExpression' && - parent.property === path.node && - !parent.computed - ) { - 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; +} - 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; - } - } +/** 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)); +} - return true; - }) - .size() > 0 +function hasReference(j, root, localName, importScope, excludeTypePositions) { + return ( + readsOf(j, root, localName, importScope, { + types: !excludeTypePositions, + }).size() > 0 ); } @@ -151,15 +140,24 @@ function hasRuntimeReference(j, root, localName, importScope) { } 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 => { for (const s of importPath.node.specifiers || []) { - if (s.type === 'ImportSpecifier' && s.imported) + if ( + s.type === 'ImportSpecifier' && + s.imported && + s.importKind !== 'type' + ) imported.add(s.imported.name || s.imported.value); } }); @@ -277,7 +275,10 @@ function transformImports( 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(); @@ -327,12 +328,11 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { 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; + } + const key = propKey(prop); if (!key) { unknownOptions.push('...'); continue; @@ -450,6 +450,13 @@ const BODY_METHODS = new Set(['post', 'put', 'patch']); 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 memberName(node) { if (node.type === 'Identifier') return node.name; if ( @@ -473,6 +480,8 @@ function parseQuery(j, text, expressions) { 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( @@ -483,12 +492,13 @@ function parseQuery(j, text, expressions) { }), ); } else if (!value.includes('\0')) { - props.push( - j.objectProperty( - j.identifier(key), - j.stringLiteral(decodeURIComponent(value)), - ), - ); + let decoded; + try { + decoded = decodeURIComponent(value.replace(/\+/g, ' ')); + } catch { + return null; + } + props.push(j.objectProperty(j.identifier(key), j.stringLiteral(decoded))); } else { return null; } @@ -544,7 +554,8 @@ function toPath(j, urlArg) { if (!path.endsWith('/') || (next && !next.startsWith('/'))) return null; const expr = expressions[i]; let name = memberName(expr) || 'param'; - const source = j(expr).toSource(); + // the same variable can fill two segments; anything else is evaluated once each + const source = isPure(expr) ? j(expr).toSource() : {}; if (names.has(name) && names.get(name) !== source) { let n = 2; while (names.has(name + n)) n++; @@ -564,14 +575,13 @@ function toPath(j, urlArg) { } if (!path) path = '/'; - const pathArgCount = args.length; + let query = []; if (queryPart) { - const query = parseQuery(j, queryPart, expressions.slice(pathExprCount)); + query = parseQuery(j, queryPart, expressions.slice(pathExprCount)); if (!query) return null; - args.push(...query); } - return { path, urlPrefix, args, pathArgCount }; + return { path, urlPrefix, args, query }; } function propKey(prop) { @@ -583,7 +593,10 @@ function propKey(prop) { return null; } -/** The type an AxiosResponse annotation wraps, if that's what it is */ +/** + * 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 ( @@ -608,35 +621,11 @@ function isDataMember(node, object) { ); } -/** Every read of `name` bound by `scope`, under `within` */ -function bindingReads(j, within, name, scope, exclude) { - return j(within) - .find(j.Identifier, { name }) - .filter(p => { - if (p.node === exclude) return false; - if (p.scope.lookup(name) !== scope) return false; - const parent = p.parent.node; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'ObjectProperty' || parent.type === 'Property') && - parent.key === p.node && - !parent.computed && - !parent.shorthand - ) - return false; - return true; - }) - .paths(); -} - /** Replaces each `name.data` read with `name`; false if `name` is read any other way */ function dropDataReads(j, within, name, scope, exclude) { - const reads = bindingReads(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; @@ -725,7 +714,8 @@ function unwrapData(j, callPath) { .paths()[0]; if (!dropDataReads(j, cbPath, param.name, cbPath.scope, param)) return false; - param.typeAnnotation = null; + const bodyType = unwrapResponseType(param.typeAnnotation); + param.typeAnnotation = bodyType ? j.tsTypeAnnotation(bodyType) : null; } else { const inner = dataPattern(param); if (!inner) return false; @@ -751,6 +741,11 @@ function unwrapData(j, callPath) { 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', @@ -767,7 +762,7 @@ const OPTION_ANCHORS = { }; function optionsTodo(keys, target = 'the endpoint') { - const anchors = [...new Set(keys.map(k => OPTION_ANCHORS[k]))]; + const anchors = [...new Set(keys.map(k => own(OPTION_ANCHORS, k)))]; const anchor = anchors.length === 1 && anchors[0] ? anchors[0] : 'quick-reference'; return [ @@ -776,7 +771,10 @@ function optionsTodo(keys, target = 'the endpoint') { ]; } -/** Adds a TODO comment above the statement holding `path`, once per message */ +/** + * 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 ( @@ -813,13 +811,13 @@ 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 { reason: 'no url' }; + if (args.length === 0) return null; const url = toPath(j, args[0]); - if (!url) return { reason: 'url' }; + if (!url) return null; if (prefixed) { // the class's urlPrefix field would override a urlPrefix option - if (url.urlPrefix) return { reason: 'absolute url' }; + if (url.urlPrefix) return null; // axios puts a '/' between baseURL and a relative url if (!url.path.startsWith('/')) url.path = '/' + url.path; } @@ -827,28 +825,54 @@ function buildCall(j, callPath, className, prefixed, isTS) { 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 { reason: 'spread' }; + if (args.some(a => a.type === 'SpreadElement')) return null; - const callArgs = [...url.args]; + const query = [...url.query]; + const spreadParams = []; const unknownOptions = []; + const todos = []; if (config) { - if (config.type !== 'ObjectExpression') return { reason: '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') { - callArgs.push(...prop.value.properties); + query.push(...prop.value.properties); } else { - callArgs.push(j.spreadElement(prop.value)); + // first, so it can't replace a path param + spreadParams.push(j.spreadElement(prop.value)); } } else if (key) { unknownOptions.push(key); } else { - return { reason: 'config' }; + 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 query) { + const key = propKey(prop); + if (!key || keys.has(key)) return null; + keys.add(key); + } + // axios serializes arrays and objects in params its own way + if ( + spreadParams.length || + query.some( + prop => + prop.value.type === 'ArrayExpression' || + prop.value.type === 'ObjectExpression', + ) + ) { + todos.push([ + 'check these query params serialize as before: arrays, nested objects and URLSearchParams need `searchToString()`', + 'query-serialization', + ]); + } + const callArgs = [...spreadParams, ...url.args, ...query]; + const options = []; if (url.urlPrefix) { options.push( @@ -869,7 +893,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { ), ); } - if (isTS && callArgs.length > url.pathArgCount) { + if (isTS && (query.length || spreadParams.length)) { // axios params are untyped too; narrow this once you know the query const searchParams = j.tsAsExpression( j.objectExpression([]), @@ -883,6 +907,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { ); 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')), @@ -916,6 +941,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { return { node: j.callExpression(endpoint, invokeArgs), unknownOptions, + todos, }; } @@ -972,7 +998,7 @@ function transformDirectCalls( prefixedClasses.has(className), isTS, ); - if (!result.node) { + if (!result) { markTodo( j, callPath, @@ -984,15 +1010,16 @@ function transformDirectCalls( } j(callPath).replaceWith(result.node); - const newPath = callPath; if (result.unknownOptions.length) { const [message, anchor] = optionsTodo(result.unknownOptions); - markTodo(j, newPath, message, anchor); + markTodo(j, callPath, message, anchor); } - if (!unwrapData(j, newPath)) { + for (const [message, anchor] of result.todos) + markTodo(j, callPath, message, anchor); + if (!unwrapData(j, callPath)) { markTodo( j, - newPath, + callPath, 'this resolves to the response body now, not an axios response, so drop `.data` (`status` and `headers` are gone too)', 'call-sites', ); @@ -1034,37 +1061,23 @@ function markLeftovers( ) { let dirty = false; + // calls it couldn't convert are marked already + const isMarkedCall = p => + p.parent.node.type === 'MemberExpression' && + p.parent.node.object === p.node && + HTTP_METHODS.has(memberName(p.parent.node)); + // 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 => { - const className = scopes.get(p.scope.lookup(name)); - if (!className) return false; - const parent = p.parent.node; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'ObjectProperty' || parent.type === 'Property') && - parent.key === p.node && - !parent.computed && - !parent.shorthand - ) - return false; - if (parent.type === 'ClassDeclaration') return false; - // calls it couldn't convert are marked already - if ( - parent.type === 'MemberExpression' && - parent.object === p.node && - HTTP_METHODS.has(memberName(parent)) - ) - return false; - return true; - }) + .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; @@ -1099,42 +1112,15 @@ function markLeftovers( } if (axiosImportScope) { - root - .find(j.Identifier, { name: axiosLocalName }) - .filter(p => { - if (p.scope.lookup(axiosLocalName) !== axiosImportScope) return false; - const parent = p.parent.node; - if (parent.type.startsWith('Import')) return false; - if (parent.type.startsWith('TS')) return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'ObjectProperty' || parent.type === 'Property') && - parent.key === p.node && - !parent.computed && - !parent.shorthand - ) - return false; - // calls it couldn't convert are marked already - if ( - parent.type === 'MemberExpression' && - parent.object === p.node && - HTTP_METHODS.has(memberName(parent)) - ) - return false; - return true; - }) + readsOf(j, root, axiosLocalName, axiosImportScope, { types: false }) + .filter(p => !isMarkedCall(p)) .forEach(p => { const parent = p.parent.node; const member = parent.type === 'MemberExpression' && parent.object === p.node ? memberName(parent) : null; - const [message, anchor] = AXIOS_MEMBER_TODOS[member] || [ + const [message, anchor] = own(AXIOS_MEMBER_TODOS, member) || [ 'replace this axios call with an endpoint', 'call-sites', ]; @@ -1157,30 +1143,14 @@ function markLeftovers( const todo = imported === 'AxiosError' ? ['check `NetworkError` instead of `AxiosError`', 'error-handling'] - : AXIOS_MEMBER_TODOS[imported]; + : own(AXIOS_MEMBER_TODOS, imported); if (!todo) continue; - root - .find(j.Identifier, { name: local }) - .filter(p => { - if (p.scope.lookup(local) !== importPath.scope) return false; - const parent = p.parent.node; - if ( - parent.type.startsWith('Import') || - parent.type.startsWith('TS') - ) - return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - return true; - }) - .forEach(p => { + readsOf(j, root, local, importPath.scope, { types: false }).forEach( + p => { markTodo(j, p, todo[0], todo[1]); dirty = true; - }); + }, + ); } }); From c6eba13e23b2db880fa71089f00cb7a29639bd54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:06:55 +0000 Subject: [PATCH 11/41] fix(codemods): Mark exported axios responses Other modules may read `.data` from an exported response, so the codemod can't drop it locally. Adds a test for + decoding as a space in literal queries. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 36 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 3 ++ 2 files changed, 39 insertions(+) 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 a7d3d5743ddb..8fa9b050137e 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1126,6 +1126,42 @@ async function getUser() { {}, ` 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 search = q => axios.get(\`/search?q=\${q}&limit=5\`).then(({ data }) => data.items); `, ` diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index d8c2957f8248..2c7ca9d0f95f 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -667,6 +667,9 @@ function unwrapData(j, callPath) { 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; From 6d9d5091e1f5c7f25761bec680c8bf49e43bf1de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:12:44 +0000 Subject: [PATCH 12/41] fix(codemods): Cast typed axios calls instead of overriding process() A per-endpoint process() replaced the base class's, so a response interceptor moved there was skipped for every typed call. Cast the call to Promise instead. Also mark urls with a fragment, and calls whose params would now run before an effectful body. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .changeset/axios-codemod-calls.md | 7 +-- .../__tests__/axios-to-rest.node.js | 30 +++++++--- .../codemods/src/transforms/axios-to-rest.js | 56 +++++++++++++++---- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/.changeset/axios-codemod-calls.md b/.changeset/axios-codemod-calls.md index cc20556340db..52a0c21555ad 100644 --- a/.changeset/axios-codemod-calls.md +++ b/.changeset/axios-codemod-calls.md @@ -16,13 +16,10 @@ return res.data; ```ts // after the codemod -const res = await new ApiEndpoint({ +const res = await (new ApiEndpoint({ path: '/posts/:id', searchParams: {} as Record, - process(value): Post { - return value; - }, -})({ id, expand: 'author' }); +})({ id, expand: 'author' }) as Promise); return res; ``` 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 8fa9b050137e..18854e4955c9 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1162,6 +1162,24 @@ const search = () => new RestEndpoint({ {}, ` 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); `, ` @@ -1476,20 +1494,16 @@ export async function getUser(id: string) { return res; } export async function listUsers(page: number) { - const data = await new ApiEndpoint({ + const data = await (new ApiEndpoint({ path: '/users', - searchParams: {} as Record, - - process(value): User[] { - return value; - } + searchParams: {} as Record })({ page - }); + }) as Promise); return data; } `, - 'keeps types: a generic base class, the response type and untyped query params', + 'keeps types: a generic base class, the response type as a cast and untyped query params', ); tsTest( diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 2c7ca9d0f95f..52d1aef06057 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -457,6 +457,18 @@ function isPure(node) { ); } +/** No side effects to reorder: a variable, member chain or literal */ +function isSimple(node) { + return ( + isPure(node) || + node.type === 'StringLiteral' || + node.type === 'NumericLiteral' || + node.type === 'BooleanLiteral' || + node.type === 'NullLiteral' || + node.type === 'Literal' + ); +} + function memberName(node) { if (node.type === 'Identifier') return node.name; if ( @@ -535,6 +547,8 @@ function toPath(j, urlArg) { // 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); @@ -918,21 +932,18 @@ function buildCall(j, callPath, className, prefixed, isTS) { } const typeArgs = callPath.node.typeParameters || callPath.node.typeArguments || null; - if (isTS && typeArgs && typeArgs.params.length) { - // keeps the response type the axios call was given - const process = j.objectMethod( - 'method', - j.identifier('process'), - [j.identifier('value')], - j.blockStatement([j.returnStatement(j.identifier('value'))]), - ); - process.returnType = j.tsTypeAnnotation(typeArgs.params[0]); - options.push(process); - } - const endpoint = j.newExpression(j.identifier(className || 'RestEndpoint'), [ j.objectExpression(options), ]); + // params now come before the body, so neither may depend on evaluation order + if ( + data && + !isSimple(data) && + [...spreadParams.map(p => p.argument), ...query.map(p => p.value)].some( + v => !isSimple(v), + ) + ) + return null; const params = callArgs.length ? j.objectExpression(callArgs) : null; const invokeArgs = data ? @@ -943,6 +954,8 @@ function buildCall(j, callPath, className, prefixed, isTS) { return { node: j.callExpression(endpoint, invokeArgs), + // the response type the axios call was given + responseType: isTS && typeArgs && typeArgs.params[0], unknownOptions, todos, }; @@ -1027,6 +1040,25 @@ function transformDirectCalls( '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; } From b06f300044a96e344a0e3b962df91886f6d464d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:18:01 +0000 Subject: [PATCH 13/41] fix(codemods): Unwrap encodeURIComponent() in axios urls RestEndpoint encodes path and query values itself, so keeping the call would encode them twice. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 19 ++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 20 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) 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 18854e4955c9..22906a6c0324 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1227,6 +1227,25 @@ async function save() { {}, ` 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 } }); `, ` diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 52d1aef06057..0ff2e0144b25 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -480,6 +480,22 @@ function memberName(node) { 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 = []; @@ -495,7 +511,7 @@ function parseQuery(j, text, expressions) { // 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++]; + const expr = unencoded(expressions[exprIndex++]); props.push( j.objectProperty.from({ key: j.identifier(key), @@ -566,7 +582,7 @@ function toPath(j, urlArg) { // 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 expr = unencoded(expressions[i]); let name = memberName(expr) || 'param'; // the same variable can fill two segments; anything else is evaluated once each const source = isPure(expr) ? j(expr).toSource() : {}; From 9477d6d29b24e4f97835a3141b53fe0622e3cc18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:21:58 +0000 Subject: [PATCH 14/41] fix(codemods): Mark axios calls whose params would run before the url Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../transforms/__tests__/axios-to-rest.node.js | 15 +++++++++++++++ packages/codemods/src/transforms/axios-to-rest.js | 6 ++++++ 2 files changed, 21 insertions(+) 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 22906a6c0324..a85d7d501114 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1227,6 +1227,21 @@ async function save() { {}, ` 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 search = async (user, q) => (await axios.get(\`/users/\${encodeURIComponent(user)}/search?q=\${encodeURIComponent(q)}\`)).data; `, ` diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 0ff2e0144b25..ea214a963abe 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -904,6 +904,12 @@ function buildCall(j, callPath, className, prefixed, isTS) { 'query-serialization', ]); } + // a spread now runs before the url's expressions, so it must not depend on order + if ( + spreadParams.some(p => !isSimple(p.argument)) && + (url.args.length || url.query.length) + ) + return null; const callArgs = [...spreadParams, ...url.args, ...query]; const options = []; From 32fd80fd47581f05cc1061ddb27242fb2f5d4316 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:27:19 +0000 Subject: [PATCH 15/41] fix(codemods): Drop nullish axios params and mark variable ones axios leaves out null and undefined params, but URLSearchParams sends them as strings. Literal nullish params are dropped; any non-literal value gets the query-serialization TODO, and the guide shows dropping them in searchToString(). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 10 ++++- .../references/axios-migration.vue.md | 10 ++++- .../references/axios-migration.md | 10 ++++- .../references/axios-migration.vue.md | 10 ++++- docs/rest/guides/axios-migration.md | 10 ++++- .../__tests__/axios-to-rest.node.js | 21 +++++++++- .../codemods/src/transforms/axios-to-rest.js | 39 ++++++++++++++----- 7 files changed, 90 insertions(+), 20 deletions(-) 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 7a9e180053c7..43e2d42a7ae0 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -546,12 +546,18 @@ export default class ApiEndpoint< O extends RestGenerics = any, > extends RestEndpoint { searchToString(searchParams: Record) { - return qs.stringify(searchParams, { arrayFormat: 'repeat' }); + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); } } ``` -See [`searchToString()`](./RestEndpoint.md#searchToString). +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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See +[`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth 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 index 99d8322558d6..bfb5650a8da9 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -546,12 +546,18 @@ export default class ApiEndpoint< O extends RestGenerics = any, > extends RestEndpoint { searchToString(searchParams: Record) { - return qs.stringify(searchParams, { arrayFormat: 'repeat' }); + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); } } ``` -See [`searchToString()`](./RestEndpoint.vue.md#searchToString). +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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See +[`searchToString()`](./RestEndpoint.vue.md#searchToString). ### Basic auth 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 7a9e180053c7..43e2d42a7ae0 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 @@ -546,12 +546,18 @@ export default class ApiEndpoint< O extends RestGenerics = any, > extends RestEndpoint { searchToString(searchParams: Record) { - return qs.stringify(searchParams, { arrayFormat: 'repeat' }); + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); } } ``` -See [`searchToString()`](./RestEndpoint.md#searchToString). +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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See +[`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth 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 index 99d8322558d6..bfb5650a8da9 100644 --- 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 @@ -546,12 +546,18 @@ export default class ApiEndpoint< O extends RestGenerics = any, > extends RestEndpoint { searchToString(searchParams: Record) { - return qs.stringify(searchParams, { arrayFormat: 'repeat' }); + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); } } ``` -See [`searchToString()`](./RestEndpoint.vue.md#searchToString). +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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See +[`searchToString()`](./RestEndpoint.vue.md#searchToString). ### Basic auth diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 4649dd08ce70..74fdc464077c 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -619,14 +619,20 @@ export default class ApiEndpoint< O extends RestGenerics = any, > extends RestEndpoint { searchToString(searchParams: Record) { - return qs.stringify(searchParams, { arrayFormat: 'repeat' }); + return qs.stringify(searchParams, { + arrayFormat: 'repeat', + skipNulls: true, + }); } } ``` -See [`searchToString()`](../api/RestEndpoint.md#searchToString). +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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See +[`searchToString()`](../api/RestEndpoint.md#searchToString). ### Basic auth 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 a85d7d501114..9aab1cd4ab07 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1227,6 +1227,24 @@ async function save() { {}, ` 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() }); `, ` @@ -1424,7 +1442,7 @@ const list = (id, filters) => axios.get(\`/users/\${id}/posts\`, { params: filte `, ` import { RestEndpoint } from '@data-client/rest'; -// TODO(data-client): check these query params serialize as before: arrays, nested objects and URLSearchParams need \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization +// TODO(data-client): check these query params serialize as before: axios leaves out null and undefined values and has its own format for arrays, nested objects and URLSearchParams; 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' @@ -1528,6 +1546,7 @@ export async function getUser(id: string) { 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 has its own format for arrays, nested objects and URLSearchParams; match it in \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization const data = await (new ApiEndpoint({ path: '/users', searchParams: {} as Record diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index ea214a963abe..0fe761dafd33 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -469,6 +469,24 @@ function isSimple(node) { ); } +function isScalarLiteral(node) { + return ( + node.type === 'StringLiteral' || + node.type === 'NumericLiteral' || + node.type === 'BooleanLiteral' || + (node.type === 'Literal' && node.value !== null) || + (node.type === 'TemplateLiteral' && !node.expressions.length) + ); +} + +function isNullish(node) { + return ( + node.type === 'NullLiteral' || + (node.type === 'Literal' && node.value === null) || + (node.type === 'Identifier' && node.name === 'undefined') + ); +} + function memberName(node) { if (node.type === 'Identifier') return node.name; if ( @@ -860,7 +878,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { const config = hasBody ? args[2] : args[1]; if (args.some(a => a.type === 'SpreadElement')) return null; - const query = [...url.query]; + const configParams = []; const spreadParams = []; const unknownOptions = []; const todos = []; @@ -870,7 +888,12 @@ function buildCall(j, callPath, className, prefixed, isTS) { const key = propKey(prop); if (key === 'params') { if (prop.value.type === 'ObjectExpression') { - query.push(...prop.value.properties); + // axios leaves out null and undefined params + configParams.push( + ...prop.value.properties.filter( + p => !p.value || !isNullish(p.value), + ), + ); } else { // first, so it can't replace a path param spreadParams.push(j.spreadElement(prop.value)); @@ -883,6 +906,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { } } + const query = [...url.query, ...configParams]; // 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 query) { @@ -890,17 +914,14 @@ function buildCall(j, callPath, className, prefixed, isTS) { if (!key || keys.has(key)) return null; keys.add(key); } - // axios serializes arrays and objects in params its own way + // axios drops nullish params and serializes arrays and objects its own way, + // so only literal scalars are sure to serialize the same if ( spreadParams.length || - query.some( - prop => - prop.value.type === 'ArrayExpression' || - prop.value.type === 'ObjectExpression', - ) + configParams.some(prop => !prop.value || !isScalarLiteral(prop.value)) ) { todos.push([ - 'check these query params serialize as before: arrays, nested objects and URLSearchParams need `searchToString()`', + 'check these query params serialize as before: axios leaves out null and undefined values and has its own format for arrays, nested objects and URLSearchParams; match it in `searchToString()`', 'query-serialization', ]); } From e0f073649a590276f648c7f99ef10cff8376d79b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:38:48 +0000 Subject: [PATCH 16/41] feat(skills): Link package skills during REST setup The axios prompt installs only data-client-rest-setup, so its next steps (data-client-rest, data-client-schema) were missing. After installing @data-client/rest, the skill now runs data-client-skills to link the skills the installed packages ship, and records the rerun line in AGENTS.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 12 ++++++++++++ .../references/data-client-rest-setup.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 5944c10b9ab0..0abf4c68a038 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -25,6 +25,18 @@ yarn add @data-client/rest pnpm add @data-client/rest ``` +`@data-client/rest` ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`), at the installed version. Link them from the project root: + +```bash +npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills +``` + +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: install them with `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. Add this line to the project's `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), using the project's package manager, and commit `.agents/skills` and `.claude/skills`: + +```md +After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. +``` + ## 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. 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 ce8aa88ea488..a833254f64c7 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 @@ -21,6 +21,18 @@ yarn add @data-client/rest pnpm add @data-client/rest ``` +`@data-client/rest` ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`), at the installed version. Link them from the project root: + +```bash +npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills +``` + +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: install them with `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. Add this line to the project's `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), using the project's package manager, and commit `.agents/skills` and `.claude/skills`: + +```md +After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. +``` + ## 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. From c67c2d95f0453dc65a6a47cc4186bf6013d0139b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:43:55 +0000 Subject: [PATCH 17/41] refactor(codemods): One evaluation-order rule for reordered axios args Simplify pass: one literal classifier, one reordersEffects() check for both moves (spread params before the url, params before the body), unwrap encodeURIComponent() where the url's expressions are read, and a shorter serialization TODO. A literal query no longer blocks a spread. Tighten the REST setup's skill-linking step. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 4 +- .../references/axios-migration.md | 3 +- .../references/axios-migration.vue.md | 3 +- .../references/data-client-rest-setup.md | 4 +- .../references/axios-migration.md | 3 +- .../references/axios-migration.vue.md | 3 +- docs/rest/guides/axios-migration.md | 3 +- .../__tests__/axios-to-rest.node.js | 25 +++++- .../codemods/src/transforms/axios-to-rest.js | 85 +++++++++---------- 9 files changed, 74 insertions(+), 59 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 0abf4c68a038..227609c7408b 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -31,7 +31,9 @@ pnpm add @data-client/rest npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -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: install them with `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. Add this line to the project's `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), using the project's package manager, and commit `.agents/skills` and `.claude/skills`: +- Read them from `.claude/skills//SKILL.md` when this setup points to them. +- 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`: ```md After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. 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 43e2d42a7ae0..63cc354a464a 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -556,8 +556,7 @@ export default class ApiEndpoint< 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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See -[`searchToString()`](./RestEndpoint.md#searchToString). +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth 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 index bfb5650a8da9..fedda79ec9a3 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -556,8 +556,7 @@ export default class ApiEndpoint< 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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See -[`searchToString()`](./RestEndpoint.vue.md#searchToString). +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.vue.md#searchToString). ### Basic auth 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 a833254f64c7..b6e200a83819 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 @@ -27,7 +27,9 @@ pnpm add @data-client/rest npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -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: install them with `npx skills add reactive/data-client --skill data-client-rest --skill data-client-schema -y` instead. Add this line to the project's `AGENTS.md` (create it if missing; if a `CLAUDE.md` exists, make sure it has an `@AGENTS.md` line), using the project's package manager, and commit `.agents/skills` and `.claude/skills`: +- Read them from `.claude/skills//SKILL.md` when this setup points to them. +- 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`: ```md After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. 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 43e2d42a7ae0..63cc354a464a 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 @@ -556,8 +556,7 @@ export default class ApiEndpoint< 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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See -[`searchToString()`](./RestEndpoint.md#searchToString). +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.md#searchToString). ### Basic auth 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 index bfb5650a8da9..fedda79ec9a3 100644 --- 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 @@ -556,8 +556,7 @@ export default class ApiEndpoint< 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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See -[`searchToString()`](./RestEndpoint.vue.md#searchToString). +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](./RestEndpoint.vue.md#searchToString). ### Basic auth diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 74fdc464077c..e3477da1ea2d 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -631,8 +631,7 @@ export default class ApiEndpoint< 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"`. When a param can be empty, drop it in `searchToString()`, as `skipNulls` does above. See -[`searchToString()`](../api/RestEndpoint.md#searchToString). +`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](../api/RestEndpoint.md#searchToString). ### Basic auth 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 9aab1cd4ab07..7355d266f17b 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1260,6 +1260,27 @@ const get = id => axios.get(\`/users/\${id}\`, { params: nextFilters() }); {}, ` import axios from 'axios'; +const get = () => axios.get('/users?sort=name', { params: nextFilters() }); + `, + ` +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 get = () => new RestEndpoint({ + path: '/users' +})({ + ...nextFilters(), + sort: 'name' +}); + `, + 'spreads a params expression before a literal query, which it cannot affect', + ); + + defineInlineTest( + transform, + {}, + ` +import axios from 'axios'; const search = async (user, q) => (await axios.get(\`/users/\${encodeURIComponent(user)}/search?q=\${encodeURIComponent(q)}\`)).data; `, ` @@ -1442,7 +1463,7 @@ const list = (id, filters) => axios.get(\`/users/\${id}/posts\`, { params: filte `, ` import { RestEndpoint } from '@data-client/rest'; -// TODO(data-client): check these query params serialize as before: axios leaves out null and undefined values and has its own format for arrays, nested objects and URLSearchParams; match it in \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization +// 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' @@ -1546,7 +1567,7 @@ export async function getUser(id: string) { 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 has its own format for arrays, nested objects and URLSearchParams; match it in \`searchToString()\`: https://dataclient.io/rest/guides/axios-migration#query-serialization + // 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 diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 0fe761dafd33..f2a41d6287b8 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -457,26 +457,20 @@ function isPure(node) { ); } -/** No side effects to reorder: a variable, member chain or literal */ -function isSimple(node) { +function isLiteral(node) { return ( - isPure(node) || node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral' || - node.type === 'Literal' + node.type === 'Literal' || + (node.type === 'TemplateLiteral' && !node.expressions.length) ); } -function isScalarLiteral(node) { - return ( - node.type === 'StringLiteral' || - node.type === 'NumericLiteral' || - node.type === 'BooleanLiteral' || - (node.type === 'Literal' && node.value !== null) || - (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) { @@ -487,6 +481,18 @@ function isNullish(node) { ); } +/** + * 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)) + ); +} + function memberName(node) { if (node.type === 'Identifier') return node.name; if ( @@ -529,7 +535,7 @@ function parseQuery(j, text, expressions) { // a repeated key would collapse into one arg if (props.some(p => p.key.name === key)) return null; if (value === '\0') { - const expr = unencoded(expressions[exprIndex++]); + const expr = expressions[exprIndex++]; props.push( j.objectProperty.from({ key: j.identifier(key), @@ -566,7 +572,7 @@ function toPath(j, urlArg) { expressions = []; } else if (urlArg.type === 'TemplateLiteral') { quasis = urlArg.quasis.map(q => q.value.cooked); - expressions = urlArg.expressions; + expressions = urlArg.expressions.map(unencoded); if (quasis.some(q => q == null || q.includes('\0'))) return null; } else { return null; @@ -600,7 +606,7 @@ function toPath(j, urlArg) { // each expression must fill a whole path segment const next = segments[i + 1]; if (!path.endsWith('/') || (next && !next.startsWith('/'))) return null; - const expr = unencoded(expressions[i]); + const expr = expressions[i]; let name = memberName(expr) || 'param'; // the same variable can fill two segments; anything else is evaluated once each const source = isPure(expr) ? j(expr).toSource() : {}; @@ -888,12 +894,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { const key = propKey(prop); if (key === 'params') { if (prop.value.type === 'ObjectExpression') { - // axios leaves out null and undefined params - configParams.push( - ...prop.value.properties.filter( - p => !p.value || !isNullish(p.value), - ), - ); + configParams.push(...prop.value.properties); } else { // first, so it can't replace a path param spreadParams.push(j.spreadElement(prop.value)); @@ -906,31 +907,34 @@ function buildCall(j, callPath, className, prefixed, isTS) { } } - const query = [...url.query, ...configParams]; + // 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; + // 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 query) { + for (const prop of [...url.query, ...configParams]) { const key = propKey(prop); if (!key || keys.has(key)) return null; keys.add(key); } - // axios drops nullish params and serializes arrays and objects its own way, - // so only literal scalars are sure to serialize the same - if ( - spreadParams.length || - configParams.some(prop => !prop.value || !isScalarLiteral(prop.value)) - ) { + // 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 has its own format for arrays, nested objects and URLSearchParams; match it in `searchToString()`', + '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', ]); } - // a spread now runs before the url's expressions, so it must not depend on order - if ( - spreadParams.some(p => !isSimple(p.argument)) && - (url.args.length || url.query.length) - ) - return null; + const query = [...url.query, ...sentParams]; const callArgs = [...spreadParams, ...url.args, ...query]; const options = []; @@ -978,15 +982,6 @@ function buildCall(j, callPath, className, prefixed, isTS) { const endpoint = j.newExpression(j.identifier(className || 'RestEndpoint'), [ j.objectExpression(options), ]); - // params now come before the body, so neither may depend on evaluation order - if ( - data && - !isSimple(data) && - [...spreadParams.map(p => p.argument), ...query.map(p => p.value)].some( - v => !isSimple(v), - ) - ) - return null; const params = callArgs.length ? j.objectExpression(callArgs) : null; const invokeArgs = data ? From baa60c6112fd21eb84a49a08c530dfa09ffa3dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:45:07 +0000 Subject: [PATCH 18/41] docs(skills): Restore when to read a linked skill by path Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 2 +- .../data-client-setup/references/data-client-rest-setup.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 227609c7408b..d5e9b32e563a 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -31,7 +31,7 @@ pnpm add @data-client/rest npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -- Read them from `.claude/skills//SKILL.md` when this setup points to them. +- 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`: 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 b6e200a83819..faf523b36b9c 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 @@ -27,7 +27,7 @@ pnpm add @data-client/rest npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills ``` -- Read them from `.claude/skills//SKILL.md` when this setup points to them. +- 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`: From 94cb108e5a88619749ecf2964cd916e881477964 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:50:07 +0000 Subject: [PATCH 19/41] docs(skills): Fix REST setup gotchas found in a skills audit - Reuse the class the axios codemod made instead of adding a second base class - Explain that axios's .data is not a response envelope, and drop the process() unwrap from the full example so it isn't copied by default - Match the guide's Zod advice (parse in process(), add an Entity) - Install step only installs @data-client/rest; name reference links Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 35 +++++++++---------- .../references/data-client-rest-setup.md | 35 +++++++++---------- .agents/skills/data-client/SKILL.md | 2 +- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index d5e9b32e563a..175013079ce5 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -12,7 +12,7 @@ The [RestEndpoint](references/RestEndpoint.md) and [resource](references/resourc ## Step 1: Installation -Install the REST package alongside the core package: +Install the REST package: ```bash # npm @@ -49,11 +49,11 @@ 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) | +| `"axios"` in dependencies, or `import.*from ['"]axios['"]` in source | **Axios** | Follow [Axios migration](references/axios-migration.md) | +| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [Fetch migration](references/fetch-migration.md) | +| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [Ky migration](references/ky-migration.md) | +| `"superagent"` in dependencies | **SuperAgent** | Follow [SuperAgent migration](references/superagent-migration.md) | +| `"got"` in dependencies (rare in browser code) | **Got** | Follow [Got migration](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 @@ -80,20 +80,20 @@ When multiple HTTP libraries are detected, run each sub-procedure on the relevan 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 +- **Axios** → [Axios migration](references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. 2. Run its 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 and drops `.data`. 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. -- **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 +- **Raw fetch** → [Fetch migration](references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling +- **Ky** → [Ky migration](references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config +- **SuperAgent** → [SuperAgent migration](references/superagent-migration.md) — chained API, plugins, agents, file uploads +- **Got** → [Got migration](references/got-migration.md) — Node.js patterns, hooks, pagination, retry ## Step 3: Custom RestEndpoint Base Class -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. +After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. If a migration already made one (the axios codemod turns each `axios.create()` instance into a class), add these overrides to that class instead of creating another. ### Detection Checklist @@ -190,10 +190,11 @@ getRequestInit(body?: RequestInit['body'] | Record): RequestIni ### Custom Response Parsing (unwrap data envelope) +Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios's `response.data` is axios's wrapper around the body, not an envelope, and the axios codemod already removed it; unwrapping again would strip a real `data` field. + ```ts process(value: any, ...args: any[]) { - // If API wraps responses in { data: ... } - return value.data ?? value; + return value.data; } ``` @@ -261,10 +262,6 @@ export class BaseEndpoint extends RestEndpoint searchToString(searchParams: Record): string { return qs.stringify(searchParams, { arrayFormat: 'brackets' }); } - - process(value: any, ...args: any[]) { - return value?.data ?? value; - } } async function getAuthToken(): Promise { @@ -308,7 +305,7 @@ export const loginEndpoint = new BaseEndpoint({ ### 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. +If the codebase already validates responses with Zod or Yup, keep that validation by parsing in `process()` and add an Entity `schema` for normalization; skip the Entity only for responses that don't need normalization (auth tokens, one-off responses). The [Axios migration](references/axios-migration.md) reference shows each option under "Coexisting with Zod or Yup". ## Next Steps 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 faf523b36b9c..3528e9333f13 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 @@ -8,7 +8,7 @@ The [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) and [res ## Step 1: Installation -Install the REST package alongside the core package: +Install the REST package: ```bash # npm @@ -45,11 +45,11 @@ 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) | +| `"axios"` in dependencies, or `import.*from ['"]axios['"]` in source | **Axios** | Follow [Axios migration](./data-client-rest-setup/references/axios-migration.md) | +| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [Fetch migration](./data-client-rest-setup/references/fetch-migration.md) | +| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [Ky migration](./data-client-rest-setup/references/ky-migration.md) | +| `"superagent"` in dependencies | **SuperAgent** | Follow [SuperAgent migration](./data-client-rest-setup/references/superagent-migration.md) | +| `"got"` in dependencies (rare in browser code) | **Got** | Follow [Got migration](./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 @@ -76,20 +76,20 @@ When multiple HTTP libraries are detected, run each sub-procedure on the relevan 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 +- **Axios** → [Axios migration](./data-client-rest-setup/references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. 2. Run its 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 and drops `.data`. 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. -- **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 +- **Raw fetch** → [Fetch migration](./data-client-rest-setup/references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling +- **Ky** → [Ky migration](./data-client-rest-setup/references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config +- **SuperAgent** → [SuperAgent migration](./data-client-rest-setup/references/superagent-migration.md) — chained API, plugins, agents, file uploads +- **Got** → [Got migration](./data-client-rest-setup/references/got-migration.md) — Node.js patterns, hooks, pagination, retry ## Step 3: Custom RestEndpoint Base Class -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. +After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. If a migration already made one (the axios codemod turns each `axios.create()` instance into a class), add these overrides to that class instead of creating another. ### Detection Checklist @@ -186,10 +186,11 @@ getRequestInit(body?: RequestInit['body'] | Record): RequestIni ### Custom Response Parsing (unwrap data envelope) +Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios's `response.data` is axios's wrapper around the body, not an envelope, and the axios codemod already removed it; unwrapping again would strip a real `data` field. + ```ts process(value: any, ...args: any[]) { - // If API wraps responses in { data: ... } - return value.data ?? value; + return value.data; } ``` @@ -257,10 +258,6 @@ export class BaseEndpoint extends RestEndpoint searchToString(searchParams: Record): string { return qs.stringify(searchParams, { arrayFormat: 'brackets' }); } - - process(value: any, ...args: any[]) { - return value?.data ?? value; - } } async function getAuthToken(): Promise { @@ -304,7 +301,7 @@ export const loginEndpoint = new BaseEndpoint({ ### 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. +If the codebase already validates responses with Zod or Yup, keep that validation by parsing in `process()` and add an Entity `schema` for normalization; skip the Entity only for responses that don't need normalization (auth tokens, one-off responses). The [Axios migration](./data-client-rest-setup/references/axios-migration.md) reference shows each option under "Coexisting with Zod or Yup". ## Next Steps diff --git a/.agents/skills/data-client/SKILL.md b/.agents/skills/data-client/SKILL.md index 8677fd57f3be..999190ebf752 100644 --- a/.agents/skills/data-client/SKILL.md +++ b/.agents/skills/data-client/SKILL.md @@ -40,4 +40,4 @@ wrap async functions, follow its guide in `data-client-setup` (`../data-client-s 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). From a66c462572a5c1f1085e00e96c9b77aa77d040c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:51:53 +0000 Subject: [PATCH 20/41] fix(codemods): Mark axios urls with credentials, which fetch rejects Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 22 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 8 +++++++ 2 files changed, 30 insertions(+) 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 7355d266f17b..d11031b87b9b 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1227,6 +1227,28 @@ async function save() { {}, ` 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; send them in 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; `, ` diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index f2a41d6287b8..e76d5de4aaae 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -353,6 +353,12 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { 'base-endpoint', ]); } + if (literal && ORIGIN.exec(prefix.value)?.[0].includes('@')) { + unknownTodos.push([ + 'fetch rejects credentials in a url; send them in an `Authorization` header from `getHeaders()`', + 'basic-auth', + ]); + } const urlProp = j.classProperty(j.identifier('urlPrefix'), prefix); urlProp.static = false; classBody.push(urlProp); @@ -581,6 +587,8 @@ function toPath(j, urlArg) { 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)]; } From 0cb2f46a17b0370063f13201dcd590836c9c57dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:53:24 +0000 Subject: [PATCH 21/41] fix(codemods): Keep .data on calls through an instance with a response interceptor An interceptor like response => response.data makes callers' .data read the body's own field. Calls on an instance (or axios) with a response interceptor in the same file keep .data and get a TODO; the guide covers interceptors set up in another file. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 4 +++ .../references/axios-migration.vue.md | 4 +++ .../references/axios-migration.md | 4 +++ .../references/axios-migration.vue.md | 4 +++ docs/rest/guides/axios-migration.md | 4 +++ .../__tests__/axios-to-rest.node.js | 26 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 25 +++++++++++++++++- 7 files changed, 70 insertions(+), 1 deletion(-) 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 63cc354a464a..7e2c5cd7660b 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -134,6 +134,10 @@ export default class ApiEndpoint< 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). +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, 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 index fedda79ec9a3..e826082f7cc2 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -134,6 +134,10 @@ export default class ApiEndpoint< 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, 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 63cc354a464a..7e2c5cd7660b 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 @@ -134,6 +134,10 @@ export default class ApiEndpoint< 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). +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, 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 index fedda79ec9a3..e826082f7cc2 100644 --- 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 @@ -134,6 +134,10 @@ export default class ApiEndpoint< 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, diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index e3477da1ea2d..609f5de21ce9 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -182,6 +182,10 @@ export default class ApiEndpoint< 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, 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 d11031b87b9b..d16ab73ca659 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1227,6 +1227,32 @@ async function save() { {}, ` 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' }); `, diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index e76d5de4aaae..e792d83ee7a2 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -1018,6 +1018,22 @@ function transformDirectCalls( 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( + root + .find(j.MemberExpression, { + object: { + type: 'MemberExpression', + object: { type: 'Identifier' }, + property: { name: 'interceptors' }, + }, + property: { name: 'response' }, + }) + .nodes() + .map(node => node.object.object.name) + .filter(name => callTargets.has(name)), + ); // innermost calls first, so a call nested in another call's args converts // before its parent rebuilds around it @@ -1078,7 +1094,14 @@ function transformDirectCalls( } for (const [message, anchor] of result.todos) markTodo(j, callPath, message, anchor); - if (!unwrapData(j, callPath)) { + 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, From 6933b419e61e31bc0433be9b610a59572aacbb97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:58:53 +0000 Subject: [PATCH 22/41] fix(codemods): Mark spread params beside a url query; say to drop url credentials Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../transforms/__tests__/axios-to-rest.node.js | 16 +++++----------- .../codemods/src/transforms/axios-to-rest.js | 5 ++++- 2 files changed, 9 insertions(+), 12 deletions(-) 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 d16ab73ca659..e6095b4d6410 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1262,7 +1262,7 @@ 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; send them in an \`Authorization\` header from \`getHeaders()\`: https://dataclient.io/rest/guides/axios-migration#basic-auth +// 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'; } @@ -1311,17 +1311,11 @@ import axios from 'axios'; const get = () => axios.get('/users?sort=name', { params: nextFilters() }); `, ` -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 get = () => new RestEndpoint({ - path: '/users' -})({ - ...nextFilters(), - sort: 'name' -}); +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() }); `, - 'spreads a params expression before a literal query, which it cannot affect', + 'marks spread params next to a url query, since a shared key would collapse', ); defineInlineTest( diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index e792d83ee7a2..758284004fb7 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -355,7 +355,7 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { } if (literal && ORIGIN.exec(prefix.value)?.[0].includes('@')) { unknownTodos.push([ - 'fetch rejects credentials in a url; send them in an `Authorization` header from `getHeaders()`', + 'fetch rejects credentials in a url: remove `user:pass@` from `urlPrefix` and send them as an `Authorization` header from `getHeaders()`', 'basic-auth', ]); } @@ -926,6 +926,9 @@ function buildCall(j, callPath, className, prefixed, isTS) { ) 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]) { From 6845996610613213a5df73a22733b14f16592434 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:47:43 +0000 Subject: [PATCH 23/41] fix(codemods): Convert calls through an axios instance imported from another file Found by Haiku evals: the codemod handled one file at a time, so a module importing `api` from the file that called axios.create() kept its axios calls and an import that no longer existed. It now reads the imported module to find the instance's class. Calls whose result is unused no longer get a .data note. The guide's prompt now says to read the setup skill's SKILL.md, since the skill is explicit-only and an agent told to "follow it" can't load it by name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .changeset/axios-codemod-calls.md | 3 + docs/rest/guides/axios-migration.md | 2 +- .../__tests__/axios-to-rest.node.js | 117 +++++++++++ .../codemods/src/transforms/axios-to-rest.js | 198 ++++++++++++++++-- 4 files changed, 304 insertions(+), 16 deletions(-) diff --git a/.changeset/axios-codemod-calls.md b/.changeset/axios-codemod-calls.md index 52a0c21555ad..efe75665d6ce 100644 --- a/.changeset/axios-codemod-calls.md +++ b/.changeset/axios-codemod-calls.md @@ -23,6 +23,9 @@ const res = await (new ApiEndpoint({ 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 609f5de21ce9..42f61b1be560 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -22,7 +22,7 @@ so how the app loads and stores data doesn't change; only the call shape does. A Paste this into your agent's chat: ```prompt -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 follow it. +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 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 e6095b4d6410..28339b821c03 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1,9 +1,12 @@ 'use strict'; +const fs = require('fs'); const { defineInlineTest, runInlineTest, } = require('jscodeshift/dist/testUtils'); +const os = require('os'); +const path = require('path'); const transform = require('../axios-to-rest'); @@ -1217,6 +1220,29 @@ async function save() { `, '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 ───────────────────────────────────────────────── @@ -1563,6 +1589,97 @@ api.interceptors.request.use(config => config); ); }); + // ── 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); + const filePath = path.join(dir, file); + return transform( + { path: filePath, source: files[file] }, + { jscodeshift: require('jscodeshift').withParser('tsx'), stats() {} }, + {}, + ).trim(); + }; + 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('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', () => { diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 758284004fb7..b55a06f72936 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -1,5 +1,8 @@ 'use strict'; +const fs = require('fs'); +const nodePath = require('path'); + /** * Axios → @data-client/rest migration codemod * @@ -449,6 +452,150 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { 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(); +// a relative 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 resolveModule(fromFile, spec) { + const base = nodePath.resolve(nodePath.dirname(fromFile), spec); + 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; + } + }); +} + +// exported name -> { className, prefixed } 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; + } + const exportedVars = new Set(); + const exportedClasses = new Map(); + root.find(j.ExportNamedDeclaration).forEach(p => { + const decl = p.node.declaration; + if (!decl) return; + if (decl.type === 'VariableDeclaration') { + for (const d of decl.declarations) + if (d.id.type === 'Identifier') exportedVars.add(d.id.name); + } else if ( + decl.type === 'ClassDeclaration' && + decl.id && + decl.superClass && + decl.superClass.type === 'Identifier' && + decl.superClass.name === 'RestEndpoint' + ) { + exportedClasses.set( + decl.id.name, + decl.body.body.some( + m => m.key && m.key.name === 'urlPrefix' && !m.static, + ), + ); + } + }); + + 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 [name, scopes] of createToClass) { + if (!exportedVars.has(name) || scopes.size !== 1) continue; + const className = [...scopes.values()][0]; + found.set(name, { + className, + prefixed: prefixedClasses.has(className), + }); + } + } + // rewritten already: the instance's export is gone and its class is there + for (const [className, prefixed] of exportedClasses) { + const name = className.replace(/Endpoint$/, ''); + for (const candidate of [ + name, + name.charAt(0).toLowerCase() + name.slice(1), + ]) + if ( + deriveClassName(candidate) === className && + !exportedVars.has(candidate) && + !found.has(candidate) + ) + found.set(candidate, { className, prefixed }); + } + return found; +} + +// named imports of axios instances from relative 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 renames = []; + if (!filePath) return { createToClass, prefixedClasses, renames }; + const taken = getExistingClassNames(j, root); + root + .find(j.ImportDeclaration) + .filter( + p => + p.node.importKind !== 'type' && + typeof p.node.source.value === 'string' && + p.node.source.value.startsWith('.'), + ) + .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.type !== 'ImportSpecifier' || s.importKind === 'type') continue; + const imported = s.imported.name || s.imported.value; + const instance = instances.get(imported); + if (!instance) continue; + const local = (s.local && s.local.name) || imported; + 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); + renames.push({ declaration: importPath.node, specifier: s, className }); + } + }); + return { createToClass, prefixedClasses, renames }; +} + // --- 3. Transform direct axios/instance method calls --- const BODY_METHODS = new Set(['post', 'put', 'patch']); @@ -711,9 +858,13 @@ function dataPattern(pattern) { 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; @@ -1267,16 +1418,19 @@ function markLeftovers( // --- 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; @@ -1294,6 +1448,10 @@ module.exports = function transformer(fileInfo, api) { 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); const calls = transformDirectCalls( j, @@ -1315,18 +1473,28 @@ module.exports = function transformer(fileInfo, api) { createResult.createToClass, ) || dirty; - dirty = - transformImports( - j, - root, - axiosLocalName, - createResult.dirty || directCallsDirty, - isTS && createResult.dirty ? - ['RestEndpoint', 'RestGenerics'] - : ['RestEndpoint'], - ) || dirty; + // after the calls, which look the instance up by its imported name + for (const { declaration, specifier, className } of imported.renames) { + declaration.specifiers = declaration.specifiers.map(s => + s === specifier ? j.importSpecifier(j.identifier(className)) : s, + ); + dirty = true; + } + + if (axiosImport.length) { + dirty = + transformImports( + j, + root, + axiosLocalName, + createResult.dirty || directCallsDirty, + isTS && createResult.dirty ? + ['RestEndpoint', 'RestGenerics'] + : ['RestEndpoint'], + ) || dirty; + } - return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; + return dirty ? root.toSource({ quote: 'single' }) : source; }; module.exports.parser = 'tsx'; From 50b64011b97d4441dab8af907458b481c3558220 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:56:01 +0000 Subject: [PATCH 24/41] fix(codemods): Follow default and specifier exports of an axios instance, and its response interceptors An importer of an instance now keeps .data (with its note) when the client module has a response interceptor. `export { api }` and `export default api` export the class after the rewrite, and importers convert through them, including a default import. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 74 +++++++++ .../codemods/src/transforms/axios-to-rest.js | 152 ++++++++++++------ 2 files changed, 179 insertions(+), 47 deletions(-) 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 28339b821c03..1512fbef0d5e 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1666,6 +1666,80 @@ export function listPosts() { ); }); + 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('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('leaves other imports with a get() alone', () => { const source = ` import { cache } from './cache'; diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index b55a06f72936..44be646a3649 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -479,8 +479,9 @@ function resolveModule(fromFile, spec) { }); } -// exported name -> { className, prefixed } for each axios instance a module -// exports, before or after this codemod has rewritten it +// 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(); @@ -491,29 +492,42 @@ function exportedInstances(j, file) { } catch { return found; } - const exportedVars = new Set(); - const exportedClasses = new Map(); + // exported name -> local binding + const exportsOf = new Map(); root.find(j.ExportNamedDeclaration).forEach(p => { - const decl = p.node.declaration; - if (!decl) return; - if (decl.type === 'VariableDeclaration') { - for (const d of decl.declarations) - if (d.id.type === 'Identifier') exportedVars.add(d.id.name); - } else if ( - decl.type === 'ClassDeclaration' && - decl.id && - decl.superClass && - decl.superClass.type === 'Identifier' && - decl.superClass.name === 'RestEndpoint' - ) { - exportedClasses.set( - decl.id.name, - decl.body.body.some( - m => m.key && m.key.name === 'urlPrefix' && !m.static, - ), - ); + 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( + spec.exported.name || spec.exported.value, + spec.local.name || spec.local.value, + ); }); + 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 intercepts = name => + root + .find(j.MemberExpression, { + object: { + type: 'MemberExpression', + object: { type: 'Identifier', name }, + property: { name: 'interceptors' }, + }, + property: { name: 'response' }, + }) + .size() > 0; const axiosDefault = root .find(j.ImportDeclaration, { source: { value: 'axios' } }) @@ -527,28 +541,47 @@ function exportedInstances(j, file) { axiosDefault.local.name, /\.[cm]?tsx?$/.test(file), ); - for (const [name, scopes] of createToClass) { - if (!exportedVars.has(name) || scopes.size !== 1) continue; + for (const [exported, local] of exportsOf) { + const scopes = createToClass.get(local); + if (!scopes || scopes.size !== 1) continue; const className = [...scopes.values()][0]; - found.set(name, { + found.set(exported, { className, prefixed: prefixedClasses.has(className), + intercepted: intercepts(local), }); } } - // rewritten already: the instance's export is gone and its class is there - for (const [className, prefixed] of exportedClasses) { - const name = className.replace(/Endpoint$/, ''); - for (const candidate of [ - name, - name.charAt(0).toLowerCase() + name.slice(1), - ]) - if ( - deriveClassName(candidate) === className && - !exportedVars.has(candidate) && - !found.has(candidate) - ) - found.set(candidate, { className, prefixed }); + // 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$/, ''); + // an aliased or default export keeps its name; otherwise the class took + // the instance's place, under the name the rewrite derived from it + const candidates = + exported !== local ? + [exported] + : [name, name.charAt(0).toLowerCase() + name.slice(1)].filter( + c => deriveClassName(c) === local && !exportsOf.has(c), + ); + for (const candidate of candidates) + if (!found.has(candidate)) + found.set(candidate, { + className: local, + prefixed: classes.get(local), + intercepted: [name, name.charAt(0).toLowerCase() + name.slice(1)] + .filter(c => deriveClassName(c) === local) + .some(intercepts), + }); } return found; } @@ -558,8 +591,10 @@ function exportedInstances(j, file) { function importedInstances(j, root, filePath) { const createToClass = new Map(); const prefixedClasses = new Set(); + const intercepted = new Set(); const renames = []; - if (!filePath) return { createToClass, prefixedClasses, renames }; + if (!filePath) + return { createToClass, prefixedClasses, intercepted, renames }; const taken = getExistingClassNames(j, root); root .find(j.ImportDeclaration) @@ -575,11 +610,14 @@ function importedInstances(j, root, filePath) { const instances = exportedInstances(j, file); if (!instances.size) return; for (const s of importPath.node.specifiers || []) { - if (s.type !== 'ImportSpecifier' || s.importKind === 'type') continue; - const imported = s.imported.name || s.imported.value; - const instance = instances.get(imported); + if (s.importKind === 'type') continue; + const imported = + s.type === 'ImportDefaultSpecifier' ? 'default' + : s.type === 'ImportSpecifier' ? s.imported.name || s.imported.value + : null; + const instance = imported && instances.get(imported); if (!instance) continue; - const local = (s.local && s.local.name) || imported; + const local = s.local.name; const { className, prefixed } = instance; // another binding already has the class's name if ( @@ -590,10 +628,11 @@ function importedInstances(j, root, filePath) { 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 }); } }); - return { createToClass, prefixedClasses, renames }; + return { createToClass, prefixedClasses, intercepted, renames }; } // --- 3. Transform direct axios/instance method calls --- @@ -1166,7 +1205,7 @@ function transformDirectCalls( root, axiosLocalName, axiosImportScope, - { createToClass, prefixedClasses }, + { createToClass, prefixedClasses, intercepted: importedIntercepted = [] }, isTS, ) { let dirty = false; @@ -1186,7 +1225,8 @@ function transformDirectCalls( }) .nodes() .map(node => node.object.object.name) - .filter(name => callTargets.has(name)), + .filter(name => callTargets.has(name)) + .concat([...importedIntercepted]), ); // innermost calls first, so a call nested in another call's args converts @@ -1339,6 +1379,20 @@ function markLeftovers( .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 = parent.exported.name || parent.exported.value; + 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 = parent.type === 'MemberExpression' && parent.object === p.node ? memberName(parent) @@ -1452,6 +1506,7 @@ module.exports = function transformer(fileInfo, api) { createResult.createToClass.set(name, scopes); for (const className of imported.prefixedClasses) createResult.prefixedClasses.add(className); + createResult.intercepted = imported.intercepted; const calls = transformDirectCalls( j, @@ -1476,7 +1531,10 @@ module.exports = function transformer(fileInfo, api) { // after the calls, which look the instance up by its imported name for (const { declaration, specifier, className } of imported.renames) { declaration.specifiers = declaration.specifiers.map(s => - s === specifier ? j.importSpecifier(j.identifier(className)) : s, + s !== specifier ? s + : s.type === 'ImportDefaultSpecifier' ? + j.importDefaultSpecifier(j.identifier(className)) + : j.importSpecifier(j.identifier(className)), ); dirty = true; } From 16c9dea29cb0d2b877b737d7a8332c7f1703551c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:59:48 +0000 Subject: [PATCH 25/41] fix(codemods): Import an aliased instance export under its alias `export { api as client }` becomes `export { ApiEndpoint as client }`, so importers now get `import { client as ApiEndpoint }` instead of an `ApiEndpoint` the module doesn't export. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 24 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 20 +++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) 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 1512fbef0d5e..35cd066000dd 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1708,6 +1708,30 @@ export { ApiEndpoint };`.trim(), ); }); + 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'; diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 44be646a3649..58b1827b481c 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -547,6 +547,8 @@ function exportedInstances(j, file) { 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), }); @@ -577,6 +579,7 @@ function exportedInstances(j, file) { if (!found.has(candidate)) found.set(candidate, { className: local, + exportName: exported, prefixed: classes.get(local), intercepted: [name, name.charAt(0).toLowerCase() + name.slice(1)] .filter(c => deriveClassName(c) === local) @@ -629,7 +632,12 @@ function importedInstances(j, root, filePath) { 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 }); + renames.push({ + declaration: importPath.node, + specifier: s, + className, + exportName: instance.exportName, + }); } }); return { createToClass, prefixedClasses, intercepted, renames }; @@ -1529,12 +1537,18 @@ module.exports = function transformer(fileInfo, api) { ) || dirty; // after the calls, which look the instance up by its imported name - for (const { declaration, specifier, className } of imported.renames) { + 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)) - : j.importSpecifier(j.identifier(className)), + : exportName === className ? j.importSpecifier(j.identifier(className)) + : j.importSpecifier(j.identifier(exportName), j.identifier(className)), ); dirty = true; } From 4f0e291d30e642f0a06a191f1a7a8eda57ddadab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:11:52 +0000 Subject: [PATCH 26/41] fix(codemods): Follow tsconfig path aliases to an axios instance's module Apps often import their client through an alias like `@/lib/api-client`, so calls through it were left as axios. The codemod now resolves imports with the nearest tsconfig.json or jsconfig.json `paths` and `baseUrl`. Found running the migration on bulletproof-react. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 21 ++++ .../codemods/src/transforms/axios-to-rest.js | 98 +++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) 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 35cd066000dd..82564f7b75c8 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1764,6 +1764,27 @@ 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'; diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 58b1827b481c..c120b02669fb 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -457,12 +457,11 @@ function transformAxiosCreate(j, root, axiosLocalName, isTS) { // 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(); -// a relative import and a member read that an instance could have +// 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/; + /from\s*['"][\s\S]*\.\s*(?:get|post|put|patch|delete|interceptors|defaults)\b/; -function resolveModule(fromFile, spec) { - const base = nodePath.resolve(nodePath.dirname(fromFile), spec); +function findFile(base) { const stem = base.replace(/\.[cm]?jsx?$/, ''); const exts = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']; const candidates = [ @@ -479,6 +478,91 @@ function resolveModule(fromFile, spec) { }); } +// 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 || {}; +} + +function resolveModule(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.replace('*', 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; +} + // 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 @@ -589,7 +673,7 @@ function exportedInstances(j, file) { return found; } -// named imports of axios instances from relative modules; renames each +// 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(); @@ -603,9 +687,7 @@ function importedInstances(j, root, filePath) { .find(j.ImportDeclaration) .filter( p => - p.node.importKind !== 'type' && - typeof p.node.source.value === 'string' && - p.node.source.value.startsWith('.'), + p.node.importKind !== 'type' && typeof p.node.source.value === 'string', ) .forEach(importPath => { const file = resolveModule(filePath, importPath.node.source.value); From 21589e15c4bf72d98efc6e6f22987db3caf27ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:14:34 +0000 Subject: [PATCH 27/41] docs(skills): Handle 401 in fetchResponse with try/catch super.fetchResponse() throws a NetworkError for non-2xx statuses, so a status check after it never ran. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 14 ++++++++------ .../references/data-client-rest-setup.md | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 175013079ce5..112bbf3af559 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -202,13 +202,15 @@ process(value: any, ...args: any[]) { ```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')); + try { + return await super.fetchResponse(input, init); + } catch (error: any) { + // super.fetchResponse() throws a NetworkError for any non-2xx status + if (error.status === 401) { + window.dispatchEvent(new CustomEvent('auth:expired')); + } + throw error; } - - return response; } ``` 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 3528e9333f13..a4709f5f66b6 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 @@ -198,13 +198,15 @@ process(value: any, ...args: any[]) { ```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')); + try { + return await super.fetchResponse(input, init); + } catch (error: any) { + // super.fetchResponse() throws a NetworkError for any non-2xx status + if (error.status === 401) { + window.dispatchEvent(new CustomEvent('auth:expired')); + } + throw error; } - - return response; } ``` From d5ce84464c97379ee12dd3e85d90a7b5235ff746 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:15:30 +0000 Subject: [PATCH 28/41] docs(skills): Guard envelope unwrap against 204 null bodies A Haiku eval run copied the example and broke deleteProduct, whose 204 response parses to null. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 3 ++- .../data-client-setup/references/data-client-rest-setup.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 112bbf3af559..5e4b810cf789 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -194,7 +194,8 @@ Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios ```ts process(value: any, ...args: any[]) { - return value.data; + // value is null for a 204 No Content response + return value?.data; } ``` 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 a4709f5f66b6..af2a827af359 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 @@ -190,7 +190,8 @@ Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios ```ts process(value: any, ...args: any[]) { - return value.data; + // value is null for a 204 No Content response + return value?.data; } ``` From df8ac4b443ac297e5d827da6204a680b3d120338 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:17:00 +0000 Subject: [PATCH 29/41] fix(codemods): Substitute tsconfig path wildcards literally String.replace expanded $ patterns in the matched module path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- packages/codemods/src/transforms/axios-to-rest.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index c120b02669fb..a018acfba640 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -555,7 +555,9 @@ function resolveModule(fromFile, spec) { rest = spec.slice(prefix.length, spec.length - suffix.length); } for (const target of targets) { - const file = findFile(nodePath.resolve(root, target.replace('*', rest))); + const file = findFile( + nodePath.resolve(root, target.split('*').join(rest)), + ); if (file) return file; } } From 91bd240925a71816703256d0fbcaef15a4e79fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:23:33 +0000 Subject: [PATCH 30/41] fix(codemods): Keep repeated path reads separate after an effectful interpolation Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 21 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) 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 82564f7b75c8..19ece8da25ca 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1425,6 +1425,27 @@ const get = (a, b) => new RestEndpoint({ {}, ` 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 api = axios.create({ baseURL: 'https://api.example.com/v1/' }); const getUser = id => api.get(\`/users/\${id}\`); `, diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index a018acfba640..38c0205a51f9 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -882,6 +882,8 @@ function toPath(j, urlArg) { const args = []; const names = new Map(); + // one effectful interpolation can change what a repeated one reads + const dedupe = expressions.slice(0, pathExprCount).every(isPure); let path = ''; const segments = pathPart.split('\0'); for (let i = 0; i < segments.length; i++) { @@ -895,7 +897,7 @@ function toPath(j, urlArg) { const expr = expressions[i]; let name = memberName(expr) || 'param'; // the same variable can fill two segments; anything else is evaluated once each - const source = isPure(expr) ? j(expr).toSource() : {}; + const source = dedupe ? j(expr).toSource() : {}; if (names.has(name) && names.get(name) !== source) { let n = 2; while (names.has(name + n)) n++; From e1c8647c50e144e184aad6c505fb876461068b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:33:01 +0000 Subject: [PATCH 31/41] docs(skills): Trim data-client-rest-setup to what agents get wrong Haiku evals on a 27-call shop app and bulletproof-react scored the same with this version as with the full one (81/81 and 14/14 hidden request checks), at a third of the length. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 312 +++--------------- .../references/data-client-rest-setup.md | 312 +++--------------- 2 files changed, 86 insertions(+), 538 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 5e4b810cf789..ea4e8c7a0472 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,26 +6,13 @@ 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 when the project calls REST APIs. It only defines endpoints and schemas, so it doesn't need the Data Client provider; leave how the app loads and stores data alone unless the user asks to change it. +Configures `@data-client/rest` for a project, fresh or migrating from another HTTP library. It 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: - -```bash -# npm -npm install @data-client/rest - -# yarn -yarn add @data-client/rest - -# pnpm -pnpm add @data-client/rest -``` - -`@data-client/rest` ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`), at the installed version. Link them from the project root: +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 npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills @@ -39,289 +26,76 @@ npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-s After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. ``` -## 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 [Axios migration](references/axios-migration.md) | -| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [Fetch migration](references/fetch-migration.md) | -| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [Ky migration](references/ky-migration.md) | -| `"superagent"` in dependencies | **SuperAgent** | Follow [SuperAgent migration](references/superagent-migration.md) | -| `"got"` in dependencies (rare in browser code) | **Got** | Follow [Got migration](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**: +## Step 2: Migrate each HTTP library -> 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) +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. -### Mixed Codebases +| 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 | -When multiple HTTP libraries are detected, run each sub-procedure on the relevant files. The sub-procedures are independent and don't conflict: +Axios: -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 +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. -## Migration References +## Step 3: Base endpoint class -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** → [Axios migration](references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress - 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. - 2. Run its 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 and drops `.data`. - 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. -- **Raw fetch** → [Fetch migration](references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling -- **Ky** → [Ky migration](references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config -- **SuperAgent** → [SuperAgent migration](references/superagent-migration.md) — chained API, plugins, agents, file uploads -- **Got** → [Got migration](references/got-migration.md) — Node.js patterns, hooks, pagination, retry - -## Step 3: Custom RestEndpoint Base Class - -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. If a migration already made one (the axios codemod turns each `axios.create()` instance into a class), add these overrides to that class instead of creating another. - -### Detection Checklist - -Scan the existing codebase for common REST patterns to include: - -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) - -### Base Class Template - -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}` }), - }; - } -} -``` - -## Common Lifecycle Overrides - -Include these based on what's detected in the codebase. See [RestEndpoint](references/RestEndpoint.md) for full API documentation. - -### Authentication (async token refresh) - -```ts -async getHeaders(headers: HeadersInit): Promise { - const token = await getValidToken(); // handles refresh - return { - ...headers, - Authorization: `Bearer ${token}`, - }; -} -``` - -### Authentication from React context (Okta, Auth0) - -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: - -```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) - -Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios's `response.data` is axios's wrapper around the body, not an envelope, and the axios codemod already removed it; unwrapping again would strip a real `data` field. - -```ts -process(value: any, ...args: any[]) { - // value is null for a 204 No Content response - return value?.data; -} -``` - -### Custom Error Handling - -```ts -async fetchResponse(input: RequestInfo, init: RequestInit): Promise { - try { - return await super.fetchResponse(input, init); - } catch (error: any) { - // super.fetchResponse() throws a NetworkError for any non-2xx status - if (error.status === 401) { - window.dispatchEvent(new CustomEvent('auth:expired')); - } - throw error; + return { ...headers, ...(token && { Authorization: `Bearer ${token}` }) }; } } ``` -### Custom Search Params (using qs library) +Override only what the app does today: -```ts -searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); -} -``` +| 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)` | +| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }` | `process(value) { return value?.data; }` (`value` is null for a 204). Only for a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed | +| 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 | +| Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | +| Non-JSON responses | `parseResponse(response)`, falling back to `super.parseResponse(response)` | -### Custom parseResponse (handle non-JSON) +## Defining endpoints -```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 +**`resource()`** for standard CRUD on one path (`get`, `getList`, `create`, `update`, `partialUpdate`, `delete`): ```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' }); - } -} - -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 or Yup, keep that validation by parsing in `process()` and add an Entity `schema` for normalization; skip the Entity only for responses that don't need normalization (auth tokens, one-off responses). The [Axios migration](references/axios-migration.md) reference shows each option under "Coexisting with Zod or Yup". - -## 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`: the endpoint only sends a body when `body` is set. -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. Only when the user asks to move components onto the store: 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-setup/references/data-client-rest-setup.md b/.agents/skills/data-client-setup/references/data-client-rest-setup.md index af2a827af359..6c40c3d997d4 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,26 +2,13 @@ # 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 when the project calls REST APIs. It only defines endpoints and schemas, so it doesn't need the Data Client provider; leave how the app loads and stores data alone unless the user asks to change it. +Configures `@data-client/rest` for a project, fresh or migrating from another HTTP library. It 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: - -```bash -# npm -npm install @data-client/rest - -# yarn -yarn add @data-client/rest - -# pnpm -pnpm add @data-client/rest -``` - -`@data-client/rest` ships the skills this setup uses next (`data-client-rest`, and `data-client-schema` from `@data-client/endpoint`), at the installed version. Link them from the project root: +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 npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-skills @@ -35,289 +22,76 @@ npx --no data-client-skills # pnpm exec data-client-skills, yarn data-client-s After adding, removing or upgrading `@data-client/*` packages, run `npx --no data-client-skills` and commit the skill links. ``` -## 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 [Axios migration](./data-client-rest-setup/references/axios-migration.md) | -| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [Fetch migration](./data-client-rest-setup/references/fetch-migration.md) | -| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [Ky migration](./data-client-rest-setup/references/ky-migration.md) | -| `"superagent"` in dependencies | **SuperAgent** | Follow [SuperAgent migration](./data-client-rest-setup/references/superagent-migration.md) | -| `"got"` in dependencies (rare in browser code) | **Got** | Follow [Got migration](./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**: +## Step 2: Migrate each HTTP library -> 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) +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. -### Mixed Codebases +| 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 | -When multiple HTTP libraries are detected, run each sub-procedure on the relevant files. The sub-procedures are independent and don't conflict: +Axios: -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 +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. -## Migration References +## Step 3: Base endpoint class -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** → [Axios migration](./data-client-rest-setup/references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress - 1. Check `git status` is clean (commit or ask first), so the codemod's changes can be reviewed on their own. - 2. Run its 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 and drops `.data`. - 3. Find what it left: 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 (base endpoint, entities and resources, call sites, error handling). 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. -- **Raw fetch** → [Fetch migration](./data-client-rest-setup/references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling -- **Ky** → [Ky migration](./data-client-rest-setup/references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config -- **SuperAgent** → [SuperAgent migration](./data-client-rest-setup/references/superagent-migration.md) — chained API, plugins, agents, file uploads -- **Got** → [Got migration](./data-client-rest-setup/references/got-migration.md) — Node.js patterns, hooks, pagination, retry - -## Step 3: Custom RestEndpoint Base Class - -After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. If a migration already made one (the axios codemod turns each `axios.create()` instance into a class), add these overrides to that class instead of creating another. - -### Detection Checklist - -Scan the existing codebase for common REST patterns to include: - -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) - -### Base Class Template - -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}` }), - }; - } -} -``` - -## Common Lifecycle Overrides - -Include these based on what's detected in the codebase. See [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) for full API documentation. - -### Authentication (async token refresh) - -```ts -async getHeaders(headers: HeadersInit): Promise { - const token = await getValidToken(); // handles refresh - return { - ...headers, - Authorization: `Bearer ${token}`, - }; -} -``` - -### Authentication from React context (Okta, Auth0) - -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: - -```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) - -Only when the JSON body itself is wrapped, like `{ "data": { "id": 1 } }`. axios's `response.data` is axios's wrapper around the body, not an envelope, and the axios codemod already removed it; unwrapping again would strip a real `data` field. - -```ts -process(value: any, ...args: any[]) { - // value is null for a 204 No Content response - return value?.data; -} -``` - -### Custom Error Handling - -```ts -async fetchResponse(input: RequestInfo, init: RequestInit): Promise { - try { - return await super.fetchResponse(input, init); - } catch (error: any) { - // super.fetchResponse() throws a NetworkError for any non-2xx status - if (error.status === 401) { - window.dispatchEvent(new CustomEvent('auth:expired')); - } - throw error; + return { ...headers, ...(token && { Authorization: `Bearer ${token}` }) }; } } ``` -### Custom Search Params (using qs library) +Override only what the app does today: -```ts -searchToString(searchParams: Record): string { - return qs.stringify(searchParams, { arrayFormat: 'brackets' }); -} -``` +| 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)` | +| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }` | `process(value) { return value?.data; }` (`value` is null for a 204). Only for a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed | +| 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 | +| Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | +| Non-JSON responses | `parseResponse(response)`, falling back to `super.parseResponse(response)` | -### Custom parseResponse (handle non-JSON) +## Defining endpoints -```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 +**`resource()`** for standard CRUD on one path (`get`, `getList`, `create`, `update`, `partialUpdate`, `delete`): ```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' }); - } -} - -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 or Yup, keep that validation by parsing in `process()` and add an Entity `schema` for normalization; skip the Entity only for responses that don't need normalization (auth tokens, one-off responses). The [Axios migration](./data-client-rest-setup/references/axios-migration.md) reference shows each option under "Coexisting with Zod or Yup". - -## 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`: the endpoint only sends a body when `body` is set. -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. Only when the user asks to move components onto the store: 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). From 5aebf20f5fb5ff012656d1eb347264cdeb0207a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:50:17 +0000 Subject: [PATCH 32/41] docs(skills): Restore rest-setup guidance on request body types and response transforms Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 2 ++ .../data-client-setup/references/data-client-rest-setup.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index ea4e8c7a0472..b80ef1af267b 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -75,6 +75,8 @@ Override only what the app does today: | 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 | | Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | | Non-JSON responses | `parseResponse(response)`, falling back to `super.parseResponse(response)` | +| Non-JSON request bodies (form-data, urlencoded) | Nothing: a plain object or array `body` is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is | +| Response transforms, like case conversion | `process(value)`. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | ## Defining endpoints 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 6c40c3d997d4..27d5afdc7055 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 @@ -71,6 +71,8 @@ Override only what the app does today: | 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 | | Arrays or nested objects in the query | `searchToString(searchParams)`, e.g. `qs.stringify(searchParams, { arrayFormat: 'brackets' })` | | Non-JSON responses | `parseResponse(response)`, falling back to `super.parseResponse(response)` | +| Non-JSON request bodies (form-data, urlencoded) | Nothing: a plain object or array `body` is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is | +| Response transforms, like case conversion | `process(value)`. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | ## Defining endpoints From 0ea4372eaa1d123d8c7070eb14eccad5b851bae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:58:30 +0000 Subject: [PATCH 33/41] fix(codemods): Share a repeated path param unless an effect runs between its reads Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 20 +++++++++++++++++++ .../codemods/src/transforms/axios-to-rest.js | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) 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 19ece8da25ca..396b2e914312 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -1446,6 +1446,26 @@ const get = id => new RestEndpoint({ {}, ` 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}\`); `, diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index 38c0205a51f9..da1e65e7a6a4 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -882,8 +882,6 @@ function toPath(j, urlArg) { const args = []; const names = new Map(); - // one effectful interpolation can change what a repeated one reads - const dedupe = expressions.slice(0, pathExprCount).every(isPure); let path = ''; const segments = pathPart.split('\0'); for (let i = 0; i < segments.length; i++) { @@ -897,7 +895,7 @@ function toPath(j, urlArg) { const expr = expressions[i]; let name = memberName(expr) || 'param'; // the same variable can fill two segments; anything else is evaluated once each - const source = dedupe ? j(expr).toSource() : {}; + const source = isPure(expr) ? j(expr).toSource() : {}; if (names.has(name) && names.get(name) !== source) { let n = 2; while (names.has(name + n)) n++; @@ -913,6 +911,8 @@ function toPath(j, urlArg) { }), ); } + // an effect can change what a later read of an earlier variable sees + if (!isSimple(expr)) for (const key of names.keys()) names.set(key, {}); path += ':' + name; } if (!path) path = '/'; From e86d5134c7114acf600bb3283156a9d2d6cbdc63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:59:02 +0000 Subject: [PATCH 34/41] docs(skills): Tighten rest-setup's override table Merge the two process() rows, point non-JSON responses at the content option, and link the fetchResponse error example. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 12 ++++++------ .../references/data-client-rest-setup.md | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index b80ef1af267b..905552cc39d5 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,7 +6,7 @@ disable-model-invocation: true # REST Protocol Setup & Migration -Configures `@data-client/rest` for a project, fresh or migrating from another HTTP library. It 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. +This setup 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. APIs: [RestEndpoint](references/RestEndpoint.md), [resource](references/resource.md). Vue projects: read `.vue.md` instead of `.md` when it exists. @@ -71,12 +71,12 @@ Override only what the app does today: | 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)` | -| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }` | `process(value) { return value?.data; }` (`value` is null for a 204). Only for a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed | -| 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 | +| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }`, or other transforms like case conversion | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Unwrap only a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed. Parse date fields in the Entity's `static schema` instead (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 | `parseResponse(response)`, falling back to `super.parseResponse(response)` | -| Non-JSON request bodies (form-data, urlencoded) | Nothing: a plain object or array `body` is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is | -| Response transforms, like case conversion | `process(value)`. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | +| Non-JSON responses (files, text) | The `content` option (`'blob'`, `'text'`, `'arrayBuffer'`, `'stream'`); `parseResponse(response)` only for anything else | + +Request bodies need no override: a plain object or array is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is. ## Defining endpoints 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 27d5afdc7055..8d3fd3e6eb21 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,7 +2,7 @@ # REST Protocol Setup & Migration -Configures `@data-client/rest` for a project, fresh or migrating from another HTTP library. It 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. +This setup 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. 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. @@ -67,12 +67,12 @@ Override only what the app does today: | 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)` | -| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }` | `process(value) { return value?.data; }` (`value` is null for a 204). Only for a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed | -| 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 | +| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }`, or other transforms like case conversion | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Unwrap only a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed. Parse date fields in the Entity's `static schema` instead (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 | `parseResponse(response)`, falling back to `super.parseResponse(response)` | -| Non-JSON request bodies (form-data, urlencoded) | Nothing: a plain object or array `body` is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is | -| Response transforms, like case conversion | `process(value)`. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | +| Non-JSON responses (files, text) | The `content` option (`'blob'`, `'text'`, `'arrayBuffer'`, `'stream'`); `parseResponse(response)` only for anything else | + +Request bodies need no override: a plain object or array is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is. ## Defining endpoints From 26271c7f2d32010a4b76e19c28b30d06f8900446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:31:52 +0000 Subject: [PATCH 35/41] refactor: Simplify codemod param sharing and rest-setup override rows Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../skills/data-client-rest-setup/SKILL.md | 9 +++---- .../references/data-client-rest-setup.md | 9 +++---- .../codemods/src/transforms/axios-to-rest.js | 24 +++++++++---------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 905552cc39d5..c9e1cd4f17f6 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,7 +6,7 @@ disable-model-invocation: true # REST Protocol Setup & Migration -This setup 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. +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. APIs: [RestEndpoint](references/RestEndpoint.md), [resource](references/resource.md). Vue projects: read `.vue.md` instead of `.md` when it exists. @@ -71,12 +71,13 @@ Override only what the app does today: | 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)` | -| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }`, or other transforms like case conversion | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Unwrap only a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | +| 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` option (`'blob'`, `'text'`, `'arrayBuffer'`, `'stream'`); `parseResponse(response)` only for anything else | +| Non-JSON responses (files, text) | The [`content`](references/RestEndpoint.md#content) option, e.g. `content: 'blob'`; otherwise `parseResponse(response)` | -Request bodies need no override: a plain object or array is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is. +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)). ## Defining endpoints 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 8d3fd3e6eb21..bcacd0c7aa30 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,7 +2,7 @@ # REST Protocol Setup & Migration -This setup 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. +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. 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. @@ -67,12 +67,13 @@ Override only what the app does today: | 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)` | -| JSON body wrapped in an envelope, like `{ "data": {...}, "meta": {...} }`, or other transforms like case conversion | `process(value)`, e.g. `return value?.data` (`value` is null for a 204). Unwrap only a real envelope: axios's `response.data` is axios's wrapper, which the codemod already removed. Parse date fields in the Entity's `static schema` instead (e.g. `createdAt: Temporal.Instant.from`; skill "data-client-schema") | +| 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` option (`'blob'`, `'text'`, `'arrayBuffer'`, `'stream'`); `parseResponse(response)` only for anything else | +| Non-JSON responses (files, text) | The [`content`](./data-client-rest-setup/references/RestEndpoint.md#content) option, e.g. `content: 'blob'`; otherwise `parseResponse(response)` | -Request bodies need no override: a plain object or array is sent as JSON, while `FormData`, `URLSearchParams`, `Blob` or a string is passed to `fetch()` as-is. +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)). ## Defining endpoints diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index da1e65e7a6a4..af97d9fb355c 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -881,7 +881,9 @@ function toPath(j, urlArg) { const pathExprCount = (pathPart.match(/\0/g) || []).length; const args = []; - const names = new Map(); + 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'); for (let i = 0; i < segments.length; i++) { @@ -893,16 +895,14 @@ function toPath(j, urlArg) { const next = segments[i + 1]; if (!path.endsWith('/') || (next && !next.startsWith('/'))) return null; const expr = expressions[i]; - let name = memberName(expr) || 'param'; - // the same variable can fill two segments; anything else is evaluated once each - const source = isPure(expr) ? j(expr).toSource() : {}; - if (names.has(name) && names.get(name) !== source) { - let n = 2; - while (names.has(name + n)) n++; - name = name + n; - } - if (!names.has(name)) { - names.set(name, source); + 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), @@ -912,7 +912,7 @@ function toPath(j, urlArg) { ); } // an effect can change what a later read of an earlier variable sees - if (!isSimple(expr)) for (const key of names.keys()) names.set(key, {}); + if (!isSimple(expr)) shared.clear(); path += ':' + name; } if (!path) path = '/'; From a784016241419bd58cc16e07a6826c3ff0b293d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:33:30 +0000 Subject: [PATCH 36/41] Tighten router's HTTP-client-only pointer Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client/SKILL.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.agents/skills/data-client/SKILL.md b/.agents/skills/data-client/SKILL.md index 999190ebf752..854af3505d2c 100644 --- a/.agents/skills/data-client/SKILL.md +++ b/.agents/skills/data-client/SKILL.md @@ -15,9 +15,8 @@ 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 replace an HTTP client (axios, fetch, ky, superagent, got) with `@data-client/rest` endpoints, follow -`../data-client-setup/references/data-client-rest-setup.md` instead. It needs no provider and leaves how the app loads -and stores data alone. +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 From b5453a22665cfb085a817df78464a9a70562e32b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:38:36 +0000 Subject: [PATCH 37/41] docs(axios): show a swapped path param in Why migrate Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 16 ++++++++++++---- .../references/axios-migration.vue.md | 16 ++++++++++++---- .../references/axios-migration.md | 16 ++++++++++++---- .../references/axios-migration.vue.md | 16 ++++++++++++---- docs/rest/guides/axios-migration.md | 16 ++++++++++++---- 5 files changed, 60 insertions(+), 20 deletions(-) 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 7e2c5cd7660b..4a999888650b 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -8,15 +8,23 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. +Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. ```ts title="Before" -axios.get(`/users/${usrId}`); +const getComment = (postId: string, commentId: string) => + axios.get(`/posts/${postId}/comments/${commentId}`); + +// callout: Swapped arguments still compile, and fetch the wrong comment. +getComment(comment.id, post.id); ``` ```ts title="After" -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -getUser({ id: '1' }); +const getComment = new RestEndpoint({ + path: '/posts/:postId/comments/:commentId', + schema: Comment, +}); +// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. +getComment({ commentId: comment.id, postId: post.id }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index e826082f7cc2..06b72f5f26a3 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -8,15 +8,23 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. +Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. ```ts title="Before" -axios.get(`/users/${usrId}`); +const getComment = (postId: string, commentId: string) => + axios.get(`/posts/${postId}/comments/${commentId}`); + +// callout: Swapped arguments still compile, and fetch the wrong comment. +getComment(comment.id, post.id); ``` ```ts title="After" -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -getUser({ id: '1' }); +const getComment = new RestEndpoint({ + path: '/posts/:postId/comments/:commentId', + schema: Comment, +}); +// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. +getComment({ commentId: comment.id, postId: post.id }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 7e2c5cd7660b..4a999888650b 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 @@ -8,15 +8,23 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. +Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. ```ts title="Before" -axios.get(`/users/${usrId}`); +const getComment = (postId: string, commentId: string) => + axios.get(`/posts/${postId}/comments/${commentId}`); + +// callout: Swapped arguments still compile, and fetch the wrong comment. +getComment(comment.id, post.id); ``` ```ts title="After" -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -getUser({ id: '1' }); +const getComment = new RestEndpoint({ + path: '/posts/:postId/comments/:commentId', + schema: Comment, +}); +// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. +getComment({ commentId: comment.id, postId: post.id }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index e826082f7cc2..06b72f5f26a3 100644 --- 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 @@ -8,15 +8,23 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are typed from the `path` template, so a typo is a compile error instead of a wrong URL. +Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. ```ts title="Before" -axios.get(`/users/${usrId}`); +const getComment = (postId: string, commentId: string) => + axios.get(`/posts/${postId}/comments/${commentId}`); + +// callout: Swapped arguments still compile, and fetch the wrong comment. +getComment(comment.id, post.id); ``` ```ts title="After" -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -getUser({ id: '1' }); +const getComment = new RestEndpoint({ + path: '/posts/:postId/comments/:commentId', + schema: Comment, +}); +// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. +getComment({ commentId: comment.id, postId: post.id }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 42f61b1be560..5da8128f01e0 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -46,15 +46,23 @@ management as they are, unless the user asks to change them. ## Why migrate? - + ```ts title="Before" -axios.get(`/users/${usrId}`); +const getComment = (postId: string, commentId: string) => + axios.get(`/posts/${postId}/comments/${commentId}`); + +// callout: Swapped arguments still compile, and fetch the wrong comment. +getComment(comment.id, post.id); ``` ```ts title="After" -const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); -getUser({ id: '1' }); +const getComment = new RestEndpoint({ + path: '/posts/:postId/comments/:commentId', + schema: Comment, +}); +// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. +getComment({ commentId: comment.id, postId: post.id }); ``` From 816c82725470a29d776d003de900bd54622b98bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:44:11 +0000 Subject: [PATCH 38/41] refactor(codemods): simplify axios-to-rest helpers Share the response-interceptor query, cache module resolution per folder, add memberOf/specName helpers, drop a dead export walk in markTodo, and use jscodeshift's applyTransform in the multi-file tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../__tests__/axios-to-rest.node.js | 13 +- .../codemods/src/transforms/axios-to-rest.js | 177 ++++++++---------- 2 files changed, 82 insertions(+), 108 deletions(-) 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 396b2e914312..892c2c81ad41 100644 --- a/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -2,6 +2,7 @@ const fs = require('fs'); const { + applyTransform, defineInlineTest, runInlineTest, } = require('jscodeshift/dist/testUtils'); @@ -1638,12 +1639,14 @@ api.interceptors.request.use(config => config); 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); - const filePath = path.join(dir, file); - return transform( - { path: filePath, source: files[file] }, - { jscodeshift: require('jscodeshift').withParser('tsx'), stats() {} }, + return applyTransform( + transform, {}, - ).trim(); + { + path: path.join(dir, file), + source: files[file], + }, + ); }; const posts = ` import { api } from './client'; diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js index af97d9fb355c..72d31d713163 100644 --- a/packages/codemods/src/transforms/axios-to-rest.js +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -126,20 +126,12 @@ function readsOf(j, within, name, scope, options) { .filter(p => p.scope.lookup(name) === scope && isRead(p, options)); } -function hasReference(j, root, localName, importScope, excludeTypePositions) { - return ( - readsOf(j, root, localName, importScope, { - types: !excludeTypePositions, - }).size() > 0 - ); -} - 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 ensureRestImports(j, root, names) { @@ -161,7 +153,7 @@ function ensureRestImports(j, root, names) { s.imported && s.importKind !== 'type' ) - imported.add(s.imported.name || s.imported.value); + imported.add(specName(s.imported)); } }); const missing = names.filter(name => !imported.has(name)); @@ -230,7 +222,7 @@ function transformImports( // axios stays, but drop the types nothing reads anymore const used = importPath.node.specifiers.filter(s => { if (s.type !== 'ImportSpecifier') return true; - const importedName = s.imported.name || s.imported.value; + const importedName = specName(s.imported); if (s.importKind !== 'type' && !AXIOS_TYPE_IMPORTS.has(importedName)) return true; const localName = (s.local && s.local.name) || importedName; @@ -248,7 +240,7 @@ function transformImports( 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)) { @@ -537,7 +529,15 @@ function aliasConfig(fromFile) { 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); @@ -565,6 +565,23 @@ function resolveModule(fromFile, spec) { 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 @@ -591,10 +608,7 @@ function exportedInstances(j, file) { } for (const spec of specifiers || []) if (spec.local && spec.exported) - exportsOf.set( - spec.exported.name || spec.exported.value, - spec.local.name || spec.local.value, - ); + exportsOf.set(specName(spec.exported), specName(spec.local)); }); root.find(j.ExportDefaultDeclaration).forEach(p => { const decl = p.node.declaration; @@ -603,17 +617,8 @@ function exportedInstances(j, file) { exportsOf.set('default', decl.id.name); }); // a response interceptor can change what `.data` means at each call - const intercepts = name => - root - .find(j.MemberExpression, { - object: { - type: 'MemberExpression', - object: { type: 'Identifier', name }, - property: { name: 'interceptors' }, - }, - property: { name: 'response' }, - }) - .size() > 0; + const interceptedNames = responseInterceptorTargets(j, root); + const intercepts = name => interceptedNames.has(name); const axiosDefault = root .find(j.ImportDeclaration, { source: { value: 'axios' } }) @@ -653,23 +658,22 @@ function exportedInstances(j, file) { 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, under the name the rewrite derived from it + // the instance's place const candidates = - exported !== local ? - [exported] - : [name, name.charAt(0).toLowerCase() + name.slice(1)].filter( - c => deriveClassName(c) === local && !exportsOf.has(c), - ); + 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: [name, name.charAt(0).toLowerCase() + name.slice(1)] - .filter(c => deriveClassName(c) === local) - .some(intercepts), + intercepted, }); } return found; @@ -700,7 +704,7 @@ function importedInstances(j, root, filePath) { if (s.importKind === 'type') continue; const imported = s.type === 'ImportDefaultSpecifier' ? 'default' - : s.type === 'ImportSpecifier' ? s.imported.name || s.imported.value + : s.type === 'ImportSpecifier' ? specName(s.imported) : null; const instance = imported && instances.get(imported); if (!instance) continue; @@ -777,6 +781,16 @@ function reordersEffects(moved, crossed) { ); } +/** 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 ( @@ -878,7 +892,6 @@ function toPath(j, urlArg) { const queryStart = joined.indexOf('?'); const pathPart = queryStart === -1 ? joined : joined.slice(0, queryStart); const queryPart = queryStart === -1 ? '' : joined.slice(queryStart + 1); - const pathExprCount = (pathPart.match(/\0/g) || []).length; const args = []; const taken = new Set(); @@ -886,6 +899,7 @@ function toPath(j, urlArg) { 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; @@ -954,13 +968,7 @@ function unwrapResponseType(annotation) { } function isDataMember(node, object) { - return ( - node.type === 'MemberExpression' && - node.object === object && - !node.computed && - node.property.type === 'Identifier' && - node.property.name === 'data' - ); + return memberOf(node, object) === 'data'; } /** Replaces each `name.data` read with `name`; false if `name` is read any other way */ @@ -1053,14 +1061,7 @@ function unwrapData(j, callPath) { return false; const param = cb.params[0]; if (param.type === 'Identifier') { - const cbPath = j(thenPath) - .find( - cb.type === 'ArrowFunctionExpression' ? - j.ArrowFunctionExpression - : j.FunctionExpression, - ) - .filter(p => p.node === cb) - .paths()[0]; + const cbPath = thenPath.get('arguments', 0); if (!dropDataReads(j, cbPath, param.name, cbPath.scope, param)) return false; const bodyType = unwrapResponseType(param.typeAnnotation); @@ -1139,13 +1140,6 @@ function markTodo(j, path, message, anchor) { ) { p = p.parent; } - if ( - p.parent && - (p.parent.node.type === 'ExportNamedDeclaration' || - p.parent.node.type === 'ExportDefaultDeclaration') - ) { - p = p.parent; - } addTodo(j, p.node, message, anchor); } @@ -1278,12 +1272,7 @@ function buildCall(j, callPath, className, prefixed, isTS) { j.objectExpression(options), ]); const params = callArgs.length ? j.objectExpression(callArgs) : null; - const invokeArgs = - data ? - params ? [params, data] - : [data] - : params ? [params] - : []; + const invokeArgs = [params, data].filter(Boolean); return { node: j.callExpression(endpoint, invokeArgs), @@ -1299,7 +1288,7 @@ function transformDirectCalls( root, axiosLocalName, axiosImportScope, - { createToClass, prefixedClasses, intercepted: importedIntercepted = [] }, + { createToClass, prefixedClasses, intercepted: importedIntercepted }, isTS, ) { let dirty = false; @@ -1307,21 +1296,12 @@ function transformDirectCalls( 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( - root - .find(j.MemberExpression, { - object: { - type: 'MemberExpression', - object: { type: 'Identifier' }, - property: { name: 'interceptors' }, - }, - property: { name: 'response' }, - }) - .nodes() - .map(node => node.object.object.name) - .filter(name => callTargets.has(name)) - .concat([...importedIntercepted]), - ); + 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 @@ -1442,6 +1422,10 @@ const AXIOS_MEMBER_TODOS = { 'use an `AbortSignal` instead of `CancelToken`', 'cancellation', ], + AxiosError: [ + 'check `NetworkError` instead of `AxiosError`', + 'error-handling', + ], }; function markLeftovers( @@ -1454,10 +1438,7 @@ function markLeftovers( let dirty = false; // calls it couldn't convert are marked already - const isMarkedCall = p => - p.parent.node.type === 'MemberExpression' && - p.parent.node.object === p.node && - HTTP_METHODS.has(memberName(p.parent.node)); + const isMarkedCall = p => HTTP_METHODS.has(memberOf(p.parent.node, p.node)); // axios instances are classes now; anything else reading them needs a hand for (const [name, scopes] of createToClass) { @@ -1476,7 +1457,7 @@ function markLeftovers( // the module exports the class now, which importers convert to if (parent.type === 'ExportSpecifier') { if (parent.local !== p.node) return; - const exported = parent.exported.name || parent.exported.value; + const exported = specName(parent.exported); parent.local = j.identifier(className); if (exported === name) parent.exported = j.identifier(className); dirty = true; @@ -1487,10 +1468,7 @@ function markLeftovers( dirty = true; return; } - const member = - parent.type === 'MemberExpression' && parent.object === p.node ? - memberName(parent) - : null; + const member = memberOf(parent, p.node); if (member === 'interceptors') { markTodo( j, @@ -1522,10 +1500,7 @@ function markLeftovers( .filter(p => !isMarkedCall(p)) .forEach(p => { const parent = p.parent.node; - const member = - parent.type === 'MemberExpression' && parent.object === p.node ? - memberName(parent) - : null; + const member = memberOf(parent, p.node); const [message, anchor] = own(AXIOS_MEMBER_TODOS, member) || [ 'replace this axios call with an endpoint', 'call-sites', @@ -1544,12 +1519,9 @@ function markLeftovers( .forEach(importPath => { for (const s of importPath.node.specifiers || []) { if (s.type !== 'ImportSpecifier' || s.importKind === 'type') continue; - const imported = s.imported.name || s.imported.value; + const imported = specName(s.imported); const local = (s.local && s.local.name) || imported; - const todo = - imported === 'AxiosError' ? - ['check `NetworkError` instead of `AxiosError`', 'error-handling'] - : own(AXIOS_MEMBER_TODOS, imported); + const todo = own(AXIOS_MEMBER_TODOS, imported); if (!todo) continue; readsOf(j, root, local, importPath.scope, { types: false }).forEach( p => { @@ -1610,7 +1582,6 @@ module.exports = function transformer(fileInfo, api) { createResult, isTS, ); - const directCallsDirty = calls.dirty; dirty = calls.dirty || calls.marked || dirty; dirty = @@ -1645,7 +1616,7 @@ module.exports = function transformer(fileInfo, api) { j, root, axiosLocalName, - createResult.dirty || directCallsDirty, + createResult.dirty || calls.dirty, isTS && createResult.dirty ? ['RestEndpoint', 'RestGenerics'] : ['RestEndpoint'], From b39af440fa556f1975eb7de6e0335e9c900ea73c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:57:33 +0000 Subject: [PATCH 39/41] skills(rest-setup): note body: undefined for POST without a body Haiku evals on a ky app: 3 of 6 skill runs called a bodyless POST endpoint with only its path params, which were sent as the body. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .agents/skills/data-client-rest-setup/SKILL.md | 2 +- .../data-client-setup/references/data-client-rest-setup.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index c9e1cd4f17f6..16cc73bbd4a4 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -91,7 +91,7 @@ export const TodoResource = resource({ }); ``` -**`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`: the endpoint only sends a body when `body` is set. +**`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. 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). 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 bcacd0c7aa30..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 @@ -87,7 +87,7 @@ export const TodoResource = resource({ }); ``` -**`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`: the endpoint only sends a body when `body` is set. +**`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. 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). From 1cf42e5422c9c71b358a5d2bb2d20eb36ea28ac9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:04:31 +0000 Subject: [PATCH 40/41] docs(axios): longer URL and no schema in Why migrate Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 22 +++++++++++-------- .../references/axios-migration.vue.md | 22 +++++++++++-------- .../references/axios-migration.md | 22 +++++++++++-------- .../references/axios-migration.vue.md | 22 +++++++++++-------- docs/rest/guides/axios-migration.md | 22 +++++++++++-------- 5 files changed, 65 insertions(+), 45 deletions(-) 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 4a999888650b..24d5e41e415d 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -8,23 +8,27 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. +The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. ```ts title="Before" -const getComment = (postId: string, commentId: string) => - axios.get(`/posts/${postId}/comments/${commentId}`); +const getComments = (userId: string, postId: string, sort: string) => + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); -// callout: Swapped arguments still compile, and fetch the wrong comment. -getComment(comment.id, post.id); +// callout: Swapped arguments still compile, and fetch another post's comments. +getComments(post.id, user.id, 'newest'); ``` ```ts title="After" -const getComment = new RestEndpoint({ - path: '/posts/:postId/comments/:commentId', - schema: Comment, +const getComments = new RestEndpoint({ + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, }); + // callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComment({ commentId: comment.id, postId: post.id }); +getComments({ postId: post.id, userId: user.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index 06b72f5f26a3..6dc43b1a095f 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -8,23 +8,27 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. +The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. ```ts title="Before" -const getComment = (postId: string, commentId: string) => - axios.get(`/posts/${postId}/comments/${commentId}`); +const getComments = (userId: string, postId: string, sort: string) => + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); -// callout: Swapped arguments still compile, and fetch the wrong comment. -getComment(comment.id, post.id); +// callout: Swapped arguments still compile, and fetch another post's comments. +getComments(post.id, user.id, 'newest'); ``` ```ts title="After" -const getComment = new RestEndpoint({ - path: '/posts/:postId/comments/:commentId', - schema: Comment, +const getComments = new RestEndpoint({ + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, }); + // callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComment({ commentId: comment.id, postId: post.id }); +getComments({ postId: post.id, userId: user.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 4a999888650b..24d5e41e415d 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 @@ -8,23 +8,27 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. +The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. ```ts title="Before" -const getComment = (postId: string, commentId: string) => - axios.get(`/posts/${postId}/comments/${commentId}`); +const getComments = (userId: string, postId: string, sort: string) => + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); -// callout: Swapped arguments still compile, and fetch the wrong comment. -getComment(comment.id, post.id); +// callout: Swapped arguments still compile, and fetch another post's comments. +getComments(post.id, user.id, 'newest'); ``` ```ts title="After" -const getComment = new RestEndpoint({ - path: '/posts/:postId/comments/:commentId', - schema: Comment, +const getComments = new RestEndpoint({ + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, }); + // callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComment({ commentId: comment.id, postId: post.id }); +getComments({ postId: post.id, userId: user.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index 06b72f5f26a3..6dc43b1a095f 100644 --- 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 @@ -8,23 +8,27 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -Path parameters are named and typed from the `path` template, so mixing them up can't request the wrong URL. +The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. ```ts title="Before" -const getComment = (postId: string, commentId: string) => - axios.get(`/posts/${postId}/comments/${commentId}`); +const getComments = (userId: string, postId: string, sort: string) => + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); -// callout: Swapped arguments still compile, and fetch the wrong comment. -getComment(comment.id, post.id); +// callout: Swapped arguments still compile, and fetch another post's comments. +getComments(post.id, user.id, 'newest'); ``` ```ts title="After" -const getComment = new RestEndpoint({ - path: '/posts/:postId/comments/:commentId', - schema: Comment, +const getComments = new RestEndpoint({ + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, }); + // callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComment({ commentId: comment.id, postId: post.id }); +getComments({ postId: post.id, userId: user.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index 5da8128f01e0..e86d814b8582 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -46,23 +46,27 @@ management as they are, unless the user asks to change them. ## Why migrate? - + ```ts title="Before" -const getComment = (postId: string, commentId: string) => - axios.get(`/posts/${postId}/comments/${commentId}`); +const getComments = (userId: string, postId: string, sort: string) => + axios.get( + `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`, + { params: { sort } }, + ); -// callout: Swapped arguments still compile, and fetch the wrong comment. -getComment(comment.id, post.id); +// callout: Swapped arguments still compile, and fetch another post's comments. +getComments(post.id, user.id, 'newest'); ``` ```ts title="After" -const getComment = new RestEndpoint({ - path: '/posts/:postId/comments/:commentId', - schema: Comment, +const getComments = new RestEndpoint({ + path: '/users/:userId/posts/:postId/comments', + searchParams: {} as { sort?: 'newest' | 'top' }, }); + // callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComment({ commentId: comment.id, postId: post.id }); +getComments({ postId: post.id, userId: user.id, sort: 'newest' }); ``` From e6cb50d6776ceb08c89c76b88a1cd08cdfe2a0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:10:52 +0000 Subject: [PATCH 41/41] docs(axios): keep the call signature the same in Why migrate Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PnnGtvQRyLnFcGsqygBK4c --- .../references/axios-migration.md | 20 +++++++++++++------ .../references/axios-migration.vue.md | 20 +++++++++++++------ .../references/axios-migration.md | 20 +++++++++++++------ .../references/axios-migration.vue.md | 20 +++++++++++++------ docs/rest/guides/axios-migration.md | 20 +++++++++++++------ 5 files changed, 70 insertions(+), 30 deletions(-) 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 24d5e41e415d..09ca8f5c6279 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -8,27 +8,35 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. +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: string, postId: string, sort: string) => +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 } }, ); -// callout: Swapped arguments still compile, and fetch another post's comments. -getComments(post.id, user.id, 'newest'); +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' }, }); -// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComments({ postId: post.id, userId: user.id, sort: 'newest' }); +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index 6dc43b1a095f..2965666de76e 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.vue.md @@ -8,27 +8,35 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. +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: string, postId: string, sort: string) => +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 } }, ); -// callout: Swapped arguments still compile, and fetch another post's comments. -getComments(post.id, user.id, 'newest'); +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' }, }); -// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComments({ postId: post.id, userId: user.id, sort: 'newest' }); +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 24d5e41e415d..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 @@ -8,27 +8,35 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. +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: string, postId: string, sort: string) => +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 } }, ); -// callout: Swapped arguments still compile, and fetch another post's comments. -getComments(post.id, user.id, 'newest'); +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' }, }); -// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComments({ postId: post.id, userId: user.id, sort: 'newest' }); +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. 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 index 6dc43b1a095f..2965666de76e 100644 --- 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 @@ -8,27 +8,35 @@ so how the app loads and stores data doesn't change; only the call shape does. A ## Why migrate? -The URL is declared once: path parameters are named, typed and encoded from the `path` template, and the rest of the arguments become the query string. +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: string, postId: string, sort: string) => +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 } }, ); -// callout: Swapped arguments still compile, and fetch another post's comments. -getComments(post.id, user.id, 'newest'); +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' }, }); -// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComments({ postId: post.id, userId: user.id, sort: 'newest' }); +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); ``` - **Typed end to end**: path parameters, bodies and responses are typed from each definition. diff --git a/docs/rest/guides/axios-migration.md b/docs/rest/guides/axios-migration.md index e86d814b8582..5566272812c0 100644 --- a/docs/rest/guides/axios-migration.md +++ b/docs/rest/guides/axios-migration.md @@ -46,27 +46,35 @@ management as they are, unless the user asks to change them. ## Why migrate? - + ```ts title="Before" -const getComments = (userId: string, postId: string, sort: string) => +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 } }, ); -// callout: Swapped arguments still compile, and fetch another post's comments. -getComments(post.id, user.id, 'newest'); +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' }, }); -// callout: Each argument is named, so order can't matter, and a missing or misspelled one is a compile error. -getComments({ postId: post.id, userId: user.id, sort: 'newest' }); +getComments({ userId: user.id, postId: post.id, sort: 'newest' }); ```