OpenAPIの仕様からKiotaで.NETクライアントを生成する

LINE Messaging APIの.NETライブラリの紹介と技術メモ

OpenAPIの仕様からKiotaで.NETクライアントを生成する

LINEプラットフォームの.NET向けクライアントライブラリ群「Line.OpenApi.*」を、1.0.0の正式版としてNuGet.orgに公開しました。メッセージ送受信、LIFF、LINE Login、MINI Appなど、LINEが提供する主要なAPIをひととおりカバーしています。

このライブラリの中身は、ほとんど手書きしていません。LINEが公開している公式のOpenAPI仕様を、MicrosoftのKiotaというツールに通して機械生成し、その周りに薄い手書きコードを添えただけです。仕組みを一言でまとめると次の3ステップになります。

  1. 公式のOpenAPI仕様を取ってくる
  2. Kiotaで.NETクライアントを生成する
  3. 仕様に書ききれない現実の都合を、薄い手書きコードで埋める

この作り方はLINEに固有のものではありません。OpenAPI仕様を公開しているサービスなら、同じ手順でクライアントライブラリを用意できます。この記事では、実際にやってみて分かった勘所を、他のサービスにも応用できる形で紹介します。あわせて、動かしながら学べるチュートリアル用リポジトリも用意したので、その中身にも触れます。

対象読者は、LINE Botや.NETでWeb APIのクライアントを扱う開発者と、「公式仕様からのクライアント生成」に興味がある方を想定しています。


なぜ手書きせず「生成」するのか

これは一般論というより、手書きで一度行き詰まった経験からの判断です。以前、LINEのメッセージングAPIを手書きでラップしたLineMessagingApiというライブラリを公開していました。ただ、LINE側のAPIが更新されるたびにモデルとエンドポイントを手で足す必要があり、だんだん追随が滞って、このリポジトリは現在Publicアーカイブとしています。手書きは書き始めは順調でも、APIが増えたり変わったりするたびに、対応するC#のコードを手で直す作業が積み上がります。エンドポイントが数十、モデルが数百になると、追随するだけでも大変です。

生成に切り替えると、この負担の性質が変わります。やることは「新しい仕様を取り直して、生成し直す」だけになります。手で書き写す作業が消え、差分は生成結果として現れます。仕様が更新されたときの作業が、翻訳から再生成へと置き換わるわけです。

生成ツールにKiotaを選んだ理由は、いくつかあります。Microsoft公式のOSSで、C#に限らず多言語に対応していること。生成されるコードが「リクエストビルダー」という形をとり、client.V2.Bot.Message.Pushのように、URLの階層をたどるようにメソッドを呼べること。型が付いているのでエディタの補完が効き、APIを探しながら書けること。ドキュメントも整っていること。NSwagやOpenAPI Generatorも検討したうえで、これらの点からKiotaに決めました。

生成コードは「開けない箱」として扱う

生成に踏み切るときに決めておくとよいのが、「生成されたコードは読まない」という割り切りです。

Kiota自身、生成コードの読みやすさを目標にしていないと明言しています。実際、生成物には人が読んで整えるようなコメントや構造の配慮はありません。そこを無理に読もうとしたりレビュー対象にしたりすると、手間の割に得るものが少ない。だから生成コードは中身を見ない「開けない箱」として扱い、評価の目は公開APIの使い勝手と運用のしやすさに向ける、と最初に決めました。

この方針はドキュメント生成にもそのまま効いています。APIリファレンスをDocFXで作るときも、生成された名前空間(Line.*.Generated)はまるごと除外し、手書きした公開部分だけをリファレンスに載せています。こうしておくと、生成し直すたびに大量の差分が出てレビューが埋もれる、という事態を避けられます。人が目を通すのは、あくまで自分たちが書いた表面だけです。

仕様が決まっているとAIエージェントに任せやすい

この作り方は、AIエージェントとも相性がよいです。

OpenAPI仕様はエンドポイントもパラメータも型も決まっていて、そこに実績のあるKiotaを通すので、生成される部分は入力が同じなら結果も同じになります。実装がブレません。

そのぶん、AIに任せる範囲もはっきりします。モデルやエンドポイントの大半は生成が引き受け、AIが手を入れるのは後半で紹介する薄い手書きグルーだけです。範囲が限られているので指示(プロンプト)も具体的に書けて、出てきた結果も確かめやすい。

実際、このライブラリの手書き部分は、仕様の確認から実装、レビューまでAIエージェントに任せました。筆者はコードを1行も書いていません。書いたのは指示と、その結果を確かめる作業だけです。生成でブレない土台があるからこそ、この任せ方が成り立っています。


生成の実際 ― 1本のスクリプトで10クライアント

Line.OpenApi.*では、仕様の取得から10個のクライアント生成までを、1本のPowerShellスクリプト(scripts/generate.ps1)にまとめています。仕様を更新したいときはこれを流すだけで、誰がやっても同じ結果になります。

生成コマンドはKiotaのCLIを呼ぶだけで、たとえばメッセージング系の制御用クライアントはこう生成しています。

kiota generate -l CSharp -d ./openapi/messaging-api.yml ` --exclude-path "**/content" --exclude-path "**/content/**" ` -c MessagingApiClient -n Line.OpenApi.Messaging.Generated.Api ` -o ./src/Line.OpenApi.Messaging/Generated/Api ` --exclude-backward-compatible ` --structured-mime-types application/json

生成方針で共通して効いてくるオプションが、いくつかあります。--exclude-backward-compatibleは後方互換のための古いコードを生成させない指定です。--class-name-c)と--namespace-name-n)はクライアントごとに明示して、複数生成したときの名前の衝突を防いでいます。

もう一つ大事なのが--structured-mime-typesです。これはリクエスト・レスポンスのどの形式を型付きで扱うかを指定するもので、仕様の性格に応じて出し分けています。整理すると次のようになります。

クライアント指定するmime-type理由
メッセージング(制御系)application/json通常のJSON API
メッセージング(データ系)指定なし画像などは*/*の生バイナリ(Streamで受ける)
チャネルアクセストークンapplication/json + application/x-www-form-urlencodedトークン発行がフォーム送信
オーディエンス管理(データ系)multipart/form-data + application/jsonファイルアップロードがある

生成後に基準となるのがkiota-lock.jsonです。どの仕様からどのオプションで生成したかが記録されるので、これをGitにコミットしておけば、次に生成したときの差分をきちんと追えます。


すぐ使える ― パッケージとコード例

公開したパッケージは、利用シーンごとに分かれています。メッセージ送信だけ使いたい人がLIFFの依存まで引き込まずに済むよう、細かく分割したうえで、まとめて入れたい人向けにメタパッケージも用意しました。

パッケージ役割
Line.OpenApi.Core共通基盤(認証、Webhook署名検証、接続先ホストの制限)
Line.OpenApi.ChannelAccessTokenチャネルアクセストークンの発行
Line.OpenApi.Messagingメッセージ送信・リッチメニューなど
Line.OpenApi.Messaging.WebhookWebhookの受信(署名検証+デシリアライズ)
Line.OpenApi.LiffLIFFアプリの管理
Line.OpenApi.LoginLINE Login + OpenID Connect
Line.OpenApi.MiniAppMINI Appのサービスメッセージ・アプリ内課金
Line.OpenApi.Insight各種統計の取得
Line.OpenApi.ManageAudienceオーディエンス管理
Line.OpenApi.ModuleModuleチャネル(パートナー・代理店向け)
Line.OpenApi.Shopミッションスタンプの送信
Line.OpenApi.BotBot一式をまとめて入れるメタパッケージ(コードなし)

Botを作るのに必要な「送信・受信・トークン発行」を一度に入れるなら、メタパッケージが手軽です。

dotnet add package Line.OpenApi.Bot

用途がはっきりしているなら、必要なパッケージだけを入れても構いません。

dotnet add package Line.OpenApi.Messaging dotnet add package Line.OpenApi.Liff dotnet add package Line.OpenApi.Login

対応フレームワークはnet10.0のみです。使い勝手を具体的に見てもらうため、代表的な3つの操作をコードで示します。

メッセージを送る

長く使えるチャネルアクセストークンがあれば、1行でクライアントを作って送信できます。

using Line.OpenApi.Messaging; using Line.OpenApi.Messaging.Generated.Api.Models; var client = MessagingClient.CreateWithStaticToken("CHANNEL_ACCESS_TOKEN"); await client.Api.V2.Bot.Message.Push.PostAsync(new PushMessageRequest { To = "U0123456789abcdef...", Messages = new() { new TextMessage { Text = "Hello, world" }, }, }); // コンテンツ取得はデータ用ホスト(api-data.line.me)へ自動で振り分けられる var stream = await client.Blob.V2.Bot.Message["<messageId>"].Content.GetAsync();

ASP.NET Coreに組み込むなら、IHttpClientFactoryを使ったDIの経路がおすすめです。HTTP接続の管理をフレームワークに任せられます。

using Line.OpenApi.Messaging.DependencyInjection; services.AddLineMessaging(o => o.ChannelAccessToken = "CHANNEL_ACCESS_TOKEN"); // 取り出し: sp.GetRequiredService<MessagingClient>()

Webhookを受け取る

Webhookの受信では、署名の検証と本文の読み取りをWebhookRequestParserの1呼び出しにまとめています。署名が合わなければWebhookSignatureException、本文が壊れていればWebhookPayloadExceptionが飛ぶので、それぞれに応じたステータスを返せます。

using Line.OpenApi.Messaging.Webhook; using Line.OpenApi.Messaging.Webhook.Generated.Models; app.MapPost("/webhook", async (HttpRequest request, WebhookRequestParser parser) => { using var ms = new MemoryStream(); await request.Body.CopyToAsync(ms); var body = ms.ToArray(); // 署名は生バイトに対して検証する var signature = request.Headers["x-line-signature"]; CallbackRequest callback; try { callback = await parser.ParseAsync(body, signature); } catch (WebhookSignatureException) { return Results.Unauthorized(); } // 署名不正 catch (WebhookPayloadException) { return Results.BadRequest(); } // 本文不正 foreach (var ev in callback.Events!) { switch (ev) { case MessageEvent m when m.Message is TextMessageContent t: Console.WriteLine($"text: {t.Text}"); break; case FollowEvent: /* 友だち追加 */ break; case PostbackEvent p: /* p.Postback!.Data */ break; } } return Results.Ok(); });

イベントの型の振り分けは、生成コードが持っている判別ロジックに任せています。MessageEventFollowEventといった具体的な型に復元されて返ってくるので、あとはswitchで分岐するだけです。

LIFFアプリを管理する

LIFFアプリの登録・更新・削除も、ファサードのメソッドで一貫して呼べます。

using Line.OpenApi.Liff; using Line.OpenApi.Liff.Generated.Models; var liff = LiffClient.CreateWithStaticToken("CHANNEL_ACCESS_TOKEN"); var apps = await liff.GetAppsAsync(); var added = await liff.AddAppAsync(new AddLiffAppRequest { View = new LiffView { Type = LiffView_type.Full, Url = "https://example.com" }, }); await liff.UpdateAppAsync(added!.LiffId!, new UpdateLiffAppRequest { Description = "updated" }); await liff.DeleteAppAsync(added.LiffId!);

コマンドラインとMCPからも触れる

ライブラリとは別に、ローカルからLINEを操作するツールLine.OpenApi.Tools(コマンド名はline)も同梱しています。トークン発行、メッセージ送信、Webhook開発の補助、LIFF管理などを、コマンドとして実行できます。

dotnet tool install -g Line.OpenApi.Tools line message push --to <id> --text "Hello" line mcp # MCPサーバーとして起動

同じ機能をMCPサーバーとしても公開しているので、Claude CodeやClaude Desktopから直接使えます。メッセージの形を組み立てて検証し、実機に送って見た目を確かめる、という試行錯誤の流れを支援するツールです。


他のサービスに応用するときの勘所

ここからが本題です。公式仕様からの生成はおおむねうまくいきますが、実際にやってみると、生成物だけでは送れないリクエストや、うまく扱えない型に何度か出くわしました。どれもLINEに限った話ではなく、他のサービスの仕様でも起こりえます。以下、遭遇した具体例と、そこから言える一般的な話をセットで並べます。

共通する解き方は一つで、いずれも生成コードには手を入れず、薄い手書きコードで包んで吸収しています。

1. 1つの仕様に複数のホストが混ざっている

メッセージングAPIは、送信などの操作をapi.line.meで、画像などのコンテンツ取得をapi-data.line.meで受け付けます。1つの仕様書の中に2つの接続先が混ざっているわけです。

ここで問題になるのが、Kiotaは1つのクライアントにつき、仕様の先頭に書かれた接続先を1つしか採用しないという性質です。エンドポイントごとに別のホストを指定する書き方は無視されます。素直に生成すると、コンテンツ取得のリクエストまでapi.line.meに飛んでしまいます。

対処として、生成をパスで2つに分けました。コンテンツ系のパスだけを含めたクライアントと、それ以外を含めたクライアントを別々に生成し、コンテンツ側だけ接続先をapi-data.line.meに上書きします。そのうえで、利用者からは1つのファサードに見えるようにまとめています。

このとき一つ落とし穴がありました。接続先の上書きは、クライアントを組み立てるにやらないと効きません。生成されたクライアントはコンストラクタの時点で接続先を内部に固定するため、後から上書きしても反映されないからです。実装では、この順序をコメントで明記しています。

// データ系: 別アダプタを用意し、接続先を api-data.line.me に上書きする。 // 重要 ― 接続先はクライアント構築の「前」に設定すること。 // 生成クライアントはコンストラクタで接続先を内部に固定するため、 // 後から設定してもリクエストは api.line.me に飛んでしまう。 var blobAdapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient); blobAdapter.BaseUrl = $"https://{LineHosts.ApiData}"; Blob = new MessagingBlobApiClient(blobAdapter);

一般化すると: エンドポイントごとのホスト指定は、多くの生成ツールで無視されます。複数ホストが混ざった仕様は、パスの集合で分割して生成し、ファサードで1つに束ねるのが定石です。同じパターンはオーディエンス管理でもそのまま使い回しました。

2. フォーム送信と「入れ子になる型」の相性が悪い

もっとも手こずったのが、トークン発行のエンドポイントです。

ステートレストークンを発行する/oauth2/v3/tokenのリクエストボディは、仕様上「複数の候補のうちどれか」という形(判別子のないoneOf)で書かれています。これをKiotaで生成すると、候補をまとめて包む合成ラッパーの型ができます。このラッパーは、中身のリクエストを入れ子のオブジェクトとして直列化しようとします。

ところがこのエンドポイントは、JSONではなくフォーム送信(application/x-www-form-urlencoded)です。フォーム送信はkey=valueを並べるだけの平らな形式で、入れ子を表現できません。生成物のまま送ると、Kiotaのフォーム直列化が"Form serialization does not support nested objects."(フォーム直列化は入れ子オブジェクトに対応していない)というエラーで止まります。

解決策は、合成ラッパーを使わないことです。中身がフラットなリクエストモデルを自前で用意し、生成コードのビルダーを経由せずに、リクエストを直接組み立てて送っています。接続先やシリアライザの設定は生成クライアントのものを再利用するので、二重管理にはなりません。

// 生成ビルダーの PostAsync は使わない。それは合成ラッパーを経由して // 入れ子直列化に落ちるため。クライアントの接続先・アダプタを再利用し、 // フラットなリクエストモデルをフォームボディとして直接載せて送る。 var adapter = _client.InternalRequestAdapter; var requestInfo = new RequestInformation( Method.POST, "{+baseurl}/oauth2/v3/token", _client.InternalPathParameters); requestInfo.SetContentFromParsable(adapter, "application/x-www-form-urlencoded", request);

一般化すると: OAuthのトークンエンドポイントは「フォーム送信」かつ「oneOfのボディ」という組み合わせになりがちで、これはKiotaの苦手なパターンです。生成物の合成ラッパーを迂回して、平らなモデルを直接送る薄いアダプタで包んでしまうのが実用的です。

3. 言語の予約型と名前がぶつかる

メッセージングの仕様にはActionという型があります。ボタンを押したときの動作を表す型で、その名前のままC#に持ってくると、標準ライブラリのSystem.Actionとぶつかります。

Kiotaはこの衝突を避けるため、生成時にこの型をActionObjectという名前に自動で変えます。生成物なので、こちらから名前を戻すことはできません。仕様上はActionでも、C#ではActionObjectとして扱うことになります。

これは実装で何かを直す話ではなく、利用者にきちんと伝えるべき話です。ドキュメントで「基底型はActionObject」と周知して、混乱を防いでいます。

一般化すると: 生成ツールは、対象言語の標準的な型と名前がぶつかると、型名を自動で変えることがあります。生成物側は変えられないので、名前の食い違いはドキュメントで案内し、必要ならファサードで自然な名前を付け直します。

4. ファイルアップロードは自動で組み立ててくれない

オーディエンス管理には、ユーザーIDの一覧をファイルでアップロードするエンドポイントがあります。multipart/form-dataでファイルを送る形式です。

このマルチパート送信は、生成ツールがボディを組み立ててくれる範囲の外にあります。生成物には送信の器はあっても、パートを詰める作業は自分でやる必要があります。実装では、マルチパートのボディを手で組み立て、各パートを追加して送っています。

一般化すると: ファイルアップロード(マルチパート)は、型付きビルダーの恩恵が薄い領域です。パートの組み立てを手書きのヘルパーに隠して、利用者からはメソッド1つに見せるのが現実的です。

補足:小さな2つの落とし穴

最後に、上の4つほど大きくはないものの、他のサービスでも起こりそうな2点を挙げておきます。

一つは、モデルはどこかのエンドポイントから参照されないと生成されないという点です。Webhookの仕様は受信データの「型」だけが欲しくて、エンドポイント自体は使いません。ところが唯一のエンドポイントを除外すると、そこから参照されているモデルまで一緒に生成されなくなってしまいます。仕方なくエンドポイントは残し、生成されたメソッドは使わずモデルだけを利用しています。

もう一つは、上流の仕様ファイルがそのままではパースできないことがあるという点です。トークン発行の仕様には、引用符で囲われていないurn:...の文字列があり、YAMLのパーサーがコロンを誤解してエラーになります。ここで仕様ファイルを直接書き換えると、次に取り直したときに元へ戻ってしまいます。そこで、生成の前に引用符を補う正規化ステップをスクリプトに挟みました。すでに引用済みなら何もしないので、何度流しても安全です。


運用:生成ツールのバージョンを固定する

生成を安定させるうえで、意外と見落としやすいのがKiotaのバージョン管理です。

Kiotaは、コードを生成するCLIツールと、生成物が実行時に依存するランタイムライブラリを、別々のバージョン体系で管理しています。CLIは1.34.1が最新で、ランタイムは2.0.0が最新、という具合に番号が揃いません。「2.0へ移行する」といっても、それはランタイムだけを上げる話で、CLIは据え置き、生成物のコードは変わらない、というケースもあります。ここを混同すると、意図しない再生成を招きます。

そこで、CLIのバージョンは生成スクリプトの中で固定し、生成前に実際のバージョンと照合しています。食い違っていれば、既定でエラーにして止めます。

$ExpectedKiotaCliVersion = "1.34.1" $actual = (& kiota --version) 2>&1 | Select-Object -First 1 # 実際のバージョンと照合し、不一致なら既定でエラー停止

ランタイムのバージョンは、Directory.Build.propsの1か所で一元管理し、全パッケージがそれを参照します。加えて、セキュリティ修正の入った下限バージョンを割り込まないよう、NuGetの脆弱性監査で見張っています。

一般化すると: 生成物を毎回同じように再現するには、生成ツールのバージョンを固定し、生成の記録(kiota-lock.json)をコミットして、ツールとランタイムを足並みそろえて更新することです。


すぐ動かせるチュートリアル ― line-companion-bot

ライブラリの使い方を一つずつ説明する記事は、それはそれで役に立ちます。ただ、実際に自分でBotを組もうとすると、個々のAPIよりも「部品どうしのつなぎ方」で迷うことが多いはずです。そこで、動かしながら学べるチュートリアル用のリポジトリを別に用意しました。

pierre3/line-companion-bot は、バーチャルペットを育てるLINE BotとLINE MINI Appのショップを組み合わせた、一つの実用的なサンプルです。動きはこうです。トーク画面のリッチメニューにある「Feed / Play / Status / Shop」のボタンを押すと、ペットの状態が更新され、Flexメッセージのステータスカードが返ってきます。「Shop」を押すとMINI Appのショップが開き、アプリ内課金でアイテムを買えます。購入は裏で動く照合サービスが検知して、アイテムを付与し、チャットに完了を知らせます。たとえば「Golden Kibble」を買って「Feed」すると、空腹度が回復してアイテムが消費される、といった具合です。

単なるオウム返しBotではなく、メッセージ送信・Webhook受信・MINI App課金を1本のループでつないだところに主眼を置いています。使っているパッケージは3つだけで、それぞれの役割ははっきり分かれています。

パッケージこのアプリでの使いどころ
Line.OpenApi.Messaging返信・プッシュ送信・リッチメニュー
Line.OpenApi.Messaging.Webhook署名検証とWebhookの読み取り
Line.OpenApi.MiniAppショップの予約・サービスメッセージ・アプリ内課金

なお、LIFFのパッケージはあえて参照していません。ショップのフロントエンドは公式のLIFF JavaScript SDKを直接読み込む作りなので、サーバー側にLIFFの依存を足す必要がなかったからです。使わない依存は入れない、という判断も含めて実際のアプリに近づけています。

チュートリアルは全10章の構成で、dotnet newでひな形を作るところから、最後の通しの動作確認まで、一章ずつ積み上げていきます。第1章から第8章まではLINEのアカウントがなくても、手元だけで動作を確認できます。実際のチャネル設定が要るのは、通しで動かす最終章だけです。各章の終わりでVS CodeのF5から起動して確認できるようになっているので、詰まりにくいはずです。説明は英語と日本語の両方を用意しています。

手元で動かす最短の手順はこうです。

git clone https://github.com/pierre3/line-companion-bot cd line-companion-bot dotnet user-secrets set LINE_CHANNEL_SECRET "<channel secret>" --project src/LineCompanionBot dotnet user-secrets set LINE_CHANNEL_ACCESS_TOKEN "<channel access token>" --project src/LineCompanionBot dotnet run --project src/LineCompanionBot

トークンを設定していなくても起動はします。ルートのヘルスエンドポイントにアクセスすると、いま何が有効で何が未設定かを教えてくれるので、少しずつ設定を足しながら進められます。参照しているパッケージはすべて公開済みの1.0.0なので、特別な準備は要りません。


まとめ

公式のOpenAPI仕様があれば、型の付いた.NETクライアントはKiotaで機械生成でき、保守は「仕様を取り直して生成し直す」に置き換わります。生成物は開けない箱と割り切って、レビューとドキュメントの目を手書きの表面だけに向けると、生成差分のノイズに悩まされずに済みます。そして、どのサービスでも出くわす「複数ホスト」「フォーム送信とoneOf」「型名の衝突」「マルチパート」といった引っかかりは、生成物に手を入れず、薄い手書きコードで包んで解決できます。仕様が決まっていて生成部分がブレないぶん、手を入れる薄い部分に範囲を絞れるので、実装やレビューをAIエージェントと進めるのにも向いています。

この作り方はLINEだからできたわけではありません。OpenAPIを公開しているサービスであれば、同じ手順がそのまま応用できます。手始めに、公開済みのパッケージやチュートリアルを触ってみてください。

コメント

0
1
0