Skip to content

Introduce utbot-spring-analyzer module #1818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ if (goIde.split(",").contains(ideType)) {
include("utbot-cli-go")
include("utbot-intellij-go")
}

include("utbot-spring-analyzer")
15 changes: 15 additions & 0 deletions utbot-spring-analyzer/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id("org.springframework.boot") version "2.7.8"
id("io.spring.dependency-management") version "1.1.0"
id("java")
}

java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
}
33 changes: 33 additions & 0 deletions utbot-spring-analyzer/src/main/kotlin/ApplicationRunner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package application

import analyzers.SpringApplicationAnalyzer
import utils.PathsUtils

/**
* To run this app, arguments must be passed in the following way:
* args[0] - classpath of current project
* args[1] - fully qualified name of configuration class
* args[2] - `.properties` file paths, separated via `;`, empty string if no files exist
* args[3] - `.xml` configuration file paths
*
* Several items in one arg are separated via `;`.
* If there are no files, empty string should be passed.
*/
fun main(args: Array<String>) {

/* FOR EXAMPLE
val arg0 = "/Users/kirillshishin/IdeaProjects/spring-starter-lesson-28/build/classes/java/main"
val arg1 = "com.dmdev.spring.config.ApplicationConfiguration"
val arg2 = "/Users/kirillshishin/IdeaProjects/spring-starter-lesson-28/src/main/resources/application.properties;/Users/kirillshishin/IdeaProjects/spring-starter-lesson-28/src/main/resources/application-web.properties"
val arg3 = "/Users/kirillshishin/IdeaProjects/spring-starter-lesson-28/src/main/resources/application.xml;/Users/kirillshishin/IdeaProjects/spring-starter-lesson-28/src/main/resources/application2.xml"
*/

val springApplicationAnalyzer = SpringApplicationAnalyzer(
applicationPath = args[0],
configurationClassFqn = args[1],
propertyFilesPaths = args[2].split(";").filter { it != PathsUtils.EMPTY_PATH },
xmlConfigurationPaths = args[3].split(";").filter { it != PathsUtils.EMPTY_PATH },
)

springApplicationAnalyzer.analyze()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package analyzers

import application.utils.FakeFileManager
import application.configurators.PropertiesConfigurator
import application.configurators.XmlFilesConfigurator
import config.TestApplicationConfiguration
import org.springframework.boot.builder.SpringApplicationBuilder
import org.springframework.context.ApplicationContextException
import utils.ConfigurationManager
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Path


class SpringApplicationAnalyzer(
private val applicationPath: String,
private val configurationClassFqn: String,
private val propertyFilesPaths: List<String>,
private val xmlConfigurationPaths: List<String>,
) {

private val applicationUrl: URL
get() = Path.of(applicationPath).toUri().toURL()

fun analyze() {
val fakeFileManager = FakeFileManager(propertyFilesPaths + xmlConfigurationPaths)
fakeFileManager.createFakeFiles()

val classLoader: ClassLoader = URLClassLoader(arrayOf(applicationUrl))
val userConfigurationClass = classLoader.loadClass(configurationClassFqn)

val configurationManager = ConfigurationManager(classLoader, userConfigurationClass)
val propertiesConfigurator = PropertiesConfigurator(propertyFilesPaths, configurationManager)
val xmlFilesConfigurator = XmlFilesConfigurator(xmlConfigurationPaths, configurationManager)

propertiesConfigurator.configure()
xmlFilesConfigurator.configure()

val app = SpringApplicationBuilder(SpringApplicationAnalyzer::class.java)
app.sources(TestApplicationConfiguration::class.java, userConfigurationClass)
for (prop in propertiesConfigurator.readProperties()) {
app.properties(prop)
}

try {
app.build()
app.run()
} catch (e: ApplicationContextException) {
println("Bean analysis finished successfully")
}finally {
fakeFileManager.deleteFakeFiles()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package analyzers

import org.w3c.dom.Document
import org.w3c.dom.Element
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult

class XmlConfigurationAnalyzer(private val userXmlFilePath: String, private val fakeXmlFilePath: String) {

fun fillFakeApplicationXml() {
val builder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
val doc = builder.parse(userXmlFilePath)

// Property placeholders may contain file names relative to user project,
// they will not be found in ours. We import all properties using another approach.
deletePropertyPlaceholders(doc)
writeXmlFile(doc)
}

private fun deletePropertyPlaceholders(doc: Document) {
val elements = doc.getElementsByTagName("context:property-placeholder")
val elementsCount = elements.length

// Xml file may contain several property placeholders:
// see https://stackoverflow.com/questions/26618400/how-to-use-multiple-property-placeholder-in-a-spring-xml-file
for (i in 0 until elementsCount) {
val element = elements.item(i) as Element
element.parentNode.removeChild(element)
}

doc.normalize()
}

private fun writeXmlFile(doc: Document) {
val tFormer = TransformerFactory.newInstance().newTransformer()
val source = DOMSource(doc)
val destination = StreamResult(fakeXmlFilePath)

tFormer.transform(source, destination)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package config

import org.springframework.beans.factory.config.BeanFactoryPostProcessor
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import post_processors.UtBotBeanFactoryPostProcessor

@Configuration
open class TestApplicationConfiguration {

@Bean
open fun utBotBeanFactoryPostProcessor(): BeanFactoryPostProcessor {
return UtBotBeanFactoryPostProcessor()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package application.configurators

import utils.ConfigurationManager
import utils.PathsUtils
import java.io.BufferedReader
import java.io.FileReader
import kotlin.io.path.Path

class PropertiesConfigurator(
private val propertiesFilesPaths: List<String>,
private val configurationManager: ConfigurationManager
) {

fun configure() {
configurationManager.clearPropertySourceAnnotation()

propertiesFilesPaths
.map { Path(it).fileName }
.forEach { fileName -> configurationManager.patchPropertySourceAnnotation(fileName) }
}

fun readProperties(): ArrayList<String> {
val props = ArrayList<String>()

for (propertiesFilePath in propertiesFilesPaths) {
if (propertiesFilePath == PathsUtils.EMPTY_PATH) continue

val reader = BufferedReader(FileReader(propertiesFilePath))
var line = reader.readLine()
while (line != null) {
props.add(line)
line = reader.readLine()
}

reader.close()
}

return props
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package application.configurators

import analyzers.XmlConfigurationAnalyzer
import application.utils.FakeFileManager
import utils.ConfigurationManager
import utils.PathsUtils
import kotlin.io.path.Path

class XmlFilesConfigurator(
private val userXmlFilePaths: List<String>,
private val configurationManager: ConfigurationManager,
) {

fun configure() {
configurationManager.clearImportResourceAnnotation()

for (userXmlFilePath in userXmlFilePaths) {
if(userXmlFilePath == PathsUtils.EMPTY_PATH)continue

val xmlConfigurationAnalyzer =
XmlConfigurationAnalyzer(userXmlFilePath, PathsUtils.createFakeFilePath(userXmlFilePath))

xmlConfigurationAnalyzer.fillFakeApplicationXml()
configurationManager.patchImportResourceAnnotation(Path(userXmlFilePath).fileName)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package post_processors

import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition
import org.springframework.beans.factory.config.BeanFactoryPostProcessor
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.core.PriorityOrdered

import java.io.File
import java.io.FileWriter
import java.util.Arrays

class UtBotBeanFactoryPostProcessor : BeanFactoryPostProcessor, PriorityOrdered {

/**
* Sets the priority of post processor to highest to avoid side effects from others.
*/
override fun getOrder(): Int = PriorityOrdered.HIGHEST_PRECEDENCE

override fun postProcessBeanFactory(beanFactory: ConfigurableListableBeanFactory) {
println("Started post-processing bean factory in UtBot")

val beanClassNames = findBeanClassNames(beanFactory)
//TODO: will be replaced with more appropriate IPC approach.
writeToFile(beanClassNames)

// After desired post-processing is completed we destroy bean definitions
// to avoid further possible actions with beans that may be unsafe.
destroyBeanDefinitions(beanFactory)

println("Finished post-processing bean factory in UtBot")
}

private fun findBeanClassNames(beanFactory: ConfigurableListableBeanFactory): ArrayList<String> {
val beanClassNames = ArrayList<String>()
for (beanDefinitionName in beanFactory.beanDefinitionNames) {
val beanDefinition = beanFactory.getBeanDefinition(beanDefinitionName)

if (beanDefinition is AnnotatedBeanDefinition) {
val factoryMethodMetadata = beanDefinition.factoryMethodMetadata
if (factoryMethodMetadata != null) {
beanClassNames.add(factoryMethodMetadata.returnTypeName)
}
} else {
var className = beanDefinition.beanClassName
if (className == null) {
className = beanFactory.getBean(beanDefinitionName).javaClass.name
}
className?.let { beanClassNames.add(it) }
}
}

return beanClassNames
}

private fun destroyBeanDefinitions(beanFactory: ConfigurableListableBeanFactory) {
for (beanDefinitionName in beanFactory.beanDefinitionNames) {
val beanRegistry = beanFactory as BeanDefinitionRegistry
beanRegistry.removeBeanDefinition(beanDefinitionName)
}
}

private fun writeToFile(beanClassNames: ArrayList<String>) {
try {
val springBeansFile = File("SpringBeans.txt")
val fileWriter = FileWriter(springBeansFile)

val distinctClassNames = beanClassNames.stream()
.distinct()
.toArray()
Arrays.sort(distinctClassNames)

for (beanClassName in distinctClassNames) {
fileWriter.append(beanClassName.toString())
fileWriter.append("\n")
}

fileWriter.flush()
fileWriter.close()

println("Storing bean information completed successfully")
} catch (e: Throwable) {
println("Storing bean information failed with exception $e")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package utils

import org.springframework.context.annotation.ImportResource
import org.springframework.context.annotation.PropertySource
import java.lang.reflect.InvocationHandler
import java.nio.file.Path
import java.util.Arrays
import kotlin.reflect.KClass

class ConfigurationManager(private val classLoader: ClassLoader, private val userConfigurationClass: Class<*>) {

fun clearPropertySourceAnnotation() = patchAnnotation(PropertySource::class, null)

fun clearImportResourceAnnotation() = patchAnnotation(ImportResource::class, null)

fun patchPropertySourceAnnotation(userPropertiesFileName: Path) =
patchAnnotation(PropertySource::class, String.format("classpath:%s", "fake_$userPropertiesFileName"))

fun patchImportResourceAnnotation(userXmlFilePath: Path) =
patchAnnotation(ImportResource::class, String.format("classpath:%s", "fake_$userXmlFilePath"))

private fun patchAnnotation(annotationClass: KClass<*>, newValue: String?) {
val proxyClass = classLoader.loadClass("java.lang.reflect.Proxy")
val hField = proxyClass.getDeclaredField("h")
hField.isAccessible = true

val propertySourceAnnotation = Arrays.stream(
userConfigurationClass.annotations
)
.filter { el: Annotation -> el.annotationClass == annotationClass }
.findFirst()

if (propertySourceAnnotation.isPresent) {
val annotationInvocationHandler = hField[propertySourceAnnotation.get()] as InvocationHandler

val annotationInvocationHandlerClass =
classLoader.loadClass("sun.reflect.annotation.AnnotationInvocationHandler")
val memberValuesField = annotationInvocationHandlerClass.getDeclaredField("memberValues")
memberValuesField.isAccessible = true

val memberValues = memberValuesField[annotationInvocationHandler] as MutableMap<String, Any>
addNewValue(memberValues, newValue)
}
}

private fun addNewValue(memberValues: MutableMap<String, Any>, newValue: String?){
if(newValue == null){
memberValues["value"] = Array(0){""}
}
else {
val list: MutableList<String> = (memberValues["value"] as Array<String>).toMutableList()
list.add(newValue)
memberValues["value"] = list.toTypedArray()
}
}
}
Loading