From a28796f85173f365f1a441f690261f8d496b6a73 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Sun, 13 Sep 2026 18:01:11 -0300 Subject: [PATCH 1/2] AsyncAPI 3.x: publish a message's example, some of the time A document's message examples are what its author knows the service accepts. A service that silently drops what it does not recognise may never answer a sampled payload, so with --probAsyncApiExamples the first example of a message is offered as a whole value beside the schema-derived genes, at that probability. Off by default, like every new feature. It reuses what REST already does with a schema's 'example': the example is put on the message's own copy of the payload schema, and the gene builder turns it into the same choice it offers REST. Only the first example is used: the OpenAPI parser the genes go through reads the schemas as 3.0, which drops the plural 'examples'. Scalars are left alone, as the builder would complain about 'example' on them. A headers example loses the field the correlation id is stamped into, as the headers gene did before it. --- .../kotlin/org/evomaster/core/EMConfig.kt | 7 + .../asyncapi/builder/AsyncApiGeneBuilder.kt | 87 ++++++++++- .../asyncapi/service/AsyncApiSampler.kt | 5 - .../builder/AsyncApiGeneBuilderTest.kt | 141 ++++++++++++++++++ .../asyncapi/service/AsyncApiSamplerTest.kt | 24 +++ docs/options.md | 1 + 6 files changed, 257 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index 5164c0b604..756b100b48 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -2895,6 +2895,13 @@ class EMConfig { @Min(1.0) var asyncApiReplyTimeoutMs = 5000 + @Experimental + @Cfg("When testing an AsyncAPI service, the probability of publishing a message's declared example as it is," + + " rather than a value sampled from its schema. A service that silently drops what it does not recognise" + + " may never reply to sampled values, and the examples are what its author knows it accepts.") + @Probability(true) + var probAsyncApiExamples = 0.0 + @Cfg("Whether to enable customized responses indicating business logic") var enableRPCCustomizedResponseTargets = true diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt index 1be992ed8e..63a7f4e842 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt @@ -51,11 +51,18 @@ object AsyncApiGeneBuilder { private const val MINIMUM = "minimum" private const val MAXIMUM = "maximum" + private const val TYPE_OBJECT = "object" private const val TYPE_STRING = "string" private const val TYPE_INTEGER = "integer" private const val TYPE_NUMBER = "number" private const val TYPE_BOOLEAN = "boolean" + /* + The two parts of a Message Example Object this builder reads. + */ + private const val EXAMPLE_PAYLOAD = "payload" + private const val EXAMPLE_HEADERS = "headers" + /** * The keywords whose value is literal data rather than a schema, so nothing inside them is * a keyword either. @@ -70,12 +77,22 @@ object AsyncApiGeneBuilder { /** * The genes for a message's payload, or null when it declares none. + * + * When the message declares examples and [options] ask for them, the first example's payload + * is offered as a whole value beside the schema-derived genes, with the probability the + * options give. */ fun buildPayloadGene( schema: AsyncApiDocument, message: AsyncApiMessage, options: RestActionBuilderV3.Options - ): Gene? = build(message.payload, "${message.id}.payload", schema, options) + ): Gene? = build( + message.payload, + "${message.id}.payload", + schema, + options, + firstExample(message, EXAMPLE_PAYLOAD) + ) /** * The genes for a message's headers, or null when it declares none. @@ -98,7 +115,42 @@ object AsyncApiGeneBuilder { schema: AsyncApiDocument, message: AsyncApiMessage, options: RestActionBuilderV3.Options - ): Gene? = build(withoutCorrelationId(message), "${message.id}.headers", schema, options) + ): Gene? = build( + withoutCorrelationId(message), + "${message.id}.headers", + schema, + options, + firstExample(message, EXAMPLE_HEADERS)?.let { withoutCorrelationField(it, message) } + ) + + /** + * The [part] of the message's first example that has one, or null. + * + * Only the first is used. The gene builder reads a schema's `example`, and the `examples` + * that could carry several are dropped by the OpenAPI parser it goes through, which reads + * them as a 3.0 document. TODO pass them all once RestActionBuilderV3 can take them. + */ + private fun firstExample(message: AsyncApiMessage, part: String): JsonNode? = + message.examples.asSequence() + .mapNotNull { it.get(part) } + .firstOrNull { !it.isNull } + + /** + * The example headers without the one the correlation id is stamped into, which the headers + * gene does not have either. + */ + private fun withoutCorrelationField(example: JsonNode, message: AsyncApiMessage): JsonNode { + + val correlation = message.correlationId + val field = correlation?.fieldName + + if (correlation == null || correlation.source != AsyncApiCorrelationId.Source.HEADER + || field == null || !example.isObject) { + return example + } + + return (example.deepCopy() as ObjectNode).apply { remove(field) } + } /** * The headers schema without the property the correlation id is stamped into. @@ -146,6 +198,9 @@ object AsyncApiGeneBuilder { /** * Options for building AsyncAPI payloads. * + * `probUseExamples` is the probability of publishing a message's declared example as it is; + * it is off unless the user asks, since it is a way of steering the search. + * * Note `invalidData = false`, which is not what REST does. That flag makes the builder add * a bogus "EVOMASTER" member to every enum, on purpose, to probe how a service handles a * value it never declared. In a message payload that backfires: an enum of one value is how @@ -160,6 +215,7 @@ object AsyncApiGeneBuilder { fun options(config: EMConfig) = RestActionBuilderV3.Options( enableConstraintHandling = config.enableSchemaConstraintHandling, invalidData = false, + probUseExamples = config.probAsyncApiExamples, usingWhiteBox = !config.blackBox, enableAdvancedFormats = config.enableAdvancedFormats, inferFormatFromNames = config.inferFormatFromNames @@ -169,7 +225,8 @@ object AsyncApiGeneBuilder { declared: JsonNode?, inlineName: String, schema: AsyncApiDocument, - options: RestActionBuilderV3.Options + options: RestActionBuilderV3.Options, + example: JsonNode? ): Gene? { if (declared == null) { @@ -200,6 +257,10 @@ object AsyncApiGeneBuilder { schemas.set(name, usable(pointedAt(ref, declared, schema))) } + if (example != null) { + offerExample(schemas.get(name), example) + } + //the format createGeneForDTO expects: the name of the wanted schema, then all of them return RestActionBuilderV3.createGeneForDTO(name, "\"$name\":$schemas", options) } @@ -253,6 +314,26 @@ object AsyncApiGeneBuilder { return current } + /** + * Make [example] the `example` of [target], for the gene builder to offer as a whole value. + * + * Only an object schema takes one: the builder attaches a deprecation warning to `example` + * on anything else, which would reach the user as a complaint about a document that is in + * order. A message's own example outranks one the schema may already carry, being the more + * specific of the two. + */ + private fun offerExample(target: JsonNode?, example: JsonNode) { + + if (target !is ObjectNode || !example.isObject || !describesObject(target)) { + return + } + + target.set(EXAMPLE, example.deepCopy()) + } + + private fun describesObject(schema: ObjectNode) = + schema.get(TYPE)?.asText() == TYPE_OBJECT || schema.has(PROPERTIES) + /** * JSON Pointer escaping: "~1" is a "/" and "~0" is a "~", undone in that order. */ diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt index 6f494ccd26..2d470aaf68 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt @@ -150,11 +150,6 @@ class AsyncApiSampler : ApiWsSampler() { return createIndividual(SampleType.RANDOM, actions) } - /* - TODO Message examples (AsyncApiMessage.getExamples) are parsed and never read. Sampling - from them some of the time, as REST does with probRestExamples, would start the search - from payloads the author knows the service accepts. - */ /** * A copy of one of the action templates, chosen at random, with its genes initialized. */ diff --git a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt index 5086ecaf4a..08b6e93ca9 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt @@ -14,6 +14,8 @@ import org.evomaster.core.search.gene.numeric.IntegerGene import org.evomaster.core.search.gene.string.StringGene import org.evomaster.core.search.gene.wrapper.ChoiceGene import org.evomaster.core.search.gene.wrapper.OptionalGene +import org.evomaster.core.search.gene.utils.GeneUtils +import org.evomaster.core.search.service.Randomness import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -443,4 +445,143 @@ class AsyncApiGeneBuilderTest { ) } } + + // ------------------------------------------------------------------ message examples + + /** + * Options asking for the example every time, so that what is published is deterministic. + */ + private val alwaysExamples = AsyncApiGeneBuilder.options(EMConfig().apply { probAsyncApiExamples = 1.0 }) + + private fun printed(gene: Gene): String { + gene.doInitialize(Randomness().apply { updateSeed(42) }) + return gene.getValueAsPrintableString(mode = GeneUtils.EscapeMode.JSON, targetFormat = null) + } + + private fun document(text: String): AsyncApiDocument = AsyncApiAccess.parseFromText(text.trimIndent()) + + @Test + fun testAMessageExampleIsOfferedAsAWholePayload() { + + val schema = AsyncApiAccess.getAsyncApiFromResource("/asyncapi/sut/scalar.yaml") + val message = schema.messages.getValue("PlanetCreated") + + val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, message, alwaysExamples)!! + + //a choice between the example and the schema-derived genes, as REST offers schema examples + assertTrue(gene is ChoiceGene<*>, gene::class.simpleName) + + //asked for every time, what goes out is the payload the author wrote, whole + val json = printed(gene) + assertTrue(json.contains("evt_1234567890"), json) + assertTrue(json.contains("\"Mars\""), json) + assertTrue(json.contains("\"CO2\""), json) + } + + @Test + fun testExamplesAreNotUsedUnlessAskedFor() { + + //the default options leave the probability at zero, so nothing changes shape + val gene = payloadOf("/asyncapi/sut/scalar.yaml", "PlanetCreated") + + assertTrue(gene is ObjectGene, gene::class.simpleName) + } + + @Test + fun testAnExampleForANonObjectPayloadIsLeftAlone() { + + /* + The gene builder complains about 'example' on anything but an object, and that + complaint would reach the user about a document that is in order. So a scalar + payload keeps its schema-derived gene, example or not. + */ + val schema = document(""" + asyncapi: 3.0.0 + info: + title: Heartbeat + version: 1.0.0 + components: + messages: + beat: + payload: + type: integer + examples: + - payload: 42 + """) + + val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("beat"), alwaysExamples)!! + + assertTrue(gene is IntegerGene, gene::class.simpleName) + } + + @Test + fun testAHeadersExampleLeavesOutTheStampedCorrelationId() { + + val schema = document(""" + asyncapi: 3.0.0 + info: + title: Headers + version: 1.0.0 + components: + messages: + order: + headers: + type: object + required: [tenant, correlationId] + properties: + tenant: + type: string + correlationId: + type: string + correlationId: + location: '${'$'}message.header#/correlationId' + payload: + type: object + examples: + - headers: + tenant: acme + correlationId: abc-123 + payload: {} + """) + + val gene = AsyncApiGeneBuilder.buildHeadersGene(schema, schema.messages.getValue("order"), alwaysExamples)!! + + //the tenant comes from the example; the correlation id is the driver's to stamp, so it is not there to copy + val json = printed(gene) + assertTrue(json.contains("\"acme\""), json) + assertFalse(json.contains("abc-123"), json) + assertFalse(json.contains("correlationId"), json) + } + + @Test + fun testOnlyTheFirstExampleIsUsedForNow() { + + //pins the limitation described on firstExample(): the parser the genes go through keeps one + val schema = document(""" + asyncapi: 3.0.0 + info: + title: Two examples + version: 1.0.0 + components: + messages: + greeting: + payload: + type: object + required: [text] + properties: + text: + type: string + examples: + - payload: + text: first + - payload: + text: second + """) + + val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("greeting"), alwaysExamples)!! + + val json = printed(gene) + assertTrue(json.contains("\"first\""), json) + assertFalse(json.contains("\"second\""), json) + } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt index 35b79fe6fa..77994ebf90 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt @@ -9,6 +9,9 @@ import org.evomaster.client.java.controller.api.dto.problem.AsyncApiProblemDto import org.evomaster.core.BaseModule import org.evomaster.core.EMConfig import org.evomaster.core.problem.asyncapi.data.AsyncApiAction +import org.evomaster.core.problem.asyncapi.param.AsyncApiParam +import org.evomaster.core.search.gene.utils.GeneUtils +import org.evomaster.core.search.service.Randomness import org.evomaster.core.problem.external.service.DummyController import org.evomaster.core.remote.SutProblemException import org.evomaster.core.problem.rest.builder.RestActionBuilderV3 @@ -235,4 +238,25 @@ class AsyncApiSamplerTest { val again = (1..NCS_OPERATIONS.size).map { sampler.sample() } assertEquals(NCS_OPERATIONS, again.map { it.seeMainExecutableActions().single().getName() }.toSet()) } + + @Test + fun testExamplesSeedTheSearchWhenAskedFor() { + + val scalar = AsyncApiAccess.readFromResource("/asyncapi/sut/scalar.yaml") + val sampler = sampler(sutInfo { schemaText = scalar }, "--blackBox=false", "--probAsyncApiExamples=1.0") + + //an action whose message declares an example + val action = sampler.seeAvailableActions() + .map { it as AsyncApiAction } + .first { sampler.document.messages.getValue(it.messageId).examples.isNotEmpty() } + val example = sampler.document.messages.getValue(action.messageId).examples.first().get("payload") + val anExampleValue = example.fields().asSequence().map { it.value }.first { it.isTextual }.asText() + + //sampled the way the search does, the payload is the author's example + val sampled = (action.copy() as AsyncApiAction).apply { doInitialize(Randomness().apply { updateSeed(7) }) } + val payload = sampled.parameters.first { it.name == AsyncApiParam.PAYLOAD }.gene + .getValueAsPrintableString(mode = GeneUtils.EscapeMode.JSON, targetFormat = null) + + assertTrue(payload.contains(anExampleValue), "expected '$anExampleValue' in $payload") + } } diff --git a/docs/options.md b/docs/options.md index d6c2badc4d..b2a2d1b601 100644 --- a/docs/options.md +++ b/docs/options.md @@ -348,6 +348,7 @@ There are 3 types of options: |`mutationTargetsSelectionStrategy`| __Enum__. Specify a strategy to select targets for evaluating mutation. *Valid values*: `FIRST_NOT_COVERED_TARGET, EXPANDED_UPDATED_NOT_COVERED_TARGET, UPDATED_NOT_COVERED_TARGET`. *Default value*: `FIRST_NOT_COVERED_TARGET`.| |`onePlusLambdaLambdaOffspringSize`| __Int__. 1+(λ,λ) GA: number of offspring (λ) per generation. *Constraints*: `min=1.0`. *Default value*: `4`.| |`prematureStopStrategy`| __Enum__. Specify how 'improvement' is defined: either any kind of improvement even if partial (ANY), or at least one new target is fully covered (NEW). *Valid values*: `ANY, NEW`. *Default value*: `NEW`.| +|`probAsyncApiExamples`| __Double__. When testing an AsyncAPI service, the probability of publishing a message's declared example as it is, rather than a value sampled from its schema. A service that silently drops what it does not recognise may never reply to sampled values, and the examples are what its author knows it accepts. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.0`.| |`probOfArazzoSampling`| __Double__. Probability of controlling the creation of Arazzo individuals. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.5`.| |`probOfHandlingLength`| __Double__. Specify a probability of applying length handling. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.0`.| |`probOfHarvestingResponsesFromActualExternalServices`| __Double__. a probability of harvesting actual responses from external services as seeds. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.0`.| From 6266f6bfafcc48d015e525cf8fcc2688452892b6 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 19 Sep 2026 20:00:36 -0300 Subject: [PATCH 2/2] AsyncAPI 3.x: pass every message example beside the schema, as REST does The first cut wrote one example into the message's copy of its payload schema and let the gene builder find it there. That path crosses the OpenAPI parser, which keeps only the first of several, drops the name an example may carry, and warns about an example on a scalar. So only one example was offered, never by name, and never for a scalar payload. The gene builder already takes examples the other way, as a list of values with optional names, for a REST parameter or request body. Its entry point for a bare schema simply did not expose that parameter. It does now, with an empty default so that no other caller changes, and the AsyncAPI builder passes every example through it: all of them are offered, a name survives to where the sampler's named-example pass can read it, and a scalar payload gets the same choice an object does. One trap came with it. The builder caches what it builds by schema text alone, so two messages sharing a schema but declaring different examples would have been handed the same gene. A call that brings examples now neither reads nor writes that cache. --- .../asyncapi/builder/AsyncApiGeneBuilder.kt | 64 +++++--------- .../rest/builder/RestActionBuilderV3.kt | 29 ++++++- .../builder/AsyncApiGeneBuilderTest.kt | 87 ++++++++++++++++--- .../asyncapi/service/AsyncApiSamplerTest.kt | 14 +++ .../problem/rest/RestActionBuilderV3Test.kt | 35 ++++++++ 5 files changed, 174 insertions(+), 55 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt index 63a7f4e842..64ae694f48 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilder.kt @@ -51,17 +51,18 @@ object AsyncApiGeneBuilder { private const val MINIMUM = "minimum" private const val MAXIMUM = "maximum" - private const val TYPE_OBJECT = "object" private const val TYPE_STRING = "string" private const val TYPE_INTEGER = "integer" private const val TYPE_NUMBER = "number" private const val TYPE_BOOLEAN = "boolean" /* - The two parts of a Message Example Object this builder reads. + What this builder reads off a Message Example Object: the two parts a gene is built for, + and the name that lets the search keep a whole example together. */ private const val EXAMPLE_PAYLOAD = "payload" private const val EXAMPLE_HEADERS = "headers" + private const val EXAMPLE_NAME = "name" /** * The keywords whose value is literal data rather than a schema, so nothing inside them is @@ -78,9 +79,8 @@ object AsyncApiGeneBuilder { /** * The genes for a message's payload, or null when it declares none. * - * When the message declares examples and [options] ask for them, the first example's payload - * is offered as a whole value beside the schema-derived genes, with the probability the - * options give. + * When the message declares examples and [options] ask for them, their payloads are offered + * as whole values beside the schema-derived genes, with the probability the options give. */ fun buildPayloadGene( schema: AsyncApiDocument, @@ -91,7 +91,7 @@ object AsyncApiGeneBuilder { "${message.id}.payload", schema, options, - firstExample(message, EXAMPLE_PAYLOAD) + examplesOf(message, EXAMPLE_PAYLOAD) ) /** @@ -120,20 +120,26 @@ object AsyncApiGeneBuilder { "${message.id}.headers", schema, options, - firstExample(message, EXAMPLE_HEADERS)?.let { withoutCorrelationField(it, message) } + examplesOf(message, EXAMPLE_HEADERS).map { (headers, name) -> + Pair(withoutCorrelationField(headers, message), name) + } ) /** - * The [part] of the message's first example that has one, or null. + * The [part] of every example the message declares that has one, each with the example's + * name where it gives one. * - * Only the first is used. The gene builder reads a schema's `example`, and the `examples` - * that could carry several are dropped by the OpenAPI parser it goes through, which reads - * them as a 3.0 document. TODO pass them all once RestActionBuilderV3 can take them. + * They go to the gene builder beside the schema, the way REST passes a parameter's + * examples, rather than written into it: that path never crosses the OpenAPI parser, which + * would keep only one, and draws no complaint on a scalar. The name is what lets the search + * pick a whole example consistently across fields, when asked to with probNamedExamples. */ - private fun firstExample(message: AsyncApiMessage, part: String): JsonNode? = - message.examples.asSequence() - .mapNotNull { it.get(part) } - .firstOrNull { !it.isNull } + private fun examplesOf(message: AsyncApiMessage, part: String): List> = + message.examples.mapNotNull { example -> + example.get(part) + ?.takeUnless { it.isNull } + ?.let { Pair(it, example.get(EXAMPLE_NAME)?.takeIf { n -> n.isTextual }?.asText()) } + } /** * The example headers without the one the correlation id is stamped into, which the headers @@ -226,7 +232,7 @@ object AsyncApiGeneBuilder { inlineName: String, schema: AsyncApiDocument, options: RestActionBuilderV3.Options, - example: JsonNode? + examples: List> ): Gene? { if (declared == null) { @@ -257,12 +263,8 @@ object AsyncApiGeneBuilder { schemas.set(name, usable(pointedAt(ref, declared, schema))) } - if (example != null) { - offerExample(schemas.get(name), example) - } - //the format createGeneForDTO expects: the name of the wanted schema, then all of them - return RestActionBuilderV3.createGeneForDTO(name, "\"$name\":$schemas", options) + return RestActionBuilderV3.createGeneForDTO(name, "\"$name\":$schemas", options, examples) } /** @@ -314,26 +316,6 @@ object AsyncApiGeneBuilder { return current } - /** - * Make [example] the `example` of [target], for the gene builder to offer as a whole value. - * - * Only an object schema takes one: the builder attaches a deprecation warning to `example` - * on anything else, which would reach the user as a complaint about a document that is in - * order. A message's own example outranks one the schema may already carry, being the more - * specific of the two. - */ - private fun offerExample(target: JsonNode?, example: JsonNode) { - - if (target !is ObjectNode || !example.isObject || !describesObject(target)) { - return - } - - target.set(EXAMPLE, example.deepCopy()) - } - - private fun describesObject(schema: ObjectNode) = - schema.get(TYPE)?.asText() == TYPE_OBJECT || schema.has(PROPERTIES) - /** * JSON Pointer escaping: "~1" is a "/" and "~0" is a "~", undone in that order. */ 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 711ca26b10..5a67a35b87 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 @@ -293,12 +293,17 @@ object RestActionBuilderV3 { * @throws IllegalArgumentException if the provided 'name' is not found at the beginning of 'allSchemas'. * @throws IllegalStateException if the schema with the specified 'name' cannot be found in 'allSchemas'. * + * @param examples values to offer for the wanted schema as a whole, each with an optional + * name, beside the genes derived from it. They take the same path as a REST + * parameter's examples, so they never go through the OpenAPI parser, and as + * for those, none is offered unless [Options.probUseExamples] gives it a chance. * @see Gene * @see Options */ fun createGeneForDTO(dtoSchemaName: String, allSchemas: String, - options: Options + options: Options, + examples: List> = listOf() ) : Gene{ if(!allSchemas.startsWith("\"$dtoSchemaName\"")){ throw IllegalArgumentException("Invalid name $dtoSchemaName for schema $allSchemas") @@ -309,7 +314,14 @@ object RestActionBuilderV3 { val schemas = getMapStringFromSchemas(allSchemasValue) val dtoSchema = schemas[dtoSchemaName] ?: throw IllegalStateException("cannot find the schema with $dtoSchemaName from $allSchemas") - if(dtoCache.containsKey(dtoSchema)){ + //gated here rather than left to the assembler, which would wrap them in a choice weighted at zero + val offered = if (options.probUseExamples > 0) examples else listOf() + + /* + The cache is keyed on the schema text alone, so it can only serve a call that brings + no examples: two callers may share a schema and still offer different examples for it. + */ + if(offered.isEmpty() && dtoCache.containsKey(dtoSchema)){ return dtoCache[dtoSchema]!!.copy() } @@ -328,7 +340,10 @@ object RestActionBuilderV3 { val currentSchema = SchemaOpenAPI(schema,swagger.openAPI, SchemaLocation.MEMORY) val schemaHolder = RestSchema(currentSchema) + var wanted: Gene? = null + schemas.forEach { (t, u) -> + val forThisOne = if (t == dtoSchemaName) offered else listOf() val gene = getGene(t, swagger.openAPI.components.schemas[t]!!, schemaHolder, @@ -336,11 +351,17 @@ object RestActionBuilderV3 { ArrayDeque(), t, options, + examples = forThisOne, messages = mutableListOf()) - dtoCache[u] = gene + if (forThisOne.isEmpty()) { + dtoCache[u] = gene + } + if (t == dtoSchemaName) { + wanted = gene + } } - return dtoCache[dtoSchema]!!.copy() + return (wanted ?: throw IllegalStateException("No gene was built for $dtoSchemaName")).copy() } /** diff --git a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt index 08b6e93ca9..7e0c8e94d1 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiGeneBuilderTest.kt @@ -9,6 +9,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.EnumGene +import org.evomaster.core.search.gene.interfaces.UserExamplesGene import org.evomaster.core.search.gene.numeric.DoubleGene import org.evomaster.core.search.gene.numeric.IntegerGene import org.evomaster.core.search.gene.string.StringGene @@ -488,12 +489,12 @@ class AsyncApiGeneBuilderTest { } @Test - fun testAnExampleForANonObjectPayloadIsLeftAlone() { + fun testAnExampleForANonObjectPayloadIsOfferedToo() { /* - The gene builder complains about 'example' on anything but an object, and that - complaint would reach the user about a document that is in order. So a scalar - payload keeps its schema-derived gene, example or not. + Passed beside the schema rather than written into it, an example draws no complaint + from the gene builder about 'example' on a scalar, so a scalar payload gets the same + choice an object does. */ val schema = document(""" asyncapi: 3.0.0 @@ -511,7 +512,8 @@ class AsyncApiGeneBuilderTest { val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("beat"), alwaysExamples)!! - assertTrue(gene is IntegerGene, gene::class.simpleName) + assertTrue(gene is ChoiceGene<*>, gene::class.simpleName) + assertEquals("42", printed(gene)) } @Test @@ -554,9 +556,9 @@ class AsyncApiGeneBuilderTest { } @Test - fun testOnlyTheFirstExampleIsUsedForNow() { + fun testEveryExampleIsOffered() { - //pins the limitation described on firstExample(): the parser the genes go through keeps one + //both are there to choose from, not only the first val schema = document(""" asyncapi: 3.0.0 info: @@ -580,8 +582,73 @@ class AsyncApiGeneBuilderTest { val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("greeting"), alwaysExamples)!! - val json = printed(gene) - assertTrue(json.contains("\"first\""), json) - assertFalse(json.contains("\"second\""), json) + //each example pins the field to its own value, so every value the field can take is one of them + val offered = gene.flatView() + .filterIsInstance>() + .filter { it.name == "text" } + .flatMap { it.values } + .toSet() + + assertEquals(setOf("first", "second"), offered) + } + + @Test + fun testAnExampleKeepsItsName() { + + //the name is what the sampler keys on to keep a whole example together across fields + val schema = AsyncApiAccess.getAsyncApiFromResource("/asyncapi/sut/scalar.yaml") + val message = schema.messages.getValue("PlanetCreated") + + val gene = AsyncApiGeneBuilder.buildPayloadGene(schema, message, alwaysExamples)!! + + val named = gene.flatView() + .filterIsInstance() + .filter { it.isUsedForExamples() } + .flatMap { it.getAvailableExampleNames() } + + assertEquals(listOf("Mars Discovery"), named) + } + + @Test + fun testTwoMessagesSharingASchemaKeepTheirOwnExamples() { + + /* + The gene builder caches what it builds by schema text. Two messages may well share a + schema, a request and its reply say, and each must still get the example it declares + rather than whichever was built first. + */ + val schema = document(""" + asyncapi: 3.0.0 + info: + title: Shared + version: 1.0.0 + components: + schemas: + Note: + type: object + required: [text] + properties: + text: + type: string + messages: + first: + payload: + ${'$'}ref: '#/components/schemas/Note' + examples: + - payload: + text: from-first + second: + payload: + ${'$'}ref: '#/components/schemas/Note' + examples: + - payload: + text: from-second + """) + + val first = printed(AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("first"), alwaysExamples)!!) + val second = printed(AsyncApiGeneBuilder.buildPayloadGene(schema, schema.messages.getValue("second"), alwaysExamples)!!) + + assertTrue(first.contains("from-first"), first) + assertTrue(second.contains("from-second"), second) } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt index 77994ebf90..a3b8569f58 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSamplerTest.kt @@ -259,4 +259,18 @@ class AsyncApiSamplerTest { assertTrue(payload.contains(anExampleValue), "expected '$anExampleValue' in $payload") } + + @Test + fun testANamedExampleReachesTheAction() { + + //what the sampler's named-example pass keys on, so a whole example can be kept together + val scalar = AsyncApiAccess.readFromResource("/asyncapi/sut/scalar.yaml") + val sampler = sampler(sutInfo { schemaText = scalar }, "--blackBox=false", "--probAsyncApiExamples=1.0") + + val named = sampler.seeAvailableActions() + .map { it as AsyncApiAction } + .flatMap { it.getNamedExamples().keys } + + assertTrue(named.contains("Mars Discovery"), named.toString()) + } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/RestActionBuilderV3Test.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/RestActionBuilderV3Test.kt index 1831a96b45..65c114ea02 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/rest/RestActionBuilderV3Test.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/RestActionBuilderV3Test.kt @@ -39,6 +39,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import java.time.format.DateTimeFormatter +import org.evomaster.core.search.gene.interfaces.UserExamplesGene class RestActionBuilderV3Test{ @@ -284,6 +285,40 @@ class RestActionBuilderV3Test{ } } + @Test + fun testExamplesPassedBesideADtoSchema() { + + RestActionBuilderV3.cleanCache() + + val name = "Note" + val dtoSchema = """ + "$name": { + "$name": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + } + } + """.trimIndent() + val example = ObjectMapper().readTree("""{"text": "hello"}""") + val options = RestActionBuilderV3.Options(probUseExamples = 0.5) + + val withExample = RestActionBuilderV3.createGeneForDTO(name, dtoSchema, options, listOf(Pair(example, "greeting"))) + assertTrue(withExample is ChoiceGene<*>, withExample::class.simpleName) + val named = withExample.flatView() + .filterIsInstance() + .filter { it.isUsedForExamples() } + .flatMap { it.getAvailableExampleNames() } + assertEquals(listOf("greeting"), named) + + //the cache is keyed on the schema alone, so neither call may be handed the other's gene + val without = RestActionBuilderV3.createGeneForDTO(name, dtoSchema, options) + assertTrue(without is ObjectGene, without::class.simpleName) + + val again = RestActionBuilderV3.createGeneForDTO(name, dtoSchema, options, listOf(Pair(example, "greeting"))) + assertTrue(again is ChoiceGene<*>, again::class.simpleName) + } + @ParameterizedTest @ValueSource(booleans = [true, false]) fun testPropertiesWithAdditionalProperties(enableConstraintHandling : Boolean){