Haskell の API 定義から Kotlin の関数を生成するライブラリを作った
servant-kotlin という Haskell パッケージを作った. そん時のメモ書き.
servant-kotlin
RESTful API を型として定義できる Haskell ライブラリ Servant という Web フレームワークがある. 例えば以下のような API の型を定義できる.
data Todo = Todo
todoId :: Int
{ title :: Text
, done :: Bool
,deriving (Show, Eq)
}
instance ToJSON Todo
type CRUD = "todos" :> Get '[JSON] [Todo]
:<|> "todos" :> ReqBody '[JSON, FormUrlEncoded] Todo :> Post '[JSON] Todo
servant-kotlin
を用いると,上記の Todo
型と CRUD
型から以下のような Kotlin のエンドポイント関数を生成できる.
class TodoAPI(private val baseURL: String) {
data class Todo(val todoId: Int, val title: String, val done: Boolean)
fun getTodos(handler: (Request, Response, Result<List<Todo>, FuelError>) -> Unit) {
.get("/" + "todos").responseObject(handler)
Fuel}
fun postTodos(body: Todo, handler: (Request, Response, Result<Todo, FuelError>) -> Unit) {
.post("/" + "todos").body(Gson().toJson(body, Todo::class.java)).responseObject(handler)
Fuel
} }
同種のものに servant-elm
や servant-ruby
というのがある. 今回は主に servant-elm
と,Haskell の型定義から Elm の型定義を生成する elm-export
を参考して作った. 参考と言うか,ほぼ丸パクリ(笑)
いきさつ
前回の記事 で紹介した通り,Elm × Haskell × Kotlin の Android + Web アプリを作った. Haskell ではもちろん Servant で API をサーバーを立てて,Elm (Web UI) 側は servant-elm
を使って API の定義から生成してたんだが,Kotlin (Android Client) 側にはそういうパッケージがまだ無い. なので作った.
自分用に作ったため,ちゃんとした動作確認は自分のパターンでしかしていない. そもそも,Header
や QueryFlag
など,今回の要件では必要なかった部分はまだ未実装だ.
まぁそのうち実装します(Kotlin 側をまじめに調べてないので,どういう形に出力すればいいかを知らない...).
作る
型の生成
まずは Haskell で定義した型から Kotlin の型(クラス)を生成する必要がある. 取りあえず,あらゆる Haskell の型を生成可能にする必要は無い(JSON として使えるやつだけ)ので,そういうつもりで実装する.
Kotlin には data class と呼ばれるクラスがある. 例えば,さっきの Todo
型に対応する data calss は以下の通り.
data class Todo(val todoId: Int, val title: String, val done: Boolean)
乱暴に言えば固有のメソッドの定義が無いクラスで,ほぼほぼ C言語の構造体と同じ. Haskell のレコード型をこの data class に対応させることにした.
elm-export
では ElmType
型クラスというのを定義している.
class ElmType a where
toElmType :: a -> ElmDatatype
ElmDatatype
型は Elm の型構造を Haskell で表現したものだ(詳しくは Elm/Type.hs
を見て). これらを参考にして,Kotlin の場合も同様に KotlinType
型クラスと KotlinClass
型を定義した.
class KotlinType a where
toKotlinType :: a -> Maybe KotlinClass
Haskell の全ての型を,必ず Kotlin のクラスに変換できるとは思わなかった(例えば,フィールド名の無いフィールドを持つクラスは定義できない)ので,念のため Maybe
を返している.
で,まずは Primitive な Haskell 型のいくつかを KotlinType
型クラスのインスタンスにしてみた.
instance KotlinType Int where
= Just $ PrimitiveClass KInt
toKotlinType _
instance KotlinType () where
= Just $ PrimitiveClass KUnit
toKotlinType _
instance KotlinType Text where
= Just $ PrimitiveClass KString
toKotlinType _
instance KotlinType a => KotlinType [a] where
= ExClass . KList <$> toKotlinType (Proxy :: Proxy a)
toKotlinType _
instance KotlinType a => KotlinType (Maybe a) where
=
toKotlinType _ PrimitiveClass . KNullable <$> toKotlinType (Proxy :: Proxy a)
文字列とか配列・リストの辺りは,本当にこんな定義でいいかは怪しいが,あんまり悩んでてもしょうがないので,暫定的にこのような定義にしてる.
ちなみに,Kotlin には Maybe
や Optional
のようなコンテナ型は無く,Nullable というモノを用いている. A
型の Nullable は A?
と書くのだ.
Generics
さて,最悪これでお終いでも良いのだが,deriving
ができないと流石に使いにくい. なので,頑張って Generics を定義する(とはいえ,elm-export
のを真似るだけだが).
Generics とは,乱暴な言い方をすると,Haskell の型構造を Haskell の型と型クラスで表現したものだ. もっと細かい情報が含まれてるとはいえ,elm-export
の ElmDatatype
型と ElmType
型クラスや,今回の KotlinClass
型と KotlinType
型クラスにイメージは似ている(もちろんもっと精巧に作られているが).
deriving
で任意の型を Generic
型クラスのインスタンスにできる(個人的にはココがすごい)ので,Generic
型クラスのインスタンスに対する default 実装を与えておけば良いのだ.
Generic
型クラスは次のような定義になっている.
class Generic a where
type Rep a :: * -> *
from :: a -> (Rep a) x
to :: (Rep a) x -> a
Rep a x
という型が Haskell の型構造を表現した型だ(x
があるのは再帰的に構造をラップするためだと思う). 例えば Bool
の Rep a x
型を見てみる.
>> from (True :: Bool)
ghciM1 {unM1 = R1 (M1 {unM1 = U1})}
>> :t from (True :: Bool)
ghciTrue :: Bool)
from ( :: D1
'MetaData "Bool" "GHC.Types" "ghc-prim" 'False)
(C1 ('MetaCons "False" 'PrefixI 'False) U1
(:+: C1 ('MetaCons "True" 'PrefixI 'False) U1)
x
なので,Rep a x
型に対しての KotlinType
型クラスのための型クラス GenericKotlinType
を定義する.
class KotlinType a where
toKotlinType :: a -> Maybe KotlinClass
= genericToKotlinType . from
toKotlinType toKotlinType :: (Generic a, GenericKotlinType (Rep a)) =>
default-> Maybe KotlinClass
a
class GenericKotlinType f where
genericToKotlinType :: f a -> Maybe KotlinClass
あとは,各データ構造の型(e.g. D1
や (:+:)
)ごとにインスタンスを定義してあげれば良い.
instance (Datatype d, GenericKotlinFields f)
=> GenericKotlinType (D1 d f) where
= fmap DataClass $
genericToKotlinType datatype KotlinDataClass (pack $ datatypeName datatype)
<$> genericToKotlinFields (unM1 datatype)
-- Kotlin のインスタンスフィールを返す型クラス
class GenericKotlinFields f where
genericToKotlinFields :: f a -> Maybe KotlinFields
instance (Selector s, GenericKotlinType a)
=> GenericKotlinFields (S1 s a) where
=
genericToKotlinFields selector case selName selector of
"" -> Nothing
->
name Node . KotlinField (pack name)
<$> genericToKotlinType (undefined :: a p)
instance GenericKotlinFields (f :+: g) where
= Nothing
genericToKotlinFields _
instance (GenericKotlinFields f, GenericKotlinFields g)
=> GenericKotlinFields (f :*: g) where
=
genericToKotlinFields _ Brunch <$> genericToKotlinFields (undefined :: f p)
<*> genericToKotlinFields (undefined :: g p)
ちなみに D1 d f
型は datatype を,S1 s a
型はレコード型のフィールドを,f :+: g
型は直和型を,f :*: g
型は直積型を表している.
Servant API の型の変換
最初に定義した Servant API の型では(書きやすいけど)扱いにくいので,変換する部分が必要だ. もちろん,Servant 側が型クラスとして定義してある.
class HasForeign lang ftype (api :: *) where
type Foreign ftype api :: *
foreignFor ::
Proxy lang
-> Proxy ftype
-> Proxy api
-> Req ftype
-> Foreign ftype api
listFromAPI ::
HasForeign lang ftype api
( GenerateList ftype (Foreign ftype api)
, => Proxy lang
) -> Proxy ftype
-> Proxy api
-> [Req ftype]
= ...
listFromAPI
class HasForeignType lang ftype a where
typeFor :: Proxy lang -> Proxy ftype -> Proxy a -> ftype
listFromAPI
関数を使うことでメタ情報の載った API の型 Req ftype
の値が手に入る. 重要なのは HasForeign
型クラスだが,インスタンスが既にかなり定義されていて,実質的には HasForeignType
型クラスのインスタンスを定義すれば良い.
data LangKotlin
instance (KotlinType a) =>
HasForeignType LangKotlin KotlinClass a where
= toKotlinType' (Proxy :: Proxy a) typeFor _ _ _
生成する
さて,後は実際に Kotlin のコードを文字列として生成する部分を作る. しかし,この部分は泥臭い部分なので細かくは説明しないでおく. servant-elm
と見比べながら書き直していった.
-- generate types
class GenerateKotlin a where
generateKotlin :: a -> [Text]
instance GenerateKotlin KotlinPrimitiveClass where
KDouble = ["Double"]
generateKotlin KFloat = ["Float"]
generateKotlin ...
-- generate apis
generateKotlinForAPIWith ::
F.HasForeign LangKotlin KotlinClass api
( F.GenerateList KotlinClass (F.Foreign KotlinClass api))
, => KotlinOptions
-> Proxy api
-> [Text]
=
generateKotlinForAPIWith opts . fmap (docToText . generateKotlinForRequest opts) . getEndpoints
nub
generateKotlinForRequest :: KotlinOptions -> F.Req KotlinClass -> Doc
= ... generateKotlinForRequest
Doc
型というのを使ってる. これは wl-pprint-text
というパッケージのモノで,直接文字列方を使うのではなく,この Doc
型を使うことでインデントなどをいい感じにしてくれる.
Elm との違いは,最終的な出力をいじる所. Elm (や Haskell) はトップレベルのインデント(関数や型定義)は揃ってるので,そのままでいいのだが,OOP 言語はクラスにラップする必要があるのでめんどくさい.
generateKotlinForAPIClass :: Text -> [Text] -> [Text]
= mconcat
generateKotlinForAPIClass className body $ "class" <+> textStrict className <> "(private val baseURL: String) {" ]
[ [ docToText fmap (docToText . vsep . fmap (indent indentNum . textStrict) . T.lines) body
, "}" ]
, [ ]
めんどくさかったので,一度 Doc
型に戻してからインデントをそろえて,Text
型に直して返している(効率悪そう...).
ファイルへ出力
最後にファイルへ出力する部分を書く. Spec
という名前はどうかと思うんだけど...(elm-export
がそうしてる).
data Spec = Spec
namespace :: [Text]
{ filename :: Text
, declarations :: [Text]
,deriving (Show) }
というの定義して,この内容から出力している. declarations
というのが,generateKotlinForAPIClass
関数などで出力した値を渡す.
完成
こんな風な Main
を書くと生成できる.
spec :: Spec
= Spec ["com", "github", "matsubara0507"] "TodoAPI" $ mconcat
spec
[ [ defKotlinImports ]"TodoAPI" $ mconcat
, generateKotlinForAPIClass Proxy :: Proxy Todo)
[ generateKotlinForDefDataClass (Proxy :: Proxy CRUD)
, generateKotlinForAPI (
]
]
main :: IO ()
= do
main "example/src/main/java" specsToDir [spec]
Stackage
出来上がったので
無事 Stack の Nightly に登録された!
サンプル
ちなみに,サンプルは GitHub にあげてある.
この Haskell コードから,この Kotlin コードを生成する.
おしまい
Generics は使ってみると,意外と分かりやすいですね. ただ,これでいいんだろうか...という不安は付いて回りますが(笑)