
MongoUserRepository below, which implements the UserRepository interface with a fake FakeUserRepository for unit tests.interface UserRepository { fun findUser(userId: String): User? fun findUsers(): List<User> fun updateUser(user: User) fun insertUser(user: User) } class MongoUserRepository : UserRepository { override fun findUser(userId: String): User? = TODO() override fun findUsers(): List<User> = TODO() override fun updateUser(user: User) { TODO() } override fun insertUser(user: User) { TODO() } } class FakeUserRepository : UserRepository { private var users = listOf<User>() override fun findUser(userId: String): User? = users.find { it.id == userId } override fun findUsers(): List<User> = users override fun updateUser(user: User) { val oldUsers = users.filter { it.id == user.id } users = users - oldUsers + user } override fun insertUser(user: User) { users = users + user } }
UserRepository is determined by the methods that we want to expose by MongoUserRepository; therefore, this class and interface often change together, so it might be simpler for UserRepository to be automatically generated based on public methods in MongoUserRepository[^10_1]. We can do this using annotation processing.@GenerateInterface("UserRepository") class MongoUserRepository : UserRepository { override fun findUser(userId: String): User? = TODO() override fun findUsers(): List<User> = TODO() override fun updateUser(user: User) { TODO() } override fun insertUser(user: User) { TODO() } } class FakeUserRepository : UserRepository { private var users = listOf<User>() override fun findUser(userId: String): User? = users.find { it.id == userId } override fun findUsers(): List<User> = users override fun updateUser(user: User) { val oldUsers = users.filter { it.id == user.id } users = users - oldUsers + user } override fun insertUser(user: User) { users = users + user } }
The complete project can be found on GitHub under the name MarcinMoskala/generateinterface-ap.
- Definition of the
GenerateInterfaceannotation. - Definition of the processor that generates the appropriate interfaces based on annotations.
generateinterface-annotations- which is just a regular module that includesGenerateInterface.generateinterface-processor- where I will define my annotation processor.
kapt plugin[^10_4]. Assuming we use Gradle[^10_3] in our project, this is how we might define our main module dependency in newly created modules.// build.gradle.kts
plugins {
kotlin("kapt") version "<your_kotlin_version>"
}
dependencies {
implementation(project(":generateinterface-annotations"))
kapt(project(":generateinterface-processor"))
// ...
}
generateinterface-annotations module is a simple file with the following annotation:package academy.kt import kotlin.annotation.AnnotationTarget.CLASS @Target(CLASS) annotation class GenerateInterface(val name: String)
generateinterface-processor module, we need to specify the annotation processor. All annotation processors must extend the AbstractProcessor class.package academy.kt class GenerateInterfaceProcessor : AbstractProcessor() { // ... }
javax.annotation.processing.Processor under the path src/main/resources/META-INF/services. Inside this file, you need to specify the processor using a fully qualified name:academy.kt.GenerateInterfaceProcessor
Alternatively, one might use the Google AutoService library and just annotate the processor with@AutoService(Processor::class.java).
getSupportedAnnotationTypes- specifies a set of annotations our processor responds to. Should returnSet<String>, where each value is a fully qualified annotation name (qualifiedNameproperty). If this set includes"*", it means that the processor is interested in all annotations.getSupportedSourceVersion- specifies the latest Java source version this processor supports. To support the latest possible version, useSourceVersion.latestSupported().process- this is where our processing and code generation will be implemented. It receives as an argument a set of annotations that are chosen based ongetSupportedAnnotationTypes. It also receives a reference toRoundEnvironment, which lets us analyze the source code of the project where the processor is running. In every round, the compiler looks for more annotated elements that could have been generated by a previous round until there are no more inputs. It returns aBooleanthat determines if the annotations from the argument should be considered claimed by this processor. So, if we returntrue, other processors will not receive these annotations. Since we operate on custom annotations, we will returntrue. In our case, we will only need theRoundEnvironmentreference, and I will make a separate method,generateInterfaces, which will generate interfaces.
class GenerateInterfaceProcessor : AbstractProcessor() { override fun getSupportedAnnotationTypes(): Set<String> = setOf(GenerateInterface::class.qualifiedName!!) override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() override fun process( annotations: Set<TypeElement>, roundEnv: RoundEnvironment ): Boolean { generateInterfaces(roundEnv) return true } private fun generateInterfaces(roundEnv: RoundEnvironment) { // ... } }
generateInterfaces method implementation. We first need to find all the elements that are annotated with GenerateInterface. For that, we can use getElementsAnnotatedWith from RoundEnvironment, which should produce a set of element references of type Element. Since our annotation can only be used for classes (this is specified using the Target meta-annotation), we can expect that all these elements are of type TypeElement. To safely cast our set, I will use the filterIsInstance method; then, we can iterate over the result using the forEach method.private fun generateInterfaces(roundEnv: RoundEnvironment) { roundEnv .getElementsAnnotatedWith(GenerateInterface::class.java) .filterIsInstance<TypeElement>() .forEach(::generateInterface) } private fun generateInterface(annotatedClass: TypeElement) { // ... }
generateInterface function. I will start by finding the expected interface name, which should be specified in the annotation. We can get the annotation reference by finding it in the annotatedClass parameter, and then we can use this value to read the annotated class name. All annotation properties must be static, therefore they are exposed in annotation references on annotation processors.val interfaceName = annotatedClass .getAnnotation(GenerateInterface::class.java) .name
getPackageOf method from elementUtils from processingEnv of our AbstractProcessor.val interfacePackage = processingEnv .elementUtils .getPackageOf(annotatedClass) .qualifiedName .toString()
enclosedElements property to get all the enclosed elements and find those that are methods and have the public modifier. All methods should implement the ExecutableElement interface; so, to safely cast our elements we can use the filterIsInstance again.val publicMethods = annotatedClass.enclosedElements .filter { it.kind == ElementKind.METHOD } .filter { Modifier.PUBLIC in it.modifiers } .filterIsInstance<ExecutableElement>()
processingEnv.filer property to actually write a file. There are a number of libraries that can help us construct a file, but I decided to use JavaPoet (created and open-sourced by Square), which is both popular and simple to use. I extracted the method buildInterfaceFile to a Java file and used writeTo on its result to write the file.private fun generateInterface(annotatedClass: TypeElement) { val interfaceName = annotatedClass .getAnnotation(GenerateInterface::class.java) .name val interfacePackage = processingEnv .elementUtils .getPackageOf(annotatedClass) .qualifiedName .toString() val publicMethods = annotatedClass.enclosedElements .filter { it.kind == ElementKind.METHOD } .filter { Modifier.PUBLIC in it.modifiers } .filterIsInstance<ExecutableElement>() buildInterfaceFile( interfacePackage, interfaceName, publicMethods ).writeTo(processingEnv.filer) }
- If we generate a Kotlin file, such a processor can only be used in projects using Kotlin/JVM[^10_5]. When we generate Java files, such processors can be used on Kotlin/JVM as well as by Java, Scala, Groovy, etc[^10_6].
- Java element references are not always suitable for Kotlin code generation. For instance, Java
java.lang.Stringtranslates to Kotlinkotlin.String. If we rely on Java references, we will usejava.lang.Stringfor parameters in generated Kotlin code, which might not work correctly. Such problems can be overcome, but let’s keep our example simple.
private fun buildInterfaceFile( interfacePackage: String, interfaceName: String, publicMethods: List<ExecutableElement> ): JavaFile = JavaFile.builder( interfacePackage, buildInterface(interfaceName, publicMethods) ).build()
private fun buildInterface( interfaceName: String, publicMethods: List<ExecutableElement> ): TypeSpec = TypeSpec .interfaceBuilder(interfaceName) .addMethods(publicMethods.map(::buildInterfaceMethod)) .build()
abstract, and add the same parameters (with the same annotations and the same result types). Note that we can find the annotationMirrors property in ExecutableElement, and it can be transformed to AnnotationSpec using the static get method.private fun buildInterfaceMethod( method: ExecutableElement ): MethodSpec = MethodSpec .methodBuilder(method.simpleName.toString()) .addModifiers(method.modifiers) .addModifiers(Modifier.ABSTRACT) .addParameters( method.parameters.map(::buildInterfaceMethodParameter) ) .addAnnotations( method.annotationMirrors.map(AnnotationSpec::get) ) .returns(method.returnType.toTypeSpec()) .build()
toTypeSpec and getAnnotationSpecs, which I defined outside our processor class:private fun TypeMirror.toTypeSpec() = TypeName.get(this) .annotated(this.getAnnotationSpecs()) private fun AnnotatedConstruct.getAnnotationSpecs() = annotationMirrors.map(AnnotationSpec::get)
VariableElement. I use it to make type specs and to find out the parameter names. I also use the same annotations as used for this parameter.private fun buildInterfaceMethodParameter( variableElement: VariableElement ): ParameterSpec = ParameterSpec .builder( variableElement.asType().toTypeSpec(), variableElement.simpleName.toString() ) .addAnnotations(variableElement.getAnnotationSpecs()) .build()
GenerateInterface annotation should compile.
UserRepository and see the Java code that our processor generated. The default location of generated code is "build/generated/source/kapt/main". Intellij's Gradle plugin will mark this location as a source code folder, thus making it navigable in IDEA.
UserRepository to work, the project needs to be built. In a newly opened project, or immediately after adding the GenerateInterface annotation, the interface will not yet have been generated and our code will look like it is not correct.
Mock and InjectMocks in test suites. Based on these annotations, the Mockito annotation processor generates a file that has a method that creates desired mocks and objects with injected mocks. To make it work, we need to call this method before each test by using Mockito’s static initMocks method, which finds the appropriate generated class that injects mocks and calls its method. We do not even need to know what this class is called, and our project does not show any errors even before it is built.class MockitoInjectMocksExamples { @Mock lateinit var emailService: EmailService @Mock lateinit var smsService: SMSService @InjectMocks lateinit var notificationSender: NotificationSender @BeforeEach fun setup() { MockitoAnnotations.initMocks(this) } // ... }
@RestController class WelcomeResource { @Value("\${welcome.message}") private lateinit var welcomeMessage: String @Autowired private lateinit var configuration: BasicConfiguration @GetMapping("/welcome") fun retrieveWelcomeMessage(): String = welcomeMessage @RequestMapping("/dynamic-configuration") fun dynamicConfiguration(): Map<String, Any?> = mapOf( "message" to configuration.message, "number" to configuration.number, "key" to configuration.isValue, ) }
@SpringBootApplication open class MyApp { companion object { @JvmStatic fun main(args: Array<String>) { SpringApplication.run(MyApp::class.java, *args) } } }
[^10_2]: By "main module" I mean the module that will use annotation processing.
[^10_3]: IDEA's built-in compiler does not directly support kapt and annotation processing.
[^10_4]: As its documentation specifies, kapt is in maintenance mode, which means its creators are keeping it up-to-date with recent Kotlin and Java releases but have no plans to implement new features.
[^10_5]: In this project, the Kotlin compiler must be used in the project build process.
[^10_6]: In this project, the Java compiler must be used in the project build process.