diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt new file mode 100644 index 0000000000..a859ce58e8 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt @@ -0,0 +1,142 @@ +package com.foo.rest.examples.bb.jsonpatch + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.evomaster.e2etests.utils.CoveredTargets +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/pets") +open class BBJsonPatchApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(BBJsonPatchApplication::class.java, *args) + } + } + + private val store: MutableMap = mutableMapOf( + 1L to BBJsonPatchDto("Dog", 3), + 2L to BBJsonPatchDto("Cat", 5) + ) + + private val mapper = ObjectMapper() + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Pet found"), + ApiResponse(responseCode = "400", description = "Invalid pet id") + ]) + @GetMapping("/{id}") + fun getPet(@PathVariable id: Long): ResponseEntity { + val pet = store[id] ?: return ResponseEntity.badRequest().build() + return ResponseEntity.ok(pet) + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document is not a JSON array") + ]) + @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) + fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (parsePatchDocument(body) == null) + return ResponseEntity.badRequest().body("Patch document must be a JSON array") + + CoveredTargets.cover("PATCHED") + return ResponseEntity.ok("patched") + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Add operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain an add operation") + ]) + @PatchMapping("/{id}/add", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun addPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "add", "JSON_PATCH_ADD") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Remove operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a remove operation") + ]) + @PatchMapping("/{id}/remove", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun removePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "remove", "JSON_PATCH_REMOVE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Replace operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a replace operation") + ]) + @PatchMapping("/{id}/replace", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun replacePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "replace", "JSON_PATCH_REPLACE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Move operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a move operation") + ]) + @PatchMapping("/{id}/move", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun movePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "move", "JSON_PATCH_MOVE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Copy operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a copy operation") + ]) + @PatchMapping("/{id}/copy", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun copyPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "copy", "JSON_PATCH_COPY") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Test operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a test operation") + ]) + @PatchMapping("/{id}/test", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun testPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "test", "JSON_PATCH_TEST") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document has multiple operations"), + ApiResponse(responseCode = "400", description = "Patch document has fewer than two operations") + ]) + @PatchMapping("/{id}/sequence", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun sequencePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (!hasMultipleOperations(body)) + return ResponseEntity.badRequest().body("Patch document must contain at least two operations") + + CoveredTargets.cover("JSON_PATCH_SEQUENCE") + return ResponseEntity.ok("sequence patched") + } + + private fun patchOperation(body: String, operation: String, target: String): ResponseEntity { + if (!hasOperation(body, operation)) + return ResponseEntity.badRequest().body("Patch document must contain a $operation operation") + + CoveredTargets.cover(target) + return ResponseEntity.ok("$operation patched") + } + + private fun hasOperation(body: String, operation: String): Boolean = + parsePatchDocument(body)?.any { it.path("op").asText() == operation } ?: false + + private fun hasMultipleOperations(body: String): Boolean = + parsePatchDocument(body) + ?.takeIf { it.size() >= 2 } + ?.all { it.hasNonNull("op") } + ?: false + + private fun parsePatchDocument(body: String): JsonNode? = + try { + mapper.readTree(body).takeIf { it.isArray } + } catch (e: Exception) { + null + } +} + +data class BBJsonPatchDto(val name: String = "", val age: Int = 0) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt new file mode 100644 index 0000000000..2a41003871 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt @@ -0,0 +1,5 @@ +package com.foo.rest.examples.bb.jsonpatch + +import com.foo.rest.examples.bb.SpringController + +class BBJsonPatchController : SpringController(BBJsonPatchApplication::class.java) diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt new file mode 100644 index 0000000000..f4d5879149 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt @@ -0,0 +1,72 @@ +package org.evomaster.e2etests.spring.rest.bb.jsonpatch + +import com.foo.rest.examples.bb.jsonpatch.BBJsonPatchController +import org.evomaster.core.EMConfig +import org.evomaster.core.output.OutputFormat +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.rest.bb.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +class BBJsonPatchTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + val config = EMConfig() + initClass(BBJsonPatchController(), config) + } + } + + @ParameterizedTest + @EnumSource + fun testBlackBoxOutput(outputFormat: OutputFormat) { + executeAndEvaluateBBTest( + outputFormat, + "BBJsonPatchEM", + 1000, + 3, + listOf( + "PATCHED", + "JSON_PATCH_ADD", + "JSON_PATCH_REMOVE", + "JSON_PATCH_REPLACE", + "JSON_PATCH_MOVE", + "JSON_PATCH_COPY", + "JSON_PATCH_TEST", + "JSON_PATCH_SEQUENCE" + ) + ) { args: MutableList -> + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/add", "add patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/add", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/remove", "remove patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/remove", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/replace", "replace patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/replace", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/move", "move patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/move", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/copy", "copy patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/copy", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/test", "test patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/test", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/sequence", "sequence patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/sequence", null) + } + } +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt new file mode 100644 index 0000000000..0527407848 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt @@ -0,0 +1,167 @@ +package com.foo.rest.examples.spring.openapi.v3.jsonpatch + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/pets") +open class JsonPatchApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(JsonPatchApplication::class.java, *args) + } + } + + private val store: MutableMap = mutableMapOf( + 1L to JsonPatchDto("Dog", 3), + 2L to JsonPatchDto("Cat", 5) + ) + + private val mapper = ObjectMapper() + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Pet found"), + ApiResponse(responseCode = "400", description = "Invalid pet id") + ]) + @GetMapping("/{id}") + fun getPet(@PathVariable id: Long): ResponseEntity { + val pet = store[id] ?: return ResponseEntity.badRequest().build() + return ResponseEntity.ok(pet) + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is invalid or patch document is not a JSON array") + ]) + @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) + fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (!store.containsKey(id)) return ResponseEntity.badRequest().body("Invalid pet id") + + if (parsePatchDocument(body) == null) + return ResponseEntity.badRequest().body("Patch document must be a JSON array") + + return ResponseEntity.ok("patched") + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Add operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain an add operation for /a") + ]) + @PatchMapping("/{id}/add", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun addPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "add") { it.path("path").asText() == "/a" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Remove operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a remove operation for /b") + ]) + @PatchMapping("/{id}/remove", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun removePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "remove") { it.path("path").asText() == "/b" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Replace operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a replace operation for /c") + ]) + @PatchMapping("/{id}/replace", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun replacePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "replace") { it.path("path").asText() == "/c" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Move operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a move operation from /a to /d") + ]) + @PatchMapping("/{id}/move", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun movePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "move") { + it.path("from").asText() == "/a" && it.path("path").asText() == "/d" + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Copy operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a copy operation from /a to /d") + ]) + @PatchMapping("/{id}/copy", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun copyPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "copy") { + it.path("from").asText() == "/a" && it.path("path").asText() == "/d" + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Test operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a test operation for /b") + ]) + @PatchMapping("/{id}/test", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun testPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "test") { it.path("path").asText() == "/b" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document has multiple operations"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not add /a before replacing /c") + ]) + @PatchMapping("/{id}/sequence", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun sequencePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (id <= 0) + return ResponseEntity.badRequest().body("Pet id must be positive") + + if (!hasAddThenReplaceSequence(body)) + return ResponseEntity.badRequest().body("Patch document must contain add followed by replace") + + return ResponseEntity.ok("sequence patched") + } + + private fun patchOperation( + id: Long, + body: String, + operation: String, + extraCheck: (JsonNode) -> Boolean + ): ResponseEntity { + if (id <= 0) + return ResponseEntity.badRequest().body("Pet id must be positive") + + if (!hasOperation(body, operation, extraCheck)) + return ResponseEntity.badRequest().body("Patch document must contain a $operation operation") + + return ResponseEntity.ok("$operation patched") + } + + private fun hasOperation(body: String, operation: String, extraCheck: (JsonNode) -> Boolean): Boolean = + parsePatchDocument(body)?.any { it.path("op").asText() == operation && extraCheck(it) } ?: false + + private fun hasAddThenReplaceSequence(body: String): Boolean { + val operations = parsePatchDocument(body) ?: return false + if (operations.size() < 2) + return false + + var hasAdd = false + var hasReplace = false + + for (operation in operations) { + if (operation.path("op").asText() == "add" && operation.path("path").asText() == "/a") + hasAdd = true + if (operation.path("op").asText() == "replace" && operation.path("path").asText() == "/c") + hasReplace = true + } + + return hasAdd && hasReplace + } + + private fun parsePatchDocument(body: String): JsonNode? = + try { + mapper.readTree(body).takeIf { it.isArray } + } catch (e: Exception) { + null + } +} + +data class JsonPatchDto(val name: String = "", val age: Int = 0) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt new file mode 100644 index 0000000000..8155e9f338 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt @@ -0,0 +1,5 @@ +package com.foo.rest.examples.spring.openapi.v3.jsonpatch + +import com.foo.rest.examples.spring.openapi.v3.SpringController + +class JsonPatchController : SpringController(JsonPatchApplication::class.java) diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt new file mode 100644 index 0000000000..c8346c61cc --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt @@ -0,0 +1,59 @@ +package org.evomaster.e2etests.spring.openapi.v3.jsonpatch + +import com.foo.rest.examples.spring.openapi.v3.jsonpatch.JsonPatchController +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class JsonPatchTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(JsonPatchController()) + } + } + + @Test + fun testRunEM() { + runTestHandlingFlakyAndCompilation( + "JsonPatchEM", + "org.foo.JsonPatchEM", + 2000, + true, + { args: MutableList -> + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/add", "add patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/add", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/remove", "remove patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/remove", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/replace", "replace patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/replace", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/move", "move patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/move", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/copy", "copy patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/copy", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/test", "test patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/test", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/sequence", "sequence patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/sequence", null) + }, + 3, + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt index 64d229d788..590ca5ccdd 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt @@ -22,6 +22,7 @@ import org.evomaster.core.search.gene.numeric.FloatGene 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.regex.RegexGene import org.evomaster.core.search.gene.string.Base64StringGene import org.evomaster.core.search.gene.string.StringGene @@ -118,6 +119,7 @@ class DtoWriter( gene is ObjectGene -> calculateDtoFromObject(gene, actionName) gene is ArrayGene<*> -> calculateDtoFromArray(gene, actionName) gene is FixedMapGene<*, *> -> calculateDtoFromFixedMapGene(gene, actionName) + gene is JsonPatchDocumentGene -> return isPrimitiveGene(gene) -> return else -> { throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName") diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt new file mode 100644 index 0000000000..af2fc95573 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt @@ -0,0 +1,106 @@ +package org.evomaster.core.problem.rest.builder + +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.Schema +import org.evomaster.core.problem.rest.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaOpenAPI +import org.evomaster.core.problem.rest.schema.SchemaUtils + +/** + * Resolves the target resource schema for a JSON Patch endpoint by inspecting + * sibling operations on the same path item. + * + * Priority: GET 2xx response → PUT requestBody → POST requestBody. + */ +object JsonPatchSchemaResolver { + + private const val JSON_PATCH_MEDIA_TYPE = "json-patch" + + fun resolveResourceSchema( + operation: Operation, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val pathItem = findPathItemForPatchOperation(operation, schemaHolder, currentSchema, messages) + ?: return null + + return fromGetResponse(pathItem, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.put, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.post, schemaHolder, currentSchema, messages) + } + + //handleBodyPayload does not have the path of the operation, we need to find it, only if it is patch + private fun findPathItemForPatchOperation( + operation: Operation, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): PathItem? { + return schemaHolder.main.schemaParsed.paths + ?.values + ?.firstNotNullOfOrNull { pathItemOrRef -> + val pathItem = if (pathItemOrRef.`$ref` != null) { + SchemaUtils.getReferencePathItem(schemaHolder, currentSchema, pathItemOrRef.`$ref`, messages) + } else { + pathItemOrRef + } + + pathItem?.takeIf { it.patch === operation } + } + } + + // For get, the resource is in the response + private fun fromGetResponse( + pathItem: PathItem, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val get = pathItem.get ?: return null + return get.responses + ?.filter { (code, _) -> code.startsWith("2") } + ?.values + ?.firstNotNullOfOrNull { response -> + val resolved = if (response.`$ref` != null) { + SchemaUtils.getReferenceResponse(schemaHolder, currentSchema, response.`$ref`, messages) + ?: return@firstNotNullOfOrNull null + } else response + extractJsonSchema(resolved.content, schemaHolder, currentSchema, messages) + } + } + + // For put and post, the resource is in the requestBody + private fun fromRequestBody( + operation: Operation?, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val body = operation?.requestBody ?: return null + val resolvedBody = if (body.`$ref` != null) { + SchemaUtils.getReferenceRequestBody(schemaHolder, currentSchema, body.`$ref`, messages) + ?: return null + } else body + return extractJsonSchema(resolvedBody.content, schemaHolder, currentSchema, messages) + } + + private fun extractJsonSchema( + content: Map?, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val schema = content + ?.filterKeys { mt -> mt.contains("json") && !mt.contains(JSON_PATCH_MEDIA_TYPE) } + ?.values + ?.firstOrNull() + ?.schema + ?: return null + return if (schema.`$ref` != null) { + SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, schema.`$ref`, messages) + } else schema + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt index 8f322cc1af..98bd2bf145 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt @@ -47,6 +47,7 @@ import org.evomaster.core.search.gene.wrapper.ChoiceGene import org.evomaster.core.search.gene.wrapper.CustomMutationRateGene import org.evomaster.core.search.gene.wrapper.OptionalGene import org.evomaster.core.search.gene.placeholder.CycleObjectGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.evomaster.core.search.gene.placeholder.LimitObjectGene import org.evomaster.core.search.gene.regex.RegexGene import org.evomaster.core.search.gene.string.Base64StringGene @@ -747,11 +748,28 @@ object RestActionBuilderV3 { listOf() } - // $ref schemas do not carry XML metadata; resolving the reference is required to obtain the correct XML element name from the target schema - val deref = obj.schema.`$ref`?.let { ref -> SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, ref, messages) } ?: obj.schema - val name = deref?.xml?.name ?: deref?.`$ref`?.substringAfterLast("/") ?: "body" + val isJsonPatch = verb == HttpVerb.PATCH && bodies.keys.any { it.contains("json-patch") } - var gene = getGene(name, obj.schema, schemaHolder,currentSchema, referenceClassDef = null, options = options, messages = messages, examples = examples) + val name: String + var gene: Gene + if (isJsonPatch) { + name = "body" + val patchResourceSchema = JsonPatchSchemaResolver.resolveResourceSchema( + operation, + schemaHolder, + currentSchema, + messages + ) + val resourceGene = patchResourceSchema?.let { + getGene(name, it, schemaHolder, currentSchema, referenceClassDef = null, options = options, messages = messages) + } + gene = JsonPatchDocumentGene(name, resourceGene) + } else { + // $ref schemas do not carry XML metadata; resolving the reference is required to obtain the correct XML element name from the target schema + val deref = obj.schema.`$ref`?.let { ref -> SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, ref, messages) } ?: obj.schema + name = deref?.xml?.name ?: deref?.`$ref`?.substringAfterLast("/") ?: "body" + gene = getGene(name, obj.schema, schemaHolder, currentSchema, referenceClassDef = null, options = options, messages = messages, examples = examples) + } if (resolvedBody.required != true && gene !is OptionalGene) { gene = OptionalGene(name, gene) diff --git a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt index f722178a4c..ecf446db49 100644 --- a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt @@ -9,10 +9,13 @@ import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallActio import org.evomaster.core.problem.rest.builder.RestActionBuilderV3 import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.param.BodyParam import org.evomaster.core.problem.rest.schema.OpenApiAccess import org.evomaster.core.problem.rest.schema.RestSchema import org.evomaster.core.search.Solution import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.collection.EnumGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.`is` @@ -95,6 +98,23 @@ class DtoWriterTest { assertEquals(dtoWriter.getCollectedDtos().size, 0) } + @Test + fun jsonPatchPayloadsAreNotCollectedAsDtos() { + val dtoWriter = DtoWriter(outputFormat) + val bodyParam = BodyParam( + gene = JsonPatchDocumentGene("patch"), + typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 } + ) + val eIndividual = getEvaluatedIndividualWith( + getRestCallAction("/pets/{id}", HttpVerb.PATCH, mutableListOf(bodyParam)) + ) + val solution = Solution(singletonList(eIndividual), "", "", Termination.NONE, emptyList(), emptyList()) + + dtoWriter.write(outputTestSuitePath, TEST_PACKAGE, solution) + + assertTrue(dtoWriter.getCollectedDtos().isEmpty()) + } + // TODO: Migrate tests to integration tests using reflection to assert correct DTO generation @Disabled("Tests disabled until migrated to integration tests") @Test diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt new file mode 100644 index 0000000000..def0cc541b --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt @@ -0,0 +1,270 @@ +package org.evomaster.core.problem.rest.builder + +import org.evomaster.core.problem.rest.schema.OpenApiAccess +import org.evomaster.core.problem.rest.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaLocation +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class JsonPatchSchemaResolverTest { + + private fun parse(json: String): RestSchema = + RestSchema(OpenApiAccess.parseOpenApi(json.trimIndent(), SchemaLocation.MEMORY)) + + private fun minimalSpec(pathsBlock: String) = """ + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { $pathsBlock } + } + """.trimIndent() + + private fun resolveForPatch( + schema: RestSchema, + path: String, + messages: MutableList = mutableListOf() + ) = JsonPatchSchemaResolver.resolveResourceSchema( + schema.main.schemaParsed.paths[path]!!.patch, + schema, + schema.main, + messages + ) + + @Test + fun testResolveFromGetResponse() { + val schema = parse(minimalSpec(""" + "/pets/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + } + } + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "required": true, + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val messages = mutableListOf() + + val result = resolveForPatch(schema, "/pets/{id}", messages) + + assertNotNull(result) + assertTrue(messages.isEmpty(), "Unexpected messages: $messages") + val props = result!!.properties + assertNotNull(props) + assertTrue(props.containsKey("name"), "Expected property 'name'") + assertTrue(props.containsKey("age"), "Expected property 'age'") + } + + @Test + fun testPreferGetOverPut() { + val schema = parse(minimalSpec(""" + "/x/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"fromGet": {"type": "string"}}}}} + } + } + }, + "put": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"fromPut": {"type": "string"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val result = resolveForPatch(schema, "/x/{id}") + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("fromGet"), "Should prefer GET schema, got $props") + assertFalse(props.containsKey("fromPut")) + } + + @Test + fun testFallbackToPut() { + val schema = parse(minimalSpec(""" + "/orders/{id}": { + "put": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"product": {"type": "string"}, "quantity": {"type": "integer"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val result = resolveForPatch(schema, "/orders/{id}") + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("product"), "Expected 'product' in $props") + assertTrue(props.containsKey("quantity"), "Expected 'quantity' in $props") + } + + @Test + fun testFallbackToPost() { + val schema = parse(minimalSpec(""" + "/users": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"email": {"type": "string"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val result = resolveForPatch(schema, "/users") + + assertNotNull(result) + assertTrue(result!!.properties.containsKey("email")) + } + + @Test + fun testReturnsNullWhenNoSiblings() { + val schema = parse(minimalSpec(""" + "/items/{id}": { + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val result = resolveForPatch(schema, "/items/{id}") + + assertNull(result, "Expected null when no sibling operations define a JSON schema") + } + + @Test + fun testIgnoresJsonPatchContentTypeInGetResponse() { + val schema = parse(minimalSpec(""" + "/docs/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": { + "application/json-patch+json": { + "schema": {"type": "array", "items": {"type": "object"}} + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val result = resolveForPatch(schema, "/docs/{id}") + + assertNull(result, "Should not use json-patch content type as resource schema") + } + + @Test + fun testResolveFromGetResponseViaRef() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/cats/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"${'$'}ref": "#/components/schemas/Cat"} + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "properties": { + "breed": {"type": "string"}, + "indoor": {"type": "boolean"} + } + } + } + } + } + """.trimIndent()) + + val result = resolveForPatch(schema, "/cats/{id}") + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("breed"), "Expected 'breed' via \$ref resolution, got $props") + assertTrue(props.containsKey("indoor"), "Expected 'indoor' via \$ref resolution, got $props") + } +}