RustでAPIを叩くCLIツールを作る

# はじめに
これは[Rustその2 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/rust-lang-2)の18日目の記事です。

こんにちわ、[@Khigashiguchi](https://twitter.com/Khigashiguchi)です。
今回は、業務上使用しているドキュメント共有ツールのAPIを叩くCLIツールを作りましたので、そのやり方をご紹介いたします。

# 今回作ったもの
勤務先で[DocBase](https://docbase.io/)というドキュメント共有サービスを使っています。[APIドキュメント](https://help.docbase.io/posts/45703)を元に、自分のローカルPCにあるmarkdownファイルをターミナル内で即座にアップするツールを作りました。
[Khigashiguchi/docbase-cli](https://github.com/Khigashiguchi/docbase-cli/tree/0.1.0)に今回のコードを公開しています。

目標は、下記の利用方法を持つCLIツールです。

```bash
$ docbase-cli -h

DocBase API Command Line Interface Application

USAGE:
    docbase-cli
    docbase-cli post <post-file-path>... <post-title>...
    docbase-cli (-h | --help)
    docbase-cli --version

Options:
    -h, --help      Show this screen.
    --version       Show version.
```

# 開発目次
- コマンドラインオプション解析
- Markdownファイルの読み込み
- リクエスト内のjsonを作成
- HTTPSリクエスト

# コマンドラインオプション解析
CLIを作る上でまずコマンドライン入力を解析してプログラムに渡す必要があります。今回は、[docopt](https://docs.rs/docopt/0.8.1/docopt/)というcrateを利用しました。

- Cargo.toml

```rust
[dependencies]
docopt = "0.8"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0.8"
futures = "0.1"
hyper = "0.11"
tokio-core = "0.1"
hyper-tls = "0.1.2"
dotenv = "0.10.1"

[dependencies.jsonway]
git = "https://github.com/rustless/jsonway"
```

今回は、使用するcratesを最初に全部読み込みます。

- main.rs

```rust
#[macro_use]
extern crate serde_derive;
extern crate docopt;

const USAGE: &'static str = "
DocBase API Command Line Interface Application

USAGE:
    docbase-cli
    docbase-cli post <post-file-path>... <post-title>...
    docbase-cli (-h | --help)
    docbase-cli --version

Options:
    -h, --help      Show this screen.
    --version       Show version.
";

#[derive(Debug, Deserialize)]
pub struct Args {
    cmd_post: bool,
    arg_post_file_path: Vec<String>,
    arg_post_title: Vec<String>,
}


fn main() {
    let args: Args = Docopt::new(USAGE)
    .and_then(|d| d.deserialize())
    .unwrap_or_else(|e| e.exit());

    println!("{:?}", args);
}
```

上記の実装で、--helpで利用方法を出力することができるようになります。

```bash
$ docbase-cli -h

DocBase API Command Line Interface Application

USAGE:
    docbase-cli
    docbase-cli post <post-file-path>... <post-title>...
    docbase-cli (-h | --help)
    docbase-cli --version

Options:
    -h, --help      Show this screen.
    --version       Show version.
```

今回は、加えて　``$ docbase-cli post <post-file-path>... <post-title>...``　で渡されたオプションを元に処理をしたいので実装を追加します。今回は、オプションごとの処理をDocbase　Traitに実装しました。

- main.rs

```rust
#[macro_use]
extern crate serde_derive;
extern crate docopt;

+ mod docbase;

+ use docopt::Docopt;
+ use docbase::Docbase;

...

fn main() {
    let args: Args = Docopt::new(USAGE)
    .and_then(|d| d.deserialize())
    .unwrap_or_else(|e| e.exit());

    + let mut docbase = Docbase::new();
    + docbase.run(args);
}
```

オプションごとの処理をDocbase.rsに定義していきます。

- docbase.rs

```rust
use super::Args;

pub struct Docbase {
}

impl Docbase {
    pub fn new() -> Docbase {
        Docbase {}
    }
    pub fn run(&mut self, args: Args) {
        if args.cmd_post {
            self.execute_post(args.arg_post_file_path, args.arg_post_title);
        } else {
            println!("{:?}", args);
        }
    }
    fn execute_post(&self, post_file_path: Vec<String>, post_title: Vec<String>) {
    }
}
```

実装方法としては、Docbase::runにコマンドオプションであるargsを渡します。cmd_post（``docbase-cli post``）であれば、execute_postメソッドを呼び出すという方式です。
execute_postの中では、下記の処理が行われる必要があります。

- コマンドオプションの``arg_post_file_path``を元にローカルのファイルをStringに読み込む。
- リクエスト内のjsonを作成
- HTTPSリクエスト
- jsonレスポンスの解析

# Markdownファイルの読み込み
実際にDocbaseに投げるローカルにあるMarkdownファイルの読み込みを行います。

- docbase.rs

```rust
+ use std::error::Error;
+ use std::fs::File;
+ use std::io::prelude::*;
+ use std::path::Path;
use super::Args;

    fn execute_post(&self, post_file_path: Vec<String>, post_title: Vec<String>) {
        // argsからファイルパスを取得する
        + let post_file = &post_file_path[0];
        + let path = Path::new(post_file);
        + let display = path.display();

        // ファイルを開く
        + let mut file = match File::open(&path) {
            Err(why) => panic!("Couldn't open {}: {}", display, Error::description(&why)),
            Ok(file) => file,
        };

        // ファイル内のテキストを読み込む
        + let mut s = String::new();
        + let body = match file.read_to_string(&mut s) {
            Err(why) => panic!("Couldn't read {}: {}", display, Error::description(&why)),
            Ok(_) => s
    }
```

# リクエスト内のjsonを作成
jsonを生成するために、[jsonway](https://crates.io/crates/jsonway) crateを利用します。

- docbase.rs

```rust
+ extern crate jsonway;

use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
use super::Args;

    fn execute_post(&self, post_file_path: Vec<String>, post_title: Vec<String>) {
        // argsからタイトルを取得する
        + let title = &post_title[0];

        // argsからファイルパスを取得する
        let post_file = &post_file_path[0];
        let path = Path::new(post_file);
        let display = path.display();

        // ファイルを開く
        let mut file = match File::open(&path) {
            Err(why) => panic!("Couldn't open {}: {}", display, Error::description(&why)),
            Ok(file) => file,
        };

        // ファイル内のテキストを読み込む
        let mut s = String::new();
        let body = match file.read_to_string(&mut s) {
            Err(why) => panic!("Couldn't read {}: {}", display, Error::description(&why)),
            Ok(_) => s

        // jsonを構築する
        + let json = jsonway::object(|json| {
            json.set("title", title.to_string());
            json.set("body", body.to_string());
            json.set("draft", "true".to_string());
        }).unwrap().to_string();
    }

```

# HTTPSリクエスト
HTTPSリクエストを生成・送信するために[hyper](https://crates.io/crates/hyper) crateを利用します。

- docbase.rs

```rust
+ extern crate hyper;
+ extern crate hyper_tls;
+ extern crate tokio_core;
+ extern crate futures;
extern crate jsonway;

+ use std::env;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
+ use self::hyper::Client;
+ use self::hyper::{Method, Request};
+ use self::hyper::header::{ContentType};
+ use self::hyper_tls::HttpsConnector;
+ use self::tokio_core::reactor::Core;
+ use self::dotenv::dotenv;
+ use self::futures::{Future, Stream};
+ use self::serde_json::Value;
use super::Args;

    fn execute_post(&self, post_file_path: Vec<String>, post_title: Vec<String>) {
        // argsからタイトルを取得する
        let title = &post_title[0];

        // argsからファイルパスを取得する
        let post_file = &post_file_path[0];
        let path = Path::new(post_file);
        let display = path.display();

        // ファイルを開く
        let mut file = match File::open(&path) {
            Err(why) => panic!("Couldn't open {}: {}", display, Error::description(&why)),
            Ok(file) => file,
        };

        // ファイル内のテキストを読み込む
        let mut s = String::new();
        let body = match file.read_to_string(&mut s) {
            Err(why) => panic!("Couldn't read {}: {}", display, Error::description(&why)),
            Ok(_) => s

        // jsonを構築する
        let json = jsonway::object(|json| {
            json.set("title", title.to_string());
            json.set("body", body.to_string());
            json.set("draft", "true".to_string());
        }).unwrap().to_string();

        // URIを生成する
        + let docbase_domain = "<your-team-name>";
        + let docbase_base_uri = "https://api.docbase.io/teams/";
        + let docbase_uri = format!("{}{}{}", docbase_base_uri, docbase_domain, "/posts");

        // 環境変数からトークンを取得する
        + let docbase_token = env::var("DOCBASE_TOKEN").unwrap();

        // HTTPSクライアントインスタンスを生成
        + let mut core = Core::new().unwrap();
        + let handle = core.handle();
        + let client = Client::configure()
            .connector(HttpsConnector::new(1, &handle).unwrap())
            .build(&handle);

        // リクエストを生成
        + let uri = docbase_uri.parse().unwrap();
        + let mut req = Request::new(Method::Post, uri);
        // リクエストヘッダーを付与
        + req.headers_mut().set(ContentType::json());
        + req.headers_mut().set_raw("X-Api-Version", "1");
        + req.headers_mut().set_raw("X-DocBaseToken", docbase_token);
        // リクエストボディを付与
        + req.set_body(json);

        + let post = client.request(req).and_then(|res| {
            println!("POST: {}", res.status());
            res.body().concat2().and_then(move |body| {
                let v: Value = serde_json::from_slice(&body).unwrap();
                println!("Success! The url posted is {}.", v["url"].to_string());
                Ok(())
            })
        });
        + core.run(post).unwrap();
    }
```

上記の実装より、下記の結果を得ることができました。

```bash
$ docbase-cli post hello.md "This is the first post from command"

POST: 201 Created
Success! The url posted is "<url>".
```

# 最後に
様々、cratesを利用することで簡単にCLIツールを作ることができました。
CLIを作る上で参考になれば幸いです。

# 参考
- [docopt](https://docs.rs/docopt/0.8.1/docopt/)
- [Rust のコマンドラインオプション解析色々](http://ubnt-intrepid.hatenablog.com/entry/rust_commandline_parsers)