From 466d24d85b0c8eb1023e2ab00574714840f6e31a Mon Sep 17 00:00:00 2001 From: Jake Wharton Date: Mon, 29 Jan 2024 14:54:22 -0500 Subject: [PATCH] Import response type keeper This annotation processor parses all types used as response bodies in Retrofit service methods and adds a keep rule for them. This ensures that even if the type isn't used by callers, it is kept and used to parse the body. --- gradle/libs.versions.toml | 9 + retrofit-response-type-keeper/README.md | 48 ++++++ retrofit-response-type-keeper/build.gradle | 15 ++ .../gradle.properties | 3 + .../RetrofitResponseTypeKeepProcessor.kt | 106 ++++++++++++ .../RetrofitResponseTypeKeepProcessorTest.kt | 162 ++++++++++++++++++ settings.gradle | 2 + 7 files changed, 345 insertions(+) create mode 100644 retrofit-response-type-keeper/README.md create mode 100644 retrofit-response-type-keeper/build.gradle create mode 100644 retrofit-response-type-keeper/gradle.properties create mode 100644 retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt create mode 100644 retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c3a60f2ce..09fa1a01d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ okhttp = "3.14.9" protobuf = "3.25.2" robovm = "2.3.14" kotlinx-serialization = "1.6.2" +autoService = "1.1.1" +incap = "1.0.0" [libraries] androidPlugin = { module = "com.android.tools.build:gradle", version = "8.2.2" } @@ -41,6 +43,12 @@ protobufPlugin = "com.google.protobuf:protobuf-gradle-plugin:0.9.4" protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } +incap-runtime = { module = "net.ltgt.gradle.incap:incap", version.ref = "incap" } +incap-processor = { module = "net.ltgt.gradle.incap:incap-processor", version.ref = "incap" } + +autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } +autoService-compiler = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } + kotlinCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.7.3" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } @@ -71,3 +79,4 @@ jsoup = { module = "org.jsoup:jsoup", version = "1.17.2" } robovm = { module = "com.mobidevelop.robovm:robovm-rt", version.ref = "robovm" } googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.19.2" ktlint = "com.pinterest.ktlint:ktlint-cli:1.1.1" +compileTesting = "com.google.testing.compile:compile-testing:0.21.0" diff --git a/retrofit-response-type-keeper/README.md b/retrofit-response-type-keeper/README.md new file mode 100644 index 0000000000..099d5ee1e8 --- /dev/null +++ b/retrofit-response-type-keeper/README.md @@ -0,0 +1,48 @@ +# Response Type Keeper + +Generates keep rules for types mentioned in generic parameter positions of Retrofit service methods. + +## Problem + +Given a service method like +```java +@GET("users/{id}") +Call getUser( + @Path("id") String id); +``` + +If you execute this request and do not actually use the returned `User` instance, R8 will remove it +and replace the return type as `Call`. This fails Retrofit's runtime validation since a wildcard +is not a valid type to pass to a converter. Note: this removal only occurs if the Retrofit's service +method definition is the only reference to `User`. + +## Solution + +This module contains an annotation processor which looks at each Retrofit method and generates +explicit `-keep` rules for the types mentioned. + +Add it to Gradle Java projects with +```groovy +annotationProcessor 'com.squareup.retrofit2:response-type-keeper:' +``` +Or Gradle Kotlin projects with +```groovy +kapt 'com.squareup.retrofit2:response-type-keeper:' +``` + +For other build systems, the `com.squareup.retrofit2:response-type-keeper` needs added to the Java +compiler `-processor` classpath. + +For the example above, the annotation processor's generated file would contain +``` +-keep com.example.User +``` + +This works for nested generics, such as `Call>`, which would produce: +``` +-keep com.example.ApiResponse +-keep com.example.User +``` + +It also works on Kotlin `suspend` functions which turn into a type like +`Continuation` in the Java bytecode. diff --git a/retrofit-response-type-keeper/build.gradle b/retrofit-response-type-keeper/build.gradle new file mode 100644 index 0000000000..c9015f23f0 --- /dev/null +++ b/retrofit-response-type-keeper/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' +apply plugin: 'org.jetbrains.kotlin.kapt' +apply plugin: 'com.vanniktech.maven.publish' + +dependencies { + compileOnly libs.autoService.annotations + compileOnly libs.incap.runtime + kapt libs.autoService.compiler + kapt libs.incap.processor + + testImplementation libs.junit + testImplementation libs.compileTesting + testImplementation libs.truth + testImplementation projects.retrofit +} diff --git a/retrofit-response-type-keeper/gradle.properties b/retrofit-response-type-keeper/gradle.properties new file mode 100644 index 0000000000..b2c35c1d07 --- /dev/null +++ b/retrofit-response-type-keeper/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=response-type-keeper +POM_NAME=Response Type Keeper +POM_DESCRIPTION=Annotation processor to generate R8 keep rules for types mentioned in generics. diff --git a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt new file mode 100644 index 0000000000..28674e0dc1 --- /dev/null +++ b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package retrofit2.keeper + +import com.google.auto.service.AutoService +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.Processor +import javax.annotation.processing.RoundEnvironment +import javax.lang.model.SourceVersion +import javax.lang.model.element.ExecutableElement +import javax.lang.model.element.TypeElement +import javax.lang.model.type.DeclaredType +import javax.lang.model.type.TypeMirror +import javax.lang.model.type.WildcardType +import javax.tools.StandardLocation.CLASS_OUTPUT +import net.ltgt.gradle.incap.IncrementalAnnotationProcessor +import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType.ISOLATING + +@AutoService(Processor::class) +@IncrementalAnnotationProcessor(ISOLATING) +class RetrofitResponseTypeKeepProcessor : AbstractProcessor() { + override fun getSupportedSourceVersion() = SourceVersion.latestSupported() + override fun getSupportedAnnotationTypes() = setOf( + "retrofit2.http.DELETE", + "retrofit2.http.GET", + "retrofit2.http.HEAD", + "retrofit2.http.HTTP", + "retrofit2.http.OPTIONS", + "retrofit2.http.PATCH", + "retrofit2.http.POST", + "retrofit2.http.PUT", + ) + + override fun process( + annotations: Set, + roundEnv: RoundEnvironment, + ): Boolean { + val elements = processingEnv.elementUtils + val types = processingEnv.typeUtils + + val methods = supportedAnnotationTypes + .mapNotNull(elements::getTypeElement) + .flatMap(roundEnv::getElementsAnnotatedWith) + + val elementToReferencedTypes = mutableMapOf>() + for (method in methods) { + val executableElement = method as ExecutableElement + + val serviceType = method.enclosingElement as TypeElement + val referenced = elementToReferencedTypes.getOrPut(serviceType, ::LinkedHashSet) + + val returnType = executableElement.returnType as DeclaredType + returnType.recursiveParameterizedTypesTo(referenced) + + // Retrofit has special support for 'suspend fun' in Kotlin which manifests as a + // final Continuation parameter whose generic type is the declared return type. + executableElement.parameters + .lastOrNull() + ?.asType() + ?.takeIf { types.erasure(it).toString() == "kotlin.coroutines.Continuation" } + ?.let { (it as DeclaredType).typeArguments.single() } + ?.recursiveParameterizedTypesTo(referenced) + } + + for ((element, referencedTypes) in elementToReferencedTypes) { + val typeName = element.qualifiedName.toString() + val outputFile = "META-INF/proguard/retrofit-response-type-keeper-$typeName.pro" + val rules = processingEnv.filer.createResource(CLASS_OUTPUT, "", outputFile, element) + rules.openWriter().buffered().use { w -> + w.write("# $typeName\n") + for (referencedType in referencedTypes.sorted()) { + w.write("-keep,allowobfuscation,allowoptimization class $referencedType\n") + } + } + } + return false + } + + private fun TypeMirror.recursiveParameterizedTypesTo(types: MutableSet) { + when (this) { + is WildcardType -> { + extendsBound?.recursiveParameterizedTypesTo(types) + superBound?.recursiveParameterizedTypesTo(types) + } + is DeclaredType -> { + for (typeArgument in typeArguments) { + typeArgument.recursiveParameterizedTypesTo(types) + } + types += (asElement() as TypeElement).qualifiedName.toString() + } + } + } +} diff --git a/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt new file mode 100644 index 0000000000..9d0746d7c8 --- /dev/null +++ b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package retrofit2.keeper + +import com.google.common.truth.Truth.assertAbout +import com.google.testing.compile.JavaFileObjects +import com.google.testing.compile.JavaSourcesSubjectFactory.javaSources +import java.nio.charset.StandardCharsets.UTF_8 +import javax.tools.StandardLocation.CLASS_OUTPUT +import org.junit.Test + +class RetrofitResponseTypeKeepProcessorTest { + @Test + fun allHttpMethods() { + val service = JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import retrofit2.*; + import retrofit2.http.*; + + class DeleteUser {} + class GetUser {} + class HeadUser {} + class HttpUser {} + class OptionsUser {} + class PatchUser {} + class PostUser {} + class PutUser {} + + interface Service { + @DELETE("/") Call delete(); + @GET("/") Call get(); + @HEAD("/") Call head(); + @HTTP(method = "CUSTOM", path = "/") Call http(); + @OPTIONS("/") Call options(); + @PATCH("/") Call patch(); + @POST("/") Call post(); + @PUT("/") Call put(); + } + """.trimIndent(), + ) + + assertAbout(javaSources()) + .that(listOf(service)) + .processedWith(RetrofitResponseTypeKeepProcessor()) + .compilesWithoutError() + .and() + .generatesFileNamed( + CLASS_OUTPUT, + "", + "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", + ).withStringContents( + UTF_8, + """ + |# test.Service + |-keep,allowobfuscation,allowoptimization class retrofit2.Call + |-keep,allowobfuscation,allowoptimization class test.DeleteUser + |-keep,allowobfuscation,allowoptimization class test.GetUser + |-keep,allowobfuscation,allowoptimization class test.HeadUser + |-keep,allowobfuscation,allowoptimization class test.HttpUser + |-keep,allowobfuscation,allowoptimization class test.OptionsUser + |-keep,allowobfuscation,allowoptimization class test.PatchUser + |-keep,allowobfuscation,allowoptimization class test.PostUser + |-keep,allowobfuscation,allowoptimization class test.PutUser + | + """.trimMargin(), + ) + } + + @Test + fun nesting() { + val service = JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import retrofit2.*; + import retrofit2.http.*; + + class One {} + class Two {} + class Three {} + + interface Service { + @GET("/") Call>> get(); + } + """.trimIndent(), + ) + + assertAbout(javaSources()) + .that(listOf(service)) + .processedWith(RetrofitResponseTypeKeepProcessor()) + .compilesWithoutError() + .and() + .generatesFileNamed( + CLASS_OUTPUT, + "", + "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", + ).withStringContents( + UTF_8, + """ + |# test.Service + |-keep,allowobfuscation,allowoptimization class retrofit2.Call + |-keep,allowobfuscation,allowoptimization class test.One + |-keep,allowobfuscation,allowoptimization class test.Three + |-keep,allowobfuscation,allowoptimization class test.Two + | + """.trimMargin(), + ) + } + + @Test + fun kotlinSuspend() { + val service = JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import kotlin.coroutines.Continuation; + import retrofit2.*; + import retrofit2.http.*; + + class Body {} + + interface Service { + @GET("/") Object get(Continuation c); + } + """.trimIndent(), + ) + + assertAbout(javaSources()) + .that(listOf(service)) + .processedWith(RetrofitResponseTypeKeepProcessor()) + .compilesWithoutError() + .and() + .generatesFileNamed( + CLASS_OUTPUT, + "", + "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", + ).withStringContents( + UTF_8, + """ + |# test.Service + |-keep,allowobfuscation,allowoptimization class java.lang.Object + |-keep,allowobfuscation,allowoptimization class test.Body + | + """.trimMargin(), + ) + } +} diff --git a/settings.gradle b/settings.gradle index a5a6b25685..3a9822f932 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,8 @@ include ':retrofit:test-helpers' include ':retrofit-mock' +include ':retrofit-response-type-keeper' + include ':retrofit-adapters:guava' include ':retrofit-adapters:java8' include ':retrofit-adapters:rxjava'