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?
Repeatable Annotations
Your annotation works as expected now, but it can only capture a single endpoint with a single path. What if you wanted to add more endpoints?
You could modify the type and path parameters to accept arrays instead of single values, but that’d be clunky for both you and the processor. Besides, annotations should be declarative as well as easy to read and maintain. Why not repeat the annotation then?
Add another @RetroQuick
above Person
:
@RetroQuick(path="person/{id}", type = RetroQuick.Type.GET)
@RetroQuick
data class Person(
Uh, oh! It throws an error:
Well, that looks simple enough to fix.
Go to RetroQuick.kt. Add @Repeatable
below @Retention(AnnotationRetention.SOURCE)
. Your code will look like this:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
@Repeatable // ADD THIS
annotation class RetroQuick(
Go back to Person.kt to make sure the error went away. Then, rebuild the project.
The error went away, but the Build output window shows that now the processor isn’t picking your annotations:
What’s the deal with that?
@Repeatable
takes care of the Kotlin compiler side of things by telling it that it’s fine for that annotation to be present multiple times on the same code element. But, annotation processing is essentially a JVM affair, meaning you need to put @Repeatable
‘s Java twin, @java.lang.annotation.Repeatable
, to use as well.
There are two steps to make repeatable annotations work:
- Create a container annotation for repeatable annotations.
- Let the processor know about it.
First, in RetroQuick.kt, declare a new annotation class above @Target(AnnotationTarget.CLASS)
:
annotation class RetroQuicks(val value: Array<RetroQuick>)
Then, annotate RetroQuick
with @java.lang.Repeatable
, passing the container annotation class RetroQuicks
as the parameter:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
@java.lang.annotation.Repeatable(RetroQuicks::class) // ADD THIS
annotation class RetroQuick(
@java.lang.annotation.Repeatable(RetroQuicks::class)
is deprecated, but you need it to continue with the tutorial.
You finished the first part! Now, open Processor.kt and add RetroQuicks
to the list of supported annotations:
override fun getSupportedAnnotationTypes() = mutableSetOf(
RetrofitProvider::class.java.canonicalName,
RetroQuick::class.java.canonicalName,
RetroQuicks::class.java.canonicalName // ADD THIS
)
This block instructs the processor to look for and parse RetroQuicks
together with RetroQuick
and RetrofitProvider
.
Then, in process()
, add this code above logger.n("All models: ${data.models}")
:
roundEnv.getElementsAnnotatedWith(RetroQuicks::class.java)
.forEach { elem ->
elem.getAnnotation(RetroQuicks::class.java).value
.forEach {
if (!getModelData(elem, it)) {
return false
}
}
}
Basically, this is the same as the statement that parsed individual @RetroQuick
-annotated elements, but expanded to do so for batches of @RetroQuick
stored in @RetroQuicks.value
.
@RetroQuick
and @RetroQuicks
to handle single and repeated annotations.
Build the project again. Open the Build output window. This time, the processor picks up your repeated annotations and extracts model data for both of them:
Now it’s time to use all the ModelData
to generate the actual code.
Code Generation
As in the Annotations: Supercharge Your Development tutorial, you’ll use KotlinPoet for this task.
First, create a new package in retroquick-processor and name it codegen. Then create a new file and name it CodeBuilder.kt. In CodeBuilder.kt add:
class CodeBuilder( // 1
private val providerName: String,
private val className: String,
private val data: ModelData) {
private val modelClassName = ClassName(data.packageName, data.modelName) // 2
fun build(): TypeSpec = TypeSpec.objectBuilder(className) // 3
.build()
}
Here you:
- Declare
CodeBuilder
that takes three parameters: the function annotated with@RetrofitProvider
, the name of the class to be generated and its model data. - Store a
ClassName
of the model class for future use. - Declare
build
that creates a newobject
.
Now it’s time to add some helper methods to CodeBuilder.kt that’ll make code generation easier.
A Few Helper Methods
First, add typeName()
below build()
:
private fun typeName(returnType: String): TypeName {
return when (returnType) {
"int" -> INT
else -> ClassName(returnType.substring(0, returnType.lastIndexOf('.')),
returnType.substring(returnType.lastIndexOf('.') + 1))
}
}
This method maps the property type to something KotlinPoet can work with. Currently, this part only supports Int
as a primitive type, although extending it for other primitives is trivial.
Second, add funName()
above typeName()
:
private fun funName(type: RetroQuick.Type): String {
return when (type) {
RetroQuick.Type.GET -> "get"
RetroQuick.Type.POST -> "post"
}
}
This method maps RetroQuick.Type
to their internal strings.
Now it’s time for even more helper methods. Who doesn’t like that? :] This front-loading will pay off in a few minutes.
Add addRetrofitAnnotation()
above funName()
:
private fun FunSpec.Builder.addRetrofitAnnotation(name: String, path: String)
: FunSpec.Builder = apply {
addAnnotation(AnnotationSpec.builder(ClassName("retrofit2.http", name))
.addMember("%S", path)
.build()
)
}
This code annotates a function or a method with @GET
or @POST
, the two annotations Retrofit2 uses to generate call code.
Finally, add the last helper method, addParams()
:
private fun FunSpec.Builder.addParams( // 1
pathComponents: List<FieldData>,
annotate: Boolean,
addBody: Boolean
): FunSpec.Builder = apply {
for (component in pathComponents) { // 2
val paramSpec = ParameterSpec.builder(component.paramName, typeName(component.returnType))
if (annotate) { // 2
paramSpec.addAnnotation(AnnotationSpec.builder(
ClassName("retrofit2.http", "Path"))
.addMember("%S", component.paramName)
.build()
)
}
addParameter(paramSpec.build())
}
if (addBody) { // 3
val paramSpec = ParameterSpec.builder("body", modelClassName)
if (annotate) { // 3
paramSpec.addAnnotation(AnnotationSpec.builder(
ClassName("retrofit2.http", "Body"))
.build()
)
}
addParameter(paramSpec.build())
}
}
This method:
- Adds parameters to a new function or method. It has three parameters:
-
pathComponents
are parameters that are passed to the endpoint’s path. -
annotate
is a flag that tells if the parameters should be annotated. It discerns between a method declaration in a Service or in the call implementation. -
addBody
is a flag that tells if the method has a parameter representing the body of the HTTP request. This is true for POST requests and false for GET.
-
- Creates a Retrofit2 annotation, such as
@GET
, and passes the path as its parameter. - Adds the body parameter, and annotates it with
@Body
if necessary.
-
pathComponents
are parameters that are passed to the endpoint’s path. -
annotate
is a flag that tells if the parameters should be annotated. It discerns between a method declaration in a Service or in the call implementation. -
addBody
is a flag that tells if the method has a parameter representing the body of the HTTP request. This is true for POST requests and false for GET.
Next, you’ll add service interface.
Adding Service Interface
The Retrofit call has two components: the service that describes the call and actual call execution.
First, tell the code generator how to build the service part. Add the following code to CodeBuilder.kt:
private fun TypeSpec.Builder.addService(): TypeSpec.Builder = apply {
val serviceBuilder = TypeSpec.interfaceBuilder("Service") // 1
for (endpoint in data.endpointData) { // 2
val name = funName(endpoint.type)
serviceBuilder.addFunction(FunSpec.builder(name) // 3
.addModifiers(KModifier.SUSPEND, KModifier.ABSTRACT) // 3
.addRetrofitAnnotation(name.toUpperCase(Locale.ROOT), endpoint.path)// 4
.addParams(endpoint.pathComponents, annotate = true,
addBody = endpoint.type == RetroQuick.Type.POST) // 5
.returns(ClassName("retrofit2", "Response") // 6
.parameterizedBy(modelClassName.copy(true))) // 6
.build()
)
}
addType(serviceBuilder.build())
}
This code:
- Creates a new
interface
named Service. - Gets each endpoint with a separate method.
- Gets the name for the endpoint function that derived from its HTTP verb. It’s a
suspend
.KModifier.ABSTRACT
is necessary for all interface methods. - Employs
addRetrofitAnnotation()
as Retrofit2 requires annotating service methods. - Similarly, uses
addParams
to add the parameters. - Each of these methods returns a
retrofit2.Response
, with the model class name as generic parameter.copy(true)
makes the parameter nullable.
Now, you’ll add call invocations.