Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.foo.rest.examples.bb.jsonpatch

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
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 {

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.

Why blackbox and whitebox, I think we need only one of them for testing this functionality.


companion object {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.run(BBJsonPatchApplication::class.java, *args)
}
}

private val store: MutableMap<Long, BBJsonPatchDto> = mutableMapOf(
1L to BBJsonPatchDto("Dog", 3),
2L to BBJsonPatchDto("Cat", 5)
)

private val mapper = ObjectMapper()

@GetMapping("/{id}")
fun getPet(@PathVariable id: Long): ResponseEntity<BBJsonPatchDto> {
val pet = store[id] ?: return ResponseEntity.badRequest().build()
return ResponseEntity.ok(pet)
}

@PatchMapping("/{id}", consumes = ["application/json-patch+json"])
fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> {
if (parsePatchDocument(body) == null)
return ResponseEntity.badRequest().body("Patch document must be a JSON array")

CoveredTargets.cover("PATCHED")
return ResponseEntity.ok("patched")
}

@PatchMapping("/{id}/add", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun addPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "add", "JSON_PATCH_ADD")

@PatchMapping("/{id}/remove", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun removePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "remove", "JSON_PATCH_REMOVE")

@PatchMapping("/{id}/replace", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun replacePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "replace", "JSON_PATCH_REPLACE")

@PatchMapping("/{id}/move", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun movePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "move", "JSON_PATCH_MOVE")

@PatchMapping("/{id}/copy", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun copyPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "copy", "JSON_PATCH_COPY")

@PatchMapping("/{id}/test", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun testPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> =
patchOperation(body, "test", "JSON_PATCH_TEST")

@PatchMapping("/{id}/sequence", consumes = ["application/json-patch+json"], produces = ["text/plain"])
fun sequencePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity<String> {
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<String> {
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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.foo.rest.examples.bb.jsonpatch

import com.foo.rest.examples.bb.SpringController

class BBJsonPatchController : SpringController(BBJsonPatchApplication::class.java)
Original file line number Diff line number Diff line change
@@ -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<String> ->

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,6 +119,11 @@ 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

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.

Add TODO on JsonPatchDocumentGene written as DTO when the test case is written

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.

these will break generated tests. somewhere, there is the option of not applying DTO for an action. you could check if action is using any JsonPatchDocumentGene, and uses string instead of DTO in those cases

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

ok, i applied that check in HttpWsTestCaseWriter.kt, lines 131-133, is that alright?

isPrimitiveGene(gene) -> return
else -> {
throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.evomaster.core.search.gene.Gene
import org.evomaster.core.search.gene.ObjectGene
import org.evomaster.core.search.gene.collection.ArrayGene
import org.evomaster.core.search.gene.collection.FixedMapGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.utils.GeneUtils
import org.evomaster.core.search.gene.wrapper.ChoiceGene
import org.slf4j.LoggerFactory
Expand Down Expand Up @@ -127,6 +128,9 @@ 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 choiceGene = primaryGene.getWrappedGene(ChoiceGene::class.java)
val actionName = call.getName()
if (choiceGene != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>
): 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<String>
): 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<String>
): 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<String>
): 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<String, MediaType>?,
schemaHolder: RestSchema,
currentSchema: SchemaOpenAPI,
messages: MutableList<String>
): 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -747,11 +748,37 @@ 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) {
/*
The body is a JSON Patch document (RFC 6902), not a regular object, so it is not built
from the media type schema. resolveResourceSchema returns the OpenAPI Schema of the resource
being patched, found by inspecting sibling operations on the same path (GET 2xx response,
else PUT/POST requestBody). We turn that schema into a gene via getGene so the patch
operations reference real fields/paths of the resource, and use it to seed the
JsonPatchDocumentGene. If no resource schema is found, the gene is still built with
resourceGene == null and emits generic, structurally valid operations.
*/
name = "body"
val patchResourceSchema = JsonPatchSchemaResolver.resolveResourceSchema(

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.

Add comment on what is the intended behaviour of this.

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)
Expand Down
Loading