Skip to content

Commit e13f655

Browse files
committed
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.
1 parent 2b2c108 commit e13f655

File tree

7 files changed

+345
-0
lines changed

7 files changed

+345
-0
lines changed

Diff for: gradle/libs.versions.toml

+9
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ okhttp = "3.14.9"
1818
protobuf = "3.25.2"
1919
robovm = "2.3.14"
2020
kotlinx-serialization = "1.6.2"
21+
autoService = "1.1.1"
22+
incap = "1.0.0"
2123

2224
[libraries]
2325
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"
4143
protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
4244
protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }
4345

46+
incap-runtime = { module = "net.ltgt.gradle.incap:incap", version.ref = "incap" }
47+
incap-processor = { module = "net.ltgt.gradle.incap:incap-processor", version.ref = "incap" }
48+
49+
autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" }
50+
autoService-compiler = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
51+
4452
kotlinCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.7.3" }
4553
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
4654
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" }
7179
robovm = { module = "com.mobidevelop.robovm:robovm-rt", version.ref = "robovm" }
7280
googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.19.2"
7381
ktlint = "com.pinterest.ktlint:ktlint-cli:1.1.1"
82+
compileTesting = "com.google.testing.compile:compile-testing:0.21.0"

Diff for: retrofit-response-type-keeper/README.md

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Response Type Keeper
2+
3+
Generates keep rules for types mentioned in generic parameter positions of Retrofit service methods.
4+
5+
## Problem
6+
7+
Given a service method like
8+
```java
9+
@GET("users/{id}")
10+
Call<User> getUser(
11+
@Path("id") String id);
12+
```
13+
14+
If you execute this request and do not actually use the returned `User` instance, R8 will remove it
15+
and replace the return type as `Call<?>`. This fails Retrofit's runtime validation since a wildcard
16+
is not a valid type to pass to a converter. Note: this removal only occurs if the Retrofit's service
17+
method definition is the only reference to `User`.
18+
19+
## Solution
20+
21+
This module contains an annotation processor which looks at each Retrofit method and generates
22+
explicit `-keep` rules for the types mentioned.
23+
24+
Add it to Gradle Java projects with
25+
```groovy
26+
annotationProcessor 'com.squareup.retrofit2:response-type-keeper:<version>'
27+
```
28+
Or Gradle Kotlin projects with
29+
```groovy
30+
kapt 'com.squareup.retrofit2:response-type-keeper:<version>'
31+
```
32+
33+
For other build systems, the `com.squareup.retrofit2:response-type-keeper` needs added to the Java
34+
compiler `-processor` classpath.
35+
36+
For the example above, the annotation processor's generated file would contain
37+
```
38+
-keep com.example.User
39+
```
40+
41+
This works for nested generics, such as `Call<ApiResponse<User>>`, which would produce:
42+
```
43+
-keep com.example.ApiResponse
44+
-keep com.example.User
45+
```
46+
47+
It also works on Kotlin `suspend` functions which turn into a type like
48+
`Continuation<? extends User>` in the Java bytecode.

Diff for: retrofit-response-type-keeper/build.gradle

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apply plugin: 'org.jetbrains.kotlin.jvm'
2+
apply plugin: 'org.jetbrains.kotlin.kapt'
3+
apply plugin: 'com.vanniktech.maven.publish'
4+
5+
dependencies {
6+
compileOnly libs.autoService.annotations
7+
compileOnly libs.incap.runtime
8+
kapt libs.autoService.compiler
9+
kapt libs.incap.processor
10+
11+
testImplementation libs.junit
12+
testImplementation libs.compileTesting
13+
testImplementation libs.truth
14+
testImplementation projects.retrofit
15+
}

Diff for: retrofit-response-type-keeper/gradle.properties

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
POM_ARTIFACT_ID=response-type-keeper
2+
POM_NAME=Response Type Keeper
3+
POM_DESCRIPTION=Annotation processor to generate R8 keep rules for types mentioned in generics.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (C) 2024 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package retrofit2.keeper
17+
18+
import com.google.auto.service.AutoService
19+
import javax.annotation.processing.AbstractProcessor
20+
import javax.annotation.processing.Processor
21+
import javax.annotation.processing.RoundEnvironment
22+
import javax.lang.model.SourceVersion
23+
import javax.lang.model.element.ExecutableElement
24+
import javax.lang.model.element.TypeElement
25+
import javax.lang.model.type.DeclaredType
26+
import javax.lang.model.type.TypeMirror
27+
import javax.lang.model.type.WildcardType
28+
import javax.tools.StandardLocation.CLASS_OUTPUT
29+
import net.ltgt.gradle.incap.IncrementalAnnotationProcessor
30+
import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType.ISOLATING
31+
32+
@AutoService(Processor::class)
33+
@IncrementalAnnotationProcessor(ISOLATING)
34+
class RetrofitResponseTypeKeepProcessor : AbstractProcessor() {
35+
override fun getSupportedSourceVersion() = SourceVersion.latestSupported()
36+
override fun getSupportedAnnotationTypes() = setOf(
37+
"retrofit2.http.DELETE",
38+
"retrofit2.http.GET",
39+
"retrofit2.http.HEAD",
40+
"retrofit2.http.HTTP",
41+
"retrofit2.http.OPTIONS",
42+
"retrofit2.http.PATCH",
43+
"retrofit2.http.POST",
44+
"retrofit2.http.PUT",
45+
)
46+
47+
override fun process(
48+
annotations: Set<TypeElement>,
49+
roundEnv: RoundEnvironment,
50+
): Boolean {
51+
val elements = processingEnv.elementUtils
52+
val types = processingEnv.typeUtils
53+
54+
val methods = supportedAnnotationTypes
55+
.mapNotNull(elements::getTypeElement)
56+
.flatMap(roundEnv::getElementsAnnotatedWith)
57+
58+
val elementToReferencedTypes = mutableMapOf<TypeElement, MutableSet<String>>()
59+
for (method in methods) {
60+
val executableElement = method as ExecutableElement
61+
62+
val serviceType = method.enclosingElement as TypeElement
63+
val referenced = elementToReferencedTypes.getOrPut(serviceType, ::LinkedHashSet)
64+
65+
val returnType = executableElement.returnType as DeclaredType
66+
returnType.recursiveParameterizedTypesTo(referenced)
67+
68+
// Retrofit has special support for 'suspend fun' in Kotlin which manifests as a
69+
// final Continuation parameter whose generic type is the declared return type.
70+
executableElement.parameters
71+
.lastOrNull()
72+
?.asType()
73+
?.takeIf { types.erasure(it).toString() == "kotlin.coroutines.Continuation" }
74+
?.let { (it as DeclaredType).typeArguments.single() }
75+
?.recursiveParameterizedTypesTo(referenced)
76+
}
77+
78+
for ((element, referencedTypes) in elementToReferencedTypes) {
79+
val typeName = element.qualifiedName.toString()
80+
val outputFile = "META-INF/proguard/retrofit-response-type-keeper-$typeName.pro"
81+
val rules = processingEnv.filer.createResource(CLASS_OUTPUT, "", outputFile, element)
82+
rules.openWriter().buffered().use { w ->
83+
w.write("# $typeName\n")
84+
for (referencedType in referencedTypes.sorted()) {
85+
w.write("-keep,allowobfuscation,allowoptimization class $referencedType\n")
86+
}
87+
}
88+
}
89+
return false
90+
}
91+
92+
private fun TypeMirror.recursiveParameterizedTypesTo(types: MutableSet<String>) {
93+
when (this) {
94+
is WildcardType -> {
95+
extendsBound?.recursiveParameterizedTypesTo(types)
96+
superBound?.recursiveParameterizedTypesTo(types)
97+
}
98+
is DeclaredType -> {
99+
for (typeArgument in typeArguments) {
100+
typeArgument.recursiveParameterizedTypesTo(types)
101+
}
102+
types += (asElement() as TypeElement).qualifiedName.toString()
103+
}
104+
}
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright (C) 2024 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package retrofit2.keeper
17+
18+
import com.google.common.truth.Truth.assertAbout
19+
import com.google.testing.compile.JavaFileObjects
20+
import com.google.testing.compile.JavaSourceSubjectFactory.javaSource
21+
import java.nio.charset.StandardCharsets.UTF_8
22+
import javax.tools.StandardLocation.CLASS_OUTPUT
23+
import org.junit.Test
24+
25+
class RetrofitResponseTypeKeepProcessorTest {
26+
@Test
27+
fun allHttpMethods() {
28+
val service = JavaFileObjects.forSourceString(
29+
"test.Service",
30+
"""
31+
package test;
32+
import retrofit2.*;
33+
import retrofit2.http.*;
34+
35+
class DeleteUser {}
36+
class GetUser {}
37+
class HeadUser {}
38+
class HttpUser {}
39+
class OptionsUser {}
40+
class PatchUser {}
41+
class PostUser {}
42+
class PutUser {}
43+
44+
interface Service {
45+
@DELETE("/") Call<DeleteUser> delete();
46+
@GET("/") Call<GetUser> get();
47+
@HEAD("/") Call<HeadUser> head();
48+
@HTTP(method = "CUSTOM", path = "/") Call<HttpUser> http();
49+
@OPTIONS("/") Call<OptionsUser> options();
50+
@PATCH("/") Call<PatchUser> patch();
51+
@POST("/") Call<PostUser> post();
52+
@PUT("/") Call<PutUser> put();
53+
}
54+
""".trimIndent(),
55+
)
56+
57+
assertAbout(javaSource())
58+
.that(service)
59+
.processedWith(RetrofitResponseTypeKeepProcessor())
60+
.compilesWithoutError()
61+
.and()
62+
.generatesFileNamed(
63+
CLASS_OUTPUT,
64+
"",
65+
"META-INF/proguard/retrofit-response-type-keeper-test.Service.pro",
66+
).withStringContents(
67+
UTF_8,
68+
"""
69+
|# test.Service
70+
|-keep,allowobfuscation,allowoptimization class retrofit2.Call
71+
|-keep,allowobfuscation,allowoptimization class test.DeleteUser
72+
|-keep,allowobfuscation,allowoptimization class test.GetUser
73+
|-keep,allowobfuscation,allowoptimization class test.HeadUser
74+
|-keep,allowobfuscation,allowoptimization class test.HttpUser
75+
|-keep,allowobfuscation,allowoptimization class test.OptionsUser
76+
|-keep,allowobfuscation,allowoptimization class test.PatchUser
77+
|-keep,allowobfuscation,allowoptimization class test.PostUser
78+
|-keep,allowobfuscation,allowoptimization class test.PutUser
79+
|
80+
""".trimMargin(),
81+
)
82+
}
83+
84+
@Test
85+
fun nesting() {
86+
val service = JavaFileObjects.forSourceString(
87+
"test.Service",
88+
"""
89+
package test;
90+
import retrofit2.*;
91+
import retrofit2.http.*;
92+
93+
class One<T> {}
94+
class Two<T> {}
95+
class Three {}
96+
97+
interface Service {
98+
@GET("/") Call<One<Two<Three>>> get();
99+
}
100+
""".trimIndent(),
101+
)
102+
103+
assertAbout(javaSource())
104+
.that(service)
105+
.processedWith(RetrofitResponseTypeKeepProcessor())
106+
.compilesWithoutError()
107+
.and()
108+
.generatesFileNamed(
109+
CLASS_OUTPUT,
110+
"",
111+
"META-INF/proguard/retrofit-response-type-keeper-test.Service.pro",
112+
).withStringContents(
113+
UTF_8,
114+
"""
115+
|# test.Service
116+
|-keep,allowobfuscation,allowoptimization class retrofit2.Call
117+
|-keep,allowobfuscation,allowoptimization class test.One
118+
|-keep,allowobfuscation,allowoptimization class test.Three
119+
|-keep,allowobfuscation,allowoptimization class test.Two
120+
|
121+
""".trimMargin(),
122+
)
123+
}
124+
125+
@Test
126+
fun kotlinSuspend() {
127+
val service = JavaFileObjects.forSourceString(
128+
"test.Service",
129+
"""
130+
package test;
131+
import kotlin.coroutines.Continuation;
132+
import retrofit2.*;
133+
import retrofit2.http.*;
134+
135+
class Body {}
136+
137+
interface Service {
138+
@GET("/") Object get(Continuation<? extends Body> c);
139+
}
140+
""".trimIndent(),
141+
)
142+
143+
assertAbout(javaSource())
144+
.that(service)
145+
.processedWith(RetrofitResponseTypeKeepProcessor())
146+
.compilesWithoutError()
147+
.and()
148+
.generatesFileNamed(
149+
CLASS_OUTPUT,
150+
"",
151+
"META-INF/proguard/retrofit-response-type-keeper-test.Service.pro",
152+
).withStringContents(
153+
UTF_8,
154+
"""
155+
|# test.Service
156+
|-keep,allowobfuscation,allowoptimization class java.lang.Object
157+
|-keep,allowobfuscation,allowoptimization class test.Body
158+
|
159+
""".trimMargin(),
160+
)
161+
}
162+
}

Diff for: settings.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ include ':retrofit:test-helpers'
1515

1616
include ':retrofit-mock'
1717

18+
include ':retrofit-response-type-keeper'
19+
1820
include ':retrofit-adapters:guava'
1921
include ':retrofit-adapters:java8'
2022
include ':retrofit-adapters:rxjava'

0 commit comments

Comments
 (0)