Skip to content
Merged
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,10 @@
package org.evomaster.core.problem.asyncapi.auth

import org.evomaster.core.problem.enterprise.auth.AuthenticationInfo
import org.evomaster.core.problem.enterprise.auth.NoAuth

/**
* No authentication. In AsyncAPI, security is declared on the server and holds for the whole
* connection, so there is nothing to vary per message yet.
*/
class AsyncApiNoAuth : AuthenticationInfo(NoAuth.NAME), NoAuth
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package org.evomaster.core.problem.asyncapi.builder

import com.webfuzzing.asyncapi.models.AsyncApiDocument
import com.webfuzzing.asyncapi.models.AsyncApiOperation
import org.evomaster.core.problem.api.param.Param
import org.evomaster.core.problem.asyncapi.data.AsyncApiAction
import org.evomaster.core.problem.asyncapi.param.AsyncApiParam
import org.evomaster.core.problem.rest.builder.RestActionBuilderV3
import org.evomaster.core.problem.util.ActionBuilderUtil
import org.evomaster.core.search.action.Action

/**
* Turns a parsed AsyncAPI document into action templates, one per message that can be
* published. Same contract as the REST and GraphQL builders: fill the cluster keyed by action
* name, and return the problems found rather than throwing.
*/
object AsyncApiActionBuilder {

/**
* Build one action per publishable message and add them to [actionCluster], replacing
* whatever it held.
*
* @return anything that could not be built, to be reported to the user
*/
fun addActionsFromSchema(
schema: AsyncApiDocument,
actionCluster: MutableMap<String, Action>,
options: RestActionBuilderV3.Options
): List<String> {

actionCluster.clear()

val messages = mutableListOf<String>()
var skipped = 0

schema.operations.values.forEach { operation ->

/*
Only what the service consumes can be published to. An operation the service
sends is something to subscribe to, not to drive, so it is counted rather than
turned into an action.
*/
if (operation.action != AsyncApiOperation.Action.RECEIVE) {
skipped++
return@forEach
}

val built = buildActionsFor(operation, schema, options, messages)

if (built.isEmpty()) {
skipped++
}

built.forEach { actionCluster[it.getName()] = it }
}

ActionBuilderUtil.printActionNumberInfo("AsyncAPI", actionCluster.size, skipped, 0)

return messages
}

private fun buildActionsFor(
operation: AsyncApiOperation,
schema: AsyncApiDocument,
options: RestActionBuilderV3.Options,
messages: MutableList<String>
): List<AsyncApiAction> {

val carried = schema.messagesOf(operation)

val actions = carried.mapNotNull { message ->

val payload = try {
AsyncApiGeneBuilder.buildPayloadGene(schema, message, options)
} catch (e: Exception) {
/*
One message that cannot be built must not cost the whole document, which is
the same rule the parser follows.
*/
messages.add(
"Failed to build the payload of message '${message.id}' for operation" +
" '${operation.name}': ${e.message}"
)
return@mapNotNull null
}

val headers = try {
AsyncApiGeneBuilder.buildHeadersGene(schema, message, options)
} catch (e: Exception) {
messages.add(
"Failed to build the headers of message '${message.id}' for operation" +
" '${operation.name}': ${e.message}"
)
null
}

if (payload == null && headers == null) {
//nothing to vary and nothing to send: there is no action to make of it
messages.add(
"Message '${message.id}' of operation '${operation.name}' declares neither a" +
" payload nor headers, so there is nothing to publish"
)
return@mapNotNull null
}

val parameters = mutableListOf<Param>()
payload?.let { parameters.add(AsyncApiParam(AsyncApiParam.PAYLOAD, it)) }
headers?.let { parameters.add(AsyncApiParam(AsyncApiParam.HEADERS, it)) }

AsyncApiAction(
operationId = operation.name,
channelName = operation.channelName,
messageId = message.id,
inputParameters = parameters,
replyTemplate = operation.reply
)
}

if (carried.isEmpty()) {
messages.add("Operation '${operation.name}' carries no message that can be published")
}

//only when an operation carries more than one does the message need naming in the action
actions.forEach { it.singleMessage = actions.size == 1 }

return actions
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.evomaster.core.problem.asyncapi.builder

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.webfuzzing.asyncapi.models.AsyncApiCorrelationId
import com.webfuzzing.asyncapi.models.AsyncApiDocument
import com.webfuzzing.asyncapi.models.AsyncApiMessage
import com.webfuzzing.asyncapi.resolver.AsyncApiRefResolver
Expand All @@ -15,7 +17,7 @@ import org.evomaster.core.search.gene.Gene
*
* The parser deliberately stops at the schema: it leaves every `$ref` inside a payload alone,
* and guarantees that whatever those references reach is present in
* [AsyncApiDocument.getComponentSchemas]. That guarantee is what this builder trades on -- it hands
* [AsyncApiDocument.componentSchemas]. That guarantee is what this builder trades on -- it hands
* the whole schema map to [RestActionBuilderV3.createGeneForDTO], which wraps it in a synthetic
* OpenAPI document and lets the existing machinery resolve the references and build the genes.
*
Expand All @@ -31,17 +33,40 @@ object AsyncApiGeneBuilder {
*/
private const val INLINE_PREFIX = "_asyncapi_"

/*
The JSON Schema keywords this builder reads or rewrites. These are JSON Schema's
vocabulary, not AsyncAPI's, which is why they live here rather than with the parser.
*/
private const val PROPERTIES = "properties"
private const val PATTERN_PROPERTIES = "patternProperties"
private const val DEFINITIONS = "definitions"
private const val DEFS = "\$defs"
private const val REQUIRED = "required"
private const val CONST = "const"
private const val DEFAULT = "default"
private const val ENUM = "enum"
private const val EXAMPLE = "example"
private const val EXAMPLES = "examples"
private const val TYPE = "type"
private const val MINIMUM = "minimum"
private const val MAXIMUM = "maximum"

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 keywords whose value is literal data rather than a schema, so nothing inside them is
* a keyword either.
*/
private val DATA_KEYWORDS = setOf("const", "default", "enum", "example", "examples")
private val DATA_KEYWORDS = setOf(CONST, DEFAULT, ENUM, EXAMPLE, EXAMPLES)

/**
* The keywords whose value maps arbitrary names to schemas. Their keys come from the
* document, so a field a service happens to call "const" or "default" must still be walked.
*/
private val SCHEMA_MAPS = setOf("properties", "patternProperties", "definitions", "\$defs")
private val SCHEMA_MAPS = setOf(PROPERTIES, PATTERN_PROPERTIES, DEFINITIONS, DEFS)

/**
* The genes for a message's payload, or null when it declares none.
Expand All @@ -57,12 +82,66 @@ object AsyncApiGeneBuilder {
*
* Headers are built separately from the payload because they travel separately on the wire:
* a transport with metadata puts them beside the body rather than in it.
*
* The header carrying the correlation id, where the message declares one, is left out. That
* value is stamped fresh at each execution so that a reply can be paired with the request
* that caused it, and a gene holding a value that is about to be overwritten is worse than
* no gene at all: the search would spend mutations on something that never reaches the wire.
*
* Only an id declared one level deep is left out, which is how every document seen so far
* writes it. One pointing further in -- `$message.header#/meta/id` -- keeps its gene, and
* the search wastes a few mutations on a field that is overwritten before it is sent. That
* is the mild failure of the two, and preferable to descending into a headers schema whose
* shape is not known here.
*/
fun buildHeadersGene(
schema: AsyncApiDocument,
message: AsyncApiMessage,
options: RestActionBuilderV3.Options
): Gene? = build(message.headers, "${message.id}.headers", schema, options)
): Gene? = build(withoutCorrelationId(message), "${message.id}.headers", schema, options)

/**
* The headers schema without the property the correlation id is stamped into.
*/
private fun withoutCorrelationId(message: AsyncApiMessage): JsonNode? {

val headers = message.headers ?: return null
val correlation = message.correlationId

if (correlation == null || correlation.source != AsyncApiCorrelationId.Source.HEADER) {
return headers
}

val field = correlation.fieldName ?: return headers
val properties = headers.get(PROPERTIES)

if (properties == null || !properties.has(field)) {
return headers
}

val copy = headers.deepCopy<JsonNode>() as ObjectNode
val kept = (copy.get(PROPERTIES) as ObjectNode).apply { remove(field) }

/*
When the stamped id was the only header, there is nothing left to vary. Returning
the empty schema would build a free-form map gene, which is worse than nothing: it
would invite the search to invent headers the contract never declared.
*/
if (kept.isEmpty) {
return null
}

//a field that is no longer there cannot be required either
(copy.get(REQUIRED) as? ArrayNode)?.let { required ->
val kept = required.filter { it.asText() != field }
copy.remove(REQUIRED)
if (kept.isNotEmpty()) {
copy.putArray(REQUIRED).apply { kept.forEach { add(it) } }
}
}

return copy
}

/**
* Options for building AsyncAPI payloads.
Expand Down Expand Up @@ -229,24 +308,24 @@ object AsyncApiGeneBuilder {
}

val obj = node as ObjectNode
val const = obj.get("const")
val const = obj.get(CONST)

if (const != null && !const.isContainerNode) {
when {
const.isTextual -> {
obj.remove("const")
obj.putArray("enum").add(const)
obj.put("type", "string")
obj.remove(CONST)
obj.putArray(ENUM).add(const)
obj.put(TYPE, TYPE_STRING)
}
const.isNumber -> {
obj.remove("const")
obj.set<JsonNode>("minimum", const)
obj.set<JsonNode>("maximum", const)
obj.put("type", if (const.isIntegralNumber) "integer" else "number")
obj.remove(CONST)
obj.set<JsonNode>(MINIMUM, const)
obj.set<JsonNode>(MAXIMUM, const)
obj.put(TYPE, if (const.isIntegralNumber) TYPE_INTEGER else TYPE_NUMBER)
}
const.isBoolean -> {
obj.remove("const")
obj.put("type", "boolean")
obj.remove(CONST)
obj.put(TYPE, TYPE_BOOLEAN)
}
}
}
Expand Down
Loading
Loading