Advanced Annotation Processing
Annotation processing is a powerful tool that allows you to pack more data into your code, and then use that data to generate more code. By Gordan Glavaš.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Advanced Annotation Processing
30 mins
- Getting Started
- Retrofit2 and Test Server
- Processor Logging and Error Handling
- Analyzing and Capturing Functions
- @RetrofitProvider Annotation
- Validating Code Elements
- A Suitable @RetrofitProvider Candidate
- @RetroQuick Annotation
- Extracting Model Data
- Repeatable Annotations
- Code Generation
- A Few Helper Methods
- Adding Service Interface
- Adding Call Invocations
- Final Touches
- Where to Go From Here?
Validating Code Elements
Add a method to Processor.kt to validate @RetrofitProvider
candidates for the conditions above:
private fun validateRetrofitProvider(elem: Element): Boolean {
(elem as? ExecutableElement)?.let { // 1
if (!typeUtils.isSameType(it.returnType,
elemUtils.getTypeElement("retrofit2.Retrofit").asType())) { // 2
logger.e("@RetrofitProvider should return retrofit2.Retrofit", elem)
return false
}
val modifiers = it.modifiers
if (Modifier.PUBLIC !in modifiers
|| Modifier.STATIC !in modifiers) { // 3
logger.e("@RetrofitProvider should be public static", elem)
return false
}
return true
} ?: return false
}
This method validates three conditions:
- First it checks that the element is a function by checking if it’s an
ExecutableElement
. - Then it checks if its
returnType
is of typeRetrofit2
. This requires a combination oftypeUtils
andelemUtils
. - Finally, it ensures the function’s modifiers are
PUBLIC
andSTATIC
.
static
isn’t even a Kotlin keyword.
With that set up, capture the function with @RetrofitProvider
and validate it. To process()
, right above the line with return true
, add:
roundEnv.getElementsAnnotatedWith(RetrofitProvider::class.java) // 1
.also {
if (!::data.isInitialized && it.size != 1) { // 2
logger.e("Must have exactly 1 @RetrofitProvider")
return false
}
}
.forEach {
if (!validateRetrofitProvider(it)) { // 3
return false
}
data = RetroQuickData(qualifiedName(it)) // 4
logger.n("RetrofitProvider located: ${data.providerName} \n") // 5
}
This code:
- Annotates all code elements with
@RetrofitProvider
. - Verifies there’s one, and only one, such element in all the processing rounds. If not, it prints an error and terminates processing.
- Validates the found element with
validateRetrofitProvider()
. It prints errors by itself, so you terminate processing here if validation fails. - If everything’s fine, it instantiates
data
with the fully qualified name of the annotated function. - Notes the path in the processor log.
Retrofit
as a parameter to generated code. However, such a solution would be more involved and is outside the scope of this tutorial.
Now take a look at a suitable @RetrofitProvider
candidate.
A Suitable @RetrofitProvider Candidate
Now it’s time to put this to use. Open MainActivity.kt and add the following outside the class:
@RetrofitProvider
fun getRetrofit(): Retrofit { // 1
return Retrofit.Builder()
.baseUrl("https://tranquil-caverns-05334.herokuapp.com/") // 2
.client(OkHttpClient.Builder() // 3
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build())
.addConverterFactory(GsonConverterFactory.create(GsonBuilder() // 3
.create()))
.addCallAdapterFactory(CoroutineCallAdapterFactory()) // 4
.build()
}
This is a:
- Public and static function that returns
Retrofit
. - It points to the test server.
- It uses a conventional
OkHttpClient
with small timeouts. - The requests and response bodies are automatically serialized or deserialized with the Gson library.
- Since RetroQuick generates suspendable functions, you need to use
CoroutineCallAdapterFactory
.
A standalone Kotlin function with no visibility modifiers is, by default, public and static. Now, build the project again. You’ll see the success note in the build log:
You can also try playing around with triggering error messages. For example, try to duplicate getRetrofit()
. Name it getRetrofit2()
and try building again.
This time, the compilation will fail, and you’ll get the following error message:
getRetrofit()
!
Next, you’ll explore @RetroQuick annotations.
@RetroQuick Annotation
Now it’s time to create the annotation that represents a RESTful endpoint for a model class. It should have a type representing its HTTP verb, GET or POST, and specify an optional path.
If the path is empty, assume the endpoint is the same as the model class name. The path can also specify path components that must map to model fields.
Create a new file named RetroQuick.kt in retroquick-annotations‘s only package. Add this as its content:
@Target(AnnotationTarget.CLASS) // 1
@Retention(AnnotationRetention.SOURCE) // 2
annotation class RetroQuick(
val type: Type = Type.POST, // 3
val path: String = ""
) {
enum class Type {
GET, POST
}
}
As you can see, this annotation class:
- Can only annotate classes.
- Is retained only during compilation.
- Has the two parameters, with
type
being a separate enum.
As before, find getSupportedAnnotationTypes()
in Processor.kt. Update it to support @RetroQuick
.
getSupportedAnnotationTypes()
now looks like this:
override fun getSupportedAnnotationTypes() = mutableSetOf(
RetrofitProvider::class.java.canonicalName,
RetroQuick::class.java.canonicalName // ADD THIS
)
Then, go to Person.kt. Annotate it to represent the GET endpoint for your test server:
@RetroQuick(path="person/{id}", type = RetroQuick.Type.GET)
data class Person
Now, you’ll extract model data.
Extracting Model Data
First, add a few helper data classes to model pieces of your final data. All of these will reside in models of retroquick-processor.
Add a named FieldData.kt. Then add:
data class FieldData(
val paramName: String,
val returnType: String
)
This code represents data about a particular field: its parameter and return type.
Then, create EndpointData.kt with the code below:
data class EndpointData(
val path: String,
val type: RetroQuick.Type,
val pathComponents: List<FieldData>
)
This data class holds information about the endpoints for the model class.
Next, add ModelData.kt. To it add:
data class ModelData(
val packageName: String,
val modelName: String,
var endpointData: List<EndpointData>,
) {
val qualifiedName get() = "$packageName.$modelName"
operator fun plusAssign(other: ModelData) {
endpointData += other.endpointData
}
}
This class holds all the information for a particular annotated model class: its package, name and data for all the endpoints.
Finally, add the following chunk to RetroQuickData.kt to contain model data:
private var modelMap = mutableMapOf<String, ModelData>()
val models get() = modelMap.values.toList()
fun addModel(model: ModelData) {
modelMap[model.qualifiedName]?.let {
it += model
} ?: run {
modelMap[model.qualifiedName] = model
}
}
Now it’s time to extract model data for each annotated model. Add this long method to Processor.kt:
private fun getModelData(elem: Element, annotation: RetroQuick): Boolean {
val packageName = elemUtils.getPackageOf(elem).toString() // 1
val modelName = elem.simpleName.toString()
val type = annotation.type //2
val path = if (annotation.path.isEmpty())
modelName.toLowerCase(Locale.ROOT)
else annotation.path
val fieldData = elem.enclosedElements.mapNotNull { //3
(it as? VariableElement)?.asType()?.let { returnType ->
FieldData(it.simpleName.toString(), returnType.toString())
}
}
val pathComponentCandidates = "[{](.*?)}".toRegex() //4
.findAll(path)
.map { it.groupValues[1] }
.toList()
val pathComponents = mutableListOf<FieldData>() //5
for (candidate in pathComponentCandidates) {
fieldData.firstOrNull { it.paramName == candidate }?.let {
pathComponents += it
} ?: run {
logger.e("Invalid path component: $candidate")
return false
}
}
val md = ModelData(packageName, modelName, //6
listOf(EndpointData(path, type, pathComponents)))
data.addModel(md)
return true
}
There’s a lot going on here! Here’s a breakdown:
- The first two lines extract the package and the name of the annotated model class.
- These lines get the HTTP verb from the annotation parameter.
- These lines get all the child elements for the model class. You filter them to take only properties and store their names and return types to a list. If
path
is empty, use the model name for the path. Otherwise, take the provided parameter and parse it further. - In the path, use a Regular Expression to find all the variable path components, as IDs within curly braces.
- Check if all those path components map to model class properties. If any don’t, print an error and abort processing.
- Finally, pack all this data into
ModelData
and add it to processor-leveldata
.
Last, you’ll get model data. Add the following code to process()
, above return true
:
roundEnv.getElementsAnnotatedWith(RetroQuick::class.java)
.forEach {
if (!getModelData(it, it.getAnnotation(RetroQuick::class.java))) {
return false
}
}
logger.n("All models: ${data.models}")
The code finds all elements annotated with @RetroQuick
, extracts model data from them and then prints a note about their structure.
That’s it. Build the project and check the Build output window. You’ll see the processor collected the necessary data!