ClojureとHaskellでWeb APIの仕様変更に対応してみた

Clojure勉強会[clj-nakano](https://clj-nakano.connpass.com/)の課題として["situated-program-challenge"](https://github.com/clj-nakano/situated-program-challenge)のClojureとHaskellによるREST APIサーバ/クライアントの実装にこれまで取り組んできました。

今回は、このREST APIに対する(テーブル変更を含む)仕様変更に対応してみた結果をご紹介します。

過去の実装とその紹介記事はこちら:

- Clojure/Duct版RESTサーバ(version 1): [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サーバ(version 1): [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)

- Clojure版RESTクライアント(version 1): [lagenorhynque/situated-program-challenge/rest-client at clj-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/clj-version1/rest-client)
- Haskell版RESTクライアント(version 1): [lagenorhynque/situated-program-challenge/rest-client at hs-version1](https://github.com/lagenorhynque/situated-program-challenge/tree/hs-version1/rest-client)
    - [ClojureとHaskellで簡単なコマンドラインツール(RESTクライアント)を作ってみた - Qiita](https://qiita.com/lagenorhynque/items/3e29259ad2659955558e)

他の参加者によるversion 1の実装例はこちら:

- 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 version 2での[仕様変更](https://github.com/clj-nakano/situated-program-challenge#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4)の内容を確認し詳細に検討してみると、通常の「会場」(venue)とは別で新たに「オンライン会場」(online-venue)を扱うために、以下のような変更が必要になることが分かる。

1. DBスキーマの変更
    - `venue_type` (会場の種別を表すPostgreSQLの[enum type](https://www.postgresql.org/docs/9.6/static/datatype-enum.html))を新規作成
    - `venues` テーブルに `url`(オンライン会場のURL)カラムを追加
    - `venues` テーブルに `venue_type`(会場種別 `venue_type`)カラムを追加
    - `meetups` テーブルに `online_venue_id`(オンライン会場の`venues.id`)カラムを追加

1. APIエンドポイントの追加
    - `GET /groups/{group-id}/online-venues`: オンライン会場一覧の取得
        - (通常の会場を含まない)オンライン会場(`venues.venue_type = online`)のみを抽出して取得
    - `POST /groups/{group-id}/online-venues`: オンライン会場の登録
        - オンライン会場(`venues.venue_type = online`)として登録

1. 既存APIの変更
    - `GET /groups/{group-id}/venues`: 会場一覧の取得
        - (オンライン会場を含まない)通常の会場(`venues.venue_type = physical`)のみを抽出して取得
    - `POST /groups/{group-id}/venues`: 会場の登録
        - 通常の会場(`venues.venue_type = physical`)として登録
    - `GET /groups/{group-id}/meetups`: ミートアップイベント一覧情報の取得
        - レスポンスのJSONで通常の会場を `"venue"` 、オンライン会場を `"online-venue"` で返す ※ミートアップイベント情報を返す他のAPIも同様
    - `POST /groups/{group-id}/meetups`: ミートアップイベントの登録
        - リクエストのJSONで通常の会場のIDを `"venue-id"` 、オンライン会場のIDを `"online-venue-id"` で受け取る
    - `GET /groups`: グループ一覧の取得
        - レスポンスのJSONで通常の会場を `"venues"` 、オンライン会場を `"online-venues"` で返す ※グループ情報を返す他のAPIも同様

# Clojure版

- Clojure/Duct版RESTサーバ(version 2): [lagenorhynque/situated-program-challenge/rest-server at clj-version2](https://github.com/lagenorhynque/situated-program-challenge/tree/clj-version2/rest-server)

## 1. DBアクセス層の改修

まず最初に、DBスキーマの変更に関連してDBアクセス層を改修する。

ClojureでPostgreSQLのenum typeを扱うにはいくつか方法があるようだが、こちらのページ

[Using PostgreSQL Enums in Clojure](https://www.bevuta.com/en/blog/using-postgresql-enums-in-clojure/)

を参考に、以下のように名前空間付きキーワードから [`org.postgresql.util.PGobject`](https://jdbc.postgresql.org/documentation/publicapi/org/postgresql/util/PGobject.html) オブジェクトを作って利用できるようなユーティリティ関数を定義してみた。

- [rest-server/src/rest_server/util.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/util.clj) (差分)

```diff
 (ns rest-server.util
   (:require [camel-snake-kebab.core :refer [->kebab-case ->snake_case]]
-            [camel-snake-kebab.extras :refer [transform-keys]])
+            [camel-snake-kebab.extras :refer [transform-keys]]
+            [clojure.string :as str])
   (:import (java.sql Timestamp)
-           (java.time Instant)))
+           (java.time Instant)
+           (org.postgresql.util PGobject)))

 (defn transform-keys-to-kebab [m]
   (transform-keys #(->kebab-case % :separator \_) m))

 (defn transform-keys-to-snake [m]
   (transform-keys #(->snake_case % :separator \-) m))

+(defn kw->pgenum [enum-kw]
+  (doto (PGobject.)
+    (.setType (-> enum-kw
+                  namespace
+                  (str/replace \- \_)))
+    (.setValue (name enum-kw))))
+
 (defn string->timestamp [s]
   (some-> s
           Instant/parse
           Timestamp/from))

 (defn now []
   (Timestamp/from (Instant/now)))
```

ここで定義した `kw->pgenum` 関数を例えば `:venue-type/physical` というキーワードに適用すると、typeが `"venue_type"` 、valueが `"physical"` の `PGobject` オブジェクトが得られる。

```clojure
user> (rest-server.util/kw->pgenum :venue-type/physical)
#object[org.postgresql.util.PGobject 0x41fa9af7 "physical"]
user> (.getType *1)
"venue_type"
user> (.getValue *2)
"physical"
```

これを利用して `venues` テーブル情報の一覧取得時に `venue_type` の条件で絞り込み、登録時に `venue_type` を指定するように変更する。

- [rest-server/src/rest_server/boundary/db/venue.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/boundary/db/venue.clj) (差分)

```diff
 (ns rest-server.boundary.db.venue
   (:require [duct.database.sql]
             [honeysql.core :as sql]
-            [rest-server.boundary.db.core :as db]))
+            [rest-server.boundary.db.core :as db]
+            [rest-server.util :as util]))

 (defprotocol Venues
-  (list-venues [db group-id])
+  (list-venues [db group-id venue-type])
   (create-venue [db venue])
   (fetch-venue [db venue-id]))

 (extend-protocol Venues
   duct.database.sql.Boundary
-  (list-venues [db group-id]
+  (list-venues [db group-id venue-type]
     (db/select db (sql/build :select :*
                              :from :venues
-                             :where [:= :group_id group-id])))
+                             :where [:and
+                                     [:= :group_id group-id]
+                                     [:= :venue_type (util/kw->pgenum venue-type)]])))
   (create-venue [db venue]
-    (db/insert! db :venues venue))
+    (db/insert! db :venues (update venue :venue-type util/kw->pgenum)))
   (fetch-venue [db venue-id]
     (db/select-one db (sql/build :select :*
                                  :from :venues
                                  :where [:= :id venue-id]))))
```

## 2. ルーティングの改修

次に、APIエンドポイントの追加に伴い、オンライン会場の一覧/登録APIのためのルーティング定義を追加する。

内容的には既存の会場一覧/登録APIとほぼ同じものになる。

- [rest-server/resources/rest_server/config.edn](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/resources/rest_server/config.edn) (差分)

```diff
 {:duct.core/project-ns  rest-server
  :duct.core/environment :production

  :duct.module/logging {}
  :duct.module.web/api {}
  :duct.module/sql
  {:database-url "jdbc:postgresql://localhost:5432/meetup?user=meetup&password=password123"}

  :duct.module/ataraxy
  {"/members"
   {:get [:member/list]
    [:post {member :body-params}] [:member/create member]
    ["/" member-id]
    {:get [:member/fetch ^int member-id]
     "/meetups"
     {["/" meetup-id]
      {:post [:meetup/join ^int member-id ^int meetup-id]}}
     "/groups"
     {["/" group-id]
      {[:post {group-member :body-params}] [:group/join ^int member-id ^int group-id group-member]}}}}
   "/groups"
   {:get [:group/list]
    [:post {group :body-params}] [:group/create group]
    ["/" group-id]
    {"/meetups"
     {:get [:meetup/list ^int group-id]
      [:post {meetup :body-params}] [:meetup/create ^int group-id meetup]
      ["/" meetup-id]
      {:get [:meetup/fetch ^int group-id ^int meetup-id]}}
     "/venues"
     {:get [:venue/list ^int group-id]
-     [:post {venue :body-params}] [:venue/create ^int group-id venue]}}}}
+     [:post {venue :body-params}] [:venue/create ^int group-id venue]}
+    "/online-venues"
+    {:get [:online-venue/list ^int group-id]
+     [:post {venue :body-params}] [:online-venue/create ^int group-id venue]}}}}

  :rest-server.handler.meetup/list {:db #ig/ref :duct.database/sql}
  :rest-server.handler.meetup/create {:db #ig/ref :duct.database/sql}
  :rest-server.handler.meetup/fetch {:db #ig/ref :duct.database/sql}
  :rest-server.handler.meetup/join {:db #ig/ref :duct.database/sql}
  :rest-server.handler.member/list {:db #ig/ref :duct.database/sql}
  :rest-server.handler.member/create {:db #ig/ref :duct.database/sql}
  :rest-server.handler.member/fetch {:db #ig/ref :duct.database/sql}
  :rest-server.handler.venue/list {:db #ig/ref :duct.database/sql}
  :rest-server.handler.venue/create {:db #ig/ref :duct.database/sql}
+ :rest-server.handler.online-venue/list {:db #ig/ref :duct.database/sql}
+ :rest-server.handler.online-venue/create {:db #ig/ref :duct.database/sql}
  :rest-server.handler.group/list {:db #ig/ref :duct.database/sql}
  :rest-server.handler.group/create {:db #ig/ref :duct.database/sql}
  :rest-server.handler.group/join {:db #ig/ref :duct.database/sql}}
```

## 3. ハンドラーの改修

最後に、ハンドラーを仕様変更内容に沿うように改修する。

オンライン会場の一覧/登録APIのためのハンドラー関数を新たに作成する。

ここでは、一覧取得時の検索条件、登録時の設定値としてvenue-typeに `:venue-type/online` を指定する。

- [rest-server/src/rest_server/handler/online_venue.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/handler/online_venue.clj)

```clojure
(ns rest-server.handler.online-venue
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [rest-server.boundary.db.venue :as db.venue]))

(defn online-venue-with-id [{:keys [id name url] :as online-venue}]
  (when online-venue
    {:online-venue-id id
     :venue-name name
     :url url}))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [{[_ group-id] :ataraxy/result}]
    [::response/ok (map online-venue-with-id
                        (db.venue/list-venues db
                                              group-id
                                              :venue-type/online))]))

(defmethod ig/init-key ::create [_ {:keys [db]}]
  (fn [{[_ group-id {:keys [venue-name url]}] :ataraxy/result}]
    (let [venue {:name venue-name
                 :group-id group-id
                 :url url
                 :venue-type :venue-type/online}
          id (db.venue/create-venue db venue)]
      [::response/ok (-> venue
                         (assoc :id id)
                         online-venue-with-id)])))
```

既存の会場APIについては、そもそも通常の会場が未登録の状況がありうるためレスポンスのJSONデータを組み立てる `venue-with-address` 関数で会場情報がない場合にはnilを返すように変更する。

また、オンライン会場APIに準じて、一覧取得時の検索条件、登録時の設定値としてvenue-typeに `:venue-type/physical` を指定する。

- [rest-server/src/rest_server/handler/venue.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/handler/venue.clj) (差分)

```diff
 (ns rest-server.handler.venue
   (:require [ataraxy.response :as response]
             [integrant.core :as ig]
             [rest-server.boundary.db.venue :as db.venue]))

-(defn venue-with-address [{:keys [id name postal-code prefecture city street1 street2]}]
-  {:venue-id id
-   :venue-name name
-   :address {:postal-code postal-code
-             :prefecture prefecture
-             :city city
-             :address1 street1
-             :address2 street2}})
+(defn venue-with-address [{:keys [id name postal-code prefecture city street1 street2] :as venue}]
+  (when venue
+    {:venue-id id
+     :venue-name name
+     :address {:postal-code postal-code
+               :prefecture prefecture
+               :city city
+               :address1 street1
+               :address2 street2}}))

 (defmethod ig/init-key ::list [_ {:keys [db]}]
   (fn [{[_ group-id] :ataraxy/result}]
     [::response/ok (map venue-with-address
-                        (db.venue/list-venues db group-id))]))
+                        (db.venue/list-venues db
+                                              group-id
+                                              :venue-type/physical))]))

 (defmethod ig/init-key ::create [_ {:keys [db]}]
   (fn [{[_ group-id {:keys [address] :as venue}] :ataraxy/result}]
     (let [venue' {:name (:venue-name venue)
                   :postal-code (:postal-code address)
                   :prefecture (:prefecture address)
                   :city (:city address)
                   :street1 (:address1 address)
                   :street2 (:address2 address)
-                  :group-id group-id}
+                  :group-id group-id
+                  :venue-type :venue-type/physical}
           id (db.venue/create-venue db venue')]
       [::response/ok (-> venue'
                          (assoc :id id)
                          venue-with-address)])))
```

ミートアップAPIでは、レスポンスのJSONに `:online-venue` を追加してミートアップに紐付いたオンライン会場情報を返せるように変更する。

- [rest-server/src/rest_server/handler/meetup.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/handler/meetup.clj) (差分)

```diff
 (ns rest-server.handler.meetup
   (:require [ataraxy.response :as response]
             [integrant.core :as ig]
             [rest-server.boundary.db.meetup :as db.meetup]
             [rest-server.boundary.db.venue :as db.venue]
             [rest-server.handler.member :as member]
+            [rest-server.handler.online-venue :as online-venue]
             [rest-server.handler.venue :as venue]
             [rest-server.util :as util]))

-(defn meetup-with-venue-and-members [{:keys [id title start-at end-at]} venue members]
+(defn meetup-with-venue-and-members [{:keys [id title start-at end-at]} venue online-venue members]
   {:event-id id
    :title title
    :start-at start-at
    :end-at end-at
    :venue (venue/venue-with-address venue)
+   :online-venue (online-venue/online-venue-with-id online-venue)
    :members (map member/member-with-id members)})

-(defn fetch-meetup-detail [db {:keys [id venue-id] :as meetup}]
+(defn fetch-meetup-detail [db {:keys [id venue-id online-venue-id] :as meetup}]
   (let [venue (db.venue/fetch-venue db venue-id)
+        online-venue (db.venue/fetch-venue db online-venue-id)
         members (db.meetup/fetch-meetup-members db id)]
-    (meetup-with-venue-and-members meetup venue members)))
+    (meetup-with-venue-and-members meetup venue online-venue members)))

 (defn get-meetup [db meetup-id]
   (when-let [meetup (db.meetup/fetch-meetup db meetup-id)]
     (fetch-meetup-detail db meetup)))

 (defmethod ig/init-key ::list [_ {:keys [db]}]
   (fn [{[_ group-id] :ataraxy/result}]
     [::response/ok (map (partial fetch-meetup-detail db)
                         (db.meetup/list-meetups db group-id))]))

 (defmethod ig/init-key ::create [_ {:keys [db]}]
   (fn [{[_ group-id {:keys [start-at end-at] :as meetup}] :ataraxy/result}]
     (let [meetup' (assoc meetup
                          :start-at (util/string->timestamp start-at)
                          :end-at (util/string->timestamp end-at)
                          :group-id group-id)
           id (db.meetup/create-meetup db meetup')]
       [::response/ok (-> meetup'
                          (assoc :id id)
                          ((partial fetch-meetup-detail db)))])))

 (defmethod ig/init-key ::fetch [_ {:keys [db]}]
   (fn [{[_ _ meetup-id] :ataraxy/result}]
     (when-let [meetup (get-meetup db meetup-id)]
       [::response/ok meetup])))

 (defmethod ig/init-key ::join [_ {:keys [db]}]
   (fn [{[_ member-id meetup-id] :ataraxy/result}]
     (db.meetup/create-meetup-member db {:meetup-id meetup-id
                                         :member-id member-id})
     [::response/ok (get-meetup db meetup-id)]))
```

グループAPIについても、レスポンスのJSONに `:online-venues` を追加してグループに紐付いた複数のオンライン会場情報を返せるように変更する(同時に、既存の `:venues` には通常の会場のみが含まれるようにする)。

- [rest-server/src/rest_server/handler/group.clj](https://github.com/lagenorhynque/situated-program-challenge/blob/clj-version2/rest-server/src/rest_server/handler/group.clj) (差分)

```diff
 (ns rest-server.handler.group
   (:require [ataraxy.response :as response]
             [integrant.core :as ig]
             [rest-server.boundary.db.group :as db.group]
             [rest-server.boundary.db.meetup :as db.meetup]
             [rest-server.boundary.db.member :as db.member]
             [rest-server.boundary.db.venue :as db.venue]
             [rest-server.handler.meetup :as meetup]
             [rest-server.handler.member :as member]
+            [rest-server.handler.online-venue :as online-venue]
             [rest-server.handler.venue :as venue]
             [rest-server.util :as util]))

 (defn group-with-admin [{:keys [id name]} admins]
   {:group-id id
    :group-name name
    :admin (map member/member-with-id admins)})

 (defn fetch-group-detail [db {:keys [id] :as group}]
   (let [admins (db.group/fetch-group-admin-members db id)
-        venues (db.venue/list-venues db id)
+        venues (db.venue/list-venues db id :venue-type/physical)
+        online-venues (db.venue/list-venues db id :venue-type/online)
         meetups (db.meetup/list-meetups db id)]
     (assoc (group-with-admin group admins)
            :venues (map venue/venue-with-address venues)
+           :online-venues (map online-venue/online-venue-with-id online-venues)
            :meetups (map (partial meetup/fetch-meetup-detail db) meetups))))

 (defmethod ig/init-key ::list [_ {:keys [db]}]
   (fn [_] [::response/ok (map (partial fetch-group-detail db)
                               (db.group/list-groups db))]))

 (defmethod ig/init-key ::create [_ {:keys [db]}]
   (fn [{[_ {:keys [group-name admin-member-ids]}] :ataraxy/result}]
     (let [group {:name group-name
                  :created-at (util/now)}
           id (db.group/create-group db group)
           admins (db.member/fetch-members db admin-member-ids)]
       (db.group/create-group-members db (map (fn [member-id]
                                                {:group-id id
                                                 :member-id member-id
                                                 :admin true})
                                              admin-member-ids))
       [::response/ok (-> group
                          (assoc :id id)
                          (group-with-admin admins))])))

 (defmethod ig/init-key ::join [_ {:keys [db]}]
   (fn [{[_ member-id group-id {:keys [admin]}] :ataraxy/result}]
     (db.group/create-group-members db [{:group-id group-id
                                         :member-id member-id
                                         :admin admin}])
     (when-let [group (db.group/fetch-group db group-id)]
       [::response/ok (fetch-group-detail db group)])))
```

以上でClojure/Duct版RESTサーバの仕様変更対応は完了(\*> ᴗ •\*)ゞ

# Haskell版

- Haskell/Yesod版RESTサーバ(version 2): [lagenorhynque/situated-program-challenge/rest-server at hs-version2](https://github.com/lagenorhynque/situated-program-challenge/tree/hs-version2/rest-server)

## 1. モデルの改修

Clojure版と同様に、まずはDBスキーマの変更に対応するため、モデルの定義に手を加える。

Haskellの[Persistent](https://github.com/yesodweb/persistent)でPostgreSQLのenum typeをいかに扱うべきか、いろいろ検討し試行錯誤した(Haskell版での作業の大半の時間をここに費やした:sweat_smile:)結果、最終的にはこちらのページ

[How to use UUID values with Persistent and Yesod](http://bitemyapp.com/posts/2016-06-15-uuids-with-persistent-yesod.html)

などを参考に、 `venue_type` を表すデータ型を定義し、それをPersistentのフィールドとして扱えるように必要な型クラスのインスタンス実装を与えることにした。

- [rest-server/src/CustomField/VenueType.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/CustomField/VenueType.hs)

```haskell
{-# LANGUAGE OverloadedStrings #-}
module CustomField.VenueType where

import qualified Data.ByteString.Char8 as BC
import           Database.Persist.Sql

data VenueType = Physical | Online deriving (Eq, Ord, Enum, Bounded)

instance Show VenueType where
    show Physical = "physical"
    show Online   = "online"

instance Read VenueType where
    readsPrec _ "physical" = [(Physical, [])]
    readsPrec _ "online"   = [(Online, [])]
    readsPrec _ _          = []

instance PersistField VenueType where
    toPersistValue = PersistDbSpecific . BC.pack . show
    fromPersistValue (PersistDbSpecific bs) = Right . read $ BC.unpack bs
    fromPersistValue _                      = Left "Not PersistDBSpecific"

instance PersistFieldSql VenueType where
    sqlType _ = SqlOther "venue_type"
```

ここで定義した `VenueType` も利用して、新たにテーブルに追加されたカラムをモデルの定義に反映する。

- [rest-server/config/models](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/config/models) (差分)

```diff
 Group sql=groups
     name Text sqltype=text Maybe
     createdAt UTCTime Maybe
     deriving Eq
     deriving Show

 GroupMember sql=groups_members
     groupId GroupId
     memberId MemberId
     Primary groupId memberId
     admin Bool default=false Maybe
     deriving Eq
     deriving Show

 Meetup sql=meetups
     title Text sqltype=text
     startAt UTCTime Maybe
     endAt UTCTime Maybe
     venueId VenueId Maybe
     groupId GroupId Maybe
+    onlineVenueId VenueId Maybe
     deriving Eq
     deriving Show

 MeetupMember sql=meetups_members
     meetupId MeetupId
     memberId MemberId
     Primary meetupId memberId
     deriving Eq
     deriving Show

 Member sql=members
     firstName Text sqltype=text Maybe
     lastName Text sqltype=text Maybe
     email Text sqltype=text Maybe
     deriving Eq
     deriving Show

 Venue sql=venues
     name Text sqltype=text Maybe
     postalCode Text sqltype=text Maybe
     prefecture Text sqltype=text Maybe
     city Text sqltype=text Maybe
     street1 Text sqltype=text Maybe
     street2 Text sqltype=text Maybe
     groupId GroupId Maybe
+    url Text sqltype=text Maybe
+    venueType VenueType Maybe default="physical"
     deriving Eq
     deriving Show
```

モデルの設定ファイルで `CustomField.VenueType` が利用できるようにimportしておき、また、 `venueType` フィールドが追加された `Venue` エンティティを直接JSONと相互変換する必要はないため `deriveJSON` しないようにしておく。

- [rest-server/src/Model.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/Model.hs) (差分)

```diff
 {-# LANGUAGE DeriveGeneric              #-}
 {-# LANGUAGE EmptyDataDecls             #-}
 {-# LANGUAGE FlexibleInstances          #-}
 {-# LANGUAGE GADTs                      #-}
 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
 {-# LANGUAGE MultiParamTypeClasses      #-}
 {-# LANGUAGE NoImplicitPrelude          #-}
 {-# LANGUAGE OverloadedStrings          #-}
 {-# LANGUAGE TemplateHaskell            #-}
 {-# LANGUAGE TypeFamilies               #-}
 module Model where

 import           ClassyPrelude.Yesod
 import           Data.Aeson.TH
 import           Database.Persist.Quasi

 import qualified Data.Aeson.Casing      as Casing
 import qualified Text.Casing            as Casing

+import           CustomField.VenueType
+
 -- You can define all of your database entities in the entities file.
 -- You can find more information on persistent and how to declare entities
 -- at:
 -- http://www.yesodweb.com/book/persistent/
 share [mkPersist sqlSettings, mkMigrate "migrateAll"]
     $(persistFileWith lowerCaseSettings "config/models")

 deriveJSON (Casing.aesonPrefix Casing.kebab) ''Group
 deriveJSON (Casing.aesonPrefix Casing.kebab) ''Meetup
 deriveJSON (Casing.aesonPrefix Casing.kebab) ''Member
-deriveJSON (Casing.aesonPrefix Casing.kebab) ''Venue
```

## 2. ルーティングの改修

次に、APIエンドポイントの追加に対応するため、オンライン会場APIのルーティング定義を追加する(`stack exec -- yesod add-handler` を利用すると、関連する設定も自動生成されて簡単:ideograph_advantage:)。

- [rest-server/config/routes](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/config/routes) (差分)

```diff
 /members MembersR GET POST
 /members/#MemberId MemberR GET
 /members/#MemberId/meetups/#MeetupId MeetupMemberR POST
 /members/#MemberId/groups/#GroupId GroupMemberR POST
 /groups GroupsR GET POST
 /groups/#GroupId/venues VenuesR GET POST
 /groups/#GroupId/meetups MeetupsR GET POST
 /groups/#GroupId/meetups/#MeetupId MeetupR GET
+/groups/#GroupId/online-venues OnlineVenuesR GET POST
```

## 3. ハンドラーの改修

最後に、APIの仕様変更内容通りにハンドラーを改修する。

改修の方針も内容もほとんどClojure版と同じなので特筆すべき点はなさそう。

強いて言えば、Clojureでは一貫してマップ(associativeなもの)としてデータを扱うのに対して、Haskellでは目的によってレコードや[aeson](https://github.com/bos/aeson)といった異なるデータ表現に変換しているため、必然的に扱い方に違いが見られる。

- [rest-server/src/Handler/OnlineVenues.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/Handler/OnlineVenues.hs)

```haskell
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE TemplateHaskell   #-}
module Handler.OnlineVenues where

import           Import

import           Data.Aeson.TH

import qualified Data.Aeson.Casing     as Casing
import qualified Text.Casing           as Casing

import qualified CustomField.VenueType as VenueType

getOnlineVenuesR :: GroupId -> Handler Value
getOnlineVenuesR groupId = do
    vs <- runDB $ selectList [VenueGroupId ==. Just groupId, VenueVenueType ==. Just VenueType.Online] []
    returnJson $ map onlineVenueValueWithId vs

postOnlineVenuesR :: GroupId -> Handler Value
postOnlineVenuesR groupId = do
    PostVenue{..} <- requireJsonBody :: Handler PostVenue
    let v = Venue { venueName = postVenueName
                  , venuePostalCode = Nothing
                  , venuePrefecture = Nothing
                  , venueCity = Nothing
                  , venueStreet1 = Nothing
                  , venueStreet2 = Nothing
                  , venueGroupId = Just groupId
                  , venueUrl = postUrl
                  , venueVenueType = Just VenueType.Online
                  }
    vid <- runDB $ insert v
    returnJson . onlineVenueValueWithId $ Entity vid v

onlineVenueValueWithId :: Entity Venue -> Value
onlineVenueValueWithId (Entity vid Venue{..}) =
    object [ "online-venue-id" .= vid
           , "venue-name" .= venueName
           , "url" .= venueUrl
           ]

data PostVenue = PostVenue
    { postVenueName :: Maybe Text
    , postUrl       :: Maybe Text
    }

deriveJSON (Casing.aesonPrefix Casing.kebab) ''PostVenue
```

- [rest-server/src/Handler/Venues.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/Handler/Venues.hs) (差分)

```diff
 {-# LANGUAGE NoImplicitPrelude #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards   #-}
 {-# LANGUAGE TemplateHaskell   #-}
 module Handler.Venues where

 import           Import

 import           Data.Aeson.TH

-import qualified Data.Aeson.Casing as Casing
-import qualified Text.Casing       as Casing
+import qualified Data.Aeson.Casing     as Casing
+import qualified Text.Casing           as Casing
+
+import qualified CustomField.VenueType as VenueType

 getVenuesR :: GroupId -> Handler Value
 getVenuesR groupId = do
-    vs <- runDB $ selectList [VenueGroupId ==. Just groupId] []
+    vs <- runDB $ selectList [VenueGroupId ==. Just groupId, VenueVenueType ==. Just VenueType.Physical] []
     returnJson $ map venueValueWithAddress vs

 postVenuesR :: GroupId -> Handler Value
 postVenuesR groupId = do
     PostVenue{..} <- requireJsonBody :: Handler PostVenue
     let PostAddress{..} = postAddress
         v = Venue { venueName = postVenueName
                   , venuePostalCode = postPostalCode
                   , venuePrefecture = postPrefecture
                   , venueCity = postCity
                   , venueStreet1 = postAddress1
                   , venueStreet2 = postAddress2
                   , venueGroupId = Just groupId
+                  , venueUrl = Nothing
+                  , venueVenueType = Just VenueType.Physical
                   }
     vid <- runDB $ insert v
     returnJson . venueValueWithAddress $ Entity vid v

 venueValueWithAddress :: Entity Venue -> Value
 venueValueWithAddress (Entity vid Venue{..}) =
     object [ "venue-id" .= vid
            , "venue-name" .= venueName
            , "address" .= object [ "postal-code" .= venuePostalCode
                                  , "prefecture" .= venuePrefecture
                                  , "city" .= venueCity
                                  , "address1" .= venueStreet1
                                  , "address2" .= venueStreet2
                                  ]
            ]

 data PostVenue = PostVenue
     { postVenueName :: Maybe Text
     , postAddress   :: PostAddress
     }

 data PostAddress = PostAddress
     { postPostalCode :: Maybe Text
     , postPrefecture :: Maybe Text
     , postCity       :: Maybe Text
     , postAddress1   :: Maybe Text
     , postAddress2   :: Maybe Text
     }

 deriveJSON (Casing.aesonPrefix Casing.kebab) ''PostVenue
 deriveJSON (Casing.aesonPrefix Casing.kebab) ''PostAddress
```

- [rest-server/src/Handler/Meetups.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/Handler/Meetups.hs) (差分)

```diff
 {-# LANGUAGE NoImplicitPrelude #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards   #-}
 module Handler.Meetups where

 import           Import

-import           Database.Esqueleto ((^.))
-import qualified Database.Esqueleto as E
+import           Database.Esqueleto   ((^.))
+import qualified Database.Esqueleto   as E

-import           Handler.Members    (memberValueWithId)
-import           Handler.Venues     (venueValueWithAddress)
+import           Handler.Members      (memberValueWithId)
+import           Handler.OnlineVenues (onlineVenueValueWithId)
+import           Handler.Venues       (venueValueWithAddress)

 getMeetupsR :: GroupId -> Handler Value
 getMeetupsR groupId = do
     ms <- runDB $ selectList [MeetupGroupId ==. Just groupId] []
     meetupDetails <- mapM fetchMeetupDetail ms
     returnJson meetupDetails

 postMeetupsR :: GroupId -> Handler Value
 postMeetupsR groupId = do
     m <- requireJsonBody :: Handler Meetup
     let m' = m { meetupGroupId = Just groupId }
     mid <- runDB $ insert m'
     meetupDetail <- fetchMeetupDetail $ Entity mid m'
     returnJson meetupDetail

 getMeetupR :: GroupId -> MeetupId -> Handler Value
 getMeetupR _ = getMeetup

 postMeetupMemberR :: MemberId -> MeetupId -> Handler Value
 postMeetupMemberR memberId meetupId = do
     _ <- runDB . insert $ MeetupMember { meetupMemberMeetupId = meetupId
                                        , meetupMemberMemberId = memberId
                                        }
     getMeetup meetupId

 fetchMeetupDetail :: Entity Meetup -> Handler Value
 fetchMeetupDetail em@(Entity mid Meetup{..}) = do
     v <- maybe (return Nothing) (runDB . getEntity) meetupVenueId
+    ov <- maybe (return Nothing) (runDB . getEntity) meetupOnlineVenueId
     ms <- runDB $ E.select
                 $ E.from $ \(member' `E.InnerJoin` meetupMember') -> do
                     E.on $ member' ^. MemberId E.==. meetupMember' ^. MeetupMemberMemberId
                     E.where_ $ meetupMember' ^. MeetupMemberMeetupId E.==. E.val mid
                     return member'
-    return $ meetupWithVenueAndMembers em v ms
+    return $ meetupWithVenueAndMembers em v ov ms

 getMeetup :: MeetupId -> Handler Value
 getMeetup mid = do
     m <- runDB $ get404 mid
     meetupDetail <- fetchMeetupDetail $ Entity mid m
     returnJson meetupDetail

-meetupWithVenueAndMembers :: Entity Meetup -> Maybe (Entity Venue) -> [Entity Member] -> Value
-meetupWithVenueAndMembers (Entity mid Meetup{..}) venue members =
+meetupWithVenueAndMembers :: Entity Meetup -> Maybe (Entity Venue) -> Maybe (Entity Venue) -> [Entity Member] -> Value
+meetupWithVenueAndMembers (Entity mid Meetup{..}) venue onlineVenue members =
     object [ "event-id" .= mid
            , "title" .= meetupTitle
            , "start-at" .= meetupStartAt
            , "end-at" .= meetupEndAt
            , "venue" .= fmap venueValueWithAddress venue
+           , "online-venue" .= fmap onlineVenueValueWithId onlineVenue
            , "members" .= fmap memberValueWithId members
            ]
```

- [rest-server/src/Handler/Groups.hs](https://github.com/lagenorhynque/situated-program-challenge/blob/hs-version2/rest-server/src/Handler/Groups.hs) (差分)

```diff
 {-# LANGUAGE NoImplicitPrelude #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards   #-}
 {-# LANGUAGE TemplateHaskell   #-}
 module Handler.Groups where

 import           Import

 import           Data.Aeson.TH

-import           Control.Lens       hiding ((.=), (^.))
-import qualified Data.Aeson.Casing  as Casing
+import           Control.Lens          hiding ((.=), (^.))
+import qualified Data.Aeson.Casing     as Casing
 import           Data.Aeson.Lens
-import           Database.Esqueleto ((^.))
-import qualified Database.Esqueleto as E
-import qualified Text.Casing        as Casing
+import           Database.Esqueleto    ((^.))
+import qualified Database.Esqueleto    as E
+import qualified Text.Casing           as Casing

-import           Handler.Meetups    (fetchMeetupDetail)
-import           Handler.Members    (memberValueWithId)
-import           Handler.Venues     (venueValueWithAddress)
+import qualified CustomField.VenueType as VenueType
+import           Handler.Meetups       (fetchMeetupDetail)
+import           Handler.Members       (memberValueWithId)
+import           Handler.OnlineVenues  (onlineVenueValueWithId)
+import           Handler.Venues        (venueValueWithAddress)

 getGroupsR :: Handler Value
 getGroupsR = do
     gs <- runDB $ selectList [] [] :: Handler [Entity Group]
     groupDetails <- mapM fetchGroupDetail gs
     returnJson groupDetails

 postGroupsR :: Handler Value
 postGroupsR = do
     PostGroup{..} <- requireJsonBody :: Handler PostGroup
     now <- liftIO getCurrentTime
     let g = Group { groupName = postGroupName
                   , groupCreatedAt = Just now
                   }
     gid <- runDB $ insert g
     _ <- runDB . insertMany $ map (\mid -> GroupMember { groupMemberGroupId = gid
                                                        , groupMemberMemberId = mid
                                                        , groupMemberAdmin = Just True
                                                        }) postAdminMemberIds
     ms <- runDB $ selectList [MemberId <-. postAdminMemberIds] []
     returnJson $ groupValueWithAdmin (Entity gid g) ms

 postGroupMemberR :: MemberId -> GroupId -> Handler Value
 postGroupMemberR memberId groupId = do
     PostGroupMember{..} <- requireJsonBody :: Handler PostGroupMember
     _ <- runDB $ insert GroupMember { groupMemberGroupId = groupId
                                     , groupMemberMemberId = memberId
                                     , groupMemberAdmin = postAdmin
                                     }
     g <- runDB $ get404 groupId
     groupDetail <- fetchGroupDetail $ Entity groupId g
     returnJson groupDetail

 fetchGroupDetail :: Entity Group -> Handler Value
 fetchGroupDetail eg@(Entity gid Group{..}) = do
     admins <- runDB $ E.select
                     $ E.from $ \(member' `E.InnerJoin` groupMember') -> do
                         E.on $ member' ^. MemberId E.==. groupMember' ^. GroupMemberMemberId
                         E.where_ $ (groupMember' ^. GroupMemberGroupId E.==. E.val gid)
                             E.&&. (groupMember' ^. GroupMemberAdmin E.==. E.val (Just True))
                         return member'
-    vs <- runDB $ selectList [VenueGroupId ==. Just gid] []
+    vs <- runDB $ selectList [VenueGroupId ==. Just gid, VenueVenueType ==. Just VenueType.Physical] []
     let venueDetails = map venueValueWithAddress vs
+    ovs <- runDB $ selectList [VenueGroupId ==. Just gid, VenueVenueType ==. Just VenueType.Online] []
+    let onlineVenueDetails = map onlineVenueValueWithId ovs
     ms <- runDB $ selectList [MeetupGroupId ==. Just gid] []
     meetupDetails <- mapM fetchMeetupDetail ms
     return $ toJSON (groupValueWithAdmin eg admins)
              & _Object . at "venues" ?~ toJSON venueDetails
+             & _Object . at "online-venues" ?~ toJSON onlineVenueDetails
              & _Object . at "meetups" ?~ toJSON meetupDetails

 groupValueWithAdmin :: Entity Group -> [Entity Member] -> Value
 groupValueWithAdmin (Entity gid Group{..}) ms =
     object [ "group-id" .= gid
            , "group-name" .= groupName
            , "admin" .= map memberValueWithId ms
            ]

 data PostGroup = PostGroup
     { postGroupName      :: Maybe Text
     , postAdminMemberIds :: [Key Member]
     }

 newtype PostGroupMember = PostGroupMember
     { postAdmin :: Maybe Bool
     }

 deriveJSON (Casing.aesonPrefix Casing.kebab) ''PostGroup
 deriveJSON (Casing.aesonPrefix Casing.kebab) ''PostGroupMember
```

以上でHaskell/Yesod版RESTサーバの仕様変更対応も無事に完了(\*> ᴗ •\*)ゞ

# まとめ

- ClojureでもHaskellでも既存実装に大きく手を入れることなく必要最小限の改修でAPI変更を実現できた
    - [Clojure版のdiff](https://github.com/lagenorhynque/situated-program-challenge/commit/5618b4f8fcb90392a37d65ad2c9473e0f54c15b5) [^1]: 7ファイル + 75行 -22行
    - [Haskell版のdiff](https://github.com/lagenorhynque/situated-program-challenge/commit/c20f56e4286e17c9cf4a7bfaef941bc97d918a25) [^1]: 9ファイル +111行 -20行
- 特定のミドルウェアの独自機能が関わるような問題はライブラリ/フレームワークの標準機能でカバーしきれないことがある(カスタマイズしやすさが問われる)
- 既存のユニットテストから始めてテスト駆動開発を実践してみても良かったかも
- Clojure楽しい＞ω＜/
- Haskell楽しい＞ω＜/

# Further Reading

- [bevuta - Blog - Using PostgreSQL Enums in Clojure](https://www.bevuta.com/en/blog/using-postgresql-enums-in-clojure/)
- [bitemyapp - How to use UUID values with Persistent and Yesod](http://bitemyapp.com/posts/2016-06-15-uuids-with-persistent-yesod.html)

[^1]: READMEとテストコードを除いた、プロダクトコードの変更行数。