diff --git a/android/plugins/retrofit2-protobuf/build.gradle b/android/plugins/retrofit2-protobuf/build.gradle new file mode 100644 index 000000000..9b796865f --- /dev/null +++ b/android/plugins/retrofit2-protobuf/build.gradle @@ -0,0 +1,34 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'maven' + +android { + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION" + implementation project(':android') + implementation project(':network-plugin') + implementation deps.protobuf + implementation "com.squareup.retrofit2:retrofit:2.9.0" + implementation "com.github.hbmartin:protobuf_java_to_protobufjs:0.0.1" + compileOnly deps.jsr305 + } +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/android/plugins/retrofit2-protobuf/gradle.properties b/android/plugins/retrofit2-protobuf/gradle.properties new file mode 100644 index 000000000..bc64e1a34 --- /dev/null +++ b/android/plugins/retrofit2-protobuf/gradle.properties @@ -0,0 +1,12 @@ +# +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the LICENSE +# file in the root directory of this source tree. +# + +POM_NAME=Retrofit2 Protobuf Adapter +POM_DESCRIPTION=Flipper plugin to automate Retrofit2 sending Protobuf definitions +POM_ARTIFACT_ID=flipper-retrofit2-protobuf-plugin +POM_PACKAGING=aar + diff --git a/android/plugins/retrofit2-protobuf/src/main/AndroidManifest.xml b/android/plugins/retrofit2-protobuf/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0fe1d3f1a --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/SendProtobufToFlipperFromRetrofit.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/SendProtobufToFlipperFromRetrofit.kt new file mode 100644 index 000000000..db827dc01 --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/SendProtobufToFlipperFromRetrofit.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf + +import com.facebook.flipper.android.AndroidFlipperClient +import com.facebook.flipper.core.FlipperArray +import com.facebook.flipper.core.FlipperObject +import com.facebook.flipper.core.FlipperValue +import com.facebook.flipper.plugins.network.NetworkFlipperPlugin +import com.facebook.flipper.plugins.retrofit2protobuf.adapter.GenericCallDefinitionsToMessageDefinitionsIfProtobuf +import com.facebook.flipper.plugins.retrofit2protobuf.adapter.RetrofitServiceToGenericCallDefinitions +import com.facebook.flipper.plugins.retrofit2protobuf.model.CallNestedMessagesPayload +import me.haroldmartin.protobufjavatoprotobufjs.adapter.FullNamedMessagesToNestedMessages + +object SendProtobufToFlipperFromRetrofit { + operator fun invoke(baseUrl: String, service: Class<*>) { + AndroidFlipperClient.getInstanceIfInitialized()?.let { client -> + (client.getPlugin(NetworkFlipperPlugin.ID) as? NetworkFlipperPlugin) + ?.send( + "addProtobufDefinitions", + FlipperObject.Builder().put( + baseUrl, generateProtobufDefinitions(service).toFlipperArray() + ).build() + ) + } + } + + private fun generateProtobufDefinitions(service: Class<*>): List { + return RetrofitServiceToGenericCallDefinitions(service).let { definitions -> + GenericCallDefinitionsToMessageDefinitionsIfProtobuf(definitions) + }.let { messages -> + messages.map { + CallNestedMessagesPayload( + path = it.path, + method = it.method, + requestMessageFullName = it.requestMessageFullName, + requestDefinitions = FullNamedMessagesToNestedMessages(it.requestModel), + responseMessageFullName = it.responseMessageFullName, + responseDefinitions = FullNamedMessagesToNestedMessages(it.responseModel) + ) + } + } + } +} + +private fun Iterable.toFlipperArray(): FlipperArray = + fold(FlipperArray.Builder()) { builder, call -> builder.put(call) }.build() diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/GenericCallDefinitionsToMessageDefinitionsIfProtobuf.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/GenericCallDefinitionsToMessageDefinitionsIfProtobuf.kt new file mode 100644 index 000000000..d3eb67328 --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/GenericCallDefinitionsToMessageDefinitionsIfProtobuf.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf.adapter + +import com.facebook.flipper.plugins.retrofit2protobuf.model.FullNamedMessagesCallDefinition +import com.facebook.flipper.plugins.retrofit2protobuf.model.GenericCallDefinition +import me.haroldmartin.protobufjavatoprotobufjs.ProtobufGeneratedJavaToProtobufJs + +internal object GenericCallDefinitionsToMessageDefinitionsIfProtobuf { + operator fun invoke(callDefinitions: List): List { + return callDefinitions.mapNotNull { definition -> + val responseRootAndMessages = definition.responseType?.let { + ProtobufGeneratedJavaToProtobufJs(it) + } + val requestRootAndMessages = definition.requestType?.let { + ProtobufGeneratedJavaToProtobufJs(it) + } + + FullNamedMessagesCallDefinition( + path = definition.path, + method = definition.method, + responseMessageFullName = responseRootAndMessages?.rootFullName, + responseModel = responseRootAndMessages?.messages, + requestMessageFullName = requestRootAndMessages?.rootFullName, + requestModel = requestRootAndMessages?.messages + ) + } + } +} diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/RetrofitServiceToGenericCallDefinitions.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/RetrofitServiceToGenericCallDefinitions.kt new file mode 100644 index 000000000..a0e863128 --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/RetrofitServiceToGenericCallDefinitions.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf.adapter + +import com.facebook.flipper.plugins.retrofit2protobuf.model.GenericCallDefinition +import java.lang.reflect.Method +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +internal object RetrofitServiceToGenericCallDefinitions { + @Suppress("LoopWithTooManyJumpStatements") + operator fun invoke(service: Class<*>): List { + val methodToProtobufDefinition = mutableListOf() + for (method in service.declaredMethods) { + val responseType = method.innerGenericReturnClass ?: continue + val (path, httpMethod) = method.annotations.urlPathAndMethod ?: continue + methodToProtobufDefinition.add( + GenericCallDefinition( + path = path, + method = httpMethod, + responseType = responseType, + requestType = method.requestBodyType + ) + ) + } + return methodToProtobufDefinition + } +} + +private val Array.urlPathAndMethod: Pair? + get() { + var path: Pair? = null + for (a in this) { + path = when (a.annotationClass) { + retrofit2.http.DELETE::class -> (a as retrofit2.http.DELETE).value to "DELETE" + retrofit2.http.GET::class -> (a as retrofit2.http.GET).value to "GET" + retrofit2.http.HEAD::class -> (a as retrofit2.http.HEAD).value to "HEAD" + retrofit2.http.OPTIONS::class -> (a as retrofit2.http.OPTIONS).value to "OPTIONS" + retrofit2.http.PATCH::class -> (a as retrofit2.http.PATCH).value to "PATCH" + retrofit2.http.POST::class -> (a as retrofit2.http.POST).value to "POST" + retrofit2.http.PUT::class -> (a as retrofit2.http.PUT).value to "PUT" + else -> null + } + if (path != null) break + } + return path + } + +private val Method.requestBodyType: Class<*>? + get() { + parameterAnnotations.forEachIndexed { index, parameters -> + parameters.forEach { annotation -> + if (annotation.annotationClass == retrofit2.http.Body::class) { + return parameterTypes[index] + } + } + } + return null + } + +private val Method.innerGenericReturnClass: Class<*>? + get() = (genericReturnType as? ParameterizedType)?.innerGenericType as? Class<*> + +private val ParameterizedType?.innerGenericType: Type? + get() { + val innerType = this?.actualTypeArguments?.get(0) + return if (innerType is ParameterizedType) { + innerType.innerGenericType + } else { + innerType + } + } diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/CallNestedMessagesPayload.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/CallNestedMessagesPayload.kt new file mode 100644 index 000000000..ef8c5a57d --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/CallNestedMessagesPayload.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf.model + +import com.facebook.flipper.core.FlipperObject +import com.facebook.flipper.core.FlipperValue + +internal data class CallNestedMessagesPayload( + val path: String, + val method: String, + val requestMessageFullName: String?, + val requestDefinitions: Map, + val responseMessageFullName: String?, + val responseDefinitions: Map +) : FlipperValue { + override fun toFlipperObject(): FlipperObject { + return FlipperObject.Builder() + .put("path", path) + .put("method", method) + .put("requestMessageFullName", requestMessageFullName) + .put("requestDefinitions", requestDefinitions.toFlipperObject()) + .put("responseMessageFullName", responseMessageFullName) + .put("responseDefinitions", responseDefinitions.toFlipperObject()) + .build() + } +} + +private fun Map<*, *>.toFlipperObject(): FlipperObject { + val builder = FlipperObject.Builder() + this.forEach { + builder.put( + it.key as String, + if (it.value is Map<*, *>) (it.value as Map<*, *>).toFlipperObject() else it.value + ) + } + return builder.build() +} diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/FullNamedMessagesCallDefinition.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/FullNamedMessagesCallDefinition.kt new file mode 100644 index 000000000..239e524ae --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/FullNamedMessagesCallDefinition.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf.model + +import me.haroldmartin.protobufjavatoprotobufjs.model.FullNamedMessages + +internal data class FullNamedMessagesCallDefinition( + val path: String, + val method: String, + val requestMessageFullName: String?, + val responseMessageFullName: String?, + val responseModel: FullNamedMessages?, + val requestModel: FullNamedMessages? +) diff --git a/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/GenericCallDefinition.kt b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/GenericCallDefinition.kt new file mode 100644 index 000000000..de2a784d8 --- /dev/null +++ b/android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/GenericCallDefinition.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.retrofit2protobuf.model + +internal data class GenericCallDefinition( + val path: String, + val method: String, + val responseType: Class<*>? = null, + val requestType: Class<*>? = null +) diff --git a/build.gradle b/build.gradle index ac2681893..62a892c9e 100644 --- a/build.gradle +++ b/build.gradle @@ -102,6 +102,7 @@ ext.deps = [ okhttp3 : 'com.squareup.okhttp3:okhttp:4.9.1', leakcanary : 'com.squareup.leakcanary:leakcanary-android:1.6.3', leakcanary2 : 'com.squareup.leakcanary:leakcanary-android:2.6', + protobuf : 'com.google.protobuf:protobuf-java:3.15.6', testCore : 'androidx.test:core:1.3.0', testRules : 'androidx.test:rules:1.3.0', // Plugin dependencies diff --git a/docs/setup/protobuf-retrofit-plugin.mdx b/docs/setup/protobuf-retrofit-plugin.mdx new file mode 100644 index 000000000..5bfeb95c1 --- /dev/null +++ b/docs/setup/protobuf-retrofit-plugin.mdx @@ -0,0 +1,37 @@ +--- +id: protobuf-retrofit-plugin +title: Protobut + Retrofit Setup +sidebar_label: Protobut + Retrofit +--- + +## Gradle Dependencies + +Ensure that you already have an explicit dependency in your application's +`build.gradle` including the plugin dependency, e.g. + +```groovy +dependencies { + implementation "com.squareup.retrofit2:retrofit:2.9.0" + implementation "com.squareup.retrofit2:converter-protobuf:2.9.0" + + // update version below to match latest Flipper client app + debugImplementation "com.facebook.flipper:flipper:0.84.0" + debugImplementation "com.facebook.flipper:flipper-network-plugin:0.84.0" + debugImplementation "com.facebook.flipper:flipper-retrofit2-protobuf-plugin:0.84.0" +} +``` + +## Network Plugin Requirement + +Ensure that `NetworkFlipperPlugin` is added as shown in the [Network setup guide](https://fbflipper.com/docs/setup/network-plugin#android) + +## Sending Retrofit Service + +Suppose you have a Retrofit service interface `PersonService` which has Protobuf body or return types. At the time you create your implementation, call the plugin with your `baseUrl` and service class: + +``` +import com.facebook.flipper.plugins.retrofit2protobuf.SendProtobufToFlipperFromRetrofit +... +val personService = retrofit.create(PersonService::class.java) +SendProtobufToFlipperFromRetrofit(baseUrl, PersonService::class.java) +``` \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 483a38d93..63a4fea11 100644 --- a/settings.gradle +++ b/settings.gradle @@ -44,3 +44,6 @@ project(':leakcanary-plugin').projectDir = file('android/plugins/leakcanary') include ':leakcanary2-plugin' project(':leakcanary2-plugin').projectDir = file('android/plugins/leakcanary2') + +include ':retrofit2-protobuf' +project(':retrofit2-protobuf').projectDir = file('android/plugins/retrofit2-protobuf')