🐷

[Elm] REST API組むとき、Decoder/Encoder手書きでまだ消耗してるの? [OpenAPI Generator]

2021/03/06に公開

GraphQLを使ってモダンな開発をしてみたい今日この頃ですが、とは言ってもまだまだREST APIは健在です。GraphQLのスキーマ駆動開発に近づきたくて、OpenAPI GeneratorElm試してみたところ予想以上の出来だったので記事にしてみました。

そもそも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の場合、dateLocalDate, 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発火やカスタムエラーもハンドリングできるCmdTaskへの変換と至れり尽くせりとなっています!これはもう使わない理由はなさそうです・・・。

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