Posted at
VASILYDay 16

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

More than 1 year has passed since last update.

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

この記事は、Swaggerで定義したAPI仕様からRetrofitで使用するinterfaceを自動生成してみる Part1の続きとなります。併せて御覧ください。


swagger.jsonのパース

swagger-api/swagger-parserを使用してパースします。パース結果はSwaggerオブジェクトの格納され、簡単に参照できます。現在のところOpenAPI Specification 3.0には対応していませんが、3.0に対応したswagger-parser v2.0.0がrc2までリリースされています。

val swagger: Swagger = SwaggerParser().read("swagger.json")


取得した情報によるコード生成

取得したSwaggerオブジェクトの情報を元にどのようにコードを生成するか、http://petstore.swagger.io/v2/swagger.jsonをサンプルとして解説します。


data class

data classはswagger.jsonのdefinitionsフィールドから生成します。definitionsフィールドはswagger.definitionsで参照できます。

forEachでtypeNameparameterを取り出し、TypeSpec.classBuilderでclassを組み立てます。この時、KModifier.DATAを指定することでdata classの作成が可能です。

swagger.definitions.forEach { typeName, parameter ->

    val typeSpecBuilder = TypeSpec.classBuilder(typeName).addModifiers(KModifier.DATA)
}

さらに、parameter.propertiesでプロパティを取得できるので、各プロパティについてPropertySpec.builderでプロパティを組み立てTypeSpec.classBuilderaddPropertyで追加します。同時に、FunSpec.constructorBuilderにプロパティと同名のパラメーターを追加します。


swagger.definitions.forEach { typeName, parameter ->

val typeSpecBuilder = TypeSpec.classBuilder(typeName).addModifiers(KModifier.DATA)

val funSpecBuilder = FunSpec.constructorBuilder()
parameter.properties.forEach {

val propertyName = it.key.split("_").let { it[0] + it.getOrElse(1, { "" }).capitalize() }

val type = when (it.value.type) {
"string" -> String::class.asTypeName()
"number" -> Int::class.asTypeName()
"integer" -> Int::class.asTypeName()
"array" -> {
val type = it.value.getDefinitionsTypeName()
if (type != null) {
ParameterizedTypeName.get(List::class.asTypeName(), ClassName("", type))
} else {
Any::class.asTypeName()
}
}
else -> {
・・・
}
}

typeSpecBuilder
.addProperty(PropertySpec.builder(propertyName, type)
.initializer(propertyName)
.build())
funSpecBuilder.addParameter(propertyName, type)
}
}

最後にFileSpec.builderでファイルとしてまとめればdata classの完成です。


swagger.definitions.forEach { typeName, parameter ->

val typeSpecBuilder = TypeSpec.classBuilder(typeName).addModifiers(KModifier.DATA)

val funSpecBuilder = FunSpec.constructorBuilder()
parameter.properties.forEach {

val propertyName = it.key.split("_").let { it[0] + it.getOrElse(1, { "" }).capitalize() }

val type = when (it.value.type) {
"string" -> String::class.asTypeName()
"number" -> Int::class.asTypeName()
"integer" -> Int::class.asTypeName()
"array" -> {
val type = it.value.getDefinitionsTypeName()
if (type != null) {
ParameterizedTypeName.get(List::class.asTypeName(), ClassName("", type))
} else {
Any::class.asTypeName()
}
}
else -> {
・・・
}
}

typeSpecBuilder
.addProperty(PropertySpec.builder(propertyName, type)
.initializer(propertyName)
.build())
funSpecBuilder.addParameter(propertyName, type)
}

FileSpec.builder(dataClassPackageName, typeName)
.addType(typeSpecBuilder.primaryConstructor(
funSpecBuilder.build())
.build())
.build()
.writeTo(System.out)
}

writeToで書きだすと以下のようなdata classが生成されます。

import kotlin.Int

import kotlin.String

data class User(
val id: Int,
val username: String,
val firstName: String,
val lastName: String,
val email: String,
val password: String,
val phone: String,
val userStatus: Int
)


Retrofit Interface

Retrofitで使用するInterfaceを生成するまでの流れを以下のようになります。


  1. tagsでpathをまとめる

  2. tagに紐付いたpathについて抽象メソッドを作成

  3. ファイルとしてまとめ、コードを生成


tagsでpathをまとめる

swagger.jsonではpathsフィールドで仕様が定義されています。このpath毎にファイルを作成を作成しても良いのですが、その場合/pet/pet/findByStatusについての抽象メソッドがそれぞれ別のinterfaceとして別ファイルに定義されます。これらはpetに対する操作なので、一つのinterfaceにまとめてしまうのが自然な気がします。したがって、各path定義のtagsフィールドの値でpathをグルーピングします。


val tagList = mutableMapOf<String, MutableMap<String, MutableList<Map.Entry<HttpMethod, Operation>>>>()
swagger.paths.forEach { path, spec ->
spec.operationMap.map {
tagList.getOrPut(it.value.tags[0], { mutableMapOf() }).getOrPut(path, { mutableListOf() }).add(it)
}
}


tagに紐付いたpathについて抽象メソッドを作成

各tagに紐付けられたpathについてforEachを回し、get、postと行ったメソッド毎に抽象メソッドを作成しています。operationMapというパラメータ名でMapを受け取り、operationMapをmethodoperationに分解宣言します。operationにはmethodが行う操作についての情報が含まれるので、そこからFunSpec.builderで抽象メソッドを組み立てていきます。

抽象メソッド名には、operationIdを使用し、KModifier.ABSTRACTを指定して抽象メソッドとします。FunSpec.Builder.buildHttpMethodAnnotationOperation.getReturnTypeFunSpec.Builder.buildParametersは独自に定義した拡張関数で、operation情報からAnnotation、戻り値、パラメータの情報を抜き出し、抽象メソッドへ追加します。こうして組み立てた抽象メソッドはfunListにまとめておきます。

tagList.forEach {

val (tag, paths) = it
val funList = mutableListOf<FunSpec>()

paths.forEach { path, operationMap ->
operationMap.forEach {
val (method, operation) = it

val returnType = operation.getReturnType()

FunSpec.builder(operation.operationId)
.addModifiers(KModifier.ABSTRACT)
.buildHttpMethodAnnotation(method, path, operation.getReturnType())
.buildParameters(operation.parameters)
.let { funList.add(it.build()) }
}
}
}


ファイルとしてまとめ、コードを生成

各tagに紐付けられたpathについて、抽象メソッドを組み立てたので、これらをファイルにまとめます。FileSpec.builderに指定するファイル名は、tagのsuffixにServiceを付けたものにします。TypeSpec.interfaceBuilderで組み立てるintefaceも同名にしておきます。先程組み立てた抽象メソッドのリストfunListから抽象メソッドを取り出し、addFunctionで追加します。これでinterfaceの組み立てが完了します。

tagList.forEach {

val (tag, paths) = it
val funList = mutableListOf<FunSpec>()

paths.forEach { path, operationMap ->
operationMap.forEach {
val (method, operation) = it

val returnType = operation.getReturnType()

FunSpec.builder(operation.operationId)
.addModifiers(KModifier.ABSTRACT)
.buildHttpMethodAnnotation(method, path, returnType)
.buildParameters(operation.parameters)
.let { funList.add(it.build()) }
}
}

FileSpec.builder(serviceClassPackageName, "${tag.capitalize()}Service")
.addType(TypeSpec
.interfaceBuilder("${tag.capitalize()}Service")
.apply { funList.forEach { addFunction(it.build()) } }
.build())
.build()
.writeTo(System.out)
}

writeToで書きだすと以下のようなinterfaceが生成されます。Single<Unit>が戻り値として定義されてしまったり、GET以外のメソッドが正しく生成されていないのは課題ですね。

import io.reactivex.Single.Single

import kotlin.String
import kotlin.Unit
import retrofit2.http.DELETE.DELETE
import retrofit2.http.GET.GET
import retrofit2.http.POST.POST
import retrofit2.http.PUT.PUT
import retrofit2.http.Path.Path
import retrofit2.http.Query.Query

interface UserService {
@POST("/user")
fun createUser(): Single<Unit>

@POST("/user/createWithArray")
fun createUsersWithArrayInput(): Single<Unit>

@POST("/user/createWithList")
fun createUsersWithListInput(): Single<Unit>

@GET("/user/login")
fun loginUser(@Query("username") username: String, @Query("password") password: String): Single<Unit>

@GET("/user/logout")
fun logoutUser(): Single<Unit>

@GET("/user/{username}")
fun getUserByName(@Path("username") username: String): Single<User>

@PUT("/user/{username}")
fun updateUser(@Path("username") username: String): Single<Unit>

@DELETE("/user/{username}")
fun deleteUser(@Path("username") username: String): Single<Unit>
}


Gradle plugin化

data class、interfaceの生成をGradle Plugin化して簡単に実行できるようにします。今回はこちらの記事を参考にKotlinで開発しました。

ソースコードはこちらです。

https://github.com/horie1024/gradle-generate-data-layer-plugin


使い方

まだportalに公開はしていないので公開したら更新しますが、classpathにプラグインのjarを指定し、apply pluginで有効化します。

pluginのjarファイルは、以下のコマンドでbuild/libs以下に生成されます。

./gradlew jar

DSLとしてgenerate_data_layerを用意していて、ファイルの出力先や生成元にするswagger.jsonを指定できるようになっています。このままだと足りないので改善してく予定です。


buildscript {
dependencies {
classpath files('build/libs/gradle-generate-data-layer-plugin-0.0.1.jar')
}
}

apply plugin: 'com.horie1024.plugins.generate.datalayer'

generate_data_layer {
data_layer_path = 'src/main/kotlin/data'
swagger_json_path = 'http://petstore.swagger.io/v2/swagger.json'
}

generateDataLayerを実行するとdata classとinterfaceが生成されます。

./gradlew generateDataLayer


まとめ

Swaggerで定義したAPI仕様からRetrofitで使用するdata classやinterfaceを生成することができました。不完全な部分や足りない部分について引き続き開発を続けていこうと思います。