こんにちは。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でtypeName
とparameter
を取り出し、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.classBuilder
のaddProperty
で追加します。同時に、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を生成するまでの流れを以下のようになります。
- tagsでpathをまとめる
- tagに紐付いたpathについて抽象メソッドを作成
- ファイルとしてまとめ、コードを生成
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をmethod
とoperation
に分解宣言します。operationにはmethodが行う操作についての情報が含まれるので、そこからFunSpec.builder
で抽象メソッドを組み立てていきます。
抽象メソッド名には、operationId
を使用し、KModifier.ABSTRACT
を指定して抽象メソッドとします。FunSpec.Builder.buildHttpMethodAnnotation
、Operation.getReturnType
、FunSpec.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で開発しました。
ソースコードはこちらです。
使い方
まだ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を生成することができました。不完全な部分や足りない部分について引き続き開発を続けていこうと思います。