【OpenAPI】TypeScript+ReactとASP.NET Web API間でデータをやり取りする

TypeScriptアドベントカレンダー24日目の記事です。

残念ながらTypeScriptの要素は2割くらいしかありません(すまん)。

この記事で話すこと

TS + Reactで作ったフロントエンドとASP.NETで作ったバックエンド間のデータのやり取りをする例を紹介します。

ありそうな例で言うと、「C#で書かれた既存のWeb APIサーバーを使って新たにWebアプリを作りたいけど、フロントサイドの技術者がTS/JSしか使えないとき」みたいな感じでしょうか。稀によくある(無い)。

完成形

↓のようにボタン押下時にリクエストを送り、戻ってきたデータをそのまま表示します。

試作品

構成は次の通りシンプルです1

構成

実際のプログラムなどは↓のレポジトリで公開しています。

最終的なディレクトリ構成

root
┣ /front
┃ ┗ /openAPI
┗ /webAPI

今回は特にDockerも使うことなく、Windows11上にプロジェクトを作りました。自動生成されるインターフェイスを含むフォルダを/openAPIと名付けています。

今回適当なディレクトリ構成にしていますが、Gitで管理されている場合はopenAPIフォルダ自体をsubmoduleとして導入することになるんじゃないかと思います。モノレポにして運用する方法もあるかと思いますが、その辺りはまあ皆さんのチームの状況を踏まえて決めればよいと思います(適当)(要するにケースバイケース)。

手順

Web APIのプロジェクトを作成

powershellでもコマンドプロンプトでも何でも良いのですが、dotnetコマンドを打てるターミナルを用意してください。次のコマンドで localhost:1234/weatherForecast のようなエンドポイントを持つAPIを作れます。出力先フォルダ名はwebapiとします。

dotnet new webapi -o webapi

生成されたProgram.csを見てみると、次のようにweatherForecastAPIが自動生成されているのが分かります。今回はこのエンドポイントをそのまま使います。

app.MapGet("/weatherforecast", () =>
{
  var forecast = Enumerable.Range(1, 5).Select(index =>
      new WeatherForecast
      (
          DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
          Random.Shared.Next(-20, 55),
          summaries[Random.Shared.Next(summaries.Length)]
      ))
      .ToArray();
  return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();

...省略

// NOTE: ご丁寧にrecordで定義してくれている
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
  public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

⚠️ ここでNugetの参照先がMicrosoft Visual Studio Offline Packages になっていると、パッケージが見つからない旨のエラーが出ます。エラーが出た場合は、onlineのパッケージをグローバルで参照するように次のコマンドで変更してください。2

dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org

OpenAPIスキーマファイルの生成

.NETのWeb APIプロジェクトでは、コードやスキーマファイルの自動生成用ライブラリとしてSwashbuckleNSwagが主に用いられています。Miscrosoftの公式ドキュメントでもこれら二つが取り上げられています。

今回は近年主流になっているNSwagを使ってスキーマファイルを生成します。詳細は上記のドキュメントを読んでください。

まずNSwagを追加します。

dotnet add package NSwag.AspNetCore

次の呪文を追加します。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddOpenApiDocument(); // ここを追加

... 省略

if (app.Environment.IsDevelopment())
{
    // Add OpenAPI 3.0 document serving middleware
    // Available at: http://localhost:<port>/swagger/v1/swagger.json
    app.UseOpenApi(); // ここを追加

    // Add web UIs to interact with the document
    // Available at: http://localhost:<port>/swagger
    app.UseSwaggerUi();  // ここを追加
}

サーバーのポート番号を指定するにはProperties/launchi.jsonのapplicationUrlを編集します。今回は適当に5200番にしておきました。

"applicationUrl": "http://localhost:5200",

この状態で、dotnet runコマンドでサーバーを起動します。localhost:5200/swaggerに移ると、↓のようにSwagger UIが表示されると思います。

Swagger UI
Swagger UI

上記で上手くいかない場合は↓の記事を参照してください。

先ほどの図中に表示されていた、「/swagger/v1/swagger.json」の部分をクリックすると、自動生成されたjsonファイル(OpenAPIのスキーマファイル)の中身を見ることができます。そのjsonファイルを保存しておいてください。

スキーマファイルの自動生成について(WIP)

.NET Core Web APIのプロジェクトでは、NSwag.MSBuildを使うことでプロジェクトのビルド時などにOpenAPIのスキーマファイルを自動生成することが可能です。下記の記事でも説明されているような方法を取ることで、ローカル側にスキーマファイルを自動生成することができます。

NSwag.MSBuildのドキュメントはこちら

しかしながら2024/2月現在、NSwag.MSBuildの最新バージョン14.0.3では.NET 8.0環境(恐らく7.0でも)でCLIに不具合が生じており、この機能を使うことができません(少なくとも私の環境では)。恐らく14.1以上のバージョンからは修正されると思いますので、修正されたらこの記事でも対応しようと思います。

詳細は↓のissueに上がっています。

Reactアプリの作成

Viteを使ってちゃちゃっと動くアプリを作成します。npmコマンドが動く状態で、以下のコマンドでReactアプリを作成・動作確認します。今回フォルダ名はfrontとしました。

npm create vite@latest
cd front
npm install
npm run dev

アプリの実行先のURLに飛ぶと次のような画面が表示されます。

動作確認
動作確認

確認出来たらsrcフォルダ内のApp.tsxを↓の様にまっさらにしておきます。

import "./App.css";

function App() {
  return <></>;
}

export default App;

OpenAPI Generatorでプログラムを自動生成

今回は数あるコードジェネレータの中でも老舗のOpenAPI Generatorを使います。次のコマンドでDevDependencyとしてインストールします。

npm install @openapitools/openapi-generator-cli -D

front(Reactのフォルダ)内のsrcフォルダ内にopenapiフォルダを作成し、フォルダ内に移動します。

cd ./front/src && mkdir openapi && cd openapi

ここはどのような方法でも構いませんが、先ほどダウンロードしておいたswagger.jsonを、作成したopenapiフォルダに移動させておきます。

mv path/to/swagger.json ./

openapiフォルダ内でswagger.jsonを元にTypeScript + axiosに見合ったAPIリクエスト用のプログラムを生成させます。次のコマンドを実行してけれ。

npx @openapitools/openapi-generator-cli generate -i swagger.json -g typescript-axios

上手くいけば幾つかファイルが生成されると思います。ひとまずTypeScriptのファイルだけが重要なので他のファイルは無視してください。生成されたプログラムはChatGPTに読ませれば使い方とか全て説明してくれると思うので、この記事では説明しません。

リクエスト送ってみるンゴ!

api.tsで定義されている次の関数を使ってリクエストを送ってみます。

export const DefaultApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
    const localVarFp = DefaultApiFp(configuration)
    return {
        /**
         *
         * @param {*} [options] Override http request option.
         * @throws {RequiredError}
         */
        getWeatherForecast(options?: any): AxiosPromise<Array<WeatherForecast>> {
            return localVarFp.getWeatherForecast(options).then((request) => request(axios, basePath));
        },
    };
};

この関数を使って↓みたいな書き方でレスポンスを得られる事が分かります。

const response = DefaultApiFactory().getWeatherForecast()

あとAPIサーバー側でCORSの設定も忘れていたので以下の様に追加しました。アプリのポート番号の部分を実際のポート番号に書き換えてください。

  • 「CORSって何?」っていう方はこちら
  • ASP.NET Web APIでのCORSの設定の詳細はこちら
// CORSの設定を追加
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
  options.AddPolicy(name: MyAllowSpecificOrigins,
   policy =>
   {
     policy.WithOrigins("http://localhost:Reactアプリのポート番号").AllowAnyHeader().AllowAnyMethod();
   });
});

... 略

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

// ここも必要
app.UseCors(MyAllowSpecificOrigins);

ということで、最終的に以下のようなコードでリクエストを送信・データの表示を行えるようになりました🎉

// 最終的なフロント側のコード

import { useState } from "react";
import "./App.css";
import { DefaultApiFactory, WeatherForecast } from "./../openapi/api";

function App() {
  const [forecasts, setForecasts] = useState<WeatherForecast[]>([]);
  const fetchData = async () => {
    try {
      const response = await DefaultApiFactory().getWeatherForecast();
      setForecasts(response.data);
    } catch (error) {
      console.log(`Featching data failed. ${error}`);
    }
  };

  return (
    <>
      <button onClick={() => fetchData()}>リクエストするンゴ~</button>
      <table>
        <thead>
          <tr>
            <th>Date</th>
            <th>Temperature</th>
            <th>TemperatureF</th>
            <th>Summary</th>
          </tr>
        </thead>
        <tbody>
          {forecasts.map((forecast, index) => {
            // なぜかforecast.Date等がundefinedになってしまう謎
            // 仕方なく各値を取り出している
            const [date, tempC, tempF, summary] = Object.values(forecast);
            return (
              <tr key={index}>
                <td>{date}</td>
                <td>{tempC}</td>
                <td>{tempF}</td>
                <td>{summary}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </>
  );
}

export default App;

forecast.data等のプロパティの値がundefinedになってしまう謎に出会ってしまったため、仕方なくObject.valuesメソッドでお茶を濁しました。本当に分からん。教えてえろい人。

とりまこれでうまく動きました。めでたし。

まとめなんてない

  • 巨人(OpenAPI Generator)の型に乗って行け
  • APIサーバー側でトークンベースのユーザー認証を付けた時とか、送受信するデータの検証を行うようなときにどんなプログラムが生成されるのか、わたし、気になります!

  1. 画像参照元:ここここ ↩︎

  2. 参考にしたのはここここ ↩︎