Haskellでhttp-conduitを使って簡単にWeb APIを叩く方法についてまとめました。
"通信系テストのためのサイトのススメ"という便利なWebサイトをまとめてくれている記事があるのでこれに沿って話を進めていきます。
example.com
まずは単純にURLを叩いてコンテンツを取得してみましょう
https://example.comからHTMLのデータを取得してみます。Network.HTTP.Simple
というモジュールにあるhttpLBS
という関数を使います。型は
httpLBS :: Request -> IO (Response ByteString)
このようになっており、Request
を引数にとり副作用を伴ってResponse ByteString
を返すというものです。Request
はIsString
のインスタンスになっているのでOverloadedStrings
というGHC拡張を指定すれば単純にURLの文字列リテラルを書くだけで値を作ることが出来ます。
Response
にはHTTPのレスポンスに関する全ての情報が含まれていて、例えばbodyの内容が欲しいときはgetResponseBody
を使って取り出します。
getResponseBody :: Response a -> a
これらを組み合わせると
{-# LANGUAGE OverloadedStrings #-}
import Network.HTTP.Simple
main :: IO ()
main = do
res <- httpLBS "https://example.com"
print (getResponseBody res)
-- $ runhaskell Main.hs
-- "<!doctype html>\n<html>\n<head>...
ちゃんとHTMLを取得できていると思います
httpbin
httpbinはHTTPに関するいろんな情報をJSONで返してくれるサイトです。ここではaesonというライブラリを使ってJSONを扱う方法を見ていきます。
/ip
送信元のIPアドレスを返してくれるAPIです。レスポンスはこんな感じ
$ curl https://httpbin.org/ip
{
"origin": "192.0.43.10"
}
まずはこれを独自のデータ構造にマッピングしようと思います
data IPRes = IPRes { origin :: String } deriving Show
aeson
にあるdecode
という関数を使えば安全にJSONの文字列をパースすることが出来ます。decode
の返り値の型はFromJSON
のインスタンスになっている必要があるのでIPRes
のFromJSON
のインスタンスを定義してJSONからデコードできるようにしてみましょう。
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson
instance FromJSON IPRes where
parseJSON (Object v) = IPRes <$> v .: "origin"
parseJSON _ = mempty
ここまでのコードを組み合わせると以下のようになります。
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson
import Network.HTTP.Simple
data IPRes = IPRes { origin :: String } deriving Show
instance FromJSON IPRes where
parseJSON (Object v) = IPRes <$> v .: "origin"
parseJSON _ = mempty
main :: IO ()
main = do
res <- httpLbs "https://httpbin.org/ip"
let ip = decode (getResponseBody res) :: Maybe IPRes
case ip of
Nothing -> putStrLn "parsing failed"
Just ip -> print ip
-- $ runhaskell Main.hs
-- IPRes {origin = "192.0.43.10"}
細かいですがIPを表す型がString
では不便だと思います。iproute
というライブラリでIP
という型が定義されているのでこれを使うことにしましょう。JSONからデコードするのでIP
もFromJSON
のインスタンスである必要がありますが実はaeson-iproute
というライブラリで定義されるのでそれを使うことにしましょう。以下のように変更してみてください。
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson
+import Data.Aeson.IP
+import Data.IP
import Network.HTTP.Simple
-data IPRes = IPRes { origin :: String } deriving Show
+data IPRes = IPRes { origin :: IP } deriving Show
instance FromJSON IPRes where
parseJSON (Object v) = IPRes <$> v .: "origin"
型を変更するだけでIP
に対応することが出来ました。
さて、独自のデータ構造を定義するたびにFromJSON
のインスタンスを作るのは少々手間です。AesonではGeneric
のインスタンスになっている型なら自動的にFromJSON
のインスタンスを導出できるようになっています。以下のように変更してみてください
+{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson
import Data.Aeson.IP
import Data.IP
+import GHC.Generics
import Network.HTTP.Simple
-data IPRes = IPRes { origin :: IP } deriving Show
+data IPRes = IPRes { origin :: IP } deriving (Generic, Show)
-instance FromJSON IPRes where
- parseJSON (Object v) = IPRes <$> v .: "origin"
- parseJSON _ = mempty
+instance FromJSON IPRes
main :: IO ()
main = do
フィールドの多い型ならコードが減る量も多くなるでしょう。このGenerics
を使った方法の欠点は名前がかぶるなどの制約でJSONとHaskellのレコードの名前を一対一に出来ない場合に使えないところです(GHC8で改善される予定です)。そういった場合はモジュールを分けるなどの工夫が必要になります。
実はNetwork.HTTP.Simple
にはhttpJSON
という関数があってJSONのパースを勝手にやってくれます。
main :: IO ()
main = do
- res <- httpLbs "https://httpbin.org/ip"
- let ip = decode (getResponseBody res) :: Maybe IPRes
- case ip of
- Nothing -> putStrLn "parsing failed"
- Just ip -> print ip
+ res <- httpJSON "https://httpbin.org/ip"
+ let ip = (getResponseBody res :: IPRes)
+ print ip
結局コードは以下のようになりました。
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson
import Data.Aeson.IP
import Data.IP
import GHC.Generics
import Network.HTTP.Simple
data IPRes = IPRes { origin :: IP } deriving (Generic, Show)
instance FromJSON IPRes
main :: IO ()
main = do
res <- httpJSON "https://httpbin.org/ip"
let ip = getResponseBody res :: IPRes
print ip
-- $ runhaskell Main.hs
-- IPRes {origin = "192.0.43.10"}
/headers
リクエストヘッダーの中身を返してくれるAPIです。レスポンスはこんな感じ
$ curl https://httpbin.org/headers
{
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.43.0"
}
}
例えばHost
の値が欲しかったとします。たった1つのパラメータにアクセスするためだけに新しい型を定義するのは面倒くさいですよね? lens
と lens-aeson
というライブラリを使えば独自の型にデコードすること無くJSONの値に自由にアクセスすることが出来ます。
{-# LANGUAGE OverloadedStrings #-}
import Control.Lens
import Data.Aeson.Lens
import Network.HTTP.Simple
main :: IO ()
main = do
res <- httpLbs "https://httpbin.org/headers"
print $ getResponseBody res ^? key "headers" . key "Host" . _String
-- $ runhaskell Main.hs
-- Just "httpbin.org"
単純に値を取り出しているだけなのでさっきのコードよりずっと短くなりました。 Lens はJSONへのアクセスに限らない汎用的な概念です。詳しく知りたい方は以下のリンクを参考にして下さい。
/post
これまでGETでアクセスしてJSONを取得していましたがPOSTでアクセスしたい時はどうすればいいでしょう。 /post
にPOSTでアクセスすると
curl -XPOST https://httpbin.org/post
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.43.0"
},
"json": null,
"origin": "192.0.43.10",
"url": "https://httpbin.org/post"
}
このような情報が返ってきます。間違ってGETでアクセスすると
$ curl https://httpbin.org/post
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
このようになります。httpLbs
を使って/post
から情報を取得してみましょう
{-# LANGUAGE OverloadedStrings #-}
import Control.Lens
import Data.Aeson.Lens
import Network.HTTP.Simple
main :: IO ()
main = do
let req = setRequestMethod "POST" "https://httpbin.org/post"
res <- httpLbs req
print $ getResponseBody res ^? key "headers" . key "Host" . _String
-- $ runhaskell Main.hs
-- Just "httpbin.org"
大事なのはこの行です。
let req = setRequestMethod "POST" "https://httpbin.org/post"
setRequestMethod :: ByteString -> Request -> Request
このような型になっていてRequest
に設定されているメソッドを単純に書き換えることが出来ます。
/status/:statusCode
もしAPIのレスポンスが正常系じゃなかった場合はどうなるのでしょうか。/status/:statusCode
を使うと指定したステータスコードのレスポンスを受け取ることが出来ます。
$ curl -v https://httpbin.org/status/400
* Trying 54.175.219.8...
* Connected to httpbin.org (54.175.219.8) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate: *.httpbin.org
* Server certificate: COMODO RSA Domain Validation Secure Server CA
* Server certificate: COMODO RSA Certification Authority
* Server certificate: AddTrust External CA Root
> GET /status/400 HTTP/1.1
> Host: httpbin.org
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 400 BAD REQUEST
< Server: nginx
< Date: Sat, 04 Jun 2016 07:22:38 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 0
< Connection: keep-alive
< Access-Control-Allow-Origin: *
< Access-Control-Allow-Credentials: true
<
* Connection #0 to host httpbin.org left intact
httpLbs
は異常系のステータスコードが返ってくるとStatusCodeException
という例外を投げます。この例外をcatch
を使って捕捉してみましょう
{-# LANGUAGE OverloadedStrings #-}
import Control.Exception
import Network.HTTP.Simple
main :: IO ()
main = do
res <- (Just <$> httpLbs "https://httpbin.org/status/500") `catch`
\(StatusCodeException s _ _) -> print s >> pure Nothing
print res
-- $ runhaskell Main.hs
-- Status {statusCode = 500, statusMessage = "INTERNAL SERVER ERROR"}
-- Nothing
/delay/:n
Web APIはインターネットを経由するのでレスポンスが返ってくるまでに時間がかかります。複数のAPIを同時に叩くとどれが最初に返ってくるか分かりません。 ここではasync
というライブラリを使って期待した順番で情報を取得できるようにしてみます。
/delay/:n
を叩くと指定した秒数だけ待ってレスポンスが返ってきます。
$ time curl "https://httpbin.org/delay/3"
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.43.0"
},
"origin": "14.3.18.120",
"url": "https://httpbin.org/delay/3"
}
curl "https://httpbin.org/delay/3" 0.03s user 0.02s system 1% cpu 4.566 total
これを使って以下の様なコードを書いてみます
{-# LANGUAGE OverloadedStrings #-}
import Control.Concurrent.Async
import Network.HTTP.Simple
main :: IO ()
main = do
a1 <- async $ httpLbs "https://httpbin.org/delay/5"
a2 <- async $ httpLbs "https://httpbin.org/delay/4"
a3 <- async $ httpLbs "https://httpbin.org/delay/3"
res1 <- wait a1
res2 <- wait a2
res3 <- wait a3
print $ getResponseBody res1
print $ getResponseBody res2
print $ getResponseBody res3
-- $ time ./Main
-- ... \"url\": \"https://httpbin.org/delay/5\"\n ...
-- ... \"url\": \"https://httpbin.org/delay/4\"\n ...
-- ... \"url\": \"https://httpbin.org/delay/3\"\n ...
-- ./Main 0.12s user 0.04s system 2% cpu 7.162 total
ちゃんと並列に実行されて期待した通りの順番で返ってきてることがわかります。
badssl.com
https://badssl.com/ を利用するとTLSのエラーのテストが出来ます。これも例外として投げられるので落ち着いてcatch
で捕捉します。
{-# LANGUAGE OverloadedStrings #-}
import Control.Exception
import Network.HTTP.Simple
main :: IO ()
main = do
res <- (Just <$> httpLbs "https://expired.badssl.com/") `catch`
\(TlsExceptionHostPort s _ _) -> print s >> pure Nothing
print res
-- $ runhaskell Main.hs
-- HandshakeFailed (Error_Protocol ("certificate has expired",True,CertificateExpired))
-- Nothing
この記事は第32回Haskellもくもく会@朝日ネットのもくもく時間を利用して書かれました。