昨年末のAdvent Calendarで2回に分けて紹介したsituated-program-challengeのClojure/Duct版実装とHaskell/Yesod版実装ですが、そこではもともとの課題のうちRESTサーバしか実現できていなかったため、今回新たにRESTクライアント実装についてご紹介します。
-
Clojure/Duct版RESTサーバ: lagenorhynque/situated-program-challenge/rest-server at clj-version1
-
Haskell/Yesod版RESTサーバ: lagenorhynque/situated-program-challenge/rest-server at hs-version1
ちなみに、situated-program-challengeのその他の実装例は、本記事執筆時点で以下のものがあるようです。
- Clojure版 by @iku000888 さん: iku000888/situated-program-challenge at clj-solution
- Scala版 by @shinichy さん: shinichy/situated-program-challenge at version1
- Common Lisp版 by @masatoi0 さん: masatoi/situated-program-challenge at cl-version1
- Ruby版 by @toku345 さん: toku345/situated-program-challenge at rb-version1
仕様の詳細検討
situated-program-challengeにおけるRESTクライアントの仕様を検討した結果、以下のように引数の仕様に若干の変更を加えることにした。
- 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
Clojure版では、HTTPクライアントとして定番のひとつと思われるclj-httpとJSON操作にCheshireを利用する。
1. プロジェクトの生成
今回は特に土台とすべきフレームワーク/ライブラリもないため、コマンドラインアプリを作るのに手軽な app
テンプレートでプロジェクト生成する。
$ lein new app rest-client
$ cd rest-client
2. 実装
(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)を利用してみた。
エラー処理はここでは単純に例外をスローすることにした。
3. 動作確認
RESTサーバ(cf. README)をあらかじめ起動した状態で
$ lein uberjar
$ java -jar target/uberjar/rest-client-0.1.0-SNAPSHOT-standalone.jar [args]
または
$ lein run [args]
で実行できる。
もしくは、deps.edn
を以下のように用意してあるので、Leiningenではなくcljコマンドから実行することもできる。
{: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"]}}}
$ clj -m rest-client.core [args]
例えば、
- メンバー一覧情報の取得
$ 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}]
- メンバーの登録
$ 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
HaskellでHTTPクライアントライブラリに何を利用するか、wreqやreqなども検討したが、最終的には
Making HTTP requests - http-client library
を参考にhttp-conduitを利用することにした。
また、RESTサーバ実装と同様にJSON操作にはaesonを利用する。
1. プロジェクトの生成
Haskell版についても、今回は特にベースとなるフレームワーク/ライブラリはなしで単純にプロジェクト生成した。
$ stack new rest-client
$ cd rest-client
2. 実装
{-# 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の &
を利用してみている(ここではControl.Lensのものを利用したが、Data.Functionで標準提供されているものでも良さそう)。
Clojure版とほぼ同等の振る舞いになるように実装してみた(エラー処理もClojure版と同様に直ちにエラーを発生させることにした)。
Haskellについてはまだまだ実用的なコードを書き慣れていないため、特に改善の余地がある気がする🤔
3. 動作確認
RESTサーバ(cf. README)をあらかじめ起動した状態で
$ stack build
$ stack exec rest-client-exe [args]
または
$ stack runghc app/Main.hs [args]
で実行できる。
例えば、
- メンバー一覧情報の取得
$ 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"}]
- メンバーの登録
$ 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楽しい>ω</