ofetch😱 徹底解剖 - fetch APIと何が違うの？

:::note
本記事は、[Nuxt / UnJSアドベントカレンダー](https://qiita.com/advent-calendar/2024/nuxt-and-unjs)の21日目の記事です🎅🎄
:::
# はじめに
 
オーマイフェッチ！😱　皆さんこんにちは。
自分は普段、業務でNuxt3を使ってフロントエンドの開発をしています。

Nuxt3では、`$fetch`というヘルパーを使って、**ofetch**というHTTPクライアントライブラリを呼び出せます。  
さらに、Nuxtの`useFetch`や`useAsyncData`などのデータ取得用コンポーザブルも、このofetchをベースにしています。

そんなNuxtのデータ通信で重要な役割を担うofetchですが、実際どんなところが便利なのか、どんな機能を持っているか自分でよくわかっていなかったため、標準のfetch APIと比較しつつ、その魅力を探ってみました！

# ofetchとは？

ofetchは**HTTPクライアントライブラリ**です。APIにリクエストを送ってデータを取得したりするのに使えます。同等の機能を持つライブラリとして有名なものにはAxiosなどがあります。

ofetchは[UnJS](https://unjs.io/)というJavaScriptのライブラリ群のひとつになります。2024年12月時点でUnJSのライブラリは60以上あり、名前を聞いたことのあるライブラリが中にはあるかもしれません。
UnJSの開発にはNuxtの開発メンバーが深く携わっており、ofetch以外にも、Nuxt3の根幹を担っているライブラリがUnJSには複数あります。

# 何が便利なの？

それでは早速、ofetchの機能を見ていきましょう！基本的な使い方は以下です。

```typescript
await ofetch('リクエストURL', オプション設定をするオブジェクト)
```

## 1. 自動でレスポンスデータを変換してくれる！

JSON形式のレスポンスを取得し、オブジェクトに変換し使用する、これはfetchの代表的な使い方だと思います。
ofetchはレスポンスの形式を分析して、自動で変換してくれます。

例えば、標準のfetch APIではJSON形式のデータを取得したい場合、以下のように変換しなくてはいけませんが……

```typescript
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const json = await response.json();
```

ofetchを使うと以下だけで済みます。

```typescript
const response = await ofetch('https://jsonplaceholder.typicode.com/posts/1');
```

:::note
ちなみに、今回はテスト用のエンドポイントとしてダミーデータを返す[JSON Placeholder](https://jsonplaceholder.typicode.com/)というサービスを使っています。コードを動かすと実際にデータを取得できます！
:::

JSONへの変換は、[destr](https://github.com/unjs/destr)という、これまたUnJSのライブラリを使って行っているようです。

JSON以外への変換もできます。text、blob、arrayBufferなどの形式に対応しています。
`await ofetch('url', { contentType: 'blob' })`のように、使用時に明示的にどの種類のデータとして変換するか明示することもできますし、指定しなかった場合は、レスポンスヘッダーのContent-Typeから情報を取得してくれます。

## 2. データ送信時も自動でデータを変換してくれる！

逆に、JSONをリクエストボディに付与してデータを送信したい場合もあると思います。
その場合、標準のfetch APIだと以下のように書きます。
```typescript
await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: JSON.stringify({
        title: 'Title',
        body: 'Hello, world!',
        userId: 1,
    }),
    headers: {
        'Content-type': 'application/json',
    },
})
```

`JSON.stringify()`でオブジェクトをJSON文字列に変換する必要があります。

ofetchは、`toJSON()`メソッドを持つオブジェクトがbodyに渡されると、自動で`JSON.stringfy()`でデータを変換してくれます。さらに、POST・PUT・PATCHメソッドを使用しているときは、headerに`Content-Type: "application/json"`（と、`accept: "application/json"`）を付与してくれます。
上記コードと同じことが以下でできるのです。

```typescript
await ofetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: {
        title: 'Title',
        body: 'Hello, world!',
        userId: 1,
    },
});
```

簡潔ですね☺️

## 3. スマートなエラーハンドリング

APIを使用するときは、エラーが返ってきたときの処理を一緒に書くことが多いと思います。
ofetchではエラーハンドリングが簡単にできます。そしてその処理に一工夫されています！😉

### API通信で起きうる2種類のエラー

API通信で発生しうるエラーは2種類あります。**ネットワークエラー**と、**サーバーが処理した結果としてのエラー**（レスポンスエラー）です。
前者はそもそも通信に失敗していて、後者は通信には成功しているが、レスポンス内容としてエラーが返ってきているという違いです。

### 標準のfetch APIの場合

標準のfetch APIの場合、この2つのエラーを検知する方法が異なります。
まずネットワークエラーですが、try/catch構文や、`Promise`オブジェクトの`catch`メソッドを使って取得します。以下のようなイメージです。
（実際は`error`に対して型エラーが起こったりするので「イメージ」です）
```typescript
try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/10000');
    // 後続の処理...
} catch (error) {
    // ネットワークエラーを検知
    console.error(error.message);
}
```
この場合、通信に成功しレスポンスが返ってきていれば、レスポンスの内容がエラーでも、エラーはキャッチされません。正常なレスポンスか、エラーレスポンスかは関係ないのです。


レスポンスが正常か否かはステータスコードを参照することでわかります。200番台が成功のステータスです。
あるいは、fetch APIから返される`Response`オブジェクトの`ok`プロパティが`true`になっているかでも判断可能です。以下のようなイメージです。
```typescript
try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/10000');
    if (!response.ok) {
        // レスポンスエラーの場合処理を中断
        console.error(`${response.status} - ${response.statusText}`);
        return;
    }
    // 後続の処理...
} catch (error) {
    // ネットワークエラーを検知
    console.error(error.message);
}
```

以上のように、標準のfetch APIを使うと、エラー処理をする部分が2箇所に分散します。
ネットワークエラーの場合とレスポンスエラーの場合で異なる処理をする場合は特に問題ないかもしれませんが、共通の処理を行う場合はやや冗長ですね。

### ofetchの場合

ofetchは2つのエラーを同じように返してくれます。具体的には、どちらの場合も`FetchError`クラスという共通のクラスを使ってエラーオブジェクトを生成し、`throw`してくれます。

<details><summary>参考：ofetchのソースコード👀</summary>

※コメントは自分で付け加えたものです

```typescript:ofetch/src/fetch.ts
// ...前略

try {
  context.response = await fetch(
    context.request,
    context.options as RequestInit
  );
} catch (error) {
  context.error = error as Error;
  if (context.options.onRequestError) {
    await callHooks(
      context as FetchContext & { error: Error },
      context.options.onRequestError
    );
  }
  return await onError(context); // エラーオブジェクトを作成しthrowする関数を呼び出す
} finally {
  if (abortTimeout) {
    clearTimeout(abortTimeout);
  }
}

// ...中略...

// ↓ ステータスコードが400~599の範囲のものであるかをみている。
// レスポンスエラーを無視するオプションもあるようです。(ignoreResponseError部分)
if (
      !context.options.ignoreResponseError &&
      context.response.status >= 400 &&
      context.response.status < 600
    ) {
      if (context.options.onResponseError) {
        await callHooks(
          context as FetchContext & { response: FetchResponse<any> },
          context.options.onResponseError
        );
      }
      return await onError(context); // エラーをcatchしたときと同じ処理をしていますね！
    }

// 後略...
```

ネットワークエラーの場合、レスポンスエラーの場合、どちらも同じ処理をしていますね！
ちなみに、`context.options.onRequestError`や`context.options.onResponseError`は、後述する「インターセプター」が設定されていた場合の処理を行っています。

---

</details>

レスポンスエラーでもエラーを`throw`してくれるので、`catch`でエラーを検知することができます。
コードのイメージは次のような感じです。
```typescript
try {
    const response = await ofetch('https://jsonplaceholder.typicode.com/posts/10000');
    // 後続の処理...
} catch (error) {
    console.error(`${error.status} - ${error.statusText}`)
}
```

シンプルですね👌
処理がまとめられて、読みやすくなりました。

### エラー時に自動で再試行してくれる

ちなみにですが、特定の[ステータスコード](https://github.com/unjs/ofetch?tab=readme-ov-file#%EF%B8%8F-auto-retry)が返ってきた場合、リクエストを再試行する設定をすることもできます。
オプション設定で、`retry`オプションに再試行回数の上限を指定することで使用できます。
（デフォルトで、GETメソッドは1回再試行し、それ以外のメソッドは再試行しない設定になっているようです）


## 4. インターセプターで任意の処理を挟み込む

ofetchの**インターセプター**と呼ばれる機能では、リクエスト時、レスポンスを受け取ったとき、エラーが発生したとき、などのイベントが発生したときに実行する処理を定義することができます。
例えば、リクエストの際に、ユーザーIDをクエリパラメーターに含める処理をしたいときは、以下のように書きます。
```typescript
const response = await ofetch(
    'https://jsonplaceholder.typicode.com/posts',
    {
        async onRequest({ request, options }) {
            options.query = options.query || {};
            options.query.userId = loginUser.id; // ログインしているユーザーのIDが入る想定
        },
    }
);
```

標準のfetch APIにはインターセプターのような概念はないと思います。
リクエスト時やレスポンスを受け取ったときに毎回行いたい処理がある場合は便利そうですね！

## 5. ベースURLの設定をして共通化

多くの場合、同一アプリケーション内でリクエストするURLのホスト部分は共通です。毎回同じURLを記述するのは冗長で保守性が低くなります。この部分の共通化は自前でもよく行われているのではないでしょうか。
ofetchではこれをbaseURLオプションとして設定できるようにしています。
以下が使用例です。ちなみに、この機能ではURLのパースなどの処理にUnJSの[ufo](https://github.com/unjs/ufo)が使われています🛸


```typescript
const baseURL = 'https://jsonplaceholder.typicode.com';
const response = await ofetch('/posts/1', { baseURL });
```

例を見て、（毎回`baseURL`のオプションを設定するのもめんどくさいなあ……）と思った方もいらっしゃるでしょう。

最後に、ofetchのオプション設定を共通化するための機能を紹介します。
任意の設定をデフォルトとしてもつofetchのオブジェクトを作成し使うことができます。`ofetch.create()`を使って作成できます。
```typescript
const baseFetcher = ofetch.create({
	baseURL: 'https://jsonplaceholder.typicode.com',
});

const posts = await baseFetcher('/posts')
const post = await baseFetcher('/posts/1')
```
`baseFetcher`を使うと、
```typescript
await ofetch('/posts', {
    baseURL: 'https://jsonplaceholder.typicode.com'
})
```
と同じ動きをします。生成したオブジェクトは`ofetch`と同じ使い方ができ、第2引数にオプション設定を指定すれば、設定の追加や上書きもできます！
この機能を使うと共通で設定したいオプションがある場合、いちいち記述しなくて済みますね。

# おわりに

なんとなく、便利なんだな〜と思っていたofetchでしたが、しっかり調べてみると、こんな機能があるんだ！という発見が多くありました。この記事で紹介しきれていない機能もまだまだあるので、気になった方はぜひofetchの[README](https://github.com/unjs/ofetch?tab=readme-ov-file#ofetch)を読んでみてください！

今回、記事を作成するにあたって、ofetchのソースコードを読んでみたのですが（会社の先輩方と一緒に読みました。ありがとうございました！！）、それによってオプション設定への理解が深まりましたし、コードの書き方が参考になりました。これからも気になったライブラリのソースコードを読んでいきたいと思います！

読んでいただきありがとうございました🙇

# 参考

https://github.com/unjs/ofetch

https://unjs.io/

https://developer.mozilla.org/ja/docs/Web/API/Response/ok