Rust で Toggl API を利用する

タイムトラッキングアプリである [Toggl](https://toggl.com/) の API を Rust で叩いてみたので、実装例のメモです。
HTTP リクエストを Rust の HTTP クライアントである [reqwest](https://docs.rs/reqwest/) を利用して送って利用します。
Rust 初心者なので、 Toggl API を通して、 HTTP リクエストの送信方法等を勉強することも兼ねました。

# API を叩く際に用意するもの

- API Token
    - API Token はユーザーごとに発行される。
    - 左下のアカウント名をクリック　→　「Profile settings」 からプロファイルを表示。下方に「API Token」が表示されている。
       ![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/753251/1f345292-7a07-ef07-c757-213c8ada0230.png)

- workspace_id
    - 「projects」等の画面を開いた際の URL にある7桁の数字
        - 例： `https://track.toggl.com/projects/1234567/list` の「1234567」
- project_id
    - 「projects」→特定のプロジェクトを開いた際に、URL にある9桁の数字
        - 例： `https://track.toggl.com/1234567/projects/123456789/team` の「123456789」

## ID を取得する別の方法

- 「Settings」→「Data export」を選択。「Projects」にチェックを入れた状態で「Compile file and send to email」をクリック。
    - 登録メールに json が送られてくる
![Untitled 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/753251/446c0251-cc88-90a3-69a5-323fc872a482.png)


- json の中身はこんな感じ：

    ```json
    [
    	{
    		"active":true,
    		"actual_hours":0,
    		"auto_estimates":null,
    		"billable":null,
    		"cid":null,
    		"client_id":null,
    		"color":"#e36a00",
    		"currency":null,
    		"estimated_hours":null,
    		"id":123456789,
    		"is_private":true,
    		"name":"Example",
    		"rate":null,
    		"template":null,
    		"wid":0,
    		"workspace_id":1234567
    	}
    ]
    ```

    - `id` が project_id, `workspace_id` が workspace_id になる

# 環境
- Rust 関連は以下の通り：

  ```shell
  $ rustc -V
  rustc 1.45.0 (5c1f21c3b 2020-07-13)
  $ cargo --version
  cargo 1.45.0 (744bd1fbb 2020-06-15)
  ```

- `Cargo.toml` には以下の内容を記載

    ```rust
    [dep[dependencies]
    reqwest = { version = "0.10", features = ["json"] }
    tokio = { version = "0.2", features = ["full"] }
    serde = "1.0.117"
    serde_derive = "1.0.117"
    serde_json = "1.0.60"
    dotenv = "0.15.0"
    config = "0.10.1"
    lazy_static = "1.4.0"endencies]
    ```

# Toggl API を利用する

代表的なものとして以下のものを記載：

- Workspace 一覧を取得する
- Workspace 内の Project ID 一覧を取得する
- Time Entry を操作する
    - Time Entry を作成する
    - Time Entry をスタートさせる
    - 進行中の Time Entry の情報を取得する
    - Time Entry を Stop させる

## 下準備

- API Token を環境変数に入れておく場合、起動時に設定内容を読み込むように処理を記述しておく
    - [dotenv](https://docs.rs/dotenv/) を利用して、プロジェクトルートに作成した `.env` に定義された環境変数を読み込むようにする
    - `.env` は以下のように作成する：

        ```
        API_TOKEN=XXX
        ```

- 予め、 Toggl API から返却される情報を入れるための構造体を用意しておく
    - API のドキュメントを参考に、構造体のメンバ変数を増減させれば、取得できる情報を増減できる

    ```rust
    #[macro_use]
    extern crate serde_derive;
    extern crate lazy_static;
    extern crate serde_json;
    use config::ConfigError;
    use dotenv::dotenv;
    use lazy_static::lazy_static;

    /**
     * 環境変数を読み込むための設定
     */
    /// 環境変数の内容を格納する構造体
    #[derive(Deserialize, Debug)]
    struct Config {
        api_token: String,
    }

    /// 環境変数からデータを読み込む処理を定義
    impl Config {
        pub fn from_env() -> Result<Self, ConfigError> {
            let mut cfg = config::Config::new();
            cfg.merge(config::Environment::new())?;
            cfg.try_into()
        }
    }

    /// 起動時に環境変数を読み込む
    lazy_static! {
        static ref CONFIG: Config = {
            dotenv().ok();
            Config::from_env().unwrap()
        };
    }

    /**
     * 取得した Toggl に関する情報を格納する構造体を定義する
     */
    /// ワークスペース情報
    #[derive(Deserialize, Debug)]
    struct Workspace {
        id: u64,
        name: String,
    }

    /// プロジェクト情報
    #[derive(Deserialize, Debug)]
    struct Project {
        id: u64,
        name: String,
    }

    /// Time Entry 情報の中身
    #[derive(Deserialize, Debug)]
    struct TimeEntryData {
        id: u64,
        pid: u64,
        wid: u64,
        start: String,
    }

    /// Time Entry 情報を取得した際に返却される情報の JSON に対応する構造体
    #[derive(Deserialize, Debug)]
    struct TimeEntryOutput {
        data: TimeEntryData,
    }

    /// Time Entry 情報を取得する際に送信する情報
    #[derive(Serialize, Deserialize, Debug)]
    struct TimeEntryInfo {
        description: String,
        tags: Vec<String>,
        pid: u64,
        created_with: String,
    }

    /// Time Entry 情報を取得する際に送信する情報の JSON に対応する構造体
    #[derive(Serialize, Deserialize, Debug)]
    struct TimeEntryInput {
        time_entry: TimeEntryInfo,
    }
    ```

## Workspace 一覧を取得する

- API の `curl` での利用方法は以下の通り：

    ```shell
    curl -v -u XXX:api_token \
    -X GET https://api.track.toggl.com/api/v8/workspaces
    ```

    - `-u USER[:PASS]` は BASIC 認証で、ここでは API Token を `XXX` に代入して実行する
- Rust で実装すると、以下の通り：

    ```rust
    /// 指定した API Token で利用可能なワークスペース一覧を取得する
    fn get_workspaces(api_token: &str) -> Vec<Workspace> {
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let client = reqwest::Client::new();
            let res: Vec<Workspace> = client
                .get("https://www.toggl.com/api/v8/workspaces")
                .basic_auth(api_token, Some("api_token"))
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res;
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let workspaces = get_workspaces(&CONFIG.api_token);
        for w in workspaces {
            println!("{}", w.id);
        }
        Ok(())
    }
    ```

## Workspace 内の Project ID 一覧を取得する

- 先程同様、 `curl` で Project 一覧を取得する方法は以下の通り：

    ```shell
    curl -v -u XXX:api_token \
    -X GET https://api.track.toggl.com/api/v8/workspaces/[workspace_id]/projects
    ```

    - `[workspace_id]` に Workspace ID を代入する
- Rust で実装すると、以下の通り：

    ```rust
    fn get_projects(api_token: &str, workspace_id: &str) -> Vec<Project> {
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: Vec<Project> = client
                .get(&format!(
                    "https://api.track.toggl.com/api/v8/workspaces/{}/projects",
                    workspace_id
                ))
                .basic_auth(api_token, Some("api_token"))
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let projects = get_projects(&CONFIG.api_token, "[workspace_id]");
        for p in projects {
            println!("{}", p.id);
        }
        Ok(())
    }
    ```

## Time Entry を操作する

### Time Entry を作成＆スタートさせる

- `curl` では以下のようにコマンドを実行する：

    ```shell
    curl -v -u XXX:api_token \
    	-H "Content-Type: application/json" \
    	-d '{"time_entry":{"description":"[タスク名を指定]","tags":["[適当なタグを指定]"],"pid":[project_id],"created_with":"[適当な名前を指定]"}}' \
    	-X POST https://api.track.toggl.com/api/v8/time_entries/start
    ```

    - JSON で Time Entry の情報を送信する
    - `[project_id]` には Time Entry を作成したい Project の ID を指定する
- Rust での実装は以下の通り：

    ```rust
    fn start_time_entry(api_token: &str, project_id: u64) -> TimeEntryOutput {
        let input = TimeEntryInfo {
            description: "[タスク名を指定]".to_string(),
            tags: vec!["[適当なタグを指定]".to_string()],
            pid: project_id,
            created_with: "[適当な名前を指定]".to_string(),
        };
        let time_entry = TimeEntryInput { time_entry: input };
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: TimeEntryOutput = client
            .post("https://api.track.toggl.com/api/v8/time_entries/start")
            .basic_auth(api_token, Some("api_token"))
            .json(&time_entry)
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
            return res;
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let time_entry = start_time_entry(&CONFIG.api_token, [project_id]);
        print!("{}", time_entry.data.start);
        Ok(())
    }
    ```

### 進行中の Time Entry の情報を取得する

- `curl` でのコマンド内容は以下の通り：

    ```shell
    curl -v -u XXX:api_token \
    -X GET https://api.track.toggl.com/api/v8/time_entries/current
    ```

- Rust での実装は以下の通り：

    ```rust
    fn get_running_time_entry(api_token: &str) -> TimeEntryOutput {
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        let client = reqwest::Client::new();

        rt.block_on(async {
            let res: TimeEntryOutput = client
            .get("https://api.track.toggl.com/api/v8/time_entries/current")
            .basic_auth(api_token, Some("api_token"))
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
            return res;
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let active_time_entry = get_running_time_entry(&CONFIG.api_token);
        print!("{}", active_time_entry.data.id);
        Ok(())
    }
    ```

### Time Entry を Stop させる

- curl でのコマンド内容は以下の通り：

    ```shell
    curl -v -u XXX:api_token \
    	-H "Content-Type: application/json" \
    	-X PUT https://api.track.toggl.com/api/v8/time_entries/[time_entry_id]/stop
    ```

    - `[time_entry_id]` に Stop させる Time Entry の ID を指定する
    - HTTP メソッドとして PUT を使用している点に注意
- Rust での実装は以下の通り：

    ```rust
    fn stop_time_entry(api_token: &str, time_entry_id: u64) -> TimeEntryOutput {
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: TimeEntryOutput = client
                .put(&format!(
                    "https://api.track.toggl.com/api/v8/time_entries/{}/stop",
                    time_entry_id
                ))
                .basic_auth(api_token, Some("api_token"))
                .header(reqwest::header::CONTENT_TYPE, "application/json")
                .json("")
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res;
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let stopped_time_entry = stop_time_entry(&CONFIG.api_token, [time_entry_id]);
        print!("{}", stopped_time_entry.data.id);
        Ok(())
    }
    ```

# まとめ

- ここまで実装した内容を `main.rs` にまとめると以下の通り：

    ```rust
    #[macro_use]
    extern crate serde_derive;
    extern crate lazy_static;
    extern crate serde_json;
    use config::ConfigError;
    use dotenv::dotenv;
    use lazy_static::lazy_static;

    /**
     * 環境変数を読み込むための設定
     */
    /// 環境変数の内容を格納する構造体
    #[derive(Deserialize, Debug)]
    struct Config {
        api_token: String,
    }

    /// 環境変数からデータを読み込む処理を定義
    impl Config {
        pub fn from_env() -> Result<Self, ConfigError> {
            let mut cfg = config::Config::new();
            cfg.merge(config::Environment::new())?;
            cfg.try_into()
        }
    }

    /// 起動時に環境変数を読み込む
    lazy_static! {
        static ref CONFIG: Config = {
            dotenv().ok();
            Config::from_env().unwrap()
        };
    }

    /**
     * 取得した Toggl に関する情報を格納する構造体を定義する
     */
    /// ワークスペース情報
    #[derive(Deserialize, Debug)]
    struct Workspace {
        id: u64,
        name: String,
    }

    /// プロジェクト情報
    #[derive(Deserialize, Debug)]
    struct Project {
        id: u64,
        name: String,
    }

    /// Time Entry 情報の中身
    #[derive(Deserialize, Debug)]
    struct TimeEntryData {
        id: u64,
        pid: u64,
        wid: u64,
        start: String,
    }

    /// Time Entry 情報を取得した際に返却される情報の JSON に対応する構造体
    #[derive(Deserialize, Debug)]
    struct TimeEntryOutput {
        data: TimeEntryData,
    }

    /// Time Entry 情報を取得する際に送信する情報
    #[derive(Serialize, Deserialize, Debug)]
    struct TimeEntryInfo {
        description: String,
        tags: Vec<String>,
        pid: u64,
        created_with: String,
    }

    /// Time Entry 情報を取得する際に送信する情報の JSON に対応する構造体
    #[derive(Serialize, Deserialize, Debug)]
    struct TimeEntryInput {
        time_entry: TimeEntryInfo,
    }

    /// ワークスペース一覧を取得する
    fn get_workspaces(api_token: &str) -> Vec<Workspace> {
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let client = reqwest::Client::new();
            let res: Vec<Workspace> = client
                .get("https://www.toggl.com/api/v8/workspaces")
                .basic_auth(api_token, Some("api_token"))
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res;
        })
    }

    /// プロジェクト一覧を取得する
    fn get_projects(api_token: &str, workspace_id: u64) -> Vec<Project> {
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: Vec<Project> = client
                .get(&format!(
                    "https://api.track.toggl.com/api/v8/workspaces/{}/projects",
                    workspace_id
                ))
                .basic_auth(api_token, Some("api_token"))
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res
        })
    }

    /// Timne Entry を作成し、スタートさせる
    fn start_time_entry(api_token: &str, project_id: u64) -> TimeEntryOutput {
        let input = TimeEntryInfo {
            description: "[タスク名を指定]".to_string(),
            tags: vec!["[適当なタグを指定]".to_string()],
            pid: project_id,
            created_with: "[適当な名前を指定]".to_string(),
        };
        let time_entry = TimeEntryInput { time_entry: input };
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: TimeEntryOutput = client
            .post("https://api.track.toggl.com/api/v8/time_entries/start")
            .basic_auth(api_token, Some("api_token"))
            .json(&time_entry)
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
            return res;
        })
    }

    /// 進行中の Time Entry を取得する
    fn get_running_time_entry(api_token: &str) -> TimeEntryOutput {
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: TimeEntryOutput = client
            .get("https://api.track.toggl.com/api/v8/time_entries/current")
            .basic_auth(api_token, Some("api_token"))
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
            return res;
        })
    }

    /// 指定した ID の Time Entry を停止する
    fn stop_time_entry(api_token: &str, time_entry_id: u64) -> TimeEntryOutput {
        let client = reqwest::Client::new();
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let res: TimeEntryOutput = client
                .put(&format!(
                    "https://api.track.toggl.com/api/v8/time_entries/{}/stop",
                    time_entry_id
                ))
                .basic_auth(api_token, Some("api_token"))
                .header(reqwest::header::CONTENT_TYPE, "application/json")
                .json("")
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();
            return res;
        })
    }

    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let workspaces = &get_workspaces(&CONFIG.api_token);
        for w in workspaces {
            println!("{}", w.id);
        }

        let projects = &get_projects(&CONFIG.api_token, workspaces[0].id);
        for p in projects {
            println!("{}", p.id);
        }
        
        let started_time_entry = start_time_entry(&CONFIG.api_token, projects[0].id);
        print!("{}", started_time_entry.data.start);

        let active_time_entry = get_running_time_entry(&CONFIG.api_token);
        print!("{}", active_time_entry.data.id);

        let stopped_time_entry = stop_time_entry(&CONFIG.api_token, active_time_entry.data.id);
        print!("{}", stopped_time_entry.data.id);
        
        Ok(())
    }
    ```

- Toggl API を例として Web API の利用方法も学びました。
    - 非同期処理の方法もよくわかってなかったので、調査に時間がかかってしまいました。。。

# 参考資料

- [Toggl API Documentation](https://github.com/toggl/toggl_api_docs)
    - API の内容全般について参考にしました。
- [PythonでTogglのAPIをたたく - Qiita](https://qiita.com/stu345/items/abebeb2da6d97382a7b1)
    - API を利用する際のコーディングを参考にしました。
- [Rustで環境変数を扱う](https://dev.classmethod.jp/articles/rust-environment-variables/)
    - Rust で環境変数を扱う方法を参考にしました
- [Rustで設定ファイルの内容をグローバル変数に保存する - Qiita](https://qiita.com/tiohsa/items/d694dfbfce52da09ea53)
    - 起動時に環境変数を読み込む方法を参考にしました。