Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

GoでDocBaseAPIのCLIクライアントを作ってみよう

この記事はモバイルファクトリー Advent Calendar 2020 22日目の記事です。

こんにちは。エンジニアの id:Eadaeda です。普段はサーバーサイドの面倒を見ています。

DocBase

弊社ではドキュメント共有ツールとしてDocBaseを利用しています。Markdown形式で書いた文章を投稿することが出来、記事の埋め込みや検索なども便利です。私もチケットのワークログや議事録、シェアナレ!で書いたものなどを投稿しています。

docbase.io

ブラウザ内のエディタでDocBaseの記事(DocBaseではメモとよんでいます)を編集する場合は、エディターへ画像や動画などのファイルをドラッグアンドドロップすることでアップロードすることが出来ます。その時、以下のように自動でMarkdownを挿入してくれます。

画像なら
![ファイル名](URL)

動画などならリンク
[動画](URL)

なので、ブラウザ内のエディタを使っている場合、ファイルを挿入したい位置にカーソルをあわせ、ドラッグアンドドロップでアップロードするだけで良いのです。特段珍しい機能とは思いませんが、嬉しい機能ですよね。

ところで、私は何らかの文章を書くとき、EmacsやVim、nanoといったコマンドライン上で動作するテキストエディタを使っていて、この記事もそこで書いています。ちなみにどのエディタを使っているかはナイショです。

こういったエディタを使うのは、出来るだけターミナルから動きたくないからなのですが、そこでメモを書いていると困ってしまうことが1つあります。メモに貼り付けたい画像などのURLが先にわからないことです。

![image](ここがわからない)

これはなかなか大変なことです。書いているときは、例えば画像を挿入したい位置に __ここに画像__ などのマーカーを置いておき、あとからブラウザ内エディタにコピペ、マーカーを探して順番通りに画像をドラッグアンドドロップしていかなければなりません。挿入したいファイルが現れるたびにアップロードし、URLを取得する方法もありますが、先に述べたように私は出来るだけターミナルから動きたくありませんでした。

これをなんとかしたい…というよりはなんとかしなければならなかったので、なにか使えるものは無いかと探していました。するとDocBaseが公開しているAPIを見つけました。

help.docbase.io

このAPIはレスポンスとして以下のようなJSONを返すようです。

[
    {
        "created_at": "[ここは投稿した時間]",
        "id": "[ここはID]",
        "markdown": "![example.png]([ここは画像へのURL])",
        "name": "example.png",
        "size": 285,
        "url": "[ここも画像へのURL]"
    }
]

嬉しいことにMarkdownへ埋め込みが返されます。これをメモに貼り付ければ良いので、一度もターミナルから離れずにメモを完成させられそうですね。

CLIツールにする

さて、毎回curlコマンドを叩くのも良いのですが、いささか書き換えが面倒です。アップロードしたいファイルへのパスを与えるだけでアップロードまで行ってくれるシェル関数を書くなどをするのも有りでしょう。

でも今回は、Goで専用のCLIツールを作ってみようと思います。ここでようやく本題です。前置きが長くて申し訳ない。ちなみになぜGoでCLIツールを作るのかと言うと、単純にGoでCLIを作るのが好きなのと社内でGoを使ったCLIツールを作る人が増えてほしいからです。

GoにはCLIフレームワークとして公開されているものがたくさん有りますが、今回はcobraを使います。この間公開されたGitHubのCLIツールにも使われていますね。私もよく使いますが、後に紹介するviperとの連携が強力で好きです。

github.com github.com

まずはどんなものを作ったのか、どんなものが作れるのかを理解してもらいたいので、ヘルプの出力とuploadサブコマンドでexample.pngをアップロードしたときの様子を以下のGIFに示します。

このような感じですね。非常にシンプルです。

実装

さて、早速私が実装したものを見てもらいます。

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

// docbaseコマンドの本体
var rootCmd = &cobra.Command{
    Version: "0.0.1",
    PreRunE: func(cmd *cobra.Command, args []string) error {
        // API Tokenとチーム名は必須なので、どちらかが空な場合はアプリを終了する
        if len(viper.GetString("token")) == 0 {
            return errors.New("DocBase API Tokenが空です")
        }
        if len(viper.GetString("team")) == 0 {
            return errors.New("チーム名が空です")
        }
        return nil
    },
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("これはサブコマンドを指定しなかったときに実行されるコードだよ")
    },
}

// uploadサブコマンド
// docbase uplaod という感じに呼び出せる
var uplaodCmd = &cobra.Command{
    Use: "upload",
    Run: func(cmd *cobra.Command, args []string) {
        err := upload(args[0])
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    },
}

func upload(path string) error {
    filename := filepath.Base(path)
    fp, err := os.Open(path)
    if err != nil {
        return err
    }
    defer fp.Close()

    content, err := ioutil.ReadAll(fp)
    if err != nil {
        return err
    }

    // viperからAPIトークンとチーム名の値をもらう
    token := viper.GetString("token")
    team := viper.GetString("team")

    // リクエストボディを作る
    // [
    // {"name": filename, "content": content}
    // ]
    data, err := json.Marshal([]struct {
        Name    string `json:"name"`
        Content []byte `json:"content"`
    }{
        {Name: filename, Content: content},
    })
    if err != nil {
        return err
    }
    body := bytes.NewBuffer(data)

    // http.Request{}を作る
    r, err := http.NewRequestWithContext(
        context.Background(),
        "POST",
        fmt.Sprintf("https://api.docbase.io/teams/%s/attachments", team),
        body)

    // ヘッダーに環境変数から拾ったアクセストークンをセット。これがないと弾かれる
    r.Header.Set("X-DocBaseToken", token)
    r.Header.Set("Content-Type", "application/json")

    // ここから実際にリクエストを投げる処理
    client := &http.Client{}
    res, err := client.Do(r)
    if err != nil {
        return err
    }
    defer res.Body.Close()

    // レスポンスを読んで、Stdoutに出力
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return err
    }

    // Jsonの出力
    var b []byte
    resJson := bytes.NewBuffer(b)
    json.Indent(resJson, resBody, "", "  ")
    fmt.Println(resJson.String())

    return nil
}

func init() {
    // オプションの追加
    // PersistentFlags に追加すると、追加されたcobra.Commandに追加したサブコマンドでも使えるオプションになる
    // そうしたくない場合は Flags に追加すれば良い
    // --tokenオプション、文字列を受け付ける。デフォルト値は ""、最後の引数は --helpで出力されるそのオプションの説明
    rootCmd.PersistentFlags().String("token", "", "DocBase API Token")
    // こっちは--teamオプション。
    rootCmd.PersistentFlags().String("team", "", "Team name")

    // viperで値を管理
    // "token" に、`token`という名前のオプションの値をバインドする
    viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
    // "token" に、`DOCBASE_TOKEN` という環境変数の値をバインドする
    viper.BindEnv("token", "DOCBASE_TOKEN")

    viper.BindPFlag("team", rootCmd.PersistentFlags().Lookup("team"))
    viper.BindEnv("team", "DOCBASE_TEAM_NAME")

    // uploadサブコマンドを追加する。
    rootCmd.AddCommand(uplaodCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

かなり愚直な実装になりましたが、とりあえずできました。ではこれを使って実際にファイルをアップロードしてみましょう

$ export DOCBASE_TOKEN="ここにあなたのアクセストークン"
$ export DOCBASE_TEAM_NAME="ここにあなたのチーム名"

$ go run main.go upload ./example.png

これで、以下のようなレスポンスが返ってきました。

[
  {
    "id": "アップロードしたファイルのID",
    "name": "example.png",
    "size": 285,
    "url": "https://image.docbase.io/uploads/アップロードしたファイルのID",
    "markdown": "![example.png](画像へのURL)",
    "created_at": "2020-12-08T17:17:09+09:00"
  }
]

アップロード出来ていそうですね。ここから"markdown"の値を取り出し、メモにを貼り付けるだけです。

あとは、go buildgo installでバイナリを得れば良いですね。

$ go build -o docbase
$ mv ./docbase "$PATHの通っているところ"

$ go install

オプション

今回の実装では、cobraviperを組み合わせて、APIトークンとチーム名をオプションで切り替えられるようにしました。ちょうど実装の以下の部分ですね。

func init() {
    // オプションの追加
    // PersistentFlags に追加すると、追加されたcobra.Commandに追加したサブコマンドでも使えるオプションになる
    // そうしたくない場合は Flags に追加すれば良い
    // --tokenオプション、文字列を受け付ける。デフォルト値は ""、最後の引数は --helpで出力されるそのオプションの説明
    rootCmd.PersistentFlags().String("token", "", "DocBase API Token")
    // こっちは--teamオプション。
    rootCmd.PersistentFlags().String("team", "", "Team name")

    // viperで値を管理
    // "token" に、`token`という名前のオプションの値をバインドする
    viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
    // "token" に、`DOCBASE_TOKEN` という環境変数の値をバインドする
    viper.BindEnv("token", "DOCBASE_TOKEN")

    viper.BindPFlag("team", rootCmd.PersistentFlags().Lookup("team"))
    viper.BindEnv("team", "DOCBASE_TEAM_NAME")

    // uploadサブコマンドを追加する。
    rootCmd.AddCommand(uplaodCmd)
}

オプションが指定されればその値を、されなければ環境変数を使う。というような感じで viperが値をよしなに決定してくれます。これにより、以下のような使い方で、APIトークンとチーム名を切り替えられるようになりました。

# オプションなし。環境変数を読みに行く
$ docbase

# --tokenに値を渡す
# APIトークンは `hoge` として処理がすすむ。チーム名は環境変数の値のまま
$ docbase --token=hoge

# --teamも渡してみる
# APIトークンは `hoge`、チーム名は `fuga` として処理される
$ docbase --token=hoge --team=fuga

cobra, viperは非常に強力で他にもまだまだできることがあるので、是非READMEなどを読んでみてほしいです。

まとめ

今回は「ターミナルから動かずにDocBaseにファイルをアップロードしたい!」という願いを叶えるため、Goを使って、DocBaseにファイルをアップロードするCLIツールを作りました。フレームワークとしては cobraviperを選択し、この後の機能拡張も考えて「アップロード処理を行うuploadサブコマンド」として実装しました。これをきっかけに社内でGoが大流行すればいいなあと思っています。

また、本当にターミナルから一歩も離れないことを目指すのであれば、メモの更新・作成を行うサブコマンドを実装する必要がありますが、投稿する前にプレビューなども見たいので、そこは手動にすることとしました。他のAPIは今後ほしいという要望が(私から)出れば実装すると思います。

明日の記事は id:nesh さんです!楽しみですね!