From b0cbf39a1a7d006440661d1dde89723a1f417f93 Mon Sep 17 00:00:00 2001 From: Harold Martin Date: Wed, 7 Apr 2021 13:30:03 -0700 Subject: [PATCH] Export protobuf definitions from Retrofit (#2084) Summary: https://github.com/facebook/flipper/pull/2080 enables storing protobuf definitions and displaying payloads in the network inspector plugin. However, describing those definitions in ProtobufJS format can be time consuming and error prone. This PR enables sending definitions from an entire Retrofit service with a single line. ## Changelog * Adds a retrofit2-protobuf plugin Pull Request resolved: https://github.com/facebook/flipper/pull/2084 Test Plan: Used as a single line per service, ie: `SendProtobufToFlipperFromRetrofit(baseUrl, PersonService::class.java)` For more details see demo app in https://github.com/hbmartin/protobuf_java_to_protobufjs Reviewed By: mweststrate Differential Revision: D27507872 Pulled By: passy fbshipit-source-id: 859d7636c9512de0abde0aa1dcb2e023851369cf --- .../plugins/retrofit2-protobuf/build.gradle | 34 ++++++++ .../retrofit2-protobuf/gradle.properties | 12 +++ .../src/main/AndroidManifest.xml | 11 +++ .../SendProtobufToFlipperFromRetrofit.kt | 52 +++++++++++++ ...finitionsToMessageDefinitionsIfProtobuf.kt | 34 ++++++++ ...RetrofitServiceToGenericCallDefinitions.kt | 77 +++++++++++++++++++ .../model/CallNestedMessagesPayload.kt | 42 ++++++++++ .../model/FullNamedMessagesCallDefinition.kt | 19 +++++ .../model/GenericCallDefinition.kt | 15 ++++ build.gradle | 1 + docs/setup/protobuf-retrofit-plugin.mdx | 37 +++++++++ settings.gradle | 3 + 12 files changed, 337 insertions(+) create mode 100644 android/plugins/retrofit2-protobuf/build.gradle create mode 100644 android/plugins/retrofit2-protobuf/gradle.properties create mode 100644 android/plugins/retrofit2-protobuf/src/main/AndroidManifest.xml create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/SendProtobufToFlipperFromRetrofit.kt create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/GenericCallDefinitionsToMessageDefinitionsIfProtobuf.kt create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/adapter/RetrofitServiceToGenericCallDefinitions.kt create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/CallNestedMessagesPayload.kt create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/FullNamedMessagesCallDefinition.kt create mode 100644 android/plugins/retrofit2-protobuf/src/main/java/com/facebook/flipper/plugins/retrofit2protobuf/model/GenericCallDefinition.kt create mode 100644 docs/setup/protobuf-retrofit-plugin.mdx 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')