Android
Kotlin
KotlinPoet
OriginalVASILYDay 3

Swaggerで定義したAPI仕様からRetrofitで使用するinterfaceを自動生成してみる Part1

こんにちは。VASILYでフロントエンドエンジニアをしている@Horie1024です。主にAndroidアプリの開発をしています。この記事はVASILY Advent Calendar 2017 3日目の記事になります。

さて、先日potatotips #45 (iOS/Android開発Tips共有会)で「Swaggerで定義したAPI仕様からRetrofitで使用するinterfaceを自動生成してみる」というタイトルで発表してきました。この記事では、potatotipsで発表した内容について掘り下げてご紹介しようと思います。

長くなってしまったのでPart1、Part2に分割してご紹介します。

追記: Part2を公開しました。
https://qiita.com/Horie1024/items/ccfa0ff50c0b4f17b932

どんな内容なのか?

VASILYでは、Androidアプリのアーキテクチャとして以下の図に示すような3層のレイヤードアーキテクチャを基本にしています。このうち「データ層で使用するAPIへリクエストするコードを自動生成する」というのが先日発表した内容になります。

image.png

なぜ自動生成したいのか?

自動生成したい理由は単純で面倒だからです。まずAndroidアプリでどのようにAPIリクエストを行うのかからご紹介します。(既にご存知の方は飛ばしてください🙇)

APIへのリクエストを行うまでの流れ

AndroidアプリでのAPIへのリクエストには、Retrofitを使用しています。Retrofitでは、API仕様に沿ったinterface用意し、そのintefaceの実装をRetrofitが提供することでアプリからのAPIリクエストを可能にします。

例としてGitHub API v3のList user repositoriesにリクエストするには以下のようなinterfaceを用意します。戻り値はRxJavaのSingle型で受け取るようにしています。RxJavaを使用しない場合Call型で受け取ります。

interface GitHubService {
  @GET("users/{user}/repos")
  fun listRepos(@Path("user") user: String): Single<List<Repo>>
}

次にRetrofitオブジェクトを用意し、GitHubServiceの実装を取得します。APIリクエストのレスポンスをRxJavaのSingleで受け取るためRxJava2CallAdapterFactoryを追加しています。また、レスポンスのJSONをKotlinのオブジェクトにデシリアライズするためにGsonを使用するためGsonConverterFactoryを追加しています。最近では、GsonではなくKotlinと相性の良いMoshiを使用する場合が多いかもしれません。弊社では、data classのコンストラクタパラメータが多すぎる場合にMoshiでのデシリアライズに失敗するバグを踏んでしまいプロジェクト途中からGsonに変えたことがあります。

val retrofit = Retrofit.Builder()
                .baseUrl("https://api.github.com/")
                .addConverterFactory(GsonConverterFactory.create(gson))
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .build()

val service = retrofit.create(GitHubService::class.java)

実際のAPIリクエストは、Retrofitが提供する実装を利用し以下のように行います。

service.listRepos(user = "horie1024")
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({
        // success
    } ,{
        // error
    })

どの部分が面倒に感じるか?

APIのエンドポイントが数個なら問題ないのですが、エンドポイントが無数にある場合、そのエンドポイント毎にinterfaceを作成する必要があります。加えて、レスポンスJSONのデシリアライズ先となるdata classも作成する必要があります。

API仕様からinterfaceやdata classといったコードに書き起こす作業は単純作業となりがちです。さらにAPIに仕様変更があればそれに応じでコードを修正する必要があります。

実際やってみるととても面倒です:innocent:

したがって、この2つについてコードを自動生成してみます。

  • Retrofitでのリクエストに使用するinterface
  • レスポンスのデシリアライズ先として使用するdata class

どのように自動生成するか?

自動生成に何が必要になるとかいうと

  • コードから簡単に扱える構造化されたAPI仕様
  • コードからコードを自動生成する手段

この2つが必要です。これらについてそれぞれ以下のツールを使うことで解決します。

さらに自動生成を簡単に実行するための手段として、Gradle Plugin化し、ターミナルからコマンド一つで簡単に実行できるようにします。

Swagger

SwaggerはRESTful APIの設計、開発、ドキュメント化および使用方法の把握に役立つオープンソースのツール群です。OpenAPI Specification(OAS)の仕様に沿ってAPI仕様を記述することで、各ツールがドキュメントやモックを自動生成することが可能です。OASの仕様はこちらにまとまっています。

swagger-api/swagger-parser

Swaggerで定義されたAPI仕様をswagger-api/swagger-parserでパースする事でコードから簡単に扱えるようになります。swagger-api/swagger-codegenを使いサーバ/クライアントのコードを自動生成する方法もありますが、後述するKotlinPoetを使うことでKotlinのコードを簡単に生成できる事から、今回は、swagger-parser + KotlinPoetの構成で実装しています。

square/kotlinpoet

square/kotlinpoetSquare製のOSSでKotlinのコードを簡単に生成できます。KotlinPoetの概要や使い方は以下の記事、資料が参考になります。

APIの詳細についてはKDocが用意されていますので必要に応じて参照します。また、基本的にsquare/javapoetと使い方が似ているの、どう目的のコードを生成して良いかわからない時は、JavaPoetでどうするか調べるとKotlinPoetでも上手くいく場合があります。

自動生成の流れ

自動生成の流れを整理すると以下のような流れになります。

  1. swagger-parserでSwaggerで定義されたAPI仕様をパース
  2. パースしたAPI仕様の情報を元にKotlinPoetでコードを生成
  3. 任意のパスに.ktファイルを書き出し

API仕様のパースは前述した通りswagger-parserを使用すれば簡単にパースすることができるので、KotlinPoetでinterfaceやdata classをどのように生成するかをコードを書いて試してみます。

GitHub API v3のList user repositoriesを例としてinterfaceやdata classをKotlinPoetで生成してみます。

KotlinPoetでのinterfaceの生成

以下のようなinterfaceをKotlinPoetで生成します。

interface UsersService {
  @GET("/users/{username}/repos")
  fun listRepos(
      @Path("username") username: String,
      @Query("type") type: String? = null,
      @Query("sort") sort: String? = null,
      @Query("direction") direction: String? = null
  ): Single<List<Repo>>
}

UsersService.ktファイルとinterfaceの生成

FileSpecクラスを使用すると.ktファイルを作成できます。FileSpec.Builderの第一引数にはpackage名を、第二引数にはファイル名を指定します。そして、TypeSpec.interfaceBuildferでUserService interfaceを作成し、addType関数で追加しています。FunSpec.Builderでは関数を作成することが可能で、listRepos関数を作成し、TypeSpec.interfaceBuilderのaddFunction関数でUserService interfaceに追加しています。

FileSpec.builder("", "UserService")
        .addType(TypeSpec
                .interfaceBuilder("UserService")
                .addFunction(FunSpec.builder("listRepos")
                        .addModifiers(KModifier.ABSTRACT)
                        .build()
                ).build()
        ).build()
        .writeTo(System.out)

このコードから生成されるコードは以下のようになります。

interface UsersService {  
    fun listRepos()
}

Annotationの追加

listReposにAnnotationを追加するには、FunSpec.BuilderaddAnnotation関数を使用します。追加するAnnotationは、AnnotationSpec.Builderで組み立て、builderの引数には作成したいAnnotationのクラス参照を渡します。さらにaddMember関数でメンバーを追加できます。

FileSpec.builder("", "UserService")
        .addType(TypeSpec
                .interfaceBuilder("UserService")
                .addFunction(FunSpec.builder("listRepos")
                        .addModifiers(KModifier.ABSTRACT)
                        .addAnnotation(AnnotationSpec.builder(GET::class)
                                .addMember("\"/users/{username}/repos\"")
                                .build())
                        .build()
                ).build()
        ).build()
        .writeTo(System.out)

これでlistRepos関数に@GETAnnotationが追加されました。

interface UserService {
    @GET("/users/{username}/repos")
    fun listRepos()
}

戻り値型の追加

listRepos関数に戻り値の型を追加するには、FunSpec.Builderreturns関数を使用します。Single<List<Repo>>を戻り値の型として指定したいので、ParameterizedTypeNameを使い組み立てます。ParameterizedTypeName.getのKDocにある通り、第二引数にネストしてParameterizedTypeNameを指定する事が可能です。また、ここではClassNameクラスでRepo型を作成しています。

FileSpec.builder("", "UserService")
        .addType(TypeSpec
                .interfaceBuilder("UserService")
                .addFunction(FunSpec.builder("listRepos")
                        .addModifiers(KModifier.ABSTRACT)
                        .addAnnotation(AnnotationSpec.builder(GET::class)
                                .addMember("\"/users/{username}/repos\"")
                                .build())
                        .returns(ParameterizedTypeName.get(Single::class.asTypeName(),
                                ParameterizedTypeName.get(List::class.asTypeName(),
                                        ClassName("", "Repo"))
                        )).build()
                ).build()
        ).build()
        .writeTo(System.out)

戻り値の型としてSingle<List<Repo>>が追加されました。

interface UserService {
    @GET("/users/{username}/repos")
    fun listRepos(): Single<List<Repo>>  
}

パラメータの追加

listRepos関数にパラメータを追加するには、FunSpec.BuilderaddParameter関数を使用します。追加するパラメータは、ParameterSpec.Builderで組み立てます。builderの第一引数にはパラメータ名、第二引数には型を指定します。asNonNullableを指定する事でnullを許容しないパラメータとすることが出来ます。また、パラメータでも関数と同様にAnnotationを追加できます。

FileSpec.builder("", "UserService")
        .addType(TypeSpec
                .interfaceBuilder("UserService")
                .addFunction(FunSpec.builder("listRepos")
                        .addModifiers(KModifier.ABSTRACT)
                        .addAnnotation(AnnotationSpec.builder(GET::class)
                                .addMember("\"/users/{username}/repos\"")
                                .build())
                        .addParameter(ParameterSpec.builder("username", String::class.asTypeName().asNonNullable())
                                .addAnnotation(AnnotationSpec.builder(Path::class)
                                        .addMember("\"username\"")
                                        .build())
                                .build())
                        .returns(ParameterizedTypeName.get(Single::class.asTypeName(),
                                ParameterizedTypeName.get(List::class.asTypeName(),
                                        ClassName("", "Repo"))
                        )).build()
                ).build()
        ).build()
        .writeTo(System.out)

listRepos関数にパラメータを追加することができました。

interface UserService {
    @GET("/users/{username}/repos")
    fun listRepos(@Path("username") username: String): Single<List<Repo>>
}

最終的に、UserService interfaceを生成するコードは以下のようになります。

FileSpec.builder("", "UserService")
        .addType(TypeSpec
                .interfaceBuilder("UserService")
                .addFunction(FunSpec.builder("listRepos")
                        .addModifiers(KModifier.ABSTRACT)
                        .addAnnotation(AnnotationSpec.builder(GET::class)
                                .addMember("\"/users/{username}/repos\"")
                                .build())
                        .addParameter(ParameterSpec.builder("username", String::class.asTypeName().asNonNullable())                                    
                                .addAnnotation(AnnotationSpec.builder(Path::class)
                                        .addMember("\"username\"")
                                        .build())
                                .build())
                        .addParameter(ParameterSpec.builder("type", String::class.asTypeName().asNullable())                                    
                               .addAnnotation(AnnotationSpec.builder(Query::class)
                                        .addMember("\"type\"")
                                        .build())
                                .defaultValue("null")
                                .build())
                        .addParameter(ParameterSpec.builder("sort", String::class.asTypeName().asNullable())
                                .addAnnotation(AnnotationSpec.builder(Query::class)
                                        .addMember("\"sort\"")
                                        .build())
                                .defaultValue("null")
                                .build())
                        .addParameter(ParameterSpec.builder("direction", String::class.asTypeName().asNullable())
                                .addAnnotation(AnnotationSpec.builder(Query::class)
                                        .addMember("\"direction\"")
                                        .build())
                                .defaultValue("null")
                                .build())
                        .returns(ParameterizedTypeName.get(
                                Single::class.asTypeName(),
                                ParameterizedTypeName.get(
                                        List::class.asTypeName(),
                                        ClassName("", "Repo"))
                        )).build())
                .build())
        .build()
        .writeTo(System.out)

KotlinPoetでのdata classの生成

data classの生成する流れは、基本的にinterfaceの生成と同様です。addModifiers関数でKModifier.DATAを指定することでdata classとなります。

FileSpec.builder("", "Repo")
        .addType(TypeSpec.classBuilder("Repo")
                .addModifiers(KModifier.DATA)
                .addProperty(PropertySpec.builder("id", String::class)
                        .initializer("id")
                        .build())
                .addProperty(PropertySpec.builder("name", String::class)
                        .initializer("name")
                        .build())
                .addProperty(PropertySpec.builder("url", String::class)
                        .initializer("url")
                        .build())
                .primaryConstructor(FunSpec.constructorBuilder()
                        .addParameter("id", String::class)
                        .addParameter("name", String::class)
                        .addParameter("url", String::class)
                        .build())
                .build())
        .build()
        .writeTo(System.out)

これで以下のようなdata classを生成できました。

data class Repo(
        val id: String,
        val name: String,
        val url: String
)

パースしたAPI仕様の情報を元にしてコードを生成

ここまでで、KotlinPoetでinterfaceやdata classの生成方法について理解することができました。そして、次にやるべきことはSwaggerで定義したAPI仕様から必要なデータを抜き出し、それを使用してのコード生成です。

Part2ではswagger-parserでのAPI仕様のパースから、そのデータを用いたコード生成、Gradle Plugin化までご紹介しようと思います。