Haskellから簡単にWeb APIを叩く方法

  • 30
    Like
  • 0
    Comment
More than 1 year has passed since last update.

Haskellでhttp-conduitを使って簡単にWeb APIを叩く方法についてまとめました。
"通信系テストのためのサイトのススメ"という便利なWebサイトをまとめてくれている記事があるのでこれに沿って話を進めていきます。

example.com

まずは単純にURLを叩いてコンテンツを取得してみましょう :exclamation:
https://example.comからHTMLのデータを取得してみます。Network.HTTP.SimpleというモジュールにあるhttpLBS という関数を使います。型は

httpLBS :: Request -> IO (Response ByteString)

このようになっており、Requestを引数にとり副作用を伴ってResponse ByteStringを返すというものです。RequestIsStringのインスタンスになっているので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を取得できていると思います :smile:

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のインスタンスになっている必要があるのでIPResFromJSONのインスタンスを定義して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からデコードするのでIPFromJSONのインスタンスである必要がありますが実は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つのパラメータにアクセスするためだけに新しい型を定義するのは面倒くさいですよね? lenslens-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

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もくもく会@朝日ネットのもくもく時間を利用して書かれました。