ClojureとHaskellで簡単なコマンドラインツール(RESTクライアント)を作ってみた

昨年末のAdvent Calendarで2回に分けて紹介した[situated-program-challenge](https://github.com/clj-nakano/situated-program-challenge)のClojure/Duct版実装とHaskell/Yesod版実装ですが、そこではもともとの課題のうちRESTサーバしか実現できていなかったため、今回新たにRESTクライアント実装についてご紹介します。

- Clojure/Duct版RESTサーバ: [lagenorhynque/situated-program-challenge/rest-server at clj-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/clj-version1/rest-server)
    - [ClojureのDuctでWeb API開発してみた - Qiita](https://qiita.com/lagenorhynque/items/57d5aa086c4a080a1c54)

- Haskell/Yesod版RESTサーバ: [lagenorhynque/situated-program-challenge/rest-server at hs-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/hs-version1/rest-server)
    - [ClojurianがHaskellでWeb API開発に入門してみた - Qiita](https://qiita.com/lagenorhynque/items/f8b14ff70a26cfd27976)

ちなみに、situated-program-challengeのその他の実装例は、本記事執筆時点で以下のものがあるようです。

- Clojure版 by @iku000888 さん: [iku000888/situated-program-challenge at clj-solution](https://github.com/iku000888/situated-program-challenge/tree/clj-solution)
- Scala版 by @shinichy さん: [shinichy/situated-program-challenge at version1](https://github.com/shinichy/situated-program-challenge/tree/version1)
- Common Lisp版 by @masatoi0 さん: [masatoi/situated-program-challenge at cl-version1](https://github.com/masatoi/situated-program-challenge/tree/cl-version1)
    - [Common LispでREST APIを作ってみる](https://masatoi.github.io/2017/12/23/cl-situated-program-challenge)
    - [RoswellのスクリプトでREST APIのクライアントを書いて実行ファイルにするまで](https://masatoi.github.io/2018/01/01/rest-client-roswell)
- Ruby版 by @toku345 さん: [toku345/situated-program-challenge at rb-version1](https://github.com/toku345/situated-program-challenge/tree/rb-version1)

# 仕様の詳細検討

situated-program-challengeにおける[RESTクライアントの仕様](https://github.com/clj-nakano/situated-program-challenge#%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%EF%BC%92-rest%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88)を検討した結果、以下のように引数の仕様に若干の変更を加えることにした。

- POSTデータは `key1=value1 key2=value2 ...` という形式で列挙するのではなく、標準入力でJSON文字列を渡すように変更(上記のScala版と同様)
- HTTPメソッド文字列は使いやすくcase-insensitiveで指定できるように変更(上記のCommon Lisp版と同様)

また、エラー処理については以下のように扱うことに決めた。

- 引数が2個以外の個数指定された場合にはエラー
- 引数で指定されたHTTPメソッドがGET, POST以外の場合にはエラー
- POSTの場合にJSONデータが正常にパースできなければエラー
- HTTPステータスコードが200以外の場合にはステータスコードも標準出力

# Clojure版

- Clojure版RESTクライアント: [lagenorhynque/situated-program-challenge/rest-client at clj-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/clj-version1/rest-client)

Clojure版では、HTTPクライアントとして定番のひとつと思われる[clj-http](https://github.com/dakrone/clj-http)とJSON操作に[Cheshire](https://github.com/dakrone/cheshire)を利用する。

## 1. プロジェクトの生成

今回は特に土台とすべきフレームワーク/ライブラリもないため、コマンドラインアプリを作るのに手軽な `app` テンプレートでプロジェクト生成する。

```sh
$ lein new app rest-client
$ cd rest-client
```

## 2. 実装

- [rest-client/src/rest_client/core.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version1/rest-client/src/rest_client/core.clj)

```clojure
(ns rest-client.core
  (:gen-class)
  (:require [cheshire.core :as cheshire]
            [clj-http.client :as client]
            [clojure.string :as str])
  (:import (java.io BufferedReader)))

(defn read-json-from-stdin []
  (cheshire/parse-stream (BufferedReader. *in*)))

(defmulti http (fn [_ method] (str/upper-case method)))
(defmethod http "GET" [url _]
  (client/get url {:accept :json
                   :throw-exceptions false}))
(defmethod http "POST" [url _]
  (client/post url {:form-params (read-json-from-stdin)
                    :content-type :json
                    :accept :json
                    :throw-exceptions false}))
(defmethod http :default [_ method]
  (throw (UnsupportedOperationException. (str "Unsupported HTTP method: " method))))

(defn -main [& args]
  (when (not= (count args) 2)
    (throw (IllegalArgumentException. "Exactly 2 arguments must be specified")))
  (let [{:keys [status body]} (apply http args)]
    (when (not= status 200)
      (print (str status \tab)))
    (println body)))
```

HTTPメソッドによって処理を振り分ける部分については、単純に `case` や `cond` などで条件分岐させることもできたが、外からの拡張に開いたマルチメソッド(cf. [Runtime Polymorphism](https://clojure.org/about/runtime_polymorphism))を利用してみた。

エラー処理はここでは単純に例外をスローすることにした。

## 3. 動作確認

RESTサーバ(cf. [README](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version1/rest-server/README.md))をあらかじめ起動した状態で

```sh
$ lein uberjar
$ java -jar target/uberjar/rest-client-0.1.0-SNAPSHOT-standalone.jar [args]
```

または

```sh
$ lein run [args]
```

で実行できる。

もしくは、`deps.edn` を以下のように用意してあるので、Leiningenではなく[clj](https://clojure.org/guides/deps_and_cli)コマンドから実行することもできる。

- [rest-client/deps.edn](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version1/rest-client/deps.edn)

```clojure
{:paths ["src"]
 :deps {cheshire {:mvn/version "5.8.1"}
        clj-http {:mvn/version "3.9.1"}
        org.clojure/clojure {:mvn/version "1.10.0"}}
 :aliases {:test {:extra-paths ["test"]}}}
```

```sh
$ clj -m rest-client.core [args]
```

例えば、

- メンバー一覧情報の取得

```sh
$ lein uberjar
$ java -jar target/uberjar/rest-client-0.1.0-SNAPSHOT-standalone.jar http://localhost:3000/members GET
[{"first-name":"You","last-name":"Watanabe","email":"y.watanabe@uranohoshi.ac.jp","member-id":1},{"first-name":"Yoshiko","last-name":"Tsushima","email":"y.tsushima@uranohoshi.ac.jp","member-id":2}]
```

- メンバーの登録

```sh
$ java -jar target/uberjar/rest-client-0.1.0-SNAPSHOT-standalone.jar http://localhost:3000/members POST
{"first-name": "Dia",
 "last-name": "Kurosawa",
 "email": "d.kurosawa@uranohoshi.ac.jp"}
{"first-name":"Dia","last-name":"Kurosawa","email":"d.kurosawa@uranohoshi.ac.jp","member-id":3}
```

# Haskell版

- Haskell版RESTクライアント: [lagenorhynque/situated-program-challenge/rest-client at hs-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/hs-version1/rest-client)

HaskellでHTTPクライアントライブラリに何を利用するか、[wreq](https://hackage.haskell.org/package/wreq)や[req](https://hackage.haskell.org/package/req)なども検討したが、最終的には

[Making HTTP requests - http-client library](https://haskell-lang.org/library/http-client)

を参考に[http-conduit](https://hackage.haskell.org/package/http-conduit)を利用することにした。

また、RESTサーバ実装と同様にJSON操作には[aeson](https://hackage.haskell.org/package/aeson)を利用する。

## 1. プロジェクトの生成

Haskell版についても、今回は特にベースとなるフレームワーク/ライブラリはなしで単純にプロジェクト生成した。

```sh
$ stack new rest-client
$ cd rest-client
```

## 2. 実装

- [rest-client/app/Main.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version1/rest-client/app/Main.hs)

```haskell
{-# LANGUAGE OverloadedStrings #-}
module Main where

import           Control.Monad              (when)
import           Data.Char                  (toUpper)
import           System.Environment         (getArgs)

import           Control.Lens               ((&))
import           Data.Aeson                 (Value, eitherDecode)
import           Data.Aeson.Text            (encodeToLazyText)
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.Text.Lazy.IO          as L
import           Network.HTTP.Simple

main :: IO ()
main = do
    args <- getArgs
    when (length args /= 2) $ error "Exactly 2 arguments must be specified"
    let [url, method] = args
    res <- http url method
    let status = getResponseStatusCode res
    when (status /= 200) . putStr $ show status ++ "\t"
    L.putStrLn . encodeToLazyText $ getResponseBody res

http :: String -> String -> IO (Response Value)
http url method = do
    req <- parseRequest url
    case map toUpper method of
        "GET" -> httpJSON $ req & setRequestMethod "GET"
                                & setRequestHeader "Accept" ["application/json"]
        "POST" -> do
            json <- readJSONfromStdin
            httpJSON $ req & setRequestMethod "POST"
                           & setRequestBodyJSON json
                           & setRequestHeader "Accept" ["application/json"]
        _ -> error $ "Unsupported HTTP method: " ++ method

readJSONfromStdin :: IO Value
readJSONfromStdin = do
    input <- BLC.getContents
    return . either error id $ eitherDecode input
```

リクエストデータを構築する部分では、読みやすさのために[lens](https://hackage.haskell.org/package/lens)の `&` を利用してみている(ここではControl.Lensのものを利用したが、[Data.Function](https://www.stackage.org/haddock/lts-10.3/base-4.10.1.0/Data-Function.html#v:-38-)で標準提供されているものでも良さそう)。

Clojure版とほぼ同等の振る舞いになるように実装してみた(エラー処理もClojure版と同様に直ちにエラーを発生させることにした)。

Haskellについてはまだまだ実用的なコードを書き慣れていないため、特に改善の余地がある気がする🤔

## 3. 動作確認

RESTサーバ(cf. [README](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version1/rest-server/README.md))をあらかじめ起動した状態で

```sh
$ stack build
$ stack exec rest-client-exe [args]
```

または

```sh
$ stack runghc app/Main.hs [args]
```

で実行できる。

例えば、

- メンバー一覧情報の取得

```sh
$ stack build
$ stack exec rest-client-exe http://localhost:3000/members GET
[{"email":"y.watanabe@uranohoshi.ac.jp","member-id":1,"last-name":"Watanabe","first-name":"You"},{"email":"y.tsushima@uranohoshi.ac.jp","member-id":2,"last-name":"Tsushima","first-name":"Yoshiko"}]
```

- メンバーの登録

```sh
$ stack exec rest-client-exe http://localhost:3000/members POST
{"first-name": "Dia",
 "last-name": "Kurosawa",
 "email": "d.kurosawa@uranohoshi.ac.jp"}
{"email":"d.kurosawa@uranohoshi.ac.jp","member-id":3,"last-name":"Kurosawa","first-name":"Dia"}
```

# まとめ

- ClojureでもHaskellでもHTTPアクセスしてJSONを扱うコマンドラインアプリが簡潔に実装できた
- Clojure楽しい＞ω＜/
- Haskell楽しい＞ω＜/

# Further Reading

- [clj-http](https://github.com/dakrone/clj-http)
- [Cheshire](https://github.com/dakrone/cheshire)
- [Making HTTP requests - http-client library](https://haskell-lang.org/library/http-client)
- [http-conduit](https://hackage.haskell.org/package/http-conduit)
- [aeson](https://hackage.haskell.org/package/aeson)
- [lens](https://hackage.haskell.org/package/lens)