ゴール
この機能が実装できるようになる。
 id,name
1,hoge
2,fuga  やり方概要
- 特定のレスポンスヘッダーを設定したAPIエンドポイントを作成する
 - APIエンドポイントを公開する
 - ユーザーにはそのエンドポイントを踏ませるようにする
 
JavaScriptをつかってごちゃごちゃ実装するより、URLを踏ませてブラウザにDLを任せるほうが簡単で確実。
サンプル実装としてはRemix(React)、NestJSを用いるが、要点としては同様のAPIエンドポイントが作れればよい。
 NestJSのAPIはインターネットに公開しないため、RemixのプロキシAPIを介して配信する構成とする。
API実装のポイント
ポイントは3つ存在する。
- CSVデータのフォーマットと文字コードを指定する
 - ブラウザで開くと自動ダウンロードするようにさせる
 - キャッシュさせないようにする
 
①CSVデータのフォーマットと文字コードを指定する
 レスポンスのContent-Typeヘッダーを用いて、CSVデータであることと、その文字コードを明確にする。  
Content-Type: text/csv; charset=utf-8  ②ブラウザで開くと自動ダウンロードするようにさせる
 レスポンスのContent-Dispositionヘッダーを用いて、 
 ブラウザがコンテンツを受け取った後にどのように処理すべきかを伝える。  
Content-Disposition: attachment; filename="sample.csv"  -  
attachmentを指定:ブラウザにダウンロードすべきであることを伝える -  
filename="sample.csv"を指定:ダウンロード時のファイル名を指定する 
③キャッシュさせないようにする
 CSVダウンロードさせたいようなデータは、基本的にはキャッシュさせる必要がない上に 
 不用意にキャッシュしてしまうと思わぬバグの原因になるため、キャッシュさせないことを意識しておく。  
Cache-Control: no-store   no-storeと no-cacheでよく混乱するが、ざっくり以下のような違いで覚えておくと良い。  
-  
no-storeを指定:絶対にキャッシュしない。 -  
no-cacheを指定:キャッシュする。ただしキャッシュを使っていいか都度確認する。 
もう少し詳細に説明した記事はこちら。
 📄Cache-Controlヘッダーにおける、no-storeとno-cacheの違い   
API側(NestJS)の実装
はじめに
以下のデータをCSV化してDLさせるものとする。
class UserCsv {
  constructor(
    public id: number,
    public name: string,
  ) {}
}  ①CSVに関するライブラリインストール
 オブジェクトをCSV形式に変換できるようなライブラリをインストールする。 
 なんでもいいが、今回は fast-csvを採用した。  
# 今回はCSVの生成のみ利用するので、format機能のみ利用
$ yarn add @fast-csv/format  ②Controllerを実装
以下のようにNestJSのControllerを実装する。
import { Controller, Get, Header, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiProduces } from '@nestjs/swagger';
import * as csv from '@fast-csv/format';
@Controller()
export class AppController {
  // GET /download でアクセスされるエンドポイント
  @Get('download')
  @ApiProduces('text/csv; charset=utf-8') // swagger用なので、APIの機能には関係ない
  @ApiOperation({ summary: 'CSVデータをダウンロードする' }) // swagger用なので、APIの機能には関係ない
  @Header('Cache-Control', 'no-store') // ポイント③: 受け取り側がCSVデータをキャッシュしないように設定
  // swagger用なので、APIの機能には関係ない
  @ApiOkResponse({
    description: 'success',
    type: UserCsv,
    example: 'id,name\n1,hoge\n2,fuga',
  })
  async downLoadUsers(): Promise<StreamableFile> {
    // サンプルデータを生成
    const users = [new UserCsv(1, 'hoge'), new UserCsv(2, 'fuga')];
    // fast-csvの機能を用いて、CSVデータのバッファに変換
    const csvBuffer = await csv.writeToBuffer(users, { headers: true });
    // StreamableFileオブジェクトとして返却する
    return new StreamableFile(csvBuffer, {
      // ポイント①: データのタイプを指定
      type: 'text/csv',
      // ポイント②: ブラウザに「sample.csv」というファイル名でダウンロードさせるように伝える
      disposition: 'attachment; filename="sample.csv"',
    });
  }
}
  これでNestJS側の実装は終わり。
動作確認
APIに対してリクエストして動作確認をする。
-  レスポンスヘッダーが指定されていること 
-  
Cache-Control、Content-Type、Content-Disposition 
 -  
 - レスポンスボディでデータが確認できること
 
$ curl -XGET "http://localhost:3020/download" -i
HTTP/1.1 200 OK
X-Powered-By: Express
Cache-Control: no-store
Content-Type: text/csv
Content-Disposition: attachment; filename="sample.csv"
Content-Length: 21
Date: Sun, 19 Jan 2025 02:26:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
id,name
1,hoge
2,fuga  
フロントエンド側(Remix)の実装
フロントエンド側では、NestJSのプロキシエンドポイントを作成し、ボタンを押したらそのURLを踏む実装をする。
①プロキシエンドポイントを作成
 RemixでAPIルートを作成するには、 loader関数のみを定義したファイルを作成すれば良い。  
 app/routes/api.downloadCsv.tsを作成する。 
 これで /api/downloadCsv のパスをブラウザが踏むとダウンロードが始まるようになる。  
export const loader = async (): Promise<Response> => {
  // NestJSのレスポンスをそのまま返却する
  // docker composeを利用しているためホスト名がbeになっているが、NestJSに疎通できるURLを設定すれば良い
  return fetch('http://be:3020/download');
};  プロキシするだけなので、NestJSに対してリクエストを行うfetch関数をそのままreturnすれば良い。
動作確認
NestJSにリクエストしたときと同じレスポンスヘッダーとレスポンスボディであることが確認できる。
$ curl -XGET "http://localhost:3010/api/downloadCsv" -i
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
cache-control: no-store
connection: keep-alive
content-disposition: attachment; filename="sample.csv"
content-length: 21
content-type: text/csv
date: Sun, 19 Jan 2025 02:35:00 GMT
keep-alive: timeout=5
x-powered-by: Express
id,name
1,hoge
2,fuga  ②ボタンを作成
Reactコンポーネントにaタグを配置すれば良い
 app/root.tsx  
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {/* 実装はRemixのプロキシAPIのURLを指定するだけ */}
        <a href="/api/downloadCsv">CSVダウンロード</a>
      </body>
    </html>
  );
}  
動作確認
リンク(リンクに見えないが)を押すとダウンロードが始まる。
 
まとめ
- APIのレスポンスヘッダーを3つ指定する
 - フロントエンドからはURL踏ませるだけ、煩わしい実装は不要