[Rust・LINEBot] line-openapi を使って Rust で sdk を作った話

## LINE DC Advent Calendar 2023

Qiitaに投稿初めて以来、初のアドベントカレンダーの参加です！

LINE DC Advent Calendar 2023 の4日目に参加させていただきます！

https://qiita.com/advent-calendar/2023/lineapi

## はじめに

今年の7月頃、`LINE OpenAPI` が公開された。

https://developers.line.biz/ja/news/2023/07/05/open-api/

ソースはこれ。

https://github.com/line/line-openapi

そして思った。


**2年くらい前に作った Rust製の `line-bot-sdk`（下記リンク参照）を最新化しよう！！！**

https://qiita.com/nanato12/items/16206cac558a50898bc4

ということで、今回は `LINE OpenAPI` を使用して、 `line-bot-sdk-rust` の最新化をした過程や、苦労した点、まだ改善できる点を書いていく！

## 成果物

わちゃわちゃ書いていくので、できた物を最初に載せておく。

- GitHub

https://github.com/nanato12/line-bot-sdk-rust

- Crate

https://crates.io/crates/line-bot-sdk-rust

## 使い方

このクレートを使用して、送られた文字をそのまま返すBot（基本的なEcho Bot）を作成していく。

まずは、 `cargo new`

```bash
$ cargo new sample
```

`actix_web` と `rocket` のサポート機能が付いている。

今回は `actix_web` を使用する。

```bash
$ cd sample
$ cargo add line-bot-sdk-rust --features actix_support
$ cargo add actix-web actix-rt dotenv serde_json
```

環境変数を `.env` に入れておく。

```bash
$ touch .env
$ echo 'LINE_CHANNEL_SECRET=xxxxx' >> .env
$ echo 'LINE_CHANNEL_ACCESS_TOKEN=xxxxx' >> .env
```

`main.rs` はこんな感じ

```rust
use actix_web::{
    error::ErrorBadRequest, middleware, post, web, App, Error, HttpResponse, HttpServer,
};
use dotenv::dotenv;
use line_bot_sdk_rust::{
    client::LINE,
    line_messaging_api::{
        apis::MessagingApiApi,
        models::{Message, ReplyMessageRequest, TextMessage},
    },
    line_webhook::models::{CallbackRequest, Event, MessageContent},
    parser::signature::validate_signature,
    support::actix::Signature,
};
use std::env;

#[post("/callback")]
async fn callback(signature: Signature, bytes: web::Bytes) -> Result<HttpResponse, Error> {
    // Get channel secret and access token by environment variable
    let channel_secret: &str =
        &env::var("LINE_CHANNEL_SECRET").expect("Failed getting LINE_CHANNEL_SECRET");
    let access_token: &str =
        &env::var("LINE_CHANNEL_ACCESS_TOKEN").expect("Failed getting LINE_CHANNEL_ACCESS_TOKEN");

    let line = LINE::new(access_token.to_string());

    let body: &str = &String::from_utf8(bytes.to_vec()).unwrap();

    if !validate_signature(channel_secret.to_string(), signature.key, body.to_string()) {
        return Err(ErrorBadRequest("x-line-signature is invalid."));
    }

    let request: Result<CallbackRequest, serde_json::Error> = serde_json::from_str(&body);
    match request {
        Err(err) => return Err(ErrorBadRequest(err.to_string())),
        Ok(req) => {
            println!("req: {req:#?}");
            for e in req.events {
                if let Event::MessageEvent(message_event) = e {
                    if let MessageContent::TextMessageContent(text_message) = *message_event.message
                    {
                        let reply_message_request = ReplyMessageRequest {
                            reply_token: message_event.reply_token.unwrap(),
                            messages: vec![Message::Text(TextMessage::new(text_message.text))],
                            notification_disabled: Some(false),
                        };
                        let result = line
                            .messaging_api_client
                            .reply_message(reply_message_request)
                            .await;
                        match result {
                            Ok(r) => println!("{:#?}", r),
                            Err(e) => println!("{:#?}", e),
                        }
                    };
                };
            }
        }
    }

    Ok(HttpResponse::Ok().body("ok"))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(callback)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}
```

ローカル環境で動作確認をするため、`ngrok` を使用する。

```bash
$ ngrok http 8080
ngrok by @inconshreveable                                                            (Ctrl+C to quit)
                                                                                                     
Session Status                online                                                                 
Account                       ななといつ (Plan: Free)                                                
Version                       2.3.41                                                                 
Region                        United States (us)                                                     
Web Interface                 http://127.0.0.1:4041                                                  
Forwarding                    http://xxxxxxxx.ngrok-free.app -> http://localhost:8080      
Forwarding                    https://xxxxxxxx.ngrok-free.app -> http://localhost:8080     
                                                                                                     
Connections                   ttl     opn     rt1     rt5     p50     p90                            
                              0       0       0.00    0.00    0.00    0.00                           
```

`https` のアドレスをLINE公式アカウントの webhook URL に設定する。

`https://xxxxxxxx.ngrok-free.app/callback`

あとは、`cargo run`

```bash
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.40s
     Running `target/debug/sample`
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/437282/def2d190-afcc-f1c6-8498-d1e92e91e2af.png)

コンソールにはこんな感じに表示されている。

```rust
req: CallbackRequest {
    destination: "U078cd692e67a90a66af06d18865830e3",
    events: [
        MessageEvent(
            MessageEvent {
                source: Some(
                    UserSource(
                        UserSource {
                            user_id: Some(
                                "Ue10d267e7ad66d524781ccf16ca6ddbd",
                            ),
                        },
                    ),
                ),
                timestamp: 1701595931169,
                mode: Active,
                webhook_event_id: "01HGQGAS0XDWZ62V9YP95JHR4V",
                delivery_context: DeliveryContext {
                    is_redelivery: false,
                },
                reply_token: Some(
                    "e750273b9d0e44b9966079b66c7a84a1",
                ),
                message: TextMessageContent(
                    TextMessageContent {
                        id: "484466652302540849",
                        text: "hello, line-bot-sdk-rust",
                        emojis: None,
                        mention: None,
                        quote_token: "PxefLxyWBibOJnd13faocQZuP8iL-UGmbOmYdBXKtII7kL8qffFVAVLjQC2dklq3XTsykucOlQRuKqZ2hQdR2E-eBPM0bmVl1HLZ0PMnDGoUwoOJHetnRwWRZOsjke0Z7QokRyydLvaLroZzAEPdXw",
                        quoted_message_id: None,
                    },
                ),
            },
        ),
    ],
}
ReplyMessageResponse {
    sent_messages: [
        SentMessage {
            id: "484466655439618465",
            quote_token: Some(
                "mkN4-wobbA6RQe-tlY3mD3XDUCDJyUQws_OIpVI1GBh5w-cLxLLpsrzKPbh6geSEsIcYfHmBg0zp6O-8IsZdKaN9MbmflRt4iAHGX2yBNDLueCT90bjn-D6sZbno7nJG30VBL8K303qc9FmCGOsHmw",
            ),
        },
    ],
}
```

## 作成手順

`line-bot-sdk-rust` を作成した過程を書いていく。

```bash
$ cargo new line-bot-sdk-rust
$ cd line-bot-sdk-rust
```

`cargo new` した後に、　`line-openapi` を submodule として追加する。

```bash
$ git submodule add https://github.com/line/line-openapi
```

あとは、この `openapi` の定義ファイルから Rust のコードを生成する。

この生成過程は `generator` として Rust で書いた。

https://github.com/nanato12/line-bot-sdk-rust/blob/develop/generator/src/main.rs

ここで、`liff` で「Cargo のバージョン表記が不正である」のようなエラー表示が出た。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/437282/35f703a7-e3f6-5938-2f41-ae673ea006a9.png)

`1.0.0` のようにパッチバージョンを書いてないといけないので、 `line-openapi` を fork して自分で修正した。

他のサービスはパッチバージョンまで書いてあったが、`liff` だけ書いてなかったので、一応PRを出しておいた。

たった2文字を追加しただけというクソPR....

https://github.com/line/line-openapi/pull/45

あとは、全ての client をメインファイル側で初期化するのがめんどくさいので、クレート側でやってあげた。

https://github.com/nanato12/line-bot-sdk-rust/blob/develop/core/lib/src/client/mod.rs

## 苦労したこと

### 1. ディレクトリ構成

例えば、 `line-openapi` にある `channel-access-token` を `openapi-generator-cli` を使用してコードを生成したとする。

```bash
$ java -jar tools/openapi-generator-cli-7.1.0.jar generate \
    --http-user-agent LINE-Bot-SDK-Rust/1 \
    -i line-openapi/channel-access-token.yml \
    -g rust \
    -o .
```

生成されたファイルがこちら

```bash
$ tree
.
├── Cargo.toml
├── README.md
├── docs
│   ├── ChannelAccessTokenApi.md
│   ├── ChannelAccessTokenKeyIdsResponse.md
│   ├── ErrorResponse.md
│   ├── IssueChannelAccessTokenResponse.md
│   ├── IssueShortLivedChannelAccessTokenResponse.md
│   ├── IssueStatelessChannelAccessTokenResponse.md
│   └── VerifyChannelAccessTokenResponse.md
├── git_push.sh
└── src
    ├── apis
    │   ├── channel_access_token_api.rs
    │   ├── configuration.rs
    │   └── mod.rs
    ├── lib.rs
    └── models
        ├── channel_access_token_key_ids_response.rs
        ├── error_response.rs
        ├── issue_channel_access_token_response.rs
        ├── issue_short_lived_channel_access_token_response.rs
        ├── issue_stateless_channel_access_token_response.rs
        ├── mod.rs
        └── verify_channel_access_token_response.rs

4 directories, 21 files
```

lib として生成されるので、`cargo` のワークスペース機能が使えそう。

https://doc.rust-jp.rs/book-ja/ch14-03-cargo-workspaces.html

そうした上で、どういう構成にしよう？と思って色々な crateを見てみると、Rocket辺りが良さそうな構成してる！

https://github.com/rwf2/Rocket

これを参考にすると、こういう構成になりそう。

```bash
.
├── Cargo.lock
├── Cargo.toml
├── core
│   ├── channel_access_token
│   │   ├── Cargo.toml
│   │   └── src
│   ├── insight
│   │   ├── Cargo.toml
│   │   └── src
│   ├── lib
│   │   ├── Cargo.toml
│   │   └── src
│   ├── liff
│   │   ├── Cargo.toml
│   │   └── src
│   ├── manage_audience
│   │   ├── Cargo.toml
│   │   └── src
│   ├── messaging_api
│   │   ├── Cargo.toml
│   │   └── src
│   ├── module
│   │   ├── Cargo.toml
│   │   └── src
│   ├── module_attach
│   │   ├── Cargo.toml
│   │   └── src
│   ├── shop
│   │   ├── Cargo.toml
│   │   └── src
│   └── webhook
│       ├── Cargo.toml
│       └── src
├── examples
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── actix_web_example
│   │   ├── Cargo.toml
│   │   └── src
│   └── rocket_example
│       ├── Cargo.toml
│       └── src
└── generator
    ├── Cargo.toml
    └── src
        └── main.rs
```

良さそう！

`generator` は `line-openapi` から rust のコードを生成する役割。

このクレートを公開するに当たって、`openapi-generator` で生成されたクレートも crate.io にあげないといけない。。。

`line-openapi` のライセンスは `Apache 2.0` なので、`openapi-generator` で生成されたコードにLINE Corp のライセンス表記を加えて公開した。

### 2. `reqwest` を使用するとクライアントが思ってたんとちゃう

`openapi-generator` で Rust コードを生成する場合、 `reqwest` がデフォルトで使用される。

例えば、`リプライメッセージを送信する` 関数の出力はこうなっている。

```rust,no_run
/// Send reply message
pub async fn reply_message(
    configuration: &configuration::Configuration,
    params: ReplyMessageParams,
) -> Result<ResponseContent<ReplyMessageSuccess>, Error<ReplyMessageError>> {
    let local_var_configuration = configuration;

    // unbox the parameters
    let reply_message_request = params.reply_message_request;

    let local_var_client = &local_var_configuration.client;

    let local_var_uri_str = format!("{}/v2/bot/message/reply", local_var_configuration.base_path);
    let mut local_var_req_builder =
        local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());

    ...
```

`Client` などの構造体はなく、ただ関数が出力されている。

他の関数も `configuration: &configuration::Configuration` を引数に取っているので、毎回引数に入れてあげないといけない・・・・・？？

それはめんどくさい。

`hyper` でも出力できるらしいので、試しに出力してみる。

https://openapi-generator.tech/docs/generators/rust/

`--library hyper` で生成してみる。

```rust,no_run
pub struct MessagingApiApiClient<C: hyper::client::connect::Connect>
where
    C: Clone + std::marker::Send + Sync + 'static,
{
    configuration: Rc<configuration::Configuration<C>>,
}

impl<C: hyper::client::connect::Connect> MessagingApiApiClient<C>
where
    C: Clone + std::marker::Send + Sync,
{
    pub fn new(configuration: Rc<configuration::Configuration<C>>) -> MessagingApiApiClient<C> {
        MessagingApiApiClient { configuration }
    }
}

pub trait MessagingApiApi {
    ...

    fn push_messages_by_phone(
        &self,
        pnp_messages_request: crate::models::PnpMessagesRequest,
        x_line_delivery_tag: Option<&str>,
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
    fn reply_message(
        &self,
        reply_message_request: crate::models::ReplyMessageRequest,
    ) -> Pin<Box<dyn Future

    ...
```

そうそう！
こういう感じ！

`hyper` を使用する方向で決定した瞬間

### 3. enum がちゃんと出力されない

`openapi-generator-cli` を使って、作成した Webhook Event の enum で問題は起こった・・・・

```rust,no_run:src/webhook/models/event.rs
/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "")]
pub enum Event {}
```

おいおい・・
Event の空 enum が定義されているだけ・・

とりあえず、手動で書き換えることにした。
各種 Event の構造体は `type` が定義されているので、一旦 `#[serde(untagged)]` で。

`untagged` の詳細は以下から。

https://serde.rs/enum-representations.html

```rust:src/webhook/models/event.rs
use super::{
    AccountLinkEvent, ActivatedEvent, BeaconEvent, BotResumedEvent, BotSuspendedEvent,
    DeactivatedEvent, FollowEvent, JoinEvent, LeaveEvent, MemberJoinedEvent, MemberLeftEvent,
    MessageEvent, ModuleEvent, PostbackEvent, ThingsEvent, UnfollowEvent, UnsendEvent,
    VideoPlayCompleteEvent,
};

/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Event {
    MessageEvent(MessageEvent),
    UnsendEvent(UnsendEvent),
    FollowEvent(FollowEvent),
    UnfollowEvent(UnfollowEvent),
    JoinEvent(JoinEvent),
    LeaveEvent(LeaveEvent),
    MemberJoinedEvent(MemberJoinedEvent),
    MemberLeftEvent(MemberLeftEvent),
    PostbackEvent(PostbackEvent),
    VideoPlayCompleteEvent(VideoPlayCompleteEvent),
    BeaconEvent(BeaconEvent),
    AccountLinkEvent(AccountLinkEvent),
    ThingsEvent(ThingsEvent),
    ModuleEvent(ModuleEvent),
    ActivatedEvent(ActivatedEvent),
    DeactivatedEvent(DeactivatedEvent),
    BotSuspendedEvent(BotSuspendedEvent),
    BotResumedEvent(BotResumedEvent),
}
```

ちなみに、`message_content` でも `source` でも起きていたので同じように修正・・・

- `openapi/src/webhook/models/message_content.rs`
- `source.rs`

ちょっと嫌な予感がしたので、一旦「メンバー退出イベント」を降らせてみる。

```rust,no_run
req: CallbackRequest {
    destination: "U078cd692e67a90a66af06d18865830e3",
    events: [
        UnfollowEvent(
            UnfollowEvent {
                type: "memberLeft",
                source: Some(
                    GroupSource(
                        GroupSource {
                            type: "group",
                            group_id: "C4e256f4c52df27c374275bb35f4e8e38",
                            user_id: None,
                        },
                    ),
                ),
                timestamp: 1701238025511,
                mode: Active,
                webhook_event_id: "01HGCV0BS3CYF5YTWX474SC5V1",
                delivery_context: DeliveryContext {
                    is_redelivery: false,
                },
            },
        ),
    ],
}
```

うわー！！！！

`UnfollowEvent` ！！！！！
`type: "memberLeft"` ！！！！

さっきも記載した [serdeの公式ドキュメント](https://serde.rs/enum-representations.html) の `untagged` の説明を抜粋

```
There is no explicit tag identifying which variant the data contains.
Serde will try to match the data against each variant in order and the first one that deserializes successfully is the one returned.
```

`UnfollowEvent` と `MemberLeftEvent` の構造体の定義が全く同じなんですよねぇ・・・・

なので、 メンバー退出イベント（MemberLeftEvent）が降ってきても先に定義されている `UnfollowEvent` に引っかかってしまう！！！

こうなったら、もう大変。
`untagged` が使えないので、 `tag` で一つずつ定義してあげる必要が・・・・

全 Event の構造体で以下の処理をしないといけない

- `r#type` 定義を削除
- new() の `r#type` 引数を削除

```diff:src/webhook/models/message_event.rs
@@ -2,9 +2,6 @@
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub struct MessageEvent {
-    /// Type of the event
-    #[serde(rename = "type")]
-    pub r#type: String,
     #[serde(rename = "source", skip_serializing_if = "Option::is_none")]
     pub source: Option<Box<crate::webhook::models::Source>>,
     /// Time of the event in milliseconds.

@@ -26,7 +23,6 @@ pub struct MessageEvent {
 impl MessageEvent {
     /// Webhook event object which contains the sent message.
     pub fn new(
-        r#type: String,
         timestamp: i64,
         mode: crate::webhook::models::EventMode,
         webhook_event_id: String,

@@ -34,7 +30,6 @@ impl MessageEvent {
         message: crate::webhook::models::MessageContent,
     ) -> MessageEvent {
         MessageEvent {
-            r#type,
             source: None,
             timestamp,
             mode,
```

これを全 Event で修正！！！！！
もはや自動生成の意味・・・・・

んで、enum Event はこう！

```rust,no_run
use super::{
    AccountLinkEvent, ActivatedEvent, BeaconEvent, BotResumedEvent, BotSuspendedEvent,
    DeactivatedEvent, FollowEvent, JoinEvent, LeaveEvent, MemberJoinedEvent, MemberLeftEvent,
    MessageEvent, ModuleEvent, PostbackEvent, ThingsEvent, UnfollowEvent, UnsendEvent,
    VideoPlayCompleteEvent,
};

/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Event {
    #[serde(rename = "message")]
    MessageEvent(MessageEvent),
    #[serde(rename = "unsend")]
    UnsendEvent(UnsendEvent),
    #[serde(rename = "follow")]
    FollowEvent(FollowEvent),
    #[serde(rename = "unfollow")]
    UnfollowEvent(UnfollowEvent),
    #[serde(rename = "join")]
    JoinEvent(JoinEvent),
    #[serde(rename = "leave")]
    LeaveEvent(LeaveEvent),
    #[serde(rename = "memberJoined")]
    MemberJoinedEvent(MemberJoinedEvent),
    #[serde(rename = "memberLeft")]
    MemberLeftEvent(MemberLeftEvent),
    #[serde(rename = "postback")]
    PostbackEvent(PostbackEvent),
    #[serde(rename = "videoPlayComplete")]
    VideoPlayCompleteEvent(VideoPlayCompleteEvent),
    #[serde(rename = "beacon")]
    BeaconEvent(BeaconEvent),
    #[serde(rename = "accountLink")]
    AccountLinkEvent(AccountLinkEvent),
    #[serde(rename = "things")]
    ThingsEvent(ThingsEvent),
    #[serde(rename = "module")]
    ModuleEvent(ModuleEvent),
    #[serde(rename = "activated")]
    ActivatedEvent(ActivatedEvent),
    #[serde(rename = "deactivated")]
    DeactivatedEvent(DeactivatedEvent),
    #[serde(rename = "botSuspended")]
    BotSuspendedEvent(BotSuspendedEvent),
    #[serde(rename = "botResumed")]
    BotResumedEvent(BotResumedEvent),
}
```

## 改善できる点

何と言っても `3. enum がちゃんと出力されない` の enum 問題・・・

ここをどうにか出来ないのか・・・

対処法としては以下の3つくらい？

- `line-openapi` 側を修正して enum が正常に出力されるようにする
- `openapi-generator` 側を修正して enum が正常に出力されるようにする
- Rust 側で各種 Event の構造体の `r#type` の定義を保持しつつ、enum 定義の tag にも `type` を使用する（できるなら）

**有識者さん、良い方法があれば教えてください。**

あと、rocket の example（Echo Bot）がまだ完成していないので、誰か書いてほしい・・・・・

https://github.com/nanato12/line-bot-sdk-rust/blob/develop/examples/rocket_example/src/main.rs

改善できる点はまだまだあると思うので、ぜひPR待ってます！！！