-
Notifications
You must be signed in to change notification settings - Fork 114
Json Patch e2e #1565
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
base: master
Are you sure you want to change the base?
Json Patch e2e #1565
Changes from all commits
98ace44
573dbc0
29f225c
9fdf0a6
78c7fed
e9176b8
b162b83
f319fca
0b88e25
51ece9d
ffc2098
fd9a969
257300b
127e14f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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,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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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,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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
There was a problem hiding this comment.
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.