Skip to content

Commit

Permalink
Import response type keeper
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
JakeWharton committed Jan 29, 2024
1 parent 2b2c108 commit de9edef
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 0 deletions.
9 changes: 9 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down Expand Up @@ -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"
48 changes: 48 additions & 0 deletions retrofit-response-type-keeper/README.md
Original file line number Diff line number Diff line change
@@ -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<User> 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:<version>'
```
Or Gradle Kotlin projects with
```groovy
kapt 'com.squareup.retrofit2:response-type-keeper:<version>'
```

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<ApiResponse<User>>`, 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<? extends User>` in the Java bytecode.
15 changes: 15 additions & 0 deletions retrofit-response-type-keeper/build.gradle
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions retrofit-response-type-keeper/gradle.properties
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<TypeElement>,
roundEnv: RoundEnvironment,
): Boolean {
val elements = processingEnv.elementUtils
val types = processingEnv.typeUtils

val methods = supportedAnnotationTypes
.mapNotNull(elements::getTypeElement)
.flatMap(roundEnv::getElementsAnnotatedWith)

val elementToReferencedTypes = mutableMapOf<TypeElement, MutableSet<String>>()
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().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<String>) {
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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DeleteUser> delete();
@GET("/") Call<GetUser> get();
@HEAD("/") Call<HeadUser> head();
@HTTP(method = "CUSTOM", path = "/") Call<HttpUser> http();
@OPTIONS("/") Call<OptionsUser> options();
@PATCH("/") Call<PatchUser> patch();
@POST("/") Call<PostUser> post();
@PUT("/") Call<PutUser> 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<T> {}
class Two<T> {}
class Three {}
interface Service {
@GET("/") Call<One<Two<Three>>> 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<? extends Body> 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(),
)
}
}
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit de9edef

Please sign in to comment.