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š.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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:

  1. First it checks that the element is a function by checking if it’s an ExecutableElement.
  2. Then it checks if its returnType is of type Retrofit2. This requires a combination of typeUtils and elemUtils.
  3. Finally, it ensures the function’s modifiers are PUBLIC and STATIC.
Note: Annotation processing is, first and foremost, a JVM feature. As such, you need to think in terms wider than Kotlin. For example, 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:

  1. Annotates all code elements with @RetrofitProvider.
  2. Verifies there’s one, and only one, such element in all the processing rounds. If not, it prints an error and terminates processing.
  3. Validates the found element with validateRetrofitProvider(). It prints errors by itself, so you terminate processing here if validation fails.
  4. If everything’s fine, it instantiates data with the fully qualified name of the annotated function.
  5. Notes the path in the processor log.
Note: You could argue that using composition would be a better solution than a static invocation of a zero-parameter function, and you’d be right. For example, you could pass 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:

  1. Public and static function that returns Retrofit.
  2. It points to the test server.
  3. It uses a conventional OkHttpClient with small timeouts.
  4. The requests and response bodies are automatically serialized or deserialized with the Gson library.
  5. 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:

The Build output window presenting a note: Task :app:kaptDebugKotlin 
Note: RetrofitProvider located: com.raywenderlich.android.retroquick.getRetrofit

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:

The Build output window with the error: Must have exactly 1 @RetrofitProvider

Note: Before you proceed, remove any extra retrofit provider method you added except 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:

  1. Can only annotate classes.
  2. Is retained only during compilation.
  3. 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:

  1. The first two lines extract the package and the name of the annotated model class.
  2. These lines get the HTTP verb from the annotation parameter.
  3. 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.
  4. In the path, use a Regular Expression to find all the variable path components, as IDs within curly braces.
  5. Check if all those path components map to model class properties. If any don’t, print an error and abort processing.
  6. Finally, pack all this data into ModelData and add it to processor-level data.

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!

The Build output window with all model