Skip to content
31 changes: 26 additions & 5 deletions core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.numeric.LongGene
import org.evomaster.core.search.gene.placeholder.CycleObjectGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.regex.RegexGene
import org.evomaster.core.search.gene.string.Base64StringGene
import org.evomaster.core.search.gene.string.StringGene
Expand Down Expand Up @@ -119,18 +120,38 @@ class DtoWriter(
gene is ObjectGene -> calculateDtoFromObject(gene, actionName)
gene is ArrayGene<*> -> calculateDtoFromArray(gene, actionName)
gene is FixedMapGene<*, *> -> calculateDtoFromFixedMapGene(gene, actionName)
// TODO: a JsonPatchDocumentGene is currently skipped from DTO collection. Once we decide
// how a JSON Patch document should be rendered when a test case is written (it is not a
// regular object/array DTO but an RFC 6902 array of operations), this should build and
// emit the corresponding DTO instead of returning.
gene is JsonPatchDocumentGene -> return
gene is JsonPatchDocumentGene -> calculateDtoFromJsonPatch(gene)
isPrimitiveGene(gene) -> return
else -> {
throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName")
}
}
}

/**
* Registers the shared JsonPatchOperation DTO and collects nested DTOs for object/array values.
*/
private fun calculateDtoFromJsonPatch(gene: JsonPatchDocumentGene) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace comment with Javadoc

val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO
val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(it) }
dtoClass.addField(GeneToDto.FIELD_OP, DtoField(GeneToDto.FIELD_OP, GeneToDto.TYPE_STRING))
dtoClass.addField(GeneToDto.FIELD_PATH, DtoField(GeneToDto.FIELD_PATH, GeneToDto.TYPE_STRING))
dtoClass.addField(GeneToDto.FIELD_FROM, DtoField(GeneToDto.FIELD_FROM, GeneToDto.TYPE_STRING))
dtoClass.addField(GeneToDto.FIELD_VALUE, DtoField(GeneToDto.FIELD_VALUE, anyType()))
dtoCollector[dtoName] = dtoClass

gene.operations.filterIsInstance<JsonPatchPathValueGene>().forEach { operation ->
when (val valueGene = operation.pathValueChoice.activeGene().second.getLeafGene()) {
is ObjectGene -> calculateDtoFromObject(valueGene, GeneToDto.FIELD_VALUE)
is ArrayGene<*> -> calculateDtoFromArray(valueGene, GeneToDto.FIELD_VALUE)
}
}
}

private fun anyType(): String {
return if (outputFormat.isJava()) GeneToDto.TYPE_JAVA_OBJECT else GeneToDto.TYPE_KOTLIN_ANY
}

private fun calculateDtoFromFixedMapGene(gene: FixedMapGene<*, *>, actionName: String) {
val dtoName = TestWriterUtils.safeVariableName(actionName)
val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(dtoName) }
Expand Down
98 changes: 98 additions & 0 deletions core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import org.evomaster.core.search.gene.collection.PairGene
import org.evomaster.core.search.gene.datetime.DateGene
import org.evomaster.core.search.gene.datetime.DateTimeGene
import org.evomaster.core.search.gene.datetime.TimeGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchFromPathGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchOperationGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathOnlyGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.numeric.DoubleGene
import org.evomaster.core.search.gene.numeric.FloatGene
import org.evomaster.core.search.gene.numeric.IntegerGene
Expand All @@ -36,6 +41,18 @@ class GeneToDto(
val outputFormat: OutputFormat
) {

companion object {
// Shared DTO class name for all JSON Patch operations (RFC 6902).
const val JSON_PATCH_OPERATION_DTO = "JsonPatchOperation"
const val FIELD_OP = "op"
const val FIELD_PATH = "path"
const val FIELD_FROM = "from"
const val FIELD_VALUE = "value"
const val TYPE_STRING = "String"
const val TYPE_JAVA_OBJECT = "Object"
const val TYPE_KOTLIN_ANY = "Any"
}

private val log: Logger = LoggerFactory.getLogger(GeneToDto::class.java)

private var dtoOutput: DtoOutput = if (outputFormat.isJava()) {
Expand Down Expand Up @@ -67,6 +84,7 @@ class GeneToDto(
}
is ChoiceGene<*> -> TestWriterUtils.safeVariableName(fallback)
is FixedMapGene<*,*> -> TestWriterUtils.safeVariableName(fallback)
is JsonPatchDocumentGene -> JSON_PATCH_OPERATION_DTO
else -> throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $fallback")
}
}
Expand All @@ -85,10 +103,90 @@ class GeneToDto(
is ArrayGene<*> -> getArrayDtoCall(gene, dtoName, counters, null, capitalize)
is ChoiceGene<*> -> getDtoCall(gene.activeGene(), dtoName, counters, capitalize)
is FixedMapGene<*,*> -> getFixedMapGeneDtoCall(gene, dtoName, counters)
is JsonPatchDocumentGene -> getJsonPatchDtoCall(gene, counters)
else -> throw RuntimeException("BUG: Gene $gene (with type ${this::class.java.simpleName}) should not be creating DTOs")
}
}

/**
* Renders a JSON Patch document as a List<JsonPatchOperation>, one DTO per active operation.
*/
private fun getJsonPatchDtoCall(gene: JsonPatchDocumentGene, counters: MutableList<Int>): DtoCall {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace comment with Javadoc

val listVarName = "list_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}"
val result = mutableListOf<String>()
result.add(dtoOutput.getNewListStatement(JSON_PATCH_OPERATION_DTO, listVarName))

var operationCounter = 1
gene.operations.forEach { operation ->
val childCounter = mutableListOf<Int>().apply {
addAll(counters)
add(operationCounter++)
}
val operationCall = getJsonPatchOperationCall(operation, childCounter)
result.addAll(operationCall.objectCalls)
result.add(dtoOutput.getAddElementToListStatement(listVarName, operationCall.varName))
}

return DtoCall(listVarName, result)
}

/**
* Renders a single RFC 6902 operation as a JsonPatchOperation DTO with only its relevant fields set.
*/
private fun getJsonPatchOperationCall(operation: JsonPatchOperationGene, counters: MutableList<Int>): DtoCall {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace comment with Javadoc

val varName = "dto_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}"
val result = mutableListOf<String>()
result.add(dtoOutput.getNewObjectStatement(JSON_PATCH_OPERATION_DTO, varName))
result.add(dtoOutput.getSetterStatement(varName, FIELD_OP, "\"${operation.operationName}\""))

when (operation) {
is JsonPatchPathOnlyGene -> {
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene)))
}
is JsonPatchFromPathGene -> {
result.add(dtoOutput.getSetterStatement(varName, FIELD_FROM, renderLeafValue(operation.fromGene)))
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene)))
}
is JsonPatchPathValueGene -> {
val pair = operation.pathValueChoice.activeGene()
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(pair.first)))
setJsonPatchValue(varName, pair.second.getLeafGene(), counters, result)
}
}

return DtoCall(varName, result)
}

/**
* Sets the "value" field: primitives are inlined as literals, objects/arrays delegate to DTO generation.
*/
private fun setJsonPatchValue(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace comment with Javadoc

varName: String,
valueGene: Gene,
counters: MutableList<Int>,
result: MutableList<String>
) {
when (valueGene) {
is ObjectGene -> {
val childCall = getDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, true)
result.addAll(childCall.objectCalls)
result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName))
}
is ArrayGene<*> -> {
val childCall = getArrayDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, FIELD_VALUE, true)
result.addAll(childCall.objectCalls)
result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName))
}
else -> result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, renderLeafValue(valueGene)))
}
}

// Returns the printable string representation of a gene's leaf value with its language-specific suffix.
private fun renderLeafValue(gene: Gene): String {
val leafGene = gene.getLeafGene()
return "${leafGene.getValueAsPrintableString(targetFormat = outputFormat)}${getValueSuffix(leafGene)}"
}

private fun getObjectDtoCall(gene: ObjectGene, dtoName: String, counters: MutableList<Int>): DtoCall {
val dtoVarName = "dto_${dtoName}_${counters.joinToString("_")}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,13 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() {
val bodyParam = call.parameters.find { p -> p is BodyParam } as BodyParam?
if (bodyParam != null && bodyParam.isJson() && payloadIsValidJson(bodyParam)) {
val primaryGene = bodyParam.primaryGene()
if (primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java) != null) {
return ""
val actionName = call.getName()
val jsonPatchGene = primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java)
if (jsonPatchGene != null) {
// A JSON Patch document is rendered as a List<JsonPatchOperation> DTO (RFC 6902).
return generateDtoCall(jsonPatchGene, actionName, lines).varName
}
val choiceGene = primaryGene.getWrappedGene(ChoiceGene::class.java)
val actionName = call.getName()
if (choiceGene != null) {
// We only generate DTOs for ChoiceGene objects that contain either an ObjectGene or ArrayGene in their
// genes. This check is necessary since when using `example` and `default` entries,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ import org.evomaster.core.search.gene.UUIDGene
import org.evomaster.core.search.gene.collection.EnumGene
import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.string.StringGene
import org.evomaster.core.output.dto.GeneToDto
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.utils.GeneUtils
import org.evomaster.core.search.gene.wrapper.CustomMutationRateGene
import org.evomaster.core.search.gene.wrapper.OptionalGene
import org.evomaster.core.search.service.Randomness
import org.evomaster.core.sql.schema.TableId
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -1643,6 +1646,40 @@ public void test() throws Exception {
}
}

@Test
fun testJsonPatchBodyRenderedAsDto() {
val format = OutputFormat.KOTLIN_JUNIT_5
val baseUrlOfSut = "baseUrlOfSut"

val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age")))
val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 }
val bodyParam = BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene)

val action = RestCallAction("1", HttpVerb.PATCH, RestPath("/items/1"), mutableListOf(bodyParam))
val individual = RestIndividual(mutableListOf(action), SampleType.RANDOM)
TestUtils.doInitializeIndividualForTesting(individual, Randomness().apply { updateSeed(42L) })

val fitnessVal = FitnessValue(0.0)
val result = RestCallResult(action.getLocalId()).apply { setStatusCode(200) }
val ei = EvaluatedIndividual(fitnessVal, individual, listOf(result))

val config = getConfig(format)
config.dtoForRequestPayload = true
config.problemType = EMConfig.ProblemType.REST

val test = TestCase(test = ei, name = "test")
val writer = RestTestCaseWriter(config, PartialOracles())
val output = writer.convertToCompilableTestCode(test, baseUrlOfSut).toString()

// The JSON Patch body must be rendered as a DTO list, not as a raw JSON string.
assertTrue(output.contains("list_${GeneToDto.JSON_PATCH_OPERATION_DTO}_"),
"Expected DTO list variable in generated output")
assertTrue(output.contains(".body(list_${GeneToDto.JSON_PATCH_OPERATION_DTO}_"),
"Expected DTO variable passed as body argument")
assertFalse(output.contains("{\"op\":"),
"Body must not contain raw JSON string representation of a patch operation")
}

@Test
fun testInActiveBodyParamInTest(){
val stringGene = StringGene("stringGene")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.evomaster.core.output.dto

import org.evomaster.core.TestUtils
import org.evomaster.core.output.OutputFormat
import org.evomaster.core.output.Termination
import org.evomaster.core.output.naming.RestActionTestCaseUtils.getEvaluatedIndividualWith
import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallAction
import org.evomaster.core.problem.api.param.Param
import org.evomaster.core.problem.enterprise.SampleType
import org.evomaster.core.problem.rest.data.HttpVerb
import org.evomaster.core.problem.rest.data.RestCallResult
import org.evomaster.core.problem.rest.data.RestIndividual
import org.evomaster.core.problem.rest.param.BodyParam
import org.evomaster.core.search.EvaluatedIndividual
import org.evomaster.core.search.FitnessValue
import org.evomaster.core.search.Solution
import org.evomaster.core.search.gene.ObjectGene
import org.evomaster.core.search.gene.collection.ArrayGene
import org.evomaster.core.search.gene.collection.EnumGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.string.StringGene
import org.evomaster.core.search.service.Randomness
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Paths
import java.util.Collections.singletonList

class DtoWriterJsonPatchTest {

private val outputTestSuitePath = Paths.get("./target/dto-writer-json-patch-test")
private val testPackage = "test.package"

private fun jsonPatchBodyParam(): Param {
val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age")))
val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 }
return BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene)
}

private fun jsonPatchSolution(): Solution<*> {
val action = getRestCallAction("/items/{id}", HttpVerb.PATCH, mutableListOf(jsonPatchBodyParam()))
val eIndividual = getEvaluatedIndividualWith(action)
return Solution(singletonList(eIndividual), "", "", Termination.NONE, emptyList(), emptyList())
}

@Test
fun collectsJsonPatchOperationDtoWithAllFields() {
val dtoWriter = DtoWriter(OutputFormat.KOTLIN_JUNIT_5)
dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution())

val dtos = dtoWriter.getCollectedDtos()
val operationDto = dtos[GeneToDto.JSON_PATCH_OPERATION_DTO]
assertNotNull(operationDto, "Expected a ${GeneToDto.JSON_PATCH_OPERATION_DTO} DTO to be collected")

val fields = operationDto!!.fieldsMap
assertEquals(DtoField(GeneToDto.FIELD_OP, "String"), fields[GeneToDto.FIELD_OP])
assertEquals(DtoField(GeneToDto.FIELD_PATH, "String"), fields[GeneToDto.FIELD_PATH])
assertEquals(DtoField(GeneToDto.FIELD_FROM, "String"), fields[GeneToDto.FIELD_FROM])
// value is the generic object type since a JSON Patch value can be any JSON value
assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Any"), fields[GeneToDto.FIELD_VALUE])
}

@Test
fun valueFieldIsObjectForJavaOutput() {
val dtoWriter = DtoWriter(OutputFormat.JAVA_JUNIT_5)
dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution())

val operationDto = dtoWriter.getCollectedDtos()[GeneToDto.JSON_PATCH_OPERATION_DTO]
assertNotNull(operationDto)
assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Object"), operationDto!!.fieldsMap[GeneToDto.FIELD_VALUE])
}

@Test
fun collectsNestedObjectDtoWhenValueIsArrayOfObjects() {
// Schema where the only patchable field is an array of objects:
// every add/replace/test operation will have an ArrayGene<ObjectGene> as its value gene,
// so calculateDtoFromJsonPatch must visit calculateDtoFromArray for those operations.
val tagSchema = ObjectGene("Tag", listOf(StringGene("label")), refType = "Tag")
val schema = ObjectGene("body", listOf(ArrayGene("tags", tagSchema)))
val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 }
val bodyParam = BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene)

val action = getRestCallAction("/items/{id}", HttpVerb.PATCH, mutableListOf(bodyParam))
val individual = RestIndividual(mutableListOf(action), SampleType.RANDOM)
TestUtils.doInitializeIndividualForTesting(individual, Randomness().apply { updateSeed(42L) })

val result = RestCallResult(action.getLocalId()).apply { setStatusCode(200) }
val ei = EvaluatedIndividual<RestIndividual>(FitnessValue(0.0), individual, listOf(result))
val solution = Solution(singletonList(ei), "", "", Termination.NONE, emptyList(), emptyList())

val dtoWriter = DtoWriter(OutputFormat.KOTLIN_JUNIT_5)
dtoWriter.write(outputTestSuitePath, testPackage, solution)
val dtos = dtoWriter.getCollectedDtos()

assertNotNull(dtos[GeneToDto.JSON_PATCH_OPERATION_DTO])

val patchGene = bodyParam.primaryGene() as JsonPatchDocumentGene
val pathValueOps = patchGene.operations.filterIsInstance<JsonPatchPathValueGene>()
assertTrue(pathValueOps.isNotEmpty(), "Seed 42L must produce at least one add/replace/test operation")
// All add/replace/test operations carry an ArrayGene<ObjectGene> value in this schema;
// the nested Tag DTO must therefore be collected.
assertNotNull(dtos["Tag"], "Nested Tag DTO must be collected for operations with array-of-objects value")
}
}
Loading
Loading