From f12670aa06ff6e616c23ef28154800c2cf3584b1 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Mon, 24 Aug 2026 14:06:50 -0300 Subject: [PATCH 1/2] AsyncAPI 3.x: the action and individual a search holds Turns the parsed document into something the search can sample and mutate. AsyncApiAction is one message published on one channel, following the shape of RPCCallAction -- the closest analogue in EvoMaster, being a call with no URL, made through a driver, whose mutable state is the input and whose response is read afterwards rather than searched over. What is deliberately not a gene is the point of the design: - the address, which the contract fixes. Sending to a channel the service does not read would only waste executions. - the correlation id, stamped fresh at each execution. Searching over it could achieve nothing, since the service only echoes it, and pairing a reply with its request needs a value unique per execution rather than one carried in the genome. The header it is stamped into is therefore left out of the header genes, and when it is the only header there are no header genes at all -- an empty schema would otherwise build a free-form map and invite the search to invent headers the contract never declared. - the reply, which is an observation, read at fitness time. AsyncApiIndividual is one individual for every transport rather than one per transport: Kafka versus AMQP versus a socket appears nowhere in it. What is searched over is the operation and the payload, which are the same whatever moves the bytes. AsyncApiActionBuilder fills an action cluster, keeping the contract the REST and GraphQL builders use: one entry per action name, and a list of what had to be skipped rather than an exception. Only operations the service consumes become actions, since a 'send' is something to subscribe to rather than to drive. An operation carrying several messages becomes several actions -- which message to send is a choice made by picking an action, not by mutating a gene -- and the message is named in the action only when there is more than one, so a generated test reads as the operation where it can. That multi-message case is not hypothetical, but it does not arise the way one would expect. No document in the corpus narrows to more than one message with a `messages:` array; what happens is the opposite -- an operation declares no array at all, and so inherits everything its channel carries. scalar.yaml is the real case, vendored unmodified under MIT with its source noted: its userEvents channel carries five genuinely different payloads, and subscribeToUserEvents does not say which of them it means. Note RPCIndividual.seeIndexedRPCCalls is not copied over. It looks up children by action type, but the children are enterprise action groups, so it returns nothing; it has no callers. seeMainExecutableActions does the job. --- .../problem/asyncapi/auth/AsyncApiNoAuth.kt | 14 + .../asyncapi/builder/AsyncApiActionBuilder.kt | 132 ++ .../asyncapi/builder/AsyncApiGeneBuilder.kt | 60 +- .../problem/asyncapi/data/AsyncApiAction.kt | 106 + .../asyncapi/data/AsyncApiIndividual.kt | 116 ++ .../problem/asyncapi/param/AsyncApiParam.kt | 21 + .../builder/AsyncApiActionBuilderTest.kt | 453 ++++ .../builder/AsyncApiGeneBuilderTest.kt | 13 +- .../test/resources/asyncapi/sut/microcks.yaml | 418 ++++ .../test/resources/asyncapi/sut/scalar.yaml | 1853 +++++++++++++++++ 10 files changed, 3180 insertions(+), 6 deletions(-) create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/asyncapi/auth/AsyncApiNoAuth.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilder.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiIndividual.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/asyncapi/param/AsyncApiParam.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilderTest.kt create mode 100644 core/src/test/resources/asyncapi/sut/microcks.yaml create mode 100644 core/src/test/resources/asyncapi/sut/scalar.yaml diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/auth/AsyncApiNoAuth.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/auth/AsyncApiNoAuth.kt new file mode 100644 index 0000000000..c81e8d7e71 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/auth/AsyncApiNoAuth.kt @@ -0,0 +1,14 @@ +package org.evomaster.core.problem.asyncapi.auth + +import org.evomaster.core.problem.enterprise.auth.AuthenticationInfo +import org.evomaster.core.problem.enterprise.auth.NoAuth + +/** + * An AsyncAPI action with no authentication set up. + * + * Authentication in AsyncAPI is a property of the connection to the broker rather than of an + * individual message: a security scheme is declared on a server, and the client is already + * authenticated by the time a message is published. So there is nothing per-action to vary + * yet, and every action carries this. + */ +class AsyncApiNoAuth : AuthenticationInfo(NoAuth.NAME), NoAuth diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilder.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilder.kt new file mode 100644 index 0000000000..104253abf3 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilder.kt @@ -0,0 +1,132 @@ +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 the actions a search samples from, one per message that + * can actually be published. + * + * This is the AsyncAPI counterpart of + * [org.evomaster.core.problem.rest.builder.RestActionBuilderV3.addActionsFromSwagger] and + * [org.evomaster.core.problem.graphql.builder.GraphQLActionBuilder.addActionsFromSchema], and + * keeps their contract: fill a cluster keyed by action name, and return what had to be skipped + * rather than raising. + */ +object AsyncApiActionBuilder { + + /** + * Build one action per publishable message and add them to [actionCluster]. + * + * @return anything that could not be built, to be reported to the user + */ + fun addActionsFromSchema( + schema: AsyncApiDocument, + actionCluster: MutableMap, + options: RestActionBuilderV3.Options + ): List { + + actionCluster.clear() + + val messages = mutableListOf() + 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 + ): List { + + 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() + 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 + } +} 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 bc46d6438c..5314db3c41 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 @@ -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 @@ -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. * @@ -45,12 +47,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() 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. diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiAction.kt new file mode 100644 index 0000000000..9781b343b6 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiAction.kt @@ -0,0 +1,106 @@ +package org.evomaster.core.problem.asyncapi.data + +import com.webfuzzing.asyncapi.models.AsyncApiReply +import org.evomaster.core.problem.api.ApiWsAction +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.problem.asyncapi.auth.AsyncApiNoAuth +import org.evomaster.core.problem.enterprise.auth.AuthenticationInfo +import org.evomaster.core.search.gene.Gene + +/** + * Publishing one message on one channel: the thing an AsyncAPI search actually does. + * + * It follows the shape of [org.evomaster.core.problem.rpc.RPCCallAction], which is the closest + * analogue in EvoMaster -- a call with no URL, made through a driver, whose mutable state is + * the input and whose response is read afterwards rather than searched over. + * + * Note what is deliberately *not* a gene: + * + * - the **address**, which is fixed by the contract. There is nothing to search over in where + * a message goes; sending to a channel the service does not read would only waste executions. + * - the **correlation id**, which is stamped fresh at each execution. Searching over it could + * achieve nothing, since the service only echoes it back, and pairing a reply with its + * request needs a value unique to each execution rather than one carried in the genome. + * - the **reply**, which is an observation. It is read at fitness time to decide what was + * covered, exactly as RPC reads its response. + */ +class AsyncApiAction( + + /** + * Key of the operation in the document. + * + * This is the unit coverage is counted against, so it is taken from the document verbatim + * and never synthesised: `(reply variant x operation)` is the AsyncAPI analogue of REST's + * `(status x endpoint)`, and it only means anything if the operation is stable. + */ + val operationId: String, + + /** + * Key of the channel the message is published on. The address it resolves to depends on the + * transport, so it is left to be resolved by whatever holds the connection. + */ + val channelName: String, + + /** + * Id of the message being published. An operation may carry several, in which case there is + * one action per message: which message to send is a choice the search makes by picking an + * action, not by mutating a gene. + */ + val messageId: String, + + /** + * The payload and, when the message declares them, the headers. These are the genes. + */ + inputParameters: MutableList, + + /** + * What the contract says a reply may be, when the operation declares one. Immutable, and + * not part of the children of this action: it is a description of what to expect, not + * something to vary. Null for a fire-and-forget operation. + */ + val replyTemplate: AsyncApiReply? = null, + + override var auth: AuthenticationInfo = AsyncApiNoAuth() + +) : ApiWsAction(auth, false, inputParameters) { + + companion object { + /** + * The name an action is known by, which must be unique within a search. + * + * An operation carrying a single message is named after the operation alone, since + * that reads better in a generated test; only when there are several does the message + * need naming too. + */ + fun nameFor(operationId: String, messageId: String, alone: Boolean) = + if (alone) operationId else "$operationId:$messageId" + } + + override fun getName(): String = nameFor(operationId, messageId, singleMessage) + + /** + * Whether this action is the only one built for its operation. Set by whoever builds the + * cluster, since it depends on the other actions rather than on this one. + */ + var singleMessage: Boolean = true + + override fun seeTopGenes(): List = parameters.flatMap { it.seeGenes() } + + override fun copyContent(): AsyncApiAction = + AsyncApiAction( + operationId, + channelName, + messageId, + parameters.asSequence().map(Param::copy).toMutableList(), + replyTemplate, + auth + ).also { it.singleMessage = singleMessage } + + /** + * Whether the contract promises something observable comes back. Only such an operation can + * be judged from outside without instrumentation. + */ + fun expectsReply(): Boolean = replyTemplate != null + + override fun toString(): String = "${getName()} on $channelName" +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiIndividual.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiIndividual.kt new file mode 100644 index 0000000000..05cdbd2b86 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/data/AsyncApiIndividual.kt @@ -0,0 +1,116 @@ +package org.evomaster.core.problem.asyncapi.data + +import org.evomaster.core.problem.api.ApiWsIndividual +import org.evomaster.core.problem.enterprise.EnterpriseActionGroup +import org.evomaster.core.problem.enterprise.EnterpriseChildTypeVerifier +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.search.GroupsOfChildren +import org.evomaster.core.search.StructuralElement +import org.evomaster.core.search.action.ActionComponent +import org.evomaster.core.search.tracer.TrackOperator +import org.evomaster.core.database.sql.SqlAction +import kotlin.math.max + +/** + * A sequence of messages to publish, and whatever is needed to set the service up first. + * + * There is one individual for every transport rather than one per transport: Kafka versus AMQP + * versus a socket appears nowhere in here. What is being searched over is the operation and the + * payload, which are the same whatever moves the bytes; which wire is used is decided below the + * driver interface. Were the transport to leak in here there would have to be a + * `KafkaIndividual` and an `AmqpIndividual`, and nothing would be shared between them. + * + * Everything structural is inherited: initialization actions for seeding a database, a main + * group of the messages under test, and cleanup. + */ +class AsyncApiIndividual( + sampleType: SampleType, + trackOperator: TrackOperator? = null, + index: Int = -1, + allActions: MutableList, + mainSize: Int = allActions.size, + sqlSize: Int = 0, + mongoSize: Int = 0, + redisSize: Int = 0, + dnsSize: Int = 0, + groups: GroupsOfChildren = + getEnterpriseTopGroups(allActions, mainSize, sqlSize, mongoSize, redisSize, dnsSize, 0, 0) +) : ApiWsIndividual( + sampleType, + trackOperator, + index, + allActions, + childTypeVerifier = EnterpriseChildTypeVerifier(AsyncApiAction::class.java), + groups +) { + + constructor( + sampleType: SampleType, + actions: MutableList, + dbInitialization: MutableList = mutableListOf(), + trackOperator: TrackOperator? = null, + index: Int = -1 + ) : this( + sampleType = sampleType, + trackOperator = trackOperator, + index = index, + allActions = mutableListOf().apply { + addAll(dbInitialization) + addAll(actions.map { EnterpriseActionGroup(mutableListOf(it), AsyncApiAction::class.java) }) + }, + mainSize = actions.size, + sqlSize = dbInitialization.size + ) + + override fun canMutateStructure(): Boolean = true + + /** + * Add a message to publish, at [relativePosition] within the main group, or at the end. + */ + fun addAction(relativePosition: Int = -1, action: AsyncApiAction) { + + val main = GroupsOfChildren.MAIN + val group = EnterpriseActionGroup(mutableListOf(action), AsyncApiAction::class.java) + + if (relativePosition == -1) { + addChildToGroup(group, main) + } else { + val base = groupsView()!!.startIndexForGroupInsertionInclusive(main) + addChildToGroup(base + relativePosition, group, main) + } + } + + /** + * Remove the message at [position] of the main group. + */ + fun removeAction(position: Int) { + killChildByIndex(firstIndexOfMainGroup() + position) + } + + private fun firstIndexOfMainGroup() = max( + 0, + max( + children.indexOfLast { it is SqlAction } + 1, + children.indexOfFirst { it is EnterpriseActionGroup<*> } + ) + ) + + /* + Every group has to be measured, not just the two this class creates for itself. The + children are copied wholesale, so a size left at its default would not match what is + actually being handed over, and the group bookkeeping rejects that outright -- a copy + would fail for an individual that had picked up, say, a Mongo insertion along the way. + */ + override fun copyContent(): AsyncApiIndividual = + AsyncApiIndividual( + sampleType, + trackOperator, + index, + children.map { it.copy() }.toMutableList() as MutableList, + mainSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.MAIN), + sqlSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_SQL), + mongoSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_MONGO), + redisSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_REDIS), + dnsSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_DNS) + ) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/param/AsyncApiParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/param/AsyncApiParam.kt new file mode 100644 index 0000000000..5a9fe400b6 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/param/AsyncApiParam.kt @@ -0,0 +1,21 @@ +package org.evomaster.core.problem.asyncapi.param + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.gene.Gene + +/** + * One part of a message the search can vary: its payload, or its headers. + * + * The two are separate parameters rather than one because they travel separately on the wire: + * a transport with metadata puts headers beside the body rather than inside it. + */ +class AsyncApiParam(name: String, gene: Gene) : Param(name, gene) { + + companion object { + const val PAYLOAD = "payload" + + const val HEADERS = "headers" + } + + override fun copyContent(): AsyncApiParam = AsyncApiParam(name, gene.copy()) +} diff --git a/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilderTest.kt new file mode 100644 index 0000000000..b2267797d0 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/asyncapi/builder/AsyncApiActionBuilderTest.kt @@ -0,0 +1,453 @@ +package org.evomaster.core.problem.asyncapi.builder + +import com.webfuzzing.asyncapi.access.AsyncApiAccess +import com.webfuzzing.asyncapi.models.AsyncApiDocument +import org.evomaster.core.EMConfig +import org.evomaster.core.database.mongo.MongoDbAction +import org.evomaster.core.problem.asyncapi.data.AsyncApiAction +import org.evomaster.core.problem.asyncapi.data.AsyncApiIndividual +import org.evomaster.core.problem.asyncapi.param.AsyncApiParam +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.problem.rest.builder.RestActionBuilderV3 +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.ObjectGene +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AsyncApiActionBuilderTest { + + private val options = AsyncApiGeneBuilder.options(EMConfig()) + + @BeforeEach + fun reset() { + RestActionBuilderV3.cleanCache() + } + + private fun build(resourcePath: String): Pair, List> { + val schema = AsyncApiAccess.getAsyncApiFromResource(resourcePath) + return build(schema) + } + + private fun build(schema: AsyncApiDocument): Pair, List> { + val cluster = mutableMapOf() + val messages = AsyncApiActionBuilder.addActionsFromSchema(schema, cluster, options) + return cluster to messages + } + + private fun actionsOf(resourcePath: String) = + build(resourcePath).first.values.map { it as AsyncApiAction } + + // ------------------------------------------------------------------ what becomes an action + + @Test + fun testOneActionPerConsumedOperation() { + + val (cluster, messages) = build("/asyncapi/sut/ncs-kafka.yaml") + + assertTrue(messages.isEmpty(), "unexpected problems: $messages") + + //six operations, each consuming a single message, so six actions named after them + assertEquals( + setOf("checkTriangle", "bessj", "expint", "fisher", "gammq", "remainder"), + cluster.keys + ) + + val bessj = cluster.getValue("bessj") as AsyncApiAction + assertEquals("bessj", bessj.operationId) + assertEquals("bessjRequest", bessj.channelName) + assertEquals("bessjRequest", bessj.messageId) + } + + @Test + fun testOperationsTheServiceEmitsAreNotDrivable() { + + /* + A 'send' operation is what the service emits. There is nothing to publish to it, so + it is not turned into an action; only what the service consumes can be driven. + */ + val schema = AsyncApiAccess.parseFromText( + """ + asyncapi: 3.0.0 + info: + title: One of each direction + version: 1.0.0 + channels: + c: + address: a + messages: + m: + payload: + type: object + operations: + consumed: + action: receive + channel: + ${'$'}ref: '#/channels/c' + emitted: + action: send + channel: + ${'$'}ref: '#/channels/c' + """.trimIndent() + ) + + assertEquals(setOf("consumed"), build(schema).first.keys) + } + + @Test + fun testAnOperationCarryingSeveralMessagesGetsOnePerMessage() { + + val cluster = build("/asyncapi/artificial/websocket-reply.yaml").first + + /* + Which message to send is a choice made by picking an action, not by mutating a gene, + so an operation carrying several becomes several actions. Here each carries one, so + each keeps the operation's own name. + */ + assertTrue(cluster.containsKey("recv_list_legs")) + assertTrue(cluster.containsKey("recv_get_leg")) + + val listLegs = cluster.getValue("recv_list_legs") as AsyncApiAction + assertEquals("listLegs", listLegs.messageId) + assertEquals("vsi", listLegs.channelName) + } + + @Test + fun testTheMessageIsNamedOnlyWhenThereIsMoreThanOne() { + + val schema = AsyncApiAccess.parseFromText( + """ + asyncapi: 3.0.0 + info: + title: An operation that can send either of two messages + version: 1.0.0 + channels: + c: + address: a + messages: + first: + ${'$'}ref: '#/components/messages/first' + second: + ${'$'}ref: '#/components/messages/second' + operations: + o: + action: receive + channel: + ${'$'}ref: '#/channels/c' + components: + messages: + first: + payload: + type: object + second: + payload: + type: object + """.trimIndent() + ) + + assertEquals(setOf("o:first", "o:second"), build(schema).first.keys) + } + + @Test + fun testAnOperationInheritsEveryMessageOfItsChannelWhenItNarrowsToNone() { + + val (cluster, _) = build("/asyncapi/sut/scalar.yaml") + + /* + This is how an operation ends up carrying several messages in practice. No document + in the corpus narrows to more than one with a `messages:` array; what happens is + the opposite -- an operation declares no array at all, and so inherits everything + its channel carries. Here one channel carries five genuinely different payloads and + the operation does not say which, so each becomes an action of its own: they are + different things to send, and picking between them is a structural choice rather + than a mutation. + */ + val userEvents = cluster.keys.filter { it.startsWith("subscribeToUserEvents") }.sorted() + + assertEquals( + listOf( + "subscribeToUserEvents:LoginAttempt", + "subscribeToUserEvents:UserAuthenticated", + "subscribeToUserEvents:UserDeleted", + "subscribeToUserEvents:UserProfileUpdated", + "subscribeToUserEvents:UserSignedUp" + ), + userEvents + ) + + //they share the operation they came from, which is what the document names + val actions = userEvents.map { cluster.getValue(it) as AsyncApiAction } + assertTrue(actions.all { it.operationId == "subscribeToUserEvents" }) + assertTrue(actions.all { it.channelName == "userEvents" }) + //but each carries its own message, and so its own payload genes + assertEquals(userEvents.size, actions.map { it.messageId }.distinct().size) + } + + // ------------------------------------------------------------------ what an action holds + + @Test + fun testThePayloadIsTheGenes() { + + val bessj = actionsOf("/asyncapi/sut/ncs-kafka.yaml").first { it.operationId == "bessj" } + + val payload = bessj.parameters.first { it.name == AsyncApiParam.PAYLOAD } + assertTrue(payload.gene is ObjectGene) + + //the genes the search may vary are exactly the input, and nothing else + assertEquals(payload.seeGenes().size, bessj.seeTopGenes().size) + } + + @Test + fun testTheReplyIsDescribedButNotSearchedOver() { + + val bessj = actionsOf("/asyncapi/sut/ncs-kafka.yaml").first { it.operationId == "bessj" } + + assertTrue(bessj.expectsReply()) + //two declared outcomes, which is what gives a black-box search something to tell apart + assertEquals(listOf("doubleResult", "error"), bessj.replyTemplate!!.messageIds) + assertEquals("bessjReply", bessj.replyTemplate!!.channelName) + + //but none of that is a gene: it is what to expect, not what to vary + assertTrue(bessj.seeTopGenes().none { it.name.contains("result", ignoreCase = true) }) + } + + @Test + fun testAFireAndForgetOperationHasNoReplyTemplate() { + + val action = actionsOf("/asyncapi/sut/microcks.yaml").first() + + assertFalse(action.expectsReply()) + assertNull(action.replyTemplate) + } + + @Test + fun testHeadersAreASeparateParameterFromThePayload() { + + val action = build(AsyncApiAccess.parseFromText(headerDocument(true))).first + .values.map { it as AsyncApiAction }.first() + + assertEquals( + listOf(AsyncApiParam.PAYLOAD, AsyncApiParam.HEADERS), + action.parameters.map { it.name } + ) + } + + @Test + fun testTheStampedCorrelationHeaderIsNotAGene() { + + val action = build(AsyncApiAccess.parseFromText(headerDocument(true))).first + .values.map { it as AsyncApiAction }.first() + + /* + The document declares where the correlation id travels, and the headers schema + declares a property of that name. The value is stamped fresh at each execution so a + reply can be paired with its request, so a gene holding it would only be overwritten + -- the search would spend mutations on something that never travels. + */ + val headers = action.parameters.first { it.name == AsyncApiParam.HEADERS }.gene as ObjectGene + assertEquals(listOf("tenant"), headers.fields.map { it.name }) + } + + @Test + fun testNoHeadersParameterWhenTheStampedIdIsTheOnlyHeader() { + + val action = build(AsyncApiAccess.parseFromText(headerDocument(false))).first + .values.map { it as AsyncApiAction }.first() + + //an empty headers schema would invite the search to invent headers never declared + assertEquals(listOf(AsyncApiParam.PAYLOAD), action.parameters.map { it.name }) + } + + /** + * A document whose message declares a correlation id in its headers, with or without + * another header of its own beside it. + */ + private fun headerDocument(withOtherHeader: Boolean) = + if (withOtherHeader) { + """ + asyncapi: 3.0.0 + info: + title: A stamped id and a header of its own + version: 1.0.0 + channels: + c: + address: a + messages: + m: + ${'$'}ref: '#/components/messages/m' + operations: + o: + action: receive + channel: + ${'$'}ref: '#/channels/c' + components: + messages: + m: + correlationId: + location: '${'$'}message.header#/correlationId' + payload: + type: object + properties: + value: + type: string + headers: + type: object + properties: + correlationId: + type: string + tenant: + type: string + """.trimIndent() + } else { + """ + asyncapi: 3.0.0 + info: + title: A stamped id and nothing else + version: 1.0.0 + channels: + c: + address: a + messages: + m: + ${'$'}ref: '#/components/messages/m' + operations: + o: + action: receive + channel: + ${'$'}ref: '#/channels/c' + components: + messages: + m: + correlationId: + location: '${'$'}message.header#/correlationId' + payload: + type: object + properties: + value: + type: string + headers: + type: object + properties: + correlationId: + type: string + """.trimIndent() + } + + // ------------------------------------------------------------------ degrading gracefully + + @Test + fun testAMessageThatCannotBePublishedIsReported() { + + val schema = AsyncApiAccess.parseFromText( + """ + asyncapi: 3.0.0 + info: + title: A message with nothing to send + version: 1.0.0 + channels: + c: + address: a + messages: + empty: + name: Empty + operations: + o: + action: receive + channel: + ${'$'}ref: '#/channels/c' + """.trimIndent() + ) + + val (cluster, messages) = build(schema) + + assertTrue(cluster.isEmpty()) + assertTrue( + messages.any { it.contains("neither a payload nor headers") }, + messages.toString() + ) + } + + @Test + fun testBuildingIsRepeatable() { + + //the gene builder keeps a static cache, so building twice must give the same shape + val first = build("/asyncapi/sut/ncs-kafka.yaml").first + val second = build("/asyncapi/sut/ncs-kafka.yaml").first + + assertEquals(first.keys, second.keys) + assertEquals( + (first.getValue("bessj") as AsyncApiAction).seeTopGenes().size, + (second.getValue("bessj") as AsyncApiAction).seeTopGenes().size + ) + } + + // ------------------------------------------------------------------ the individual + + @Test + fun testAnIndividualHoldsTheMessagesToPublish() { + + val actions = actionsOf("/asyncapi/sut/ncs-kafka.yaml").take(2).map { it.copy() as AsyncApiAction } + + val individual = AsyncApiIndividual(SampleType.RANDOM, actions.toMutableList()) + + assertEquals(2, individual.seeMainExecutableActions().size) + assertTrue(individual.canMutateStructure()) + } + + @Test + fun testMessagesCanBeAddedAndRemoved() { + + val actions = actionsOf("/asyncapi/sut/ncs-kafka.yaml").map { it.copy() as AsyncApiAction } + + val individual = AsyncApiIndividual(SampleType.RANDOM, mutableListOf(actions[0])) + assertEquals(1, individual.seeMainExecutableActions().size) + + individual.addAction(action = actions[1]) + assertEquals(2, individual.seeMainExecutableActions().size) + + individual.removeAction(0) + assertEquals(1, individual.seeMainExecutableActions().size) + assertEquals(actions[1].getName(), individual.seeMainExecutableActions().first().getName()) + } + + @Test + fun testCopyingAnIndividualKeepsItsMessages() { + + val actions = actionsOf("/asyncapi/sut/ncs-kafka.yaml").take(3).map { it.copy() as AsyncApiAction } + val individual = AsyncApiIndividual(SampleType.RANDOM, actions.toMutableList()) + + val copy = individual.copy() as AsyncApiIndividual + + assertEquals( + individual.seeMainExecutableActions().map { it.getName() }, + copy.seeMainExecutableActions().map { it.getName() } + ) + //a copy must be independent, or mutating one would change the other + assertNotSame(individual.seeMainExecutableActions()[0], copy.seeMainExecutableActions()[0]) + } + + @Test + fun testCopyingAnIndividualThatWasSetUpWithMoreThanSql() { + + /* + Only SQL is put in front of the messages today, but the individual inherits every + other kind of setup an enterprise individual can hold. The children are copied + wholesale, so a group whose size was not measured would not match what is handed + over, and the copy would fail outright rather than come back wrong. + */ + val actions = actionsOf("/asyncapi/sut/ncs-kafka.yaml").take(1).map { it.copy() as AsyncApiAction } + val individual = AsyncApiIndividual(SampleType.RANDOM, actions.toMutableList()) + + individual.addInitializingMongoDbActions( + actions = listOf(MongoDbAction("db", "collection", "collection", listOf())) + ) + + val copy = individual.copy() as AsyncApiIndividual + + assertEquals(1, copy.seeMainExecutableActions().size) + assertEquals( + individual.seeInitializingActions().size, + copy.seeInitializingActions().size + ) + } +} 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 9cbefb232c..5086ecaf4a 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 @@ -97,15 +97,20 @@ class AsyncApiGeneBuilderTest { fun testHeadersAreBuiltSeparatelyFromThePayload() { val schema = AsyncApiAccess.getAsyncApiFromResource("/asyncapi/artificial/messages.yaml") - val message = schema.messages.getValue("signupRequest") - - val headers = AsyncApiGeneBuilder.buildHeadersGene(schema, message, options)!! - assertTrue(field(headers, "correlationId") is StringGene) //a message declaring no headers gets none, rather than an empty object assertNull( AsyncApiGeneBuilder.buildHeadersGene(schema, schema.messages.getValue("heartbeat"), options) ) + + /* + signupRequest declares one header, and it is the one the correlation id is stamped + into. That value is written fresh at each execution, so a gene holding it would only + be overwritten -- and with nothing else left, there are no header genes at all. + */ + assertNull( + AsyncApiGeneBuilder.buildHeadersGene(schema, schema.messages.getValue("signupRequest"), options) + ) } @Test diff --git a/core/src/test/resources/asyncapi/sut/microcks.yaml b/core/src/test/resources/asyncapi/sut/microcks.yaml new file mode 100644 index 0000000000..89afd315c4 --- /dev/null +++ b/core/src/test/resources/asyncapi/sut/microcks.yaml @@ -0,0 +1,418 @@ +# Source: https://github.com/microcks/microcks (Apache-2.0), unmodified. +asyncapi: 3.0.0 +info: + title: Microcks Events API v1.10 + version: 1.10.1 + description: "Events API offered by Microcks, the Kubernetes native tool for API and microservices\ + \ mocking and testing (microcks.io)" + contact: + name: Laurent Broudoux + url: https://github.com/microcks + email: laurent@microcks.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + x-logo: + backgroundColor: '#ffffff' + url: https://microcks.io/images/microcks-logo-blue.png +defaultContentType: application/json +channels: + service-changes: + description: A channel where Services changes are published + messages: + serviceChangeEvent: + $ref: '#/components/messages/serviceChangeEvent' + bindings: + kafka: + key: + type: string + bindings: + ws: + method: POST + kafka: + topic: 'microcks-services-updates' +operations: + receivedServiceChanges: + action: receive + channel: + $ref: '#/channels/service-changes' + summary: Receive information about Service changes + messages: + - $ref: '#/channels/service-changes/messages/serviceChangeEvent' +components: + messages: + serviceChangeEvent: + description: An event describing that Service has been modified (created, updated or deleted) + payload: + $ref: '#/components/schemas/ServiceChangeEvent' + schemas: + ServiceChangeEvent: + type: object + required: + - serviceId + - serviceView + - changeType + - timestamp + properties: + serviceId: + type: string + serviceView: + type: object + schema: + $ref: '#/components/schemas/ServiceView' + changeType: + type: string + enum: + - CREATED + - UPDATED + - DELETED + timestamp: + type: number + format: int32 + additionalProperties: true + ServiceView: + description: Aggregate bean for grouping a Service an its messages pairs + type: object + required: + - service + - messagesMap + properties: + service: + $ref: '#/components/schemas/Service' + description: Wrapped service description + messagesMap: + type: object + description: "Map of messages for this Service. Keys are operation name,\ + \ values are array of messages for this operation" + additionalProperties: + $ref: '#/components/schemas/MessageArray' + additionalProperties: true + Service: + description: Represents a Service or API definition as registred into Microcks + repository + required: + - name + - version + - type + - sourceArtifact + properties: + id: + description: Unique identifier for this Service or API + type: string + name: + description: Distinct name for this Service or API (maybe shared among many + versions) + type: string + version: + description: Distinct version for a named Service or API + type: string + type: + description: Service or API Type + type: string + enum: + - REST + - SOAP_HTTP + - GENERIC_REST + - GENERIC_EVENT + - EVENT + - GRPC + - GRAPHQL + operations: + description: Set of Operations for Service or API + type: array + items: + $ref: '#/components/schemas/Operation' + xmlNS: + description: Associated Xml Namespace in case of Xml based Service + type: string + metadata: + $ref: '#/components/schemas/Metadata' + description: Metadata of Service + sourceArtifact: + description: Short name of the main/primary artifact this service was created + from + type: string + Metadata: + description: Commodity object for holding metadata on any entity. This object + is inspired by Kubernetes metadata. + type: object + required: + - createdOn + - lastUpdate + properties: + createdOn: + description: Creation date of attached object + type: number + readOnly: true + lastUpdate: + description: Last update of attached object + type: number + readOnly: true + annotations: + description: Annotations of attached object + type: object + additionalProperties: + type: string + labels: + description: Labels put on attached object + type: object + additionalProperties: + type: string + Operation: + description: An Operation of a Service or API + type: object + required: + - name + - method + properties: + name: + description: Unique name of this Operation within Service scope + type: string + method: + description: Represents transport method + type: string + inputName: + description: Name of input parameters in case of Xml based Service + type: string + outputName: + description: Name of output parameters in case of Xml based Service + type: string + dispatcher: + description: Dispatcher strategy used for mocks + type: string + dispatcherRules: + description: DispatcherRules used for mocks + type: string + defaultDelay: + description: Default response time delay for mocks + type: number + resourcePaths: + description: Paths the mocks endpoints are mapped on + type: array + items: + type: string + parameterContraints: + description: Contraints that may apply to mock invocatino on this operation + type: array + items: + $ref: '#/components/schemas/ParameterConstraint' + bindings: + description: Map of protocol binding details for this operation + type: object + additionalProperties: + $ref: '#/components/schemas/Binding' + ParameterConstraint: + description: Companion object for Operation that may be used to express constraints + on request parameters + type: object + required: + - name + properties: + name: + description: Parameter name + type: string + required: + description: Whether it's a required constraint + type: boolean + recopy: + description: Whether it's a recopy constraint + type: boolean + mustMatchRegexp: + description: Whether it's a regular expression matching constraint + type: string + in: + description: Parameter location + type: string + enum: + - path + - query + - header + Binding: + description: Protocol binding details for asynchronous operations + type: object + required: + - type + - destinationName + properties: + type: + description: Protocol binding identifier + type: string + enum: + - KAFKA + - MQTT + - WS + - AMQP + - NATS + - GOOGLEPUBSUB + - SQS + - SNS + keyType: + description: Type of key for Kafka messages + type: string + destinationType: + description: Type of destination for asynchronous messages of this operation + type: string + destinationName: + description: Name of destination for asynchronous messages of this operation + type: string + qoS: + description: Quality of Service attribute for MQTT binding + type: string + persistent: + description: Persistent attribute for MQTT binding + type: boolean + method: + description: HTTP method for WebSocket binding + type: string + MessageArray: + description: Array of Message for Service operations + type: array + items: + $ref: '#/components/schemas/Exchange' + Exchange: + description: "Abstract representation of a Service or API exchange type (request/response,\ + \ event based, ...)" + oneOf: + - $ref: '#/components/schemas/RequestResponsePair' + - $ref: '#/components/schemas/UnidirectionalEvent' + discriminator: type + AbstractExchange: + description: Abstract bean representing a Service or API Exchange. + type: object + required: + - type + properties: + type: + description: Discriminant type for identifying kind of exchange + type: string + enum: + - reqRespPair + - unidirEvent + RequestResponsePair: + description: Request associated with corresponding Response + type: object + allOf: + - type: object + properties: + request: + $ref: '#/components/schemas/Request' + description: The request part of the pair + response: + $ref: '#/components/schemas/Response' + description: The Response part of the pair + required: + - request + - response + - $ref: '#/components/schemas/AbstractExchange' + UnidirectionalEvent: + description: Representation of an unidirectional exchange as an event message + type: object + allOf: + - type: object + required: + - eventMessage + properties: + eventMessage: + $ref: '#/components/schemas/EventMessage' + description: Asynchronous message for this unidirectional event + - $ref: '#/components/schemas/AbstractExchange' + Request: + description: A mock invocation or test request + required: + - name + - operationId + properties: + id: + description: Unique identifier of Request + type: string + name: + description: Unique distinct name of this Request + type: string + content: + description: Body content for this request + type: string + operationId: + description: Identifier of Operation this Request is associated to + type: string + testCaseId: + description: Unique identifier of TestCase this Request is attached (in + case of a test) + type: string + headers: + description: Headers for this Request + type: array + items: + $ref: '#/components/schemas/Header' + Response: + description: A mock invocation or test response + required: + - operationId + - name + properties: + operationId: + description: Identifier of Operation this Response is associated to + type: string + content: + description: Body content of this Response + type: string + id: + description: Unique identifier of Response + type: string + name: + description: Unique distinct name of this Response + type: string + testCaseId: + description: Unique identifier of TestCase this Response is attached (in + case of a test) + type: string + headers: + description: Headers for this Response + type: array + items: + $ref: '#/components/schemas/Header' + Header: + description: Transport headers for both Requests and Responses + required: + - name + - values + type: object + properties: + name: + description: Unique distinct name of this Header + type: string + values: + description: Values for this Header + type: array + items: + type: string + EventMessage: + description: A mock event message + required: + - id + - mediaType + type: object + properties: + id: + description: Unique identifier of this message + type: string + mediaType: + description: Content type of message + type: string + name: + description: Unique distinct name of this message + type: string + content: + description: Body content for this message + type: string + operationId: + description: Identifier of Operation this message is associated to + type: string + testCaseId: + description: Unique identifier of TestCase this message is attached (in + case of a test) + type: string + headers: + description: Headers for this message + type: array + items: + $ref: '#/components/schemas/Header' diff --git a/core/src/test/resources/asyncapi/sut/scalar.yaml b/core/src/test/resources/asyncapi/sut/scalar.yaml new file mode 100644 index 0000000000..94e6f977aa --- /dev/null +++ b/core/src/test/resources/asyncapi/sut/scalar.yaml @@ -0,0 +1,1853 @@ +# Source: https://github.com/scalar/scalar (MIT), unmodified. +asyncapi: 3.0.0 +info: + title: Scalar Galaxy Events + version: 1.0.0 + description: | + The Scalar Galaxy Events provides real-time notifications and event-driven capabilities for the Scalar Galaxy API. This AsyncAPI document complements the [REST API](https://galaxy.scalar.com) by enabling event-driven interactions. + + ## Event-Driven Features + + * Real-time Planet Updates: Get notified when planets are created, updated, or experience cosmic events + * User Activity Streams: Track user signups, authentication events, and profile changes + * Celestial Body Monitoring: Monitor changes to satellites, asteroids, and other celestial objects + * System Notifications: Receive alerts about system maintenance, rate limits, and security events + + ## Resources + + * https://github.com/scalar/scalar + * https://github.com/asyncapi/spec + * https://scalar.com + contact: + name: Scalar Support + url: https://scalar.com + email: support@scalar.com + license: + name: MIT + url: https://opensource.org/license/MIT + externalDocs: + description: Documentation + url: https://github.com/scalar/scalar +servers: + production: + host: galaxy.scalar.com + protocol: wss + description: Production WebSocket server for real-time events + security: + - $ref: '#/components/securitySchemes/bearerAuth' + - $ref: '#/components/securitySchemes/httpApiKey' + tags: + - name: production + description: Production environment + development: + host: localhost:8080 + protocol: ws + description: Local development server + security: [] + tags: + - name: development + description: Development environment +defaultContentType: application/json +channels: + planetEvents: + address: 'planets/{planetId}/events' + description: | + Real-time events related to specific planets. Subscribe to get notified about: + + * Planet creation and updates + * Atmospheric changes + * Discovery of new satellites + * Cosmic events (explosions, collisions, etc.) + * Image uploads and media updates + parameters: + planetId: + $ref: '#/components/parameters/planetId' + messages: + planetCreated: + $ref: '#/components/messages/PlanetCreated' + planetUpdated: + $ref: '#/components/messages/PlanetUpdated' + planetDeleted: + $ref: '#/components/messages/PlanetDeleted' + planetExploded: + $ref: '#/components/messages/PlanetExploded' + satelliteDiscovered: + $ref: '#/components/messages/SatelliteDiscovered' + atmosphereChanged: + $ref: '#/components/messages/AtmosphereChanged' + imageUploaded: + $ref: '#/components/messages/ImageUploaded' + bindings: + ws: + method: GET + query: + type: object + properties: + includeHistory: + type: boolean + description: Include historical events + default: false + eventTypes: + type: array + items: + type: string + description: Filter by specific event types + userEvents: + address: 'users/{userId}/events' + description: | + User-related events including authentication, profile changes, and activity tracking. + + Security Note: These events contain sensitive user information and require proper authentication. + parameters: + userId: + $ref: '#/components/parameters/userId' + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' + userAuthenticated: + $ref: '#/components/messages/UserAuthenticated' + userProfileUpdated: + $ref: '#/components/messages/UserProfileUpdated' + userDeleted: + $ref: '#/components/messages/UserDeleted' + loginAttempt: + $ref: '#/components/messages/LoginAttempt' + bindings: + ws: + method: GET + systemEvents: + address: 'system/events' + description: | + System-wide events including maintenance notifications, rate limit alerts, and security events. + + These events are broadcast to all connected clients and don't require specific subscriptions. + messages: + systemMaintenance: + $ref: '#/components/messages/SystemMaintenance' + rateLimitExceeded: + $ref: '#/components/messages/RateLimitExceeded' + securityAlert: + $ref: '#/components/messages/SecurityAlert' + apiVersionDeprecated: + $ref: '#/components/messages/ApiVersionDeprecated' + bindings: + ws: + method: GET + celestialBodyEvents: + address: 'celestial-bodies/events' + description: | + Events related to all types of celestial bodies (planets, satellites, asteroids, comets). + + This is a general channel for monitoring the entire galaxy's celestial activity. + messages: + celestialBodyCreated: + $ref: '#/components/messages/CelestialBodyCreated' + celestialBodyUpdated: + $ref: '#/components/messages/CelestialBodyUpdated' + orbitalCollision: + $ref: '#/components/messages/OrbitalCollision' + newDiscovery: + $ref: '#/components/messages/NewDiscovery' + bindings: + ws: + method: GET + query: + type: object + properties: + bodyType: + type: string + enum: [planet, satellite, asteroid, comet] + description: Filter by celestial body type + minHabitability: + type: number + minimum: 0 + maximum: 1 + description: Filter by minimum habitability index +operations: + subscribeToPlanetEvents: + action: receive + channel: + $ref: '#/channels/planetEvents' + title: Subscribe to Planet Events + description: Subscribe to real-time events for a specific planet + traits: + - $ref: '#/components/operationTraits/authenticated' + bindings: + ws: + bindingVersion: 0.1.0 + method: GET + subscribeToUserEvents: + action: receive + channel: + $ref: '#/channels/userEvents' + title: Subscribe to User Events + description: Subscribe to events for a specific user (requires authentication) + traits: + - $ref: '#/components/operationTraits/authenticated' + - $ref: '#/components/operationTraits/rateLimited' + bindings: + ws: + bindingVersion: 0.1.0 + method: GET + subscribeToSystemEvents: + action: receive + channel: + $ref: '#/channels/systemEvents' + title: Subscribe to System Events + description: Subscribe to system-wide notifications and alerts + bindings: + ws: + bindingVersion: 0.1.0 + method: GET + subscribeToCelestialBodyEvents: + action: receive + channel: + $ref: '#/channels/celestialBodyEvents' + title: Subscribe to Celestial Body Events + description: Subscribe to events for all celestial bodies in the galaxy + traits: + - $ref: '#/components/operationTraits/rateLimited' + bindings: + ws: + bindingVersion: 0.1.0 + method: GET +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication for WebSocket connections + httpApiKey: + type: httpApiKey + in: query + name: api_key + description: API key for WebSocket authentication + oauth2: + type: oauth2 + flows: + implicit: + authorizationUrl: https://galaxy.scalar.com/oauth/authorize + availableScopes: + read:events: Subscribe to events + write:events: Publish events (admin only) + read:user:events: Subscribe to user events + parameters: + planetId: + description: The ID of the planet + userId: + description: The ID of the user + operationTraits: + authenticated: + description: This operation requires authentication + security: + - $ref: '#/components/securitySchemes/bearerAuth' + - $ref: '#/components/securitySchemes/httpApiKey' + rateLimited: + description: This operation is subject to rate limiting + bindings: + ws: + bindingVersion: 0.1.0 + headers: + type: object + properties: + X-RateLimit-Limit: + type: integer + description: Maximum requests per minute + X-RateLimit-Remaining: + type: integer + description: Remaining requests in current window + messages: + PlanetCreated: + name: PlanetCreated + title: Planet Created Event + summary: A new planet has been discovered or created + description: | + This event is published when a new planet is added to the Scalar Galaxy. + + Event Flow: + 1. Planet creation request via REST API + 2. Planet validation and storage + 3. This event published to subscribers + 4. Optional webhook callbacks triggered + contentType: application/json + payload: + $ref: '#/components/schemas/PlanetCreatedEvent' + examples: + - name: Mars Discovery + summary: Mars was just added to the galaxy + payload: + eventId: 'evt_1234567890' + eventType: 'planet.created' + timestamp: '2024-01-15T14:30:00Z' + planet: + id: 4 + name: 'Mars' + description: 'The red planet' + type: 'terrestrial' + habitabilityIndex: 0.68 + physicalProperties: + mass: 0.107 + radius: 0.532 + gravity: 0.378 + atmosphere: + - compound: 'CO2' + percentage: 95.3 + discoveredAt: '1610-01-07T00:00:00Z' + creator: + id: 1 + name: 'Marc' + metadata: + source: 'rest-api' + userId: 1 + requestId: 'req_abc123' + bindings: + ws: + bindingVersion: 0.1.0 + message: + type: object + properties: + eventId: + type: string + description: Unique event identifier + eventType: + type: string + description: Type of event + timestamp: + type: string + format: date-time + description: When the event occurred + PlanetUpdated: + name: PlanetUpdated + title: Planet Updated Event + summary: An existing planet has been modified + description: Published when planet properties are updated via the REST API + contentType: application/json + payload: + $ref: '#/components/schemas/PlanetUpdatedEvent' + examples: + - name: Mars Atmosphere Update + summary: Mars atmosphere composition was updated + payload: + eventId: 'evt_1234567891' + eventType: 'planet.updated' + timestamp: '2024-01-15T15:45:00Z' + planet: + id: 4 + name: 'Mars' + atmosphere: + - compound: 'CO2' + percentage: 95.3 + - compound: 'N2' + percentage: 2.7 + changes: + - field: 'atmosphere' + oldValue: [{ 'compound': 'CO2', 'percentage': 95.3 }] + newValue: + [ + { 'compound': 'CO2', 'percentage': 95.3 }, + { 'compound': 'N2', 'percentage': 2.7 }, + ] + metadata: + source: 'rest-api' + userId: 1 + requestId: 'req_def456' + bindings: + ws: + bindingVersion: 0.1.0 + PlanetDeleted: + name: PlanetDeleted + title: Planet Deleted Event + summary: A planet has been removed from the galaxy + description: | + ⚠️ Warning: This event indicates a planet has been deleted. + + This is a destructive operation that may affect related celestial bodies and data. + contentType: application/json + payload: + $ref: '#/components/schemas/PlanetDeletedEvent' + examples: + - name: Planet Destruction + summary: A planet was deleted (hopefully by accident!) + payload: + eventId: 'evt_1234567892' + eventType: 'planet.deleted' + timestamp: '2024-01-15T16:00:00Z' + planetId: 999 + planetName: 'Test Planet' + reason: 'Experimental deletion' + metadata: + source: 'rest-api' + userId: 1 + requestId: 'req_ghi789' + bindings: + ws: + bindingVersion: 0.1.0 + PlanetExploded: + name: PlanetExploded + title: Planet Exploded Event + summary: A planet has experienced a cosmic explosion + description: | + 🚨 Cosmic Event: A planet has exploded due to natural or artificial causes. + + This is a rare but dramatic event that may create new celestial bodies or affect nearby objects. + contentType: application/json + payload: + $ref: '#/components/schemas/PlanetExplodedEvent' + examples: + - name: Supernova Event + summary: A planet went supernova + payload: + eventId: 'evt_1234567893' + eventType: 'planet.exploded' + timestamp: '2024-01-15T16:30:00Z' + planet: + id: 42 + name: 'Krypton' + type: 'super_earth' + explosion: + type: 'supernova' + intensity: 9.8 + energyReleased: '1.2e44' + newCelestialBodies: + - type: 'asteroid' + name: 'Krypton Fragment Alpha' + - type: 'asteroid' + name: 'Krypton Fragment Beta' + affectedObjects: + - planetId: 41 + distance: 1500000 + impact: 'moderate' + metadata: + source: 'cosmic-simulator' + severity: 'critical' + bindings: + ws: + bindingVersion: 0.1.0 + SatelliteDiscovered: + name: SatelliteDiscovered + title: Satellite Discovered Event + summary: A new satellite has been discovered orbiting a planet + description: Published when a new moon, asteroid, or other satellite is found + contentType: application/json + payload: + $ref: '#/components/schemas/SatelliteDiscoveredEvent' + examples: + - name: New Moon Discovery + summary: A new moon was discovered around Mars + payload: + eventId: 'evt_1234567894' + eventType: 'satellite.discovered' + timestamp: '2024-01-15T17:00:00Z' + satellite: + id: 15 + name: 'Deimos II' + type: 'moon' + diameter: 12.4 + orbit: + planet: + id: 4 + name: 'Mars' + orbitalPeriod: 1.26 + distance: 23460 + discoveryMethod: 'telescope_observation' + metadata: + source: 'astronomical-survey' + observerId: 2 + bindings: + ws: + bindingVersion: 0.1.0 + AtmosphereChanged: + name: AtmosphereChanged + title: Atmosphere Changed Event + summary: A planet's atmosphere composition has changed + description: Published when atmospheric properties are modified + contentType: application/json + payload: + $ref: '#/components/schemas/AtmosphereChangedEvent' + examples: + - name: Terraforming Progress + summary: Mars atmosphere is being terraformed + payload: + eventId: 'evt_1234567895' + eventType: 'atmosphere.changed' + timestamp: '2024-01-15T17:30:00Z' + planet: + id: 4 + name: 'Mars' + atmosphere: + - compound: 'CO2' + percentage: 95.3 + - compound: 'O2' + percentage: 2.1 + - compound: 'N2' + percentage: 2.6 + change: + type: 'terraforming' + progress: 0.15 + estimatedCompletion: '2050-12-31T00:00:00Z' + metadata: + source: 'terraforming-station' + stationId: 'mars-alpha-1' + bindings: + ws: + bindingVersion: 0.1.0 + ImageUploaded: + name: ImageUploaded + title: Image Uploaded Event + summary: A new image has been uploaded for a planet + description: Published when a planet image is successfully uploaded + contentType: application/json + payload: + $ref: '#/components/schemas/ImageUploadedEvent' + examples: + - name: Mars Photo Upload + summary: A new photo of Mars was uploaded + payload: + eventId: 'evt_1234567896' + eventType: 'image.uploaded' + timestamp: '2024-01-15T18:00:00Z' + planet: + id: 4 + name: 'Mars' + image: + url: 'https://cdn.scalar.com/images/mars-latest.jpg' + fileSize: 2048576 + mimeType: 'image/jpeg' + uploadedAt: '2024-01-15T18:00:00Z' + uploader: + id: 1 + name: 'Marc' + metadata: + source: 'rest-api' + requestId: 'req_jkl012' + bindings: + ws: + bindingVersion: 0.1.0 + UserSignedUp: + name: UserSignedUp + title: User Signed Up Event + summary: A new user has registered in the Scalar Galaxy + description: Published when a user successfully creates an account + contentType: application/json + payload: + $ref: '#/components/schemas/UserSignedUpEvent' + examples: + - name: New User Registration + summary: A new user joined the galaxy + payload: + eventId: 'evt_1234567897' + eventType: 'user.signed_up' + timestamp: '2024-01-15T18:30:00Z' + user: + id: 100 + name: 'Alice' + email: 'alice@example.com' + registration: + method: 'email' + source: 'web' + ipAddress: '192.168.1.100' + metadata: + source: 'rest-api' + requestId: 'req_mno345' + bindings: + ws: + bindingVersion: 0.1.0 + UserAuthenticated: + name: UserAuthenticated + title: User Authenticated Event + summary: A user has successfully authenticated + description: Published when a user logs in or refreshes their token + contentType: application/json + payload: + $ref: '#/components/schemas/UserAuthenticatedEvent' + examples: + - name: Successful Login + summary: User logged in successfully + payload: + eventId: 'evt_1234567898' + eventType: 'user.authenticated' + timestamp: '2024-01-15T19:00:00Z' + user: + id: 1 + name: 'Marc' + authentication: + method: 'bearer_token' + tokenType: 'JWT' + expiresAt: '2024-01-16T19:00:00Z' + session: + id: 'sess_abc123' + ipAddress: '192.168.1.100' + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + metadata: + source: 'auth-service' + requestId: 'req_pqr678' + bindings: + ws: + bindingVersion: 0.1.0 + UserProfileUpdated: + name: UserProfileUpdated + title: User Profile Updated Event + summary: A user's profile information has been modified + description: Published when user profile data is updated + contentType: application/json + payload: + $ref: '#/components/schemas/UserProfileUpdatedEvent' + examples: + - name: Profile Update + summary: User updated their profile + payload: + eventId: 'evt_1234567899' + eventType: 'user.profile_updated' + timestamp: '2024-01-15T19:30:00Z' + user: + id: 1 + name: 'Marc Updated' + changes: + - field: 'name' + oldValue: 'Marc' + newValue: 'Marc Updated' + metadata: + source: 'rest-api' + userId: 1 + requestId: 'req_stu901' + bindings: + ws: + bindingVersion: 0.1.0 + UserDeleted: + name: UserDeleted + title: User Deleted Event + summary: A user account has been deleted + description: Published when a user account is permanently removed + contentType: application/json + payload: + $ref: '#/components/schemas/UserDeletedEvent' + examples: + - name: Account Deletion + summary: A user deleted their account + payload: + eventId: 'evt_1234567900' + eventType: 'user.deleted' + timestamp: '2024-01-15T20:00:00Z' + user: + id: 99 + name: 'Deleted User' + deletion: + reason: 'user_request' + dataRetention: '30_days' + metadata: + source: 'user-service' + adminId: 1 + requestId: 'req_vwx234' + bindings: + ws: + bindingVersion: 0.1.0 + LoginAttempt: + name: LoginAttempt + title: Login Attempt Event + summary: A user login attempt has been made + description: Published for both successful and failed login attempts + contentType: application/json + payload: + $ref: '#/components/schemas/LoginAttemptEvent' + examples: + - name: Failed Login + summary: Someone tried to log in with wrong credentials + payload: + eventId: 'evt_1234567901' + eventType: 'login.attempt' + timestamp: '2024-01-15T20:30:00Z' + attempt: + email: 'marc@scalar.com' + success: false + reason: 'invalid_credentials' + ipAddress: '192.168.1.200' + userAgent: 'curl/7.68.0' + security: + riskScore: 0.3 + suspiciousActivity: false + metadata: + source: 'auth-service' + requestId: 'req_yza567' + bindings: + ws: + bindingVersion: 0.1.0 + SystemMaintenance: + name: SystemMaintenance + title: System Maintenance Event + summary: System maintenance has been scheduled or is in progress + description: Published for planned and emergency maintenance events + contentType: application/json + payload: + $ref: '#/components/schemas/SystemMaintenanceEvent' + examples: + - name: Scheduled Maintenance + summary: System maintenance is starting + payload: + eventId: 'evt_1234567902' + eventType: 'system.maintenance' + timestamp: '2024-01-15T21:00:00Z' + maintenance: + type: 'scheduled' + status: 'starting' + duration: 'PT2H' + description: 'Database optimization and security updates' + affectedServices: + - 'api' + - 'events' + - 'storage' + schedule: + startTime: '2024-01-15T21:00:00Z' + endTime: '2024-01-15T23:00:00Z' + metadata: + source: 'system-monitor' + maintenanceId: 'maint_20240115' + bindings: + ws: + bindingVersion: 0.1.0 + RateLimitExceeded: + name: RateLimitExceeded + title: Rate Limit Exceeded Event + summary: A user or system has exceeded rate limits + description: Published when rate limits are exceeded + contentType: application/json + payload: + $ref: '#/components/schemas/RateLimitExceededEvent' + examples: + - name: API Rate Limit Hit + summary: A user hit the API rate limit + payload: + eventId: 'evt_1234567903' + eventType: 'rate_limit.exceeded' + timestamp: '2024-01-15T21:30:00Z' + rateLimit: + limit: 1000 + window: 'PT1H' + exceededBy: 50 + user: + id: 1 + name: 'Marc' + request: + endpoint: '/planets' + method: 'GET' + ipAddress: '192.168.1.100' + metadata: + source: 'rate-limiter' + requestId: 'req_bcd890' + bindings: + ws: + bindingVersion: 0.1.0 + SecurityAlert: + name: SecurityAlert + title: Security Alert Event + summary: A security-related event has been detected + description: Published for security incidents and suspicious activity + contentType: application/json + payload: + $ref: '#/components/schemas/SecurityAlertEvent' + examples: + - name: Suspicious Activity + summary: Suspicious login pattern detected + payload: + eventId: 'evt_1234567904' + eventType: 'security.alert' + timestamp: '2024-01-15T22:00:00Z' + alert: + severity: 'medium' + type: 'suspicious_login_pattern' + description: 'Multiple failed login attempts from different IP addresses' + activity: + attempts: 15 + timeWindow: 'PT5M' + ipAddresses: + - '192.168.1.100' + - '192.168.1.101' + - '10.0.0.50' + actions: + - 'account_temporarily_locked' + - 'admin_notified' + metadata: + source: 'security-monitor' + alertId: 'alert_sec_001' + bindings: + ws: + bindingVersion: 0.1.0 + ApiVersionDeprecated: + name: ApiVersionDeprecated + title: API Version Deprecated Event + summary: An API version has been deprecated + description: Published when API versions are marked as deprecated + contentType: application/json + payload: + $ref: '#/components/schemas/ApiVersionDeprecatedEvent' + examples: + - name: Version Deprecation + summary: API version 1.0 is being deprecated + payload: + eventId: 'evt_1234567905' + eventType: 'api.version_deprecated' + timestamp: '2024-01-15T22:30:00Z' + deprecation: + version: '1.0' + deprecatedAt: '2024-01-15T22:30:00Z' + sunsetAt: '2024-07-15T22:30:00Z' + migrationGuide: 'https://docs.scalar.com/migration/v1-to-v2' + impact: + affectedEndpoints: + - '/planets' + - '/users' + breakingChanges: true + metadata: + source: 'api-gateway' + deprecationId: 'dep_20240115' + bindings: + ws: + bindingVersion: 0.1.0 + CelestialBodyCreated: + name: CelestialBodyCreated + title: Celestial Body Created Event + summary: A new celestial body has been added to the galaxy + description: Published when any type of celestial body is created + contentType: application/json + payload: + $ref: '#/components/schemas/CelestialBodyCreatedEvent' + examples: + - name: New Asteroid + summary: A new asteroid was discovered + payload: + eventId: 'evt_1234567906' + eventType: 'celestial_body.created' + timestamp: '2024-01-15T23:00:00Z' + celestialBody: + type: 'satellite' + satellite: + id: 16 + name: 'Asteroid X-2024' + type: 'asteroid' + diameter: 1.2 + discovery: + method: 'automated_survey' + observatory: 'Hubble Space Telescope' + metadata: + source: 'astronomical-survey' + surveyId: 'survey_20240115' + bindings: + ws: + bindingVersion: 0.1.0 + CelestialBodyUpdated: + name: CelestialBodyUpdated + title: Celestial Body Updated Event + summary: An existing celestial body has been modified + description: Published when celestial body properties are updated + contentType: application/json + payload: + $ref: '#/components/schemas/CelestialBodyUpdatedEvent' + examples: + - name: Orbit Update + summary: A satellite's orbit was recalculated + payload: + eventId: 'evt_1234567907' + eventType: 'celestial_body.updated' + timestamp: '2024-01-15T23:30:00Z' + celestialBody: + type: 'satellite' + satellite: + id: 15 + name: 'Deimos II' + orbit: + orbitalPeriod: 1.28 + distance: 23500 + changes: + - field: 'orbit.orbitalPeriod' + oldValue: 1.26 + newValue: 1.28 + - field: 'orbit.distance' + oldValue: 23460 + newValue: 23500 + metadata: + source: 'orbital-mechanics' + calculationId: 'calc_20240115' + bindings: + ws: + bindingVersion: 0.1.0 + OrbitalCollision: + name: OrbitalCollision + title: Orbital Collision Event + summary: Two celestial bodies have collided + description: Published when orbital collisions occur + contentType: application/json + payload: + $ref: '#/components/schemas/OrbitalCollisionEvent' + examples: + - name: Asteroid Impact + summary: An asteroid collided with a planet + payload: + eventId: 'evt_1234567908' + eventType: 'orbital.collision' + timestamp: '2024-01-16T00:00:00Z' + collision: + type: 'asteroid_planet' + severity: 'minor' + energy: '1.2e12' + objects: + - type: 'satellite' + id: 17 + name: 'Asteroid Y-2024' + mass: 1.5e12 + - type: 'planet' + id: 4 + name: 'Mars' + impact: + location: 'Valles Marineris' + craterSize: 150 + atmosphericEffects: 'dust_cloud' + metadata: + source: 'collision-detector' + simulationId: 'sim_20240116' + bindings: + ws: + bindingVersion: 0.1.0 + NewDiscovery: + name: NewDiscovery + title: New Discovery Event + summary: A significant astronomical discovery has been made + description: Published for major discoveries and breakthroughs + contentType: application/json + payload: + $ref: '#/components/schemas/NewDiscoveryEvent' + examples: + - name: Habitable Planet + summary: A potentially habitable planet was discovered + payload: + eventId: 'evt_1234567909' + eventType: 'discovery.new' + timestamp: '2024-01-16T00:30:00Z' + discovery: + type: 'habitable_planet' + significance: 'high' + description: 'Earth-like planet with liquid water detected' + celestialBody: + type: 'planet' + planet: + id: 50 + name: 'Kepler-442b' + type: 'super_earth' + habitabilityIndex: 0.84 + physicalProperties: + mass: 2.36 + radius: 1.34 + temperature: + average: 233 + research: + method: 'transit_photometry' + observatory: 'Kepler Space Telescope' + paper: 'https://arxiv.org/abs/2401.15000' + metadata: + source: 'research-team' + discoveryId: 'disc_20240116' + bindings: + ws: + bindingVersion: 0.1.0 + schemas: + PlanetCreatedEvent: + type: object + required: [eventId, eventType, timestamp, planet] + properties: + eventId: + type: string + format: uuid + description: Unique identifier for this event + examples: ['evt_1234567890'] + eventType: + type: string + enum: ['planet.created'] + description: Type of event + timestamp: + type: string + format: date-time + description: When the event occurred + examples: ['2024-01-15T14:30:00Z'] + planet: + $ref: '#/components/schemas/Planet' + metadata: + $ref: '#/components/schemas/EventMetadata' + PlanetUpdatedEvent: + type: object + required: [eventId, eventType, timestamp, planet, changes] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['planet.updated'] + timestamp: + type: string + format: date-time + planet: + $ref: '#/components/schemas/Planet' + changes: + type: array + items: + $ref: '#/components/schemas/FieldChange' + description: List of fields that were modified + metadata: + $ref: '#/components/schemas/EventMetadata' + PlanetDeletedEvent: + type: object + required: [eventId, eventType, timestamp, planetId, planetName] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['planet.deleted'] + timestamp: + type: string + format: date-time + planetId: + type: integer + format: int64 + planetName: + type: string + reason: + type: string + description: Reason for deletion + metadata: + $ref: '#/components/schemas/EventMetadata' + PlanetExplodedEvent: + type: object + required: [eventId, eventType, timestamp, planet, explosion] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['planet.exploded'] + timestamp: + type: string + format: date-time + planet: + $ref: '#/components/schemas/Planet' + explosion: + type: object + required: [type, intensity] + properties: + type: + type: string + enum: ['supernova', 'nova', 'collision', 'artificial'] + intensity: + type: number + minimum: 0 + maximum: 10 + description: Explosion intensity on a scale of 0-10 + energyReleased: + type: string + description: Energy released in joules (scientific notation) + newCelestialBodies: + type: array + items: + type: object + properties: + type: + type: string + enum: ['asteroid', 'comet', 'planet'] + name: + type: string + affectedObjects: + type: array + items: + type: object + properties: + planetId: + type: integer + format: int64 + distance: + type: number + description: Distance in kilometers + impact: + type: string + enum: ['none', 'minor', 'moderate', 'severe'] + metadata: + $ref: '#/components/schemas/EventMetadata' + SatelliteDiscoveredEvent: + type: object + required: [eventId, eventType, timestamp, satellite] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['satellite.discovered'] + timestamp: + type: string + format: date-time + satellite: + $ref: '#/components/schemas/Satellite' + discoveryMethod: + type: string + enum: + [ + 'telescope_observation', + 'space_probe', + 'mathematical_prediction', + 'collision_remnant', + ] + metadata: + $ref: '#/components/schemas/EventMetadata' + AtmosphereChangedEvent: + type: object + required: [eventId, eventType, timestamp, planet, atmosphere] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['atmosphere.changed'] + timestamp: + type: string + format: date-time + planet: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + atmosphere: + type: array + items: + type: object + properties: + compound: + type: string + percentage: + type: number + format: float + change: + type: object + properties: + type: + type: string + enum: ['terraforming', 'natural', 'pollution', 'atmospheric_loss'] + progress: + type: number + minimum: 0 + maximum: 1 + description: Progress of the change (0-1) + estimatedCompletion: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/EventMetadata' + ImageUploadedEvent: + type: object + required: [eventId, eventType, timestamp, planet, image] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['image.uploaded'] + timestamp: + type: string + format: date-time + planet: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + image: + type: object + required: [url, fileSize, mimeType, uploadedAt] + properties: + url: + type: string + format: uri + fileSize: + type: integer + description: File size in bytes + mimeType: + type: string + uploadedAt: + type: string + format: date-time + uploader: + $ref: '#/components/schemas/User' + metadata: + $ref: '#/components/schemas/EventMetadata' + UserSignedUpEvent: + type: object + required: [eventId, eventType, timestamp, user] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['user.signed_up'] + timestamp: + type: string + format: date-time + user: + $ref: '#/components/schemas/User' + registration: + type: object + properties: + method: + type: string + enum: ['email', 'oauth', 'sso'] + source: + type: string + enum: ['web', 'mobile', 'api'] + ipAddress: + type: string + format: ipv4 + metadata: + $ref: '#/components/schemas/EventMetadata' + UserAuthenticatedEvent: + type: object + required: [eventId, eventType, timestamp, user, authentication] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['user.authenticated'] + timestamp: + type: string + format: date-time + user: + $ref: '#/components/schemas/User' + authentication: + type: object + required: [method, tokenType] + properties: + method: + type: string + enum: ['bearer_token', 'basic_auth', 'oauth', 'api_key'] + tokenType: + type: string + enum: ['JWT', 'OAuth', 'Basic'] + expiresAt: + type: string + format: date-time + session: + type: object + properties: + id: + type: string + ipAddress: + type: string + format: ipv4 + userAgent: + type: string + metadata: + $ref: '#/components/schemas/EventMetadata' + UserProfileUpdatedEvent: + type: object + required: [eventId, eventType, timestamp, user, changes] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['user.profile_updated'] + timestamp: + type: string + format: date-time + user: + $ref: '#/components/schemas/User' + changes: + type: array + items: + $ref: '#/components/schemas/FieldChange' + metadata: + $ref: '#/components/schemas/EventMetadata' + UserDeletedEvent: + type: object + required: [eventId, eventType, timestamp, user] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['user.deleted'] + timestamp: + type: string + format: date-time + user: + $ref: '#/components/schemas/User' + deletion: + type: object + properties: + reason: + type: string + enum: + ['user_request', 'admin_action', 'policy_violation', 'inactive'] + dataRetention: + type: string + enum: ['immediate', '30_days', '90_days', '1_year'] + metadata: + $ref: '#/components/schemas/EventMetadata' + LoginAttemptEvent: + type: object + required: [eventId, eventType, timestamp, attempt] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['login.attempt'] + timestamp: + type: string + format: date-time + attempt: + type: object + required: [email, success] + properties: + email: + type: string + format: email + success: + type: boolean + reason: + type: string + enum: + [ + 'invalid_credentials', + 'account_locked', + 'rate_limited', + 'success', + ] + ipAddress: + type: string + format: ipv4 + userAgent: + type: string + security: + type: object + properties: + riskScore: + type: number + minimum: 0 + maximum: 1 + suspiciousActivity: + type: boolean + metadata: + $ref: '#/components/schemas/EventMetadata' + SystemMaintenanceEvent: + type: object + required: [eventId, eventType, timestamp, maintenance] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['system.maintenance'] + timestamp: + type: string + format: date-time + maintenance: + type: object + required: [type, status, duration] + properties: + type: + type: string + enum: ['scheduled', 'emergency', 'hotfix'] + status: + type: string + enum: + ['scheduled', 'starting', 'in_progress', 'completed', 'failed'] + duration: + type: string + format: duration + description: Expected duration in ISO 8601 format + description: + type: string + affectedServices: + type: array + items: + type: string + schedule: + type: object + properties: + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + metadata: + $ref: '#/components/schemas/EventMetadata' + RateLimitExceededEvent: + type: object + required: [eventId, eventType, timestamp, rateLimit] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['rate_limit.exceeded'] + timestamp: + type: string + format: date-time + rateLimit: + type: object + required: [limit, window, exceededBy] + properties: + limit: + type: integer + window: + type: string + format: duration + exceededBy: + type: integer + user: + $ref: '#/components/schemas/User' + request: + type: object + properties: + endpoint: + type: string + method: + type: string + ipAddress: + type: string + format: ipv4 + metadata: + $ref: '#/components/schemas/EventMetadata' + SecurityAlertEvent: + type: object + required: [eventId, eventType, timestamp, alert] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['security.alert'] + timestamp: + type: string + format: date-time + alert: + type: object + required: [severity, type, description] + properties: + severity: + type: string + enum: ['low', 'medium', 'high', 'critical'] + type: + type: string + enum: + [ + 'suspicious_login_pattern', + 'brute_force', + 'data_breach', + 'malware_detected', + ] + description: + type: string + activity: + type: object + properties: + attempts: + type: integer + timeWindow: + type: string + format: duration + ipAddresses: + type: array + items: + type: string + format: ipv4 + actions: + type: array + items: + type: string + metadata: + $ref: '#/components/schemas/EventMetadata' + ApiVersionDeprecatedEvent: + type: object + required: [eventId, eventType, timestamp, deprecation] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['api.version_deprecated'] + timestamp: + type: string + format: date-time + deprecation: + type: object + required: [version, deprecatedAt, sunsetAt] + properties: + version: + type: string + deprecatedAt: + type: string + format: date-time + sunsetAt: + type: string + format: date-time + migrationGuide: + type: string + format: uri + impact: + type: object + properties: + affectedEndpoints: + type: array + items: + type: string + breakingChanges: + type: boolean + metadata: + $ref: '#/components/schemas/EventMetadata' + CelestialBodyCreatedEvent: + type: object + required: [eventId, eventType, timestamp, celestialBody] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['celestial_body.created'] + timestamp: + type: string + format: date-time + celestialBody: + $ref: '#/components/schemas/CelestialBody' + discovery: + type: object + properties: + method: + type: string + enum: + [ + 'telescope_observation', + 'space_probe', + 'mathematical_prediction', + 'collision_remnant', + ] + observatory: + type: string + metadata: + $ref: '#/components/schemas/EventMetadata' + CelestialBodyUpdatedEvent: + type: object + required: [eventId, eventType, timestamp, celestialBody, changes] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['celestial_body.updated'] + timestamp: + type: string + format: date-time + celestialBody: + $ref: '#/components/schemas/CelestialBody' + changes: + type: array + items: + $ref: '#/components/schemas/FieldChange' + metadata: + $ref: '#/components/schemas/EventMetadata' + OrbitalCollisionEvent: + type: object + required: [eventId, eventType, timestamp, collision, objects] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['orbital.collision'] + timestamp: + type: string + format: date-time + collision: + type: object + required: [type, severity, energy] + properties: + type: + type: string + enum: + [ + 'asteroid_planet', + 'planet_planet', + 'satellite_planet', + 'asteroid_asteroid', + ] + severity: + type: string + enum: ['minor', 'moderate', 'major', 'catastrophic'] + energy: + type: string + description: Energy released in joules (scientific notation) + objects: + type: array + minItems: 2 + maxItems: 2 + items: + type: object + properties: + type: + type: string + enum: ['planet', 'satellite'] + id: + type: integer + format: int64 + name: + type: string + mass: + type: number + description: Mass in kilograms + impact: + type: object + properties: + location: + type: string + craterSize: + type: number + description: Crater size in meters + atmosphericEffects: + type: string + enum: + [ + 'none', + 'dust_cloud', + 'atmospheric_heating', + 'atmospheric_loss', + ] + metadata: + $ref: '#/components/schemas/EventMetadata' + NewDiscoveryEvent: + type: object + required: [eventId, eventType, timestamp, discovery, celestialBody] + properties: + eventId: + type: string + format: uuid + eventType: + type: string + enum: ['discovery.new'] + timestamp: + type: string + format: date-time + discovery: + type: object + required: [type, significance, description] + properties: + type: + type: string + enum: + [ + 'habitable_planet', + 'new_element', + 'alien_life', + 'wormhole', + 'black_hole', + ] + significance: + type: string + enum: ['low', 'medium', 'high', 'revolutionary'] + description: + type: string + celestialBody: + $ref: '#/components/schemas/CelestialBody' + research: + type: object + properties: + method: + type: string + observatory: + type: string + paper: + type: string + format: uri + metadata: + $ref: '#/components/schemas/EventMetadata' + # Shared schemas + EventMetadata: + type: object + properties: + source: + type: string + description: Source system that generated the event + userId: + type: integer + format: int64 + description: ID of the user who triggered the event (if applicable) + requestId: + type: string + description: ID of the request that triggered the event + correlationId: + type: string + description: Correlation ID for tracing related events + severity: + type: string + enum: ['info', 'warning', 'error', 'critical'] + description: Event severity level + FieldChange: + type: object + required: [field, oldValue, newValue] + properties: + field: + type: string + description: Name of the field that changed + oldValue: + description: Previous value of the field + newValue: + description: New value of the field + # Reuse schemas from OpenAPI document + Planet: + description: A planet in the Scalar Galaxy + type: object + required: [id, name] + properties: + id: + type: integer + format: int64 + readOnly: true + name: + type: string + description: + type: string + type: + type: string + enum: [terrestrial, gas_giant, ice_giant, dwarf, super_earth] + habitabilityIndex: + type: number + format: float + minimum: 0 + maximum: 1 + physicalProperties: + type: object + properties: + mass: + type: number + format: float + radius: + type: number + format: float + gravity: + type: number + format: float + temperature: + type: object + properties: + min: + type: number + format: float + max: + type: number + format: float + average: + type: number + format: float + atmosphere: + type: array + items: + type: object + properties: + compound: + type: string + percentage: + type: number + format: float + discoveredAt: + type: string + format: date-time + image: + type: string + nullable: true + satellites: + type: array + items: + $ref: '#/components/schemas/Satellite' + creator: + $ref: '#/components/schemas/User' + tags: + type: array + items: + type: string + lastUpdated: + type: string + format: date-time + readOnly: true + Satellite: + description: Every satellite in the Scalar Galaxy + type: object + required: [name] + properties: + id: + type: integer + format: int64 + readOnly: true + name: + type: string + description: + type: string + diameter: + type: number + format: float + type: + type: string + enum: [moon, asteroid, comet] + orbit: + type: object + properties: + planet: + $ref: '#/components/schemas/Planet' + orbitalPeriod: + type: number + format: float + distance: + type: number + format: float + CelestialBody: + type: object + required: [type] + properties: + type: + type: string + enum: ['planet', 'satellite'] + description: Type of celestial body + planet: + $ref: '#/components/schemas/Planet' + description: Planet data (when type is 'planet') + satellite: + $ref: '#/components/schemas/Satellite' + description: Satellite data (when type is 'satellite') + description: A celestial body which can be either a planet or a satellite + User: + description: A user + type: object + properties: + id: + type: integer + format: int64 + readOnly: true + name: + type: string From fee5769e09089abcdd06d9c6e585fb1ccbd7bd1f Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Mon, 24 Aug 2026 14:08:46 -0300 Subject: [PATCH 2/2] AsyncAPI 3.x: list scalar.yaml in reused_code.md It is vendored unmodified from github.com/scalar/scalar under MIT and names its source at the top of the file, but docs/for_developers.md also asks for third-party material to be listed in docs/reused_code.md. --- docs/reused_code.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reused_code.md b/docs/reused_code.md index 40813d5d15..303d914d5c 100644 --- a/docs/reused_code.md +++ b/docs/reused_code.md @@ -29,3 +29,7 @@ are listed here: * _asyncapi/sut/openagents-cache.yaml_: AsyncAPI description of the OpenAgents shared-cache API, used unmodified as a test resource of the `asyncapi-parser` module. Released under MIT license. + +* _asyncapi/sut/scalar.yaml_: AsyncAPI description from + [Scalar](https://github.com/scalar/scalar), used unmodified as a test resource of `core`. + Released under MIT license.