HaskellからWordPressのREST APIを叩くメモ

**この記事はメモ書きです。**

どうも、[自分のブログ](https://blog.miz-ar.info)の脱WordPress化を画策しているmod_poppoです。

今日は、WordPressのREST APIを叩いてデータを取ってみたいと思います。APIのマニュアルは[この辺](https://developer.wordpress.org/rest-api/)にあり、例えば

```shell-session
$ curl https://example.com/wp-json/wp/v2/posts | jq
```

とすると直近10件くらいの投稿が取得できます。

# JSON

WordPressのREST APIのレスポンスはJSONで帰ってきます。これをいい感じにHaskellのデータ型にマップしたいです。

最初は自分でデータ型を定義して `Generic` で `FromJSON` / `ToJSON` のインスタンスを定義しようとしたのですが、フィールド名のアレが面倒なのでやめました。代わりに [extensible](https://hackage.haskell.org/package/extensible) を使います。

extensible を使うと、投稿を表すデータ型が次のように定義できます。各フィールドにアクセスするには Lens と OverloadedLabels を使って `post ^. #id` という風にすれば良いようです。

```haskell
type Post = Record '[ "date" >: LocalTime
                    , "date_gmt" >: LocalTime
                    , "guid" >: JSON.Object
                    , "id" >: Int
                    , "link" >: T.Text
                    , "modified" >: LocalTime
                    , "modified_gmt" >: LocalTime
                    , "slug" >: T.Text
                    , "status" >: T.Text
                    , "type" >: T.Text
                    , "title" >: JSON.Object
                    , "content" >: JSON.Object
                    , "author" >: Int
                    , "excerpt" >: JSON.Object
                    , "featured_media" >: Int
                    , "comment_status" >: T.Text -- one of "open", "closed"
                    , "ping_status" >: T.Text -- one of "open", "closed"
                    , "format" >: T.Text -- one of "standard", "aside", "chat", "gallery", "link", "image", "quote", "status", "video", "audio"
                    , "meta" >: JSON.Value
                    , "sticky" >: Bool
                    , "template" >: T.Text
                    , "categories" >: [Int]
                    , "tags" >: [Int]
                    ]
```

ところで、TypeScriptでいうところのリテラル型のunion `"open" | "closed"` みたいなやつはHaskellでどう書いたらいいんでしょうか。もちろん自分でデータ型を定義すればいいんですけど、いちいち名前をつけるのが面倒です。わからん。

# REST APIの定義

RESTのAPIを関数っぽい形で叩けると便利です。 `GET /wp/v2/posts/123` が `getPost 123` という具合です。

やり方は色々あると思いますが、ここでは `Servant.Client` を使ってみました。`GET /wp/v2/posts` と `GET /wp/v2/posts/:id` の例は次のような感じになります：

```haskell
type WordPressAPI = "wp" :> "v2" :> "posts" :> API.QueryParam "page" Int :> API.QueryParam "offset" Int :> API.Get '[JSON] [Post]
               :<|> "wp" :> "v2" :> "posts" :> API.Capture "id" Int :> API.Get '[JSON] Post

wp_api :: Proxy WordPressAPI
wp_api = Proxy

getPosts :: Maybe Int -> Maybe Int -> ClientM [Post]
getPost :: Int -> ClientM Post
getPosts :<|> getPost = Client.client wp_api
```

エンドポイントが対応している `QueryParam` を全部書くと `getPosts` の引数が大量発生して大変そうです。 `GetPostsParam` みたいなデータ型にまとめられないんでしょうか。よくわかりません。

ちなみにクエリパラメーター等の仕様は

```shell-session
$ curl -X OPTIONS https://blog.miz-ar.info/wp-json/wp/v2/posts | jq
```

みたいな感じで取ってこれるようです。

# コード全体

動くコードを載せておきます。依存するパッケージは base 以外だと

- aeson
- extensible
- http-client
- http-client-tls
- lens
- lens-aeson
- servant
- servant-client
- text
- time

あたりになります。

```haskell
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}

import           Control.Lens
import           Control.Monad
import qualified Data.Aeson as JSON
import           Data.Aeson.Lens
import           Data.Extensible
import qualified Data.Text as T
import qualified Data.Text.IO as T
import           Data.Time (LocalTime, UTCTime)
import           Network.HTTP.Client (newManager)
import           Network.HTTP.Client.TLS (tlsManagerSettings)
import           Servant.API hiding (Post)
import qualified Servant.API as API
import           Servant.Client as Client

type Post = Record '[ "date" >: LocalTime
                    , "date_gmt" >: LocalTime
                    , "guid" >: JSON.Object
                    , "id" >: Int
                    , "link" >: T.Text
                    , "modified" >: LocalTime
                    , "modified_gmt" >: LocalTime
                    , "slug" >: T.Text
                    , "status" >: T.Text
                    , "type" >: T.Text
                    , "title" >: JSON.Object
                    , "content" >: JSON.Object
                    , "author" >: Int
                    , "excerpt" >: JSON.Object
                    , "featured_media" >: Int
                    , "comment_status" >: T.Text -- one of "open", "closed"
                    , "ping_status" >: T.Text -- one of "open", "closed"
                    , "format" >: T.Text -- one of "standard", "aside", "chat", "gallery", "link", "image", "quote", "status", "video", "audio"
                    , "meta" >: JSON.Value
                    , "sticky" >: Bool
                    , "template" >: T.Text
                    , "categories" >: [Int]
                    , "tags" >: [Int]
                    ]

type WordPressAPI = "wp" :> "v2" :> "posts" :> API.QueryParam "page" Int :> API.QueryParam "offset" Int :> API.Get '[JSON] [Post]
               :<|> "wp" :> "v2" :> "posts" :> API.Capture "id" Int :> API.Get '[JSON] Post

wp_api :: Proxy WordPressAPI
wp_api = Proxy

getPosts :: Maybe Int -> Maybe Int -> ClientM [Post]
getPost :: Int -> ClientM Post
getPosts :<|> getPost = Client.client wp_api

main :: IO ()
main = do
  manager <- newManager tlsManagerSettings
  baseUrl <- Client.parseBaseUrl "https://example.com/wp-json/"
  let clientEnv = Client.mkClientEnv manager baseUrl
  result <- runClientM (getPosts Nothing Nothing) clientEnv
  case result of
    Left err -> print err
    Right result -> forM_ result $ \post -> case post ^? #title . ix "rendered" . _String of
                                              Just title -> T.putStrLn $ "[" <> T.pack (show (post ^. #date)) <> "] " <> title
                                              Nothing -> T.putStrLn $ "[" <> T.pack (show (post ^. #date)) <> "]: title not available"
```

私のブログについて実行すると

```
[2019-10-12 19:14:03] Haskellのscan系関数を使いこなす
[2019-10-01 22:47:30] 技術書典7の振り返り
[2019-09-19 22:55:03] 技術書典7に、Haskellで競技プログラミングをやる本を出します
[2019-08-16 21:33:35] TeXにとってやばい入力ファイル名
[2019-07-15 18:50:05] AtCoderで青色になった
[2019-06-30 22:43:59] Haskellでの浮動小数点数の方向付き丸めを考える
[2019-05-27 21:17:37] HaskellでAtCoderに参戦して水色になった
[2019-04-24 20:57:59] 技術書典6の振り返り
[2019-04-11 20:39:13] 技術書典6に、ClutTeX（LaTeX文書処理自動化ツール）の本を出します
[2019-03-25 22:35:55] AtCoder Beginner Contest 122 の D の別解 (ABC122-D)
```

という風な結果が得られました。やったね。

デフォルトだと直近10件の記事ですが、 offset または page を変えるともっと古い記事も得られます。

# その他

移行などの目的で自分の管理しているWordPressからデータを取ってくるには管理画面の「エクスポート」を使って WordPress eXtended Rss という形式のファイルを吐かせるという方法もあるようです。その場合はXMLのパースが必要になります。

追記：WXRをパースする話を書きました→ [HaskellでWordPress eXtended RSS (WXR)ファイルをパースする](https://qiita.com/mod_poppo/items/c0847757a6b7679a8891)