# Migrating from Axios

[`@data-client/rest`](https://dataclient.io/rest.md) 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.

To migrate an app from axios, install the REST setup skill from the project root:

```bash
npx skills add reactive/data-client --skill data-client-rest-setup -y
```

Then read `.agents/skills/data-client-rest-setup/SKILL.md` and follow it; it detects axios and follows the
[steps](#steps) below. Only replace the axios calls and their definitions: leave components, data loading and state
management as they are, unless the user asks to change them.

## Why migrate?

The URL is declared once: path parameters are typed and encoded from the `path` template, and the rest become the query string. Calls don't change.

```ts title="Before"
const getComments = ({
  userId,
  postId,
  sort,
}: {
  userId: string;
  postId: string;
  sort?: 'newest' | 'top';
}) =>
  // callout: Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type.
  axios.get(
    `/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`,
    { params: { sort } },
  );

getComments({ userId: user.id, postId: post.id, sort: 'newest' });
```

```ts title="After"
const getComments = new RestEndpoint({
  // callout: `userId` and `postId` are typed and encoded from the template, so a missing or misspelled one is a compile error.
  path: '/users/:userId/posts/:postId/comments',
  searchParams: {} as { sort?: 'newest' | 'top' },
});

getComments({ userId: user.id, postId: post.id, sort: 'newest' });
```

- **Typed end to end**: path parameters, bodies and responses are typed from each definition.
- **Less code**: [`resource()`](https://dataclient.io/rest/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()`](https://dataclient.io/docs/api/useSuspense.md) and the
  normalized cache, whenever you adopt them.

## Migrate step by step {#steps}

### 1. Install {#install}

```bash
npm install @data-client/rest
```

### 2. Run the codemod {#codemod}

A [jscodeshift](https://github.com/facebook/jscodeshift) codemod does the mechanical part. Commit first, then run it on
your source folders, never the repo root:

```bash
npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/
```

Each axios call becomes a call to an endpoint. Path params, `params` and the body become its arguments, and it resolves to the body, so `.data` goes away.

```ts title="Before"
import axios from 'axios';

const api = axios.create({ baseURL: 'https://api.example.com' });

export async function getPost(id: string) {
  const res = await api.get(`/posts/${id}`);
  return res.data;
}

export async function createPost(body: { title: string }) {
  const { data } = await api.post('/posts', body);
  return data;
}
```

```ts title="After"
import { RestEndpoint, RestGenerics } from '@data-client/rest';

class ApiEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
  urlPrefix = 'https://api.example.com';
}

export async function getPost(id: string) {
  const res = await new ApiEndpoint({ path: '/posts/:id' })({ id });
  return res;
}

export async function createPost(body: { title: string }) {
  const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })(
    body,
  );
  return data;
}
```

What it can't convert, such as interceptors, `timeout`, `isAxiosError()` or a function that returns the axios
response to its callers, gets a `// TODO(data-client):` comment linking the section below that covers it. Search for
`TODO(data-client)` and resolve each one as you go through the next steps.

If the project wraps axios in its own class or function and never calls `axios.get()` or `.post()` directly, or only
calls `axios(config)`, the codemod has little to do. Skip it and start at [step 3](#base-endpoint).

### 3. Move interceptors into a base endpoint {#base-endpoint}

Axios interceptors and instance config become methods on one `RestEndpoint` subclass that every endpoint extends. The
codemod creates this class from `axios.create()`; move the interceptors into it.

`RestEndpoint` parses JSON itself, so an interceptor that only unwraps `response.data` has nothing left to do.

```ts title="Before"
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  headers: { 'X-API-Key': 'my-key' },
});

api.interceptors.request.use(config => {
  config.headers.Authorization = `Bearer ${getToken()}`;
  return config;
});

api.interceptors.response.use(response => response.data);

export default api;
```

```ts title="After"
import { RestEndpoint, RestGenerics } from '@data-client/rest';

export default class ApiEndpoint<
  O extends RestGenerics = any,
> extends RestEndpoint<O> {
  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()`](https://dataclient.io/rest/api/RestEndpoint.md#process); see [snakes to camels](https://dataclient.io/rest/guides/network-transform.md#snakes-to-camels).

When a response interceptor returned `response.data`, a caller's `.data` read a field of the body, not axios's response,
so keep it. The codemod marks these calls when the interceptor is in the same file; if a global
`axios.interceptors.response` lives in another file, check the `.data` it removed from calls there.

#### Server error messages

Axios codebases often show the user `error.response.data.error` or `.message`. Read it from the `Response` body once,
in the base class's [`fetchResponse()`](https://dataclient.io/rest/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<O> {
  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()`](https://dataclient.io/rest/api/RestEndpoint.md#url), [`getRequestInit()`](https://dataclient.io/rest/api/RestEndpoint.md#getRequestInit),
[`fetchResponse()`](https://dataclient.io/rest/api/RestEndpoint.md#fetchResponse) and [`parseResponse()`](https://dataclient.io/rest/api/RestEndpoint.md#parseResponse):

```ts
import { RestEndpoint, RestGenerics } from '@data-client/rest';

export class LegacyEndpoint<
  O extends RestGenerics = any,
> extends RestEndpoint<O> {
  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()`](https://dataclient.io/rest/api/RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps
working.

#### Context-based auth

When tokens come from React context (Okta, Auth0) rather than storage, `getHeaders()` can't
read them. Use [`hookifyResource()`](https://dataclient.io/rest/api/hookifyResource.md) to add the headers through a
hook. See the
[authentication guide](https://dataclient.io/rest/guides/auth.md) for this and other patterns.

### 4. Define entities and resources {#entities}

The codemod makes endpoints without a `schema`. Define an [Entity](https://dataclient.io/rest/api/Entity.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()`](https://dataclient.io/rest/api/resource.md):

One `resource()` replaces a module of CRUD functions, and `schema` types every response.

```ts title="Before"
import api from './api';

export interface Post {
  id: string;
  title: string;
  body: string;
}

export const getPosts = () => api.get<Post[]>('/posts');
export const getPost = (id: string) => api.get<Post>(`/posts/${id}`);
export const createPost = (data: Omit<Post, 'id'>) =>
  api.post<Post>('/posts', data);
export const updatePost = (id: string, data: Partial<Post>) =>
  api.patch<Post>(`/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<O> {
  urlPrefix = 'https://api.example.com';

  getHeaders(headers: HeadersInit) {
    return {
      ...headers,
      'X-API-Key': 'my-key',
    };
  }
}
```

```ts title="PostResource"
import { resource } from '@data-client/rest';
import ApiEndpoint from './ApiEndpoint';
import Post from './Post';

export const PostResource = resource({
  path: '/posts/:id',
  schema: Post,
  Endpoint: ApiEndpoint,
});
```

```ts title="Usage" column
import { PostResource } from './PostResource';
PostResource.get({ id: '1' });
```

Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Keep standalone `new ApiEndpoint()` for
operations that aren't CRUD, like search, custom actions and auth.

> **Warning**
>
> Don't define Entity classes and then leave `schema` unset on the endpoints: without `schema`, responses lose their
> types, and the store can't normalize them when you adopt it.

#### Non-standard primary keys

Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity.md#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`](https://dataclient.io/rest/api/RestEndpoint.md#body)`: undefined` as having
no body argument. [`resource()`](https://dataclient.io/rest/api/resource.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()`](https://dataclient.io/rest/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 give
  types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity.md#validate) for any fields the server might
  send malformed.

- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't gain from
  normalization (auth tokens, one-off responses).

### 5. Update call sites {#call-sites}

Components and services keep their own loading and state; only the call changes.

Endpoints resolve to the parsed body, so `.data` goes away, and parameters become one object.

```ts title="Before"
import { createPost, getPost, getPosts } from './api/posts';

const { data: posts } = await getPosts();
const { data: post } = await getPost('1');
const { data: created } = await createPost({ title: 'Hello', body: '' });
```

```ts title="After"
import { PostResource } from './api/posts';

const posts = await PostResource.getList();
const post = await PostResource.get({ id: '1' });
const created = await PostResource.getList.push({
  title: 'Hello',
  body: '',
});
```

Reading these endpoints through the store, with [`useSuspense()`](https://dataclient.io/docs/api/useSuspense.md) and the normalized cache, is a
separate step you can take later; see [data dependencies](https://dataclient.io/docs/getting-started/data-dependency.md).

### 6. Migrate error handling {#error-handling}

`NetworkError` has the `status` and the raw `Response`, in place of axios's `error.response`.

```ts title="Before"
import axios from 'axios';
import { getPost } from './api/posts';

try {
  const { data } = await getPost('1');
} catch (err) {
  if (axios.isAxiosError(err)) {
    console.log(err.response?.status);
    console.log(err.response?.data);
  }
}
```

```ts title="After"
import { NetworkError } from '@data-client/rest';
import { PostResource } from './api/posts';

try {
  const post = await PostResource.get({ id: '1' });
} catch (err) {
  if (err instanceof NetworkError) {
    console.log(err.status);
    console.log(err.response);
  }
}
```

Read a server's error message from the body once, in the base endpoint; see
[server error messages](#server-error-messages).

### 7. Remove axios {#remove-axios}

Search for what still uses axios:

| Pattern                                    | Finds                     |
| ------------------------------------------ | ------------------------- |
| `TODO\(data-client\)`                      | what the codemod left     |
| `import.*from ['"]axios['"]`               | import statements         |
| `axios\.create`                            | instance creation         |
| `axios\.(get\|post\|put\|patch\|delete)`   | direct calls              |
| `\.interceptors\.(request\|response)\.use` | interceptors              |
| `isAxiosError`                             | error handling            |
| `cancelToken\|CancelToken`                 | cancellation (deprecated) |
| `onUploadProgress\|onDownloadProgress`     | progress callbacks        |

Migrate each match with the [reference](#quick-reference) below. When none are left, uninstall axios, then run the
type checker and tests:

```bash
npm uninstall axios
```

## Axios options reference {#quick-reference}

| Axios                                    | @data-client/rest                                                                                                                                                 |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseURL`                                | [`urlPrefix`](https://dataclient.io/rest/api/RestEndpoint.md#urlPrefix)                                                                                           |
| `headers` config                         | [`getHeaders()`](https://dataclient.io/rest/api/RestEndpoint.md#getHeaders)                                                                                       |
| `interceptors.request`                   | [`getHeaders()`](https://dataclient.io/rest/api/RestEndpoint.md#getHeaders) / [`getRequestInit()`](https://dataclient.io/rest/api/RestEndpoint.md#getRequestInit) |
| `interceptors.response`                  | [`parseResponse()`](https://dataclient.io/rest/api/RestEndpoint.md#parseResponse) / [`process()`](https://dataclient.io/rest/api/RestEndpoint.md#process)         |
| `transformRequest`                       | [`getRequestInit()`](https://dataclient.io/rest/api/RestEndpoint.md#getRequestInit)                                                                               |
| `transformResponse`                      | [`process()`](https://dataclient.io/rest/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`](https://dataclient.io/rest/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

Both axios's `signal` and the deprecated `CancelToken` map to an
[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`:

```ts title="Before"
const source = axios.CancelToken.source();
axios.get('/users/1', { cancelToken: source.token });
source.cancel();
```

```ts title="After"
const getUser = new ApiEndpoint({ path: '/users/:id' });
const controller = new AbortController();
// callout: `extend()` gives this one request its own signal; a `signal` set when the endpoint is created would abort all of its requests.
getUser.extend({ signal: controller.signal })({ id: '1' });
controller.abort();
```

See the [abort guide](https://dataclient.io/rest/guides/abort.md) for more patterns.

### Timeout

```ts title="Before"
axios.get('/users', { timeout: 5000 });
```

```ts title="After"
export default class ApiEndpoint<
  O extends RestGenerics = any,
> extends RestEndpoint<O> {
  // 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<string, unknown>,
  ) {
    const init = await super.getRequestInit(body);
    const timeout = AbortSignal.timeout(5000);
    // callout: Keeps `extend({ signal })` cancellation working: either signal aborts the request.
    const signal = init.signal
      ? AbortSignal.any([init.signal, timeout])
      : timeout;
    return { ...init, signal };
  }
}
```

### Binary responses

```ts title="Before"
axios.get('/files/1', { responseType: 'blob' });
```

```ts title="After"
const downloadFile = new RestEndpoint({
  path: '/files/:id',
  content: 'blob',
});
```

[`content`](https://dataclient.io/rest/api/RestEndpoint.md#content) also takes `'arrayBuffer'` and `'text'`. See
[file download](https://dataclient.io/rest/guides/network-transform.md#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<O> {
  searchToString(searchParams: Record<string, any>) {
    return qs.stringify(searchParams, {
      arrayFormat: 'repeat',
      skipNulls: true,
    });
  }
}
```

axios leaves out `null` and `undefined` params, but the default `searchToString()` uses
[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams), which sends them as the strings
`"null"` and `"undefined"`; `skipNulls` above drops them. See [`searchToString()`](https://dataclient.io/rest/api/RestEndpoint.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<O> {
  getHeaders(headers: HeadersInit) {
    return {
      ...headers,
      Authorization: `Basic ${btoa('user:pass')}`,
    };
  }
}
```

### Accepting error statuses

[`fetchResponse()`](https://dataclient.io/rest/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.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<O> {
  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()`](https://dataclient.io/rest/api/RestEndpoint.md#getHeaders) for requests that aren't `GET`. See
[Django Integration](https://dataclient.io/rest/guides/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()`](https://dataclient.io/rest/api/RestEndpoint.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<O> {
  declare onProgress?: (progress: number) => void;

  fetchResponse(input: RequestInfo, init: RequestInit) {
    return new Promise<Response>((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](https://dataclient.io/rest/guides/auth.md): token and cookie auth patterns
- [Aborting Fetch](https://dataclient.io/rest/guides/abort.md): cancellation and debouncing
- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform.md): response transforms, field renaming, file downloads
- [Django Integration](https://dataclient.io/rest/guides/django.md): CSRF and cookie auth for Django
