[Elm] REST API組むとき、Decoder/Encoder手書きでまだ消耗してるの? [OpenAPI Generator]
GraphQLを使ってモダンな開発をしてみたい今日この頃ですが、とは言ってもまだまだREST APIは健在です。GraphQLのスキーマ駆動開発に近づきたくて、OpenAPI GeneratorをElm試してみたところ予想以上の出来だったので記事にしてみました。
そもそもOpenAPIとかOpenAPI Generatorの使い方は?という方は、先駆者様の記事をどうぞ。
以下のような手順でOpenAPIのyamlから生成しました。
$ npm i -g @openapitools/openapi-generator-cli
$ openapi-generator-cli generate -i reference/api.v1.yaml -g elm -o generated-project
今回OpenAPIを作成するにあたって使用したツール: Stoplight
完璧な型の生成
Generatorが吐く型がどれほど対応しているか様々な型を指定してみました。Models
にはGET用(Hoge
)とPOST用(Foo
)の型を用意しました。
Hoge全体の型が以下になります。赤い!の丸がRequired
のチェックになります。外すとnull
が含まれているかもしれない値となります。加えて、ネストしたレコードのリスト(nestedList)も型として用意しました。
required:
- id
- number
- integer
- bool
- nestedList
- enum
- date
enum
は型の指定はstring
ですが、詳細にはenum
です。
enum:
type: string
enum:
- AAA
- BBB
- CCC
- DDD
同様にdate
は型の指定はstring
ですが、formatはdate-time
になります。
date:
type: string
format: date-time
生成されたHoge
の型は以下のようになります。想定通りの型付けです!!Elmは型がシンプルなためキチンと型付けがされていきます。例えば、Javaの場合、date
がLocalDate
, ZonedDatetTime
, OffsetDateTime
等に変化する可能性があり型をOpenAPIでコントロールするのは困難になります(それだけ厳密なドキュメントにできるということかもしれませんが、コントロールは難しいです)。
type alias Hoge =
{ id : String
, number : Float
, integer : Int
, bool : Bool
, nestedList : List (HogeNestedList)
, enum : HogeEnum
, date : Posix
, integerMaybe : Maybe Int
}
type HogeEnum
= HogeEnumAAA
| HogeEnumBBB
| HogeEnumCCC
| HogeEnumDDD
hogeEnumVariants : List HogeEnum
hogeEnumVariants =
[ HogeEnumAAA
, HogeEnumBBB
, HogeEnumCCC
, HogeEnumDDD
]
type alias HogeNestedList =
{ nestedId : String
, nestedNumber : Float
}
Enumはカスタムタイプに変換されていますが、キチンと文字列へ変換する関数が用意されています。
stringFromHogeEnum : HogeEnum -> String
stringFromHogeEnum model =
case model of
HogeEnumAAA ->
"AAA"
HogeEnumBBB ->
"BBB"
HogeEnumCCC ->
"CCC"
HogeEnumDDD ->
"DDD"
Decoder/Encoderの生成
Decoderについて見て行きましょう。こちらも想定通りです。DateTime
のDecoderに関しては別途ライブラリを利用して、Decoderを作っています。気になる方は生成されたコードを覗いてみてください。EnumのDecoderはModels
に別途切り出さないと使用してくれないので注意が必要です。
hogeDecoder : Json.Decode.Decoder Hoge
hogeDecoder =
Json.Decode.succeed Hoge
|> decode "id" Json.Decode.string
|> decode "number" Json.Decode.float
|> decode "integer" Json.Decode.int
|> decode "bool" Json.Decode.bool
|> decode "nestedList" (Json.Decode.list hogeNestedListDecoder)
|> decode "enum" hogeEnumDecoder
|> decode "date" Api.Time.dateTimeDecoder
|> maybeDecode "integerMaybe" Json.Decode.int Nothing
hogeEnumDecoder : Json.Decode.Decoder HogeEnum
hogeEnumDecoder =
Json.Decode.string
|> Json.Decode.andThen
(\value ->
case value of
"AAA" ->
Json.Decode.succeed HogeEnumAAA
"BBB" ->
Json.Decode.succeed HogeEnumBBB
"CCC" ->
Json.Decode.succeed HogeEnumCCC
"DDD" ->
Json.Decode.succeed HogeEnumDDD
other ->
Json.Decode.fail <| "Unknown type: " ++ other
Encoderもキチンと生成されています。
encodeFooPairs : Foo -> List EncodedField
encodeFooPairs model =
let
pairs =
[ encode "fooId" Json.Encode.string model.fooId
, encode "fooNumber" Json.Encode.float model.fooNumber
, encode "fooBool" Json.Encode.bool model.fooBool
]
in
API呼び出しも自動生成
API呼び出しも完璧に用意されていました。ここは詳しく深堀りしていないのですが、Headerも対応してくれていそうです。ここでのポイントは、Cmd型ではなくApi.Request
という独自な型になっている点です。
getHoges : Maybe String -> Api.Request (List Api.Data.Hoge)
getHoges authorization_header =
Api.request
"GET"
"/hoges"
[]
[]
[ ( "Authorization", Maybe.map identity authorization_header ) ]
Nothing
(Json.Decode.list Api.Data.hogeDecoder)
postFoos : Maybe String -> Maybe Api.Data.Foo -> Api.Request ()
postFoos authorization_header foo_body =
Api.request
"POST"
"/foos"
[]
[]
[ ( "Authorization", Maybe.map identity authorization_header ) ]
(Maybe.map Api.Data.encodeFoo foo_body)
(Json.Decode.succeed ())
なんとRequest
から通常のCmd
発火やカスタムエラーもハンドリングできるCmd
、Task
への変換と至れり尽くせりとなっています!これはもう使わない理由はなさそうです・・・。
send : (Result Http.Error a -> msg) -> Request a -> Cmd msg
send toMsg req =
sendWithCustomError identity toMsg req
sendWithCustomError : (Http.Error -> e) -> (Result e a -> msg) -> Request a -> Cmd msg
sendWithCustomError mapError toMsg (Request req) =
Http.request
{ method = req.method
, headers = req.headers
, url = Url.Builder.crossOrigin req.basePath req.pathParams req.queryParams
, body = req.body
, expect = expectJson mapError toMsg req.decoder
, timeout = req.timeout
, tracker = req.tracker
}
task : Request a -> Task.Task Http.Error a
task (Request req) =
Http.task
{ method = req.method
, headers = req.headers
, url = Url.Builder.crossOrigin req.basePath req.pathParams req.queryParams
, body = req.body
, resolver = jsonResolver req.decoder
, timeout = req.timeout
}
まとめ
自動生成ツールはどうしてもコントロール不可能な部分が多く、実戦投入するには思った通りのコードを吐き出してくれるか、かなり不安なものです。そのためOpenAPIはドキュメントツールとして割り切っていたのですが、よくあるRESTの組み合わせを作ってみたところ本当に完璧な形で生成されていてビックリしました。特にRequestを組んだ後のCmdかTaskかカスタムエラーハンドリングしたいかのような分岐が綺麗に抽象化されていたため、逆にここを手で組んでしまうと毎度同じようなコードををプロジェクトに書かなければならないため、この自動生成はとても実戦向きに考えられているツールだなと感じました。是非使ってみてください!
Discussion