Simple Annotation Processor

I recently needed to develop an annotation processor in Kotlin, posting here for future reference.

This is the class level annotation:

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class CRUDEntity(
  val restPath: String,
  val overrideRepository: Boolean = false,
  val overrideService: Boolean = false,
  val overrideResource: Boolean = false,
  val generateAPIs: Boolean = true,
  val useSoftDelete: Boolean = false,
)

And here is the declaration of the annotation processor:

import com.google.auto.service.AutoService
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.ProcessingEnvironment
import javax.annotation.processing.Processor
import javax.annotation.processing.SupportedOptions
import javax.annotation.processing.RoundEnvironment
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.ElementKind
import javax.lang.model.element.TypeElement
import javax.tools.Diagnostic

@AutoService(Processor::class)
@SupportedOptions(CRUDEntityProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME)
class CRUDEntityProcessor : AbstractProcessor() {

Followed by the usual overrides:

companion object {
  const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
}

override fun init(processingEnv: ProcessingEnvironment?) {
  super.init(processingEnv)
}

override fun getSupportedAnnotationTypes(): MutableSet<String> {
  return mutableSetOf(CRUDEntity::class.java.canonicalName)
}

override fun getSupportedSourceVersion(): SourceVersion {
  return SourceVersion.latestSupported()
}   

These fields keep track of what has been processed:

private val classesToProcess: MutableMap<String, Pair<EntityConfig, Element>>
  = mutableMapOf()

private val classesProcessed: MutableMap<String, EntityConfig> 
  = mutableMapOf()

The process method:

@Suppress("ReturnCount")
override fun process(annotations: MutableSet<out TypeElement>?, 
  roundEnv: RoundEnvironment?) : Boolean {
  
  if (roundEnv == null) return false

identifies types to process, and extracts configuration information.

roundEnv.getElementsAnnotatedWith(CRUDEntity::class.java).forEach { element ->

  val classElement = element as TypeElement
  val qualifiedClassName = classElement.qualifiedName.toString() 
  val crudEntity = element.getAnnotation(CRUDEntity::class.java)

  if (!classesProcessed.containsKey(qualifiedClassName)) {
    val entityConfig = EntityConfig(
      qualifiedName = qualifiedClassName,
      restPath = crudEntity.restPath,
      overrideRepository = crudEntity.overrideRepository,
      overrideService = crudEntity.overrideService,
      overrideResource = crudEntity.overrideResource,
      generateAPIs = crudEntity.generateAPIs,
      useSoftDelete = crudEntity.useSoftDelete)
      
    classesToProcess[qualifiedClassName] = Pair(entityConfig, element)
  }
}

Next it iterates over the types to process and calls various generators to create their artifacts:

val processList = classesToProcess.values.toList()

processList.forEach { (entityConfig, element) ->

  RepositoryGenerator.generate(processingEnv.filer, entityConfig)
  ResourceGenerator.generate(processingEnv.filer, entityConfig, element)
  ServiceGenerator.generate(processingEnv.filer, entityConfig, element)
  
  // ...etc

  classesProcessed[entityConfig.qualifiedName] = entityConfig
  classesToProcess.remove(entityConfig.qualifiedName)
}

The individual generators build and persist their files:

val builderFile = filer
  .createSourceFile("${entityConfig.qualifiedName}Repository")
  
PrintWriter(builderFile.openWriter()).use { out -> out.println(code) }

Often calling helpers to generate specific items. This is an example of a field annotation:

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FIELD)
annotation class ResourceFieldAccessor(val name: String = "")

Which has the following helper:

import javax.lang.model.element.Element
import javax.lang.model.element.ElementKind
import javax.lang.model.element.TypeElement

/* 
 * The purpose of this...etc.
 *
 * In the Player example CRUD, this property:
 *
 *   @ResourceFieldAccessor
 *   var teamId: Int = 0
 *
 * Would generate a resource method with the path: 
 *
 *   /test/v1/player/teamId/{value}
 *
 * The generated method would return all players with the teamId of value.
 *
 * This property:
 *
 *   @ResourceFieldAccessor(name = "pos")
 *   var position: String = ""
 *
 * Would generate a resource method with the path:
 * 
 *   /test/v1/players/pos/{value}
 *
 * Resource, Service and SQL artifacts can all be generated. To use this  
 * class, call the appropriate method from the specific Generator, i.e.
 *
 *   val resourceMethods = 
 *       ResourceFieldGenerator.createResourceMethods(element, entityConfig)
 *
 */
object ResourceFieldGenerator {

  fun createSQLIndexes(element: Element, tableName: String): String {
    val sb = StringBuilder()

    val fields = getAnnotatedFields(element as TypeElement)

    fields.forEach { field ->
      sb.append(
        SQL_TEMPLATE
          .replace("#COLUMN", field.name.lowercase())
          .replace("#TABLE", tableName)
      )
      sb.appendLine()
    }

    return sb.toString()
  }

  fun createResourceMethods(element: Element, entityConfig: EntityConfig)
    = generateMethods(RESOURCE_TEMPLATE, element, entityConfig)

  fun createServiceMethods(element: Element, entityConfig: EntityConfig)
    = generateMethods(SERVICE_TEMPLATE, element, entityConfig)

  private fun generateMethods(
    template: String, element: Element, entityConfig: EntityConfig) : String{
    
    val sb = StringBuilder()

    val fields = getAnnotatedFields(element as TypeElement)

    fields.forEach { field ->
      sb.append(
        template
          .replace("#PATH", field.path)
          .replace("#CLASS", entityConfig.getClassName())
          .replace("#PROPTYPE", field.type)
          .replace("#PROP", field.name.capitalize())
          .replace("#SCOPE", entityConfig.getClassName().lowercase())
          .replace("#FIELD", field.name)
      )
      sb.appendLine()
    }

    return sb.toString()
  }

  private fun getAnnotatedFields(typeElement: TypeElement): List<Field> {
    val annotatedFields = mutableListOf<Field>()

    val fields = typeElement.enclosedElements.filter 
      { it.kind == ElementKind.FIELD }

    fields.forEach { field ->
      val annotation = field.getAnnotation(ResourceFieldAccessor::class.java)

      if (annotation != null) {
        val name = field.simpleName.toString()
        val path = if (annotation.name != "") annotation.name else name
        val type = toTypeName(field)

        val annotatedField = Field(path, name, type)
        annotatedFields.add(annotatedField)
      }
    }

    return annotatedFields
  }

  private data class Field(
    val path: String, val name: String, val type: String)
}

The templates are loaded from resource files:

  private const val RESOURCE_TEMPLATE = ...

  private const val SERVICE_TEMPLATE = ...

  private const val SQL_TEMPLATE = ...

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s