From ab89e44351d899efd099920b3a9cd6ecfa207562 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Mon, 24 Aug 2026 12:43:27 -0300 Subject: [PATCH 1/2] AsyncAPI 3.x: say what the collection fields hold docs/for_developers.md asks for a comment on fields that are data structures, not only on maps. Both were documented on their getters, which left the fields themselves reading bare -- the same gap already fixed for the maps. --- .../com/webfuzzing/asyncapi/models/AsyncApiDocument.java | 5 +++++ .../java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java index 633998fa66..fb2fe52c0a 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java @@ -56,6 +56,10 @@ public class AsyncApiDocument { */ private final Map componentSchemas; + /** + * Everything that could not be read but did not stop the document being usable, one entry + * per problem, in the order the parser met them. + */ private final List warnings; private AsyncApiDocument(Builder builder) { @@ -135,6 +139,7 @@ public static class Builder { private Map messages = Collections.emptyMap(); /** @see AsyncApiDocument#componentSchemas */ private Map componentSchemas = Collections.emptyMap(); + /** @see AsyncApiDocument#warnings */ private List warnings = Collections.emptyList(); private Builder(String rawText, DocumentLocation sourceLocation, String version) { diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java index ab7426db51..95ff9c72e2 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java @@ -34,6 +34,10 @@ public class AsyncApiMessage { */ private final Map bindings; + /** + * The entries of the message's {@code examples} array, each a raw node, in declaration + * order. Not interpreted here. + */ private final List examples; private final String title; @@ -160,6 +164,7 @@ public static class Builder { private JsonNode kafkaKey; /** @see AsyncApiMessage#bindings */ private Map bindings = Collections.emptyMap(); + /** @see AsyncApiMessage#examples */ private List examples = Collections.emptyList(); private String title; private String summary; From a83b2cb765cf1ef9e5de46a53e14007cb890cfcd Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Mon, 24 Aug 2026 14:04:43 -0300 Subject: [PATCH 2/2] AsyncAPI 3.x: parse channels and operations Second step of the AsyncAPI parser, on top of the message layer: where those messages travel, and which of them a given interaction uses. - AsyncApiChannel, an addressable place on the broker plus the messages it carries. Its address is nullable on purpose: the specification allows an explicit 'address: null' to say it is only known at run time, and real documents use that. - AsyncApiOperation and AsyncApiReply. The reply is the construct 3.0 added that makes an asynchronous interaction observable without instrumentation, which is why 3.x is the version being targeted first. - AsyncApiDocument gains channels, operations, and the accessors for resolving an operation to its channel and messages. Three things in here are less obvious than they look: - A message may be written inline inside a channel rather than in components. Those are promoted into the one message map under a synthetic id, so that nothing downstream has to care where a message was declared. - A $ref may address a message through its channel, as '#/channels//messages/', and that local key is frequently not the component id. Hence the messageKeys indirection on the channel. - An operation's 'messages' array narrows what it carries to a subset of the channel's. When an entry of that array cannot be resolved -- typically because the message itself had to be dropped -- the operation is left with fewer messages, and never falls back to the whole channel: widening a selection the document deliberately narrowed would have the operation drive messages it never claimed to. --- .../asyncapi/models/AsyncApiChannel.java | 109 +++++ .../asyncapi/models/AsyncApiDocument.java | 121 +++++- .../asyncapi/models/AsyncApiMessage.java | 9 +- .../asyncapi/models/AsyncApiOperation.java | 139 +++++++ .../asyncapi/models/AsyncApiReply.java | 62 +++ .../asyncapi/parser/AsyncApiParser.java | 389 ++++++++++++++++++ .../asyncapi/AsyncApiParserTest.java | 266 ++++++++++++ .../asyncapi/artificial/broken-parts.yaml | 52 +++ .../asyncapi/artificial/inline-messages.yaml | 58 +++ .../artificial/narrowed-selection.yaml | 57 +++ .../asyncapi/artificial/websocket-reply.yaml | 124 ++++++ 11 files changed, 1376 insertions(+), 10 deletions(-) create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannel.java create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiOperation.java create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiReply.java create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/broken-parts.yaml create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/inline-messages.yaml create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/narrowed-selection.yaml create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/websocket-reply.yaml diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannel.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannel.java new file mode 100644 index 0000000000..232df4c21f --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannel.java @@ -0,0 +1,109 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** + * An entry under {@code channels:}: one addressable place on the broker (a Kafka topic, an AMQP + * queue or routing key, an MQTT topic, a WebSocket endpoint) plus the set of messages it + * carries. + */ +public class AsyncApiChannel { + + private final String name; + + private final String address; + + /** + * Key is the channel-local message key, which is what a {@code $ref} of the form + * {@code #/channels//messages/} addresses and is frequently not the + * component id. Value is the id of that message in {@link AsyncApiDocument#getMessages()}. + */ + private final Map messageKeys; + + /** + * Ids of every message this channel carries, in declaration order and distinct: the values + * of {@link #messageKeys}, since two local keys may name the same message. + */ + private final List messageIds; + + private AsyncApiChannel(Builder builder) { + this.name = builder.name; + this.address = builder.address; + this.messageKeys = Collections.unmodifiableMap(new LinkedHashMap<>(builder.messageKeys)); + //distinct, as two local keys may well point at the same message definition + this.messageIds = Collections.unmodifiableList( + new ArrayList<>(new LinkedHashSet<>(this.messageKeys.values()))); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + /** + * The map key under {@code channels:}, e.g. "bessjRequest". This is the key {@code $ref} + * uses, and it is not the same thing as the broker address. + */ + public String getName() { + return name; + } + + /** + * The broker-side address, e.g. "ncs.bessj.request". + * + * Null on purpose: the specification allows an explicit {@code address: null} to say the + * address is not known statically and is determined at run time. It may also contain + * {@code {parameter}} placeholders, which are left in place here. + */ + public String getAddress() { + return address; + } + + /** + * Channel-local message key -> the id of that message in + * {@link AsyncApiDocument#getMessages()}. + * + * The indirection is needed because a {@code $ref} may address a message through the + * channel, as {@code #/channels//messages/}, and the local key is + * frequently not the component id: a channel may expose + * {@code #/components/messages/errorMessage} as just "error". Messages written inline in + * the channel are also here, mapped to the synthetic id they were promoted under. + */ + public Map getMessageKeys() { + return messageKeys; + } + + /** + * The ids of every message this channel carries, in declaration order. + */ + public List getMessageIds() { + return messageIds; + } + + public static class Builder { + + private final String name; + private String address; + /** @see AsyncApiChannel#messageKeys */ + private Map messageKeys = Collections.emptyMap(); + + private Builder(String name) { + this.name = name; + } + + public Builder address(String address) { this.address = address; return this; } + + public Builder messageKeys(Map messageKeys) { + this.messageKeys = messageKeys; + return this; + } + + public AsyncApiChannel build() { + return new AsyncApiChannel(this); + } + } +} diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java index fb2fe52c0a..2f6c35a522 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiDocument.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -13,9 +14,10 @@ * "Normalised" means three things: * *
    - *
  1. every message is reachable from {@link #getMessages()} by a single id;
  2. - *
  3. all {@code $ref} between AsyncAPI constructs (messages, correlation ids, traits) are - * already followed, and are represented as plain keys;
  4. + *
  5. every message is reachable from {@link #getMessages()} by a single id, including those + * written inline inside a channel, which are promoted there under a synthetic id;
  6. + *
  7. all {@code $ref} between AsyncAPI constructs (channels, operations, messages, correlation + * ids, traits) are already followed, and are represented as plain keys;
  8. *
  9. all {@code $ref} inside a message payload / headers JSON Schema are instead left * verbatim, in the local {@code #/components/schemas/} form, and every schema they can * reach is in {@link #getComponentSchemas()}. A message whose schema reaches a reference @@ -45,8 +47,21 @@ public class AsyncApiDocument { private final String defaultContentType; /** - * Key is the message id, i.e. its key under {@code components.messages}. - * Value is the message declared under it. + * Key is the channel key, i.e. its key under {@code channels} and the one a {@code $ref} + * addresses it by. Value is the channel declared under it. + */ + private final Map channels; + + /** + * Key is the operation key, i.e. its key under {@code operations}. + * Value is the operation declared under it. + */ + private final Map operations; + + /** + * Key is the message id: its key under {@code components.messages}, or the synthetic + * {@code .} for a message written inline in a channel. + * Value is the message that id refers to. */ private final Map messages; @@ -67,6 +82,8 @@ private AsyncApiDocument(Builder builder) { this.sourceLocation = builder.sourceLocation; this.version = builder.version; this.defaultContentType = builder.defaultContentType; + this.channels = Collections.unmodifiableMap(builder.channels); + this.operations = Collections.unmodifiableMap(builder.operations); this.messages = Collections.unmodifiableMap(builder.messages); this.componentSchemas = Collections.unmodifiableMap(builder.componentSchemas); this.warnings = Collections.unmodifiableList(builder.warnings); @@ -107,7 +124,23 @@ public String getDefaultContentType() { } /** - * Message id -> message, as declared under {@code components.messages}. + * Channel key -> channel. + */ + public Map getChannels() { + return channels; + } + + /** + * Operation key -> operation. + */ + public Map getOperations() { + return operations; + } + + /** + * Message id -> message. Contains both the messages declared under + * {@code components.messages} and those declared inline inside a channel, the latter under + * the synthetic id {@code .}. */ public Map getMessages() { return messages; @@ -129,12 +162,78 @@ public List getWarnings() { return warnings; } + /** + * The messages an operation can carry on its own channel, resolved to their definitions. + */ + public List messagesOf(AsyncApiOperation operation) { + return resolveMessages(operation.getMessageIds()); + } + + /** + * The messages the operation's reply can be. Empty when it declares no reply. + */ + public List replyMessagesOf(AsyncApiOperation operation) { + + if (operation.getReply() == null) { + return Collections.emptyList(); + } + + return resolveMessages(operation.getReply().getMessageIds()); + } + + /** + * The channel an operation acts on. An operation naming a channel that is not declared is + * dropped while parsing, so this is always present. + */ + public AsyncApiChannel channelOf(AsyncApiOperation operation) { + + AsyncApiChannel channel = channels.get(operation.getChannelName()); + + if (channel == null) { + throw new IllegalArgumentException( + "Operation '" + operation.getName() + "' acts on channel '" + + operation.getChannelName() + "', which this document does not declare"); + } + + return channel; + } + + /** + * The channel an operation's reply arrives on, when it declares one that is usable. + */ + public AsyncApiChannel replyChannelOf(AsyncApiOperation operation) { + + if (operation.getReply() == null || operation.getReply().getChannelName() == null) { + return null; + } + + return channels.get(operation.getReply().getChannelName()); + } + + private List resolveMessages(List ids) { + + List found = new ArrayList<>(); + + for (String id : ids) { + AsyncApiMessage message = messages.get(id); + if (message != null) { + found.add(message); + } + } + + return found; + } + public static class Builder { private final String rawText; private final DocumentLocation sourceLocation; private final String version; private String defaultContentType = DEFAULT_CONTENT_TYPE; + /** @see AsyncApiDocument#channels */ + private Map channels = Collections.emptyMap(); + /** @see AsyncApiDocument#operations */ + private Map operations = Collections.emptyMap(); /** @see AsyncApiDocument#messages */ private Map messages = Collections.emptyMap(); /** @see AsyncApiDocument#componentSchemas */ @@ -153,6 +252,16 @@ public Builder defaultContentType(String defaultContentType) { return this; } + public Builder channels(Map channels) { + this.channels = channels; + return this; + } + + public Builder operations(Map operations) { + this.operations = operations; + return this; + } + public Builder messages(Map messages) { this.messages = messages; return this; diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java index 95ff9c72e2..1b853637aa 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiMessage.java @@ -9,8 +9,8 @@ /** * One message definition, i.e. the shape of what travels on a channel. * - * A message is what a channel carries: a payload, optional headers, and the metadata that says - * how to read them. + * Messages declared under {@code components.messages} and messages written inline inside a + * channel both end up here; the only difference is the {@link #getId()} the latter get. */ public class AsyncApiMessage { @@ -66,8 +66,9 @@ public static Builder builder(String id) { } /** - * The key this message is registered under in {@link AsyncApiDocument#getMessages()}, which - * is its component key under {@code components.messages}. + * The key this message is registered under in {@link AsyncApiDocument#getMessages()}: its + * component key when it was declared under {@code components.messages}, or the synthetic + * {@code .} when it was written inline in a channel. */ public String getId() { return id; diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiOperation.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiOperation.java new file mode 100644 index 0000000000..c3073059b3 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiOperation.java @@ -0,0 +1,139 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.Collections; +import java.util.List; + +/** + * An entry under {@code operations:}, i.e. something an application does on a channel. + * + * A {@link Action#RECEIVE} operation carrying a {@link #getReply()} is the case black-box + * testing is built on: it is the only shape in which publishing produces something observable + * from outside. + */ +public class AsyncApiOperation { + + /** + * What the application does with the messages on the channel -- stated from the + * application's point of view, which is usually the system under test's. + * + * So a {@link #RECEIVE} is what the service consumes, and therefore what a tester would + * publish to; a {@link #SEND} is what it emits, and therefore what a tester would subscribe + * to. The polarity is easy to invert and worth stating: AsyncAPI 2.x expressed the same + * idea with the opposite one, naming its blocks after what other parties do. + */ + public enum Action { SEND, RECEIVE } + + private final String name; + + private final Action action; + + private final String channelName; + + /** + * Ids of the messages this operation carries, in declaration order: the subset its + * {@code messages} array selects, or all of its channel's when it declares none. + */ + private final List messageIds; + + private final AsyncApiReply reply; + + private final String title; + + private final String summary; + + private final String description; + + private AsyncApiOperation(Builder builder) { + this.name = builder.name; + this.action = builder.action; + this.channelName = builder.channelName; + this.messageIds = Collections.unmodifiableList(builder.messageIds); + this.reply = builder.reply; + this.title = builder.title; + this.summary = builder.summary; + this.description = builder.description; + } + + public static Builder builder(String name, Action action, String channelName) { + return new Builder(name, action, channelName); + } + + /** + * The map key under {@code operations:}. This is the stable identity a coverage target + * would hang on, so it is never synthesised or rewritten. + */ + public String getName() { + return name; + } + + public Action getAction() { + return action; + } + + /** + * Key of the channel this operation acts on. + */ + public String getChannelName() { + return channelName; + } + + /** + * Ids of the messages this operation carries on its own channel: the subset its + * {@code messages} array selects, or all of the channel's when it declares none. + * + * The distinction matters on transports like WebSocket, where one channel routinely carries + * dozens of unrelated messages and each operation drives exactly one. + */ + public List getMessageIds() { + return messageIds; + } + + public AsyncApiReply getReply() { + return reply; + } + + public String getTitle() { + return title; + } + + public String getSummary() { + return summary; + } + + public String getDescription() { + return description; + } + + public static class Builder { + + private final String name; + private final Action action; + private final String channelName; + /** @see AsyncApiOperation#messageIds */ + private List messageIds = Collections.emptyList(); + private AsyncApiReply reply; + private String title; + private String summary; + private String description; + + private Builder(String name, Action action, String channelName) { + this.name = name; + this.action = action; + this.channelName = channelName; + } + + public Builder messageIds(List messageIds) { this.messageIds = messageIds; return this; } + + public Builder reply(AsyncApiReply reply) { this.reply = reply; return this; } + + public Builder title(String title) { this.title = title; return this; } + + public Builder summary(String summary) { this.summary = summary; return this; } + + public Builder description(String description) { this.description = description; return this; } + + public AsyncApiOperation build() { + return new AsyncApiOperation(this); + } + } +} diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiReply.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiReply.java new file mode 100644 index 0000000000..8db376cb8a --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiReply.java @@ -0,0 +1,62 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * The {@code reply:} of a request/reply operation -- the construct AsyncAPI 3.0 added that + * makes an asynchronous interaction observable without instrumentation. 2.x has no equivalent. + */ +public class AsyncApiReply { + + private final String channelName; + + /** + * Ids of the messages the reply may be, in declaration order. More than one means the + * contract enumerates distinct outcomes, such as a result and an error. + */ + private final List messageIds; + + private final String addressLocation; + + /** + * {@code channelName} and {@code addressLocation} are both nullable: a reply may name no + * usable channel as long as it announces an address, and vice versa. + */ + public AsyncApiReply(String channelName, List messageIds, String addressLocation) { + Objects.requireNonNull(messageIds, "messageIds"); + this.channelName = channelName; + this.messageIds = Collections.unmodifiableList(messageIds); + this.addressLocation = addressLocation; + } + + /** + * Key of the channel the reply arrives on, quite often the very channel the request went + * out on, since a WebSocket connection is duplex. + * + * Null when the operation declares a reply without naming a usable channel, which is + * legitimate only alongside an {@link #getAddressLocation()}. + */ + public String getChannelName() { + return channelName; + } + + /** + * Ids of the messages the reply may be. More than one means the contract enumerates + * distinct outcomes -- a result and an error, say -- which is what would give a black-box + * search something to tell apart. + */ + public List getMessageIds() { + return messageIds; + } + + /** + * {@code reply.address.location} verbatim, when the reply address is not fixed but is + * announced by the requester inside the request itself. Its presence is what allows the + * reply channel to have no address of its own. + */ + public String getAddressLocation() { + return addressLocation; + } +} diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/parser/AsyncApiParser.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/parser/AsyncApiParser.java index b1dd4a02ab..fee2bea33d 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/parser/AsyncApiParser.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/parser/AsyncApiParser.java @@ -4,9 +4,12 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.webfuzzing.asyncapi.mapper.AsyncApiMapper; +import com.webfuzzing.asyncapi.models.AsyncApiChannel; import com.webfuzzing.asyncapi.models.AsyncApiCorrelationId; import com.webfuzzing.asyncapi.models.AsyncApiDocument; import com.webfuzzing.asyncapi.models.AsyncApiMessage; +import com.webfuzzing.asyncapi.models.AsyncApiOperation; +import com.webfuzzing.asyncapi.models.AsyncApiReply; import com.webfuzzing.asyncapi.models.DocumentLocation; import com.webfuzzing.asyncapi.resolver.AsyncApiRefResolver; @@ -18,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -36,6 +40,10 @@ */ public class AsyncApiParser { + private static final String CHANNEL_REF_PREFIX = "#/channels/"; + + private static final String MESSAGE_REF_PREFIX = "#/components/messages/"; + private static final String KAFKA = "kafka"; private static final String REF = "$ref"; @@ -112,6 +120,7 @@ public static AsyncApiDocument parse(String schemaText, DocumentLocation locatio } } + //messages first: channels refer to them, and inline ones are added to the same map Map messages = new LinkedHashMap<>(); for (Map.Entry entry : componentsOf(root, "messages").entrySet()) { AsyncApiMessage message = parseMessage( @@ -121,8 +130,26 @@ public static AsyncApiDocument parse(String schemaText, DocumentLocation locatio } } + Map channels = new LinkedHashMap<>(); + for (Map.Entry entry : objectFieldsOf(root.get("channels")).entrySet()) { + channels.put(entry.getKey(), parseChannel( + entry.getKey(), entry.getValue(), root, defaultContentType, + componentSchemas, messages, warnings)); + } + + Map operations = new LinkedHashMap<>(); + for (Map.Entry entry : objectFieldsOf(root.get("operations")).entrySet()) { + AsyncApiOperation operation = parseOperation( + entry.getKey(), entry.getValue(), root, channels, messages, warnings); + if (operation != null) { + operations.put(entry.getKey(), operation); + } + } + return AsyncApiDocument.builder(schemaText, location, version) .defaultContentType(defaultContentType) + .channels(channels) + .operations(operations) .messages(messages) .componentSchemas(componentSchemas) .warnings(warnings) @@ -346,6 +373,347 @@ private static AsyncApiCorrelationId parseCorrelationId( return parsed; } + // ------------------------------------------------------------------ channels + + private static AsyncApiChannel parseChannel( + String name, + JsonNode rawNode, + JsonNode root, + String defaultContentType, + Map componentSchemas, + Map messages, + List warnings) { + + JsonNode dereferenced = dereference(rawNode, root, warnings); + JsonNode node = dereferenced == null ? rawNode : dereferenced; + + Map messageKeys = new LinkedHashMap<>(); + + for (Map.Entry entry : objectFieldsOf(node.get("messages")).entrySet()) { + String id = resolveChannelMessage( + name, entry.getKey(), entry.getValue(), root, defaultContentType, + componentSchemas, messages, warnings); + if (id != null) { + messageKeys.put(entry.getKey(), id); + } + } + + return AsyncApiChannel.builder(name) + .address(scalarOf(node.get("address"))) + .messageKeys(messageKeys) + .build(); + } + + /** + * Work out which message a channel entry stands for, registering it if it was written + * inline rather than referenced. + * + * @return the id under which the message can be found, or null when it could not be read + */ + private static String resolveChannelMessage( + String channelName, + String localKey, + JsonNode entry, + JsonNode root, + String defaultContentType, + Map componentSchemas, + Map messages, + List warnings) { + + String ref = AsyncApiRefResolver.refOf(entry); + String id = channelName + "." + localKey; + + if (ref == null) { + //written inline: promote it, so that it is reachable like any other message + return register( + parseMessage(id, entry, root, defaultContentType, componentSchemas, warnings), messages); + } + + /* + A channel may add to what it references. When it does, the result is a variant + belonging to this channel rather than the shared definition, so it is registered + separately: another channel carrying the same message must not inherit it. + */ + boolean overridden = hasFieldsBesidesRef(entry); + + if (!overridden) { + + String componentKey = AsyncApiRefResolver.refKey(ref, MESSAGE_REF_PREFIX); + + if (componentKey != null) { + + if (messages.containsKey(componentKey)) { + return componentKey; + } + + warnings.add( + "Channel '" + channelName + "' refers to message '" + componentKey + "', which" + + " is not declared (or could not be read). The message is ignored."); + return null; + } + } + + /* + Either an overridden message, or a pointer somewhere other than the component + messages. Follow it all the way: the target may itself be an alias, and overlaying + onto an alias would leave a $ref that later throws the overrides away again. + */ + JsonNode resolved = dereference(entry, root, warnings); + + if (resolved == null) { + warnings.add( + "Channel '" + channelName + "' has a message '" + localKey + "' that could not be read"); + return null; + } + + JsonNode definition = overridden ? shallowMerge(resolved, entry) : resolved; + + return register( + parseMessage(id, definition, root, defaultContentType, componentSchemas, warnings), messages); + } + + /** + * Add a message to the map it is reachable from, and give back its id. + */ + private static String register(AsyncApiMessage message, Map messages) { + + if (message == null) { + return null; + } + + messages.put(message.getId(), message); + + return message.getId(); + } + + private static boolean hasFieldsBesidesRef(JsonNode node) { + + Iterator names = node.fieldNames(); + + while (names.hasNext()) { + if (!REF.equals(names.next())) { + return true; + } + } + + return false; + } + + // ------------------------------------------------------------------ operations + + private static AsyncApiOperation parseOperation( + String name, + JsonNode rawNode, + JsonNode root, + Map channels, + Map messages, + List warnings) { + + JsonNode declared = dereference(rawNode, root, warnings); + + if (declared == null) { + return null; + } + + JsonNode node = applyTraits(declared, root, "operationTraits", warnings); + + AsyncApiOperation.Action action = actionOf(node.get("action")); + + if (action == null) { + warnings.add( + "Operation '" + name + "' declares no valid 'action' (must be 'send' or 'receive')," + + " and is ignored"); + return null; + } + + String channelRef = AsyncApiRefResolver.refOf(node.get("channel")); + String channelName = channelRef == null + ? null + : AsyncApiRefResolver.refKey(channelRef, CHANNEL_REF_PREFIX); + + if (channelName == null || !channels.containsKey(channelName)) { + warnings.add( + "Operation '" + name + "' does not refer to a declared channel" + + (channelRef == null ? "" : " (reference was '" + channelRef + "')") + + ", and is ignored"); + return null; + } + + AsyncApiChannel channel = channels.get(channelName); + List messageIds = + selectMessages(node.get("messages"), channel, channels, messages, name, warnings); + + if (messageIds.isEmpty()) { + warnings.add( + "Operation '" + name + "' has no usable message on channel '" + channelName + "'," + + " so nothing can be built for it"); + } + + return AsyncApiOperation.builder(name, action, channelName) + .messageIds(messageIds) + .reply(parseReply(node.get("reply"), root, channels, messages, name, warnings)) + .title(scalarOf(node.get("title"))) + .summary(scalarOf(node.get("summary"))) + .description(scalarOf(node.get("description"))) + .build(); + } + + private static AsyncApiOperation.Action actionOf(JsonNode node) { + + String action = scalarOf(node); + + if (action == null) { + return null; + } + + switch (action.toLowerCase(Locale.ENGLISH)) { + case "send": + return AsyncApiOperation.Action.SEND; + case "receive": + return AsyncApiOperation.Action.RECEIVE; + default: + return null; + } + } + + private static AsyncApiReply parseReply( + JsonNode rawNode, + JsonNode root, + Map channels, + Map messages, + String operationName, + List warnings) { + + if (rawNode == null || rawNode.isNull()) { + return null; + } + + JsonNode node = dereference(rawNode, root, warnings); + + if (node == null) { + return null; + } + + String channelRef = AsyncApiRefResolver.refOf(node.get("channel")); + String channelName = channelRef == null + ? null + : AsyncApiRefResolver.refKey(channelRef, CHANNEL_REF_PREFIX); + + AsyncApiChannel replyChannel = channelName == null ? null : channels.get(channelName); + + if (channelName != null && replyChannel == null) { + warnings.add( + "The reply of operation '" + operationName + "' refers to channel '" + channelName + + "', which is not declared"); + } + + List messageIds = replyChannel == null + ? new ArrayList() + : selectMessages(node.get("messages"), replyChannel, channels, messages, operationName, warnings); + + //the address may be declared here or shared through components.replyAddresses + JsonNode rawAddress = node.get("address"); + JsonNode address = rawAddress == null ? null : dereference(rawAddress, root, warnings); + + return new AsyncApiReply( + replyChannel == null ? null : replyChannel.getName(), + messageIds, + address == null ? null : scalarOf(address.get("location"))); + } + + /** + * The messages an operation, or a reply, actually carries. + * + * A {@code messages} array picks out a subset of what the channel offers; without one, + * every message of the channel is in play. Entries normally address the channel + * ({@code #/channels//messages/}) but a direct reference to a component message is + * accepted too, as documents in the wild write both. + */ + private static List selectMessages( + JsonNode node, + AsyncApiChannel channel, + Map channels, + Map messages, + String owner, + List warnings) { + + if (node == null || !node.isArray() || node.size() == 0) { + return new ArrayList<>(channel.getMessageIds()); + } + + /* + Note there is no falling back to the whole channel when nothing here can be + resolved: the operation narrowed the set deliberately, and quietly widening it again + would have it drive messages it never claimed to. + */ + Set selected = new LinkedHashSet<>(); + + for (String ref : refsOf(node)) { + + /* + Both forms have to be checked against what actually survived parsing, not just + turned into a key: a message may have been dropped for being unreadable, and an + operation left holding its id would look drivable while having nothing to send. + */ + String id = AsyncApiRefResolver.refKey(ref, MESSAGE_REF_PREFIX); + + if (id == null) { + id = channelScopedMessage(ref, channels); + } + + if (id != null && messages.containsKey(id)) { + selected.add(id); + continue; + } + + if (isChannelScopedMessageRef(ref) || ref.startsWith(MESSAGE_REF_PREFIX)) { + warnings.add( + "Operation '" + owner + "' selects message '" + ref + "', which is not available" + + " (it may itself have been skipped)"); + } else { + warnings.add( + "Operation '" + owner + "' selects a message with unsupported reference '" + + ref + "'"); + } + } + + return new ArrayList<>(selected); + } + + /** + * Resolve {@code #/channels//messages/} to a message id. + */ + private static String channelScopedMessage(String ref, Map channels) { + + String[] segments = channelScopedMessageSegments(ref); + + if (segments == null) { + return null; + } + + AsyncApiChannel channel = channels.get(segments[0]); + + return channel == null ? null : channel.getMessageKeys().get(segments[2]); + } + + /** + * Whether a reference addresses a message through its channel, whatever it resolves to. + */ + private static boolean isChannelScopedMessageRef(String ref) { + return channelScopedMessageSegments(ref) != null; + } + + private static String[] channelScopedMessageSegments(String ref) { + + if (!ref.startsWith(CHANNEL_REF_PREFIX)) { + return null; + } + + String[] segments = ref.substring(CHANNEL_REF_PREFIX.length()).split("/", -1); + + return segments.length == 3 && "messages".equals(segments[1]) ? segments : null; + } + // ------------------------------------------------------------------ shared helpers /** @@ -545,6 +913,27 @@ private static String scalarOr(JsonNode node, String fallback) { return value == null ? fallback : value; } + /** + * The {@code $ref} of every entry of an array. + */ + private static List refsOf(JsonNode node) { + + List refs = new ArrayList<>(); + + if (node == null || !node.isArray()) { + return refs; + } + + for (JsonNode entry : node) { + String ref = AsyncApiRefResolver.refOf(entry); + if (ref != null) { + refs.add(ref); + } + } + + return refs; + } + /** * The object entries of an array, which is the only shape {@code examples} is read in. */ diff --git a/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiParserTest.java b/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiParserTest.java index 0e0a55661e..582ea60c50 100644 --- a/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiParserTest.java +++ b/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiParserTest.java @@ -1,9 +1,12 @@ package com.webfuzzing.asyncapi; import com.webfuzzing.asyncapi.access.AsyncApiAccess; +import com.webfuzzing.asyncapi.models.AsyncApiChannel; import com.webfuzzing.asyncapi.models.AsyncApiCorrelationId; import com.webfuzzing.asyncapi.models.AsyncApiDocument; import com.webfuzzing.asyncapi.models.AsyncApiMessage; +import com.webfuzzing.asyncapi.models.AsyncApiOperation; +import com.webfuzzing.asyncapi.models.AsyncApiReply; import com.webfuzzing.asyncapi.parser.AsyncApiParsingException; import org.junit.jupiter.api.Test; @@ -66,6 +69,17 @@ private static Set setOf(String... values) { return new LinkedHashSet<>(Arrays.asList(values)); } + private static List namesOf(List messages) { + + List names = new ArrayList<>(); + + for (AsyncApiMessage message : messages) { + names.add(message.getName()); + } + + return names; + } + // ------------------------------------------------------------------ the shape of a document @Test @@ -199,6 +213,7 @@ public void testDocumentDeclaringNoMessages() { assertTrue(document.getMessages().isEmpty()); assertTrue(document.getComponentSchemas().isEmpty()); assertTrue(document.getWarnings().isEmpty()); + assertTrue(document.getOperations().isEmpty()); } // ------------------------------------------------------------------ correlation @@ -549,4 +564,255 @@ public void testSchemasMayReferToThemselves() { assertTrue(document.getComponentSchemas().containsKey("Node")); } + // ------------------------------------------------------------------ channels and operations + + @Test + public void testInlineMessagesArePromoted() { + + AsyncApiDocument document = load("/asyncapi/artificial/inline-messages.yaml"); + + //no components.messages at all: everything was written inside its channel + assertEquals(2, document.getMessages().size()); + assertTrue(document.getMessages().containsKey("signup.request")); + assertTrue(document.getMessages().containsKey("signupReply.ok")); + + AsyncApiChannel channel = document.getChannels().get("signup"); + assertEquals("user/signup", channel.getAddress()); + assertEquals(setOf("request"), channel.getMessageKeys().keySet()); + assertEquals("signup.request", channel.getMessageKeys().get("request")); + + AsyncApiMessage message = document.getMessages().get("signup.request"); + assertEquals("SignupRequest", message.getName()); + //assert the content, so that swapping payload and headers would be caught + assertTrue(message.getPayload().get("properties").has("email")); + assertTrue(message.getHeaders().get("properties").has("correlationId")); + } + + @Test + public void testInlineChannelMessageThatIsNotAnObjectIsDroppedWithAWarning() { + + /* + A message written inside a channel is dropped like any other when its payload is not + a schema that can be read. The channel is the path where that used to happen in + silence: nothing is registered under the local key, and unlike a message referenced + by '$ref' there is no second warning about the channel to hint at what went missing. + */ + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: An inline message that cannot be read\n" + + " version: 1.0.0\n" + + "channels:\n" + + " c:\n" + + " address: a\n" + + " messages:\n" + + " broken:\n" + + " payload: not a schema\n" + + " fine:\n" + + " payload:\n" + + " type: object\n"); + + AsyncApiChannel channel = document.getChannels().get("c"); + + //the one that could be read is still there, so the channel is not lost with it + assertEquals(setOf("fine"), channel.getMessageKeys().keySet()); + assertTrue(warns(document, "not a schema"), document.getWarnings().toString()); + } + + @Test + public void testChannelWithoutAddressAndDynamicReplyAddress() { + + AsyncApiDocument document = load("/asyncapi/artificial/inline-messages.yaml"); + + //an explicit 'address: null' means the address is only known at run time + assertNull(document.getChannels().get("signupReply").getAddress()); + + AsyncApiReply reply = document.getOperations().get("onSignup").getReply(); + assertNotNull(reply); + assertEquals("signupReply", reply.getChannelName()); + assertEquals("$message.header#/replyTo", reply.getAddressLocation()); + //no explicit message selection: everything the reply channel carries + assertEquals(Arrays.asList("signupReply.ok"), reply.getMessageIds()); + } + + @Test + public void testOperationSelectsSubsetOfChannelMessages() { + + AsyncApiDocument document = load("/asyncapi/artificial/websocket-reply.yaml"); + + //one duplex channel carrying five different messages + AsyncApiChannel channel = document.getChannels().get("vsi"); + assertEquals(5, channel.getMessageIds().size()); + + AsyncApiOperation operation = document.getOperations().get("recv_list_legs"); + assertEquals(Arrays.asList("listLegs"), operation.getMessageIds()); + + AsyncApiReply reply = operation.getReply(); + //the reply comes back on the very same channel: there is only one socket + assertEquals("vsi", reply.getChannelName()); + assertEquals(Arrays.asList("listLegsResult", "error"), reply.getMessageIds()); + } + + @Test + public void testChannelLocalMessageKeysDifferFromMessageIds() { + + AsyncApiChannel channel = + load("/asyncapi/artificial/websocket-reply.yaml").getChannels().get("vsi"); + + //the key a $ref uses is the channel's own, not the component id + assertEquals("listLegsResult", channel.getMessageKeys().get("list_legs.result")); + assertEquals("error", channel.getMessageKeys().get("error")); + } + + @Test + public void testNarrowedSelectionIsNotWidenedWhenItsMessageIsSkipped() { + + AsyncApiDocument document = load("/asyncapi/artificial/narrowed-selection.yaml"); + + //the Avro message could not be read, so the channel is left with only the other one + assertEquals(Arrays.asList("usable"), document.getChannels().get("events").getMessageIds()); + + //an operation that asked for the skipped message gets nothing, rather than the other one + assertTrue(document.getOperations().get("onUnreadable").getMessageIds().isEmpty()); + assertEquals(Arrays.asList("usable"), document.getOperations().get("onUsable").getMessageIds()); + + //while one that asked for nothing in particular gets what is left + assertEquals(Arrays.asList("usable"), document.getOperations().get("onAnything").getMessageIds()); + + assertTrue(warns(document, "not available"), document.getWarnings().toString()); + } + + @Test + public void testAChannelMayOverrideWhatItReferences() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: A channel adding to a shared message\n" + + " version: 1.0.0\n" + + "channels:\n" + + " c:\n" + + " address: a\n" + + " messages:\n" + + " m:\n" + + " $ref: '#/components/messages/shared'\n" + + " title: only on this channel\n" + + "operations:\n" + + " o:\n" + + " action: receive\n" + + " channel:\n" + + " $ref: '#/channels/c'\n" + + "components:\n" + + " messages:\n" + + " shared:\n" + + " name: Shared\n" + + " payload:\n" + + " type: object\n"); + + /* + The override makes it a variant belonging to this channel, registered under its own + id. The shared definition must be left alone, or every other channel carrying the + same message would silently inherit something meant for this one. + */ + assertEquals(Arrays.asList("c.m"), document.getOperations().get("o").getMessageIds()); + assertEquals("only on this channel", document.getMessages().get("c.m").getTitle()); + assertEquals("Shared", document.getMessages().get("c.m").getName()); + assertNull(document.getMessages().get("shared").getTitle()); + } + + @Test + public void testSendAndReceiveKeepTheirDirection() { + + AsyncApiDocument document = load("/asyncapi/artificial/broken-parts.yaml"); + + /* + The polarity matters and is easy to invert: 'receive' is what the service consumes, + so it is what a tester would publish to, and 'send' is what it emits. + */ + assertEquals(AsyncApiOperation.Action.RECEIVE, document.getOperations().get("works").getAction()); + assertEquals( + AsyncApiOperation.Action.SEND, + document.getOperations().get("badCorrelationTarget").getAction()); + } + + @Test + public void testOperationTraitsAreMerged() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Boilerplate factored out of the operations\n" + + " version: 1.0.0\n" + + "channels:\n" + + " c:\n" + + " address: a\n" + + " messages:\n" + + " m:\n" + + " payload:\n" + + " type: object\n" + + "operations:\n" + + " o:\n" + + " action: receive\n" + + " channel:\n" + + " $ref: '#/channels/c'\n" + + " traits:\n" + + " - $ref: '#/components/operationTraits/documented'\n" + + " summary: what the operation states itself\n" + + "components:\n" + + " operationTraits:\n" + + " documented:\n" + + " summary: overridden by the operation\n" + + " description: from the trait\n"); + + AsyncApiOperation operation = document.getOperations().get("o"); + assertEquals("what the operation states itself", operation.getSummary()); + assertEquals("from the trait", operation.getDescription()); + } + + // ------------------------------------------------------------------ degrading gracefully + + @Test + public void testBrokenPartsAreSkippedAndTheRestSurvives() { + + AsyncApiDocument document = load("/asyncapi/artificial/broken-parts.yaml"); + + //only the two well-formed operations are kept + assertEquals(setOf("works", "badCorrelationTarget"), document.getOperations().keySet()); + + assertTrue(warns(document, "noAction"), document.getWarnings().toString()); + assertTrue(warns(document, "wrongAction"), document.getWarnings().toString()); + assertTrue(warns(document, "missingChannel"), document.getWarnings().toString()); + assertTrue(warns(document, "doesNotExist"), document.getWarnings().toString()); + + //the dangling message reference costs only that one message + assertEquals(Arrays.asList("request"), document.getChannels().get("good").getMessageIds()); + } + + // ------------------------------------------------------------------ the model's read API + + @Test + public void testResolvingOperationsToTheirChannelAndMessages() { + + AsyncApiDocument document = load("/asyncapi/artificial/websocket-reply.yaml"); + AsyncApiOperation operation = document.getOperations().get("recv_list_legs"); + + assertEquals("vsi", document.channelOf(operation).getName()); + assertEquals("vsi", document.replyChannelOf(operation).getName()); + assertEquals(Arrays.asList("ListLegs"), namesOf(document.messagesOf(operation))); + assertEquals( + Arrays.asList("ListLegsResult", "Error"), + namesOf(document.replyMessagesOf(operation))); + } + + @Test + public void testResolvingAnOperationThatDeclaresNoReply() { + + AsyncApiDocument document = load("/asyncapi/artificial/broken-parts.yaml"); + AsyncApiOperation operation = document.getOperations().get("works"); + + assertNull(operation.getReply()); + assertNull(document.replyChannelOf(operation)); + assertTrue(document.replyMessagesOf(operation).isEmpty()); + } + } diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/broken-parts.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/broken-parts.yaml new file mode 100644 index 0000000000..061f1f1f72 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/broken-parts.yaml @@ -0,0 +1,52 @@ +asyncapi: 3.0.0 +info: + title: A document with several unusable parts + version: 1.0.0 + +channels: + good: + address: good.request + messages: + request: + $ref: '#/components/messages/request' + dangling: + $ref: '#/components/messages/doesNotExist' + +operations: + # this one is fine, and must survive everything below + works: + action: receive + channel: + $ref: '#/channels/good' + + noAction: + channel: + $ref: '#/channels/good' + + wrongAction: + action: publish + channel: + $ref: '#/channels/good' + + missingChannel: + action: receive + channel: + $ref: '#/channels/notThere' + + badCorrelationTarget: + action: send + channel: + $ref: '#/channels/good' + +components: + messages: + request: + name: Request + # not one of the two runtime expressions the specification defines + correlationId: + location: 'somewhere/else' + payload: + type: object + properties: + value: + type: integer diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/inline-messages.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/inline-messages.yaml new file mode 100644 index 0000000000..b467ef8334 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/inline-messages.yaml @@ -0,0 +1,58 @@ +asyncapi: 3.0.0 +info: + title: Messages written inline in their channel + version: 1.0.0 + +# no servers block at all, which is common: the broker is supplied at deploy time + +channels: + signup: + address: user/signup + parameters: + tenantId: + description: The tenant the signup belongs to + default: acme + enum: + - acme + - globex + messages: + request: + name: SignupRequest + contentType: application/json + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + required: [email] + properties: + email: + type: string + format: email + headers: + type: object + properties: + correlationId: + type: string + + signupReply: + # deliberately unknown at design time + address: null + messages: + ok: + name: SignupOk + payload: + type: object + properties: + userId: + type: string + +operations: + onSignup: + action: receive + channel: + $ref: '#/channels/signup' + reply: + channel: + $ref: '#/channels/signupReply' + address: + location: '$message.header#/replyTo' diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/narrowed-selection.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/narrowed-selection.yaml new file mode 100644 index 0000000000..6664121e15 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/narrowed-selection.yaml @@ -0,0 +1,57 @@ +asyncapi: 3.0.0 +info: + title: An operation that narrows to a message which cannot be read + version: 1.0.0 + +channels: + events: + address: events + messages: + usable: + $ref: '#/components/messages/usable' + unreadable: + $ref: '#/components/messages/unreadable' + +operations: + # narrows to the one message that has to be skipped: it must NOT fall back to the other one + onUnreadable: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/unreadable' + + # narrows to the good one + onUsable: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/usable' + + # no selection at all: everything the channel still carries + onAnything: + action: receive + channel: + $ref: '#/channels/events' + +components: + messages: + usable: + name: Usable + payload: + $ref: '#/components/schemas/Fine' + unreadable: + name: Unreadable + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: Whatever + + schemas: + Fine: + type: object + properties: + value: + type: string diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/websocket-reply.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/websocket-reply.yaml new file mode 100644 index 0000000000..fce57b8fc4 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/websocket-reply.yaml @@ -0,0 +1,124 @@ +asyncapi: 3.0.0 +info: + title: A duplex socket carrying many messages + version: 1.0.0 +defaultContentType: application/json + +servers: + socket: + host: localhost:8088 + protocol: ws + pathname: /v1/vsi + +channels: + vsi: + address: /v1/vsi + messages: + list_legs: + $ref: '#/components/messages/listLegs' + list_legs.result: + $ref: '#/components/messages/listLegsResult' + get_leg: + $ref: '#/components/messages/getLeg' + get_leg.result: + $ref: '#/components/messages/getLegResult' + error: + $ref: '#/components/messages/error' + bindings: + ws: + method: GET + +operations: + recv_list_legs: + action: receive + summary: List all active legs + channel: + $ref: '#/channels/vsi' + messages: + - $ref: '#/channels/vsi/messages/list_legs' + reply: + channel: + $ref: '#/channels/vsi' + messages: + - $ref: '#/channels/vsi/messages/list_legs.result' + - $ref: '#/channels/vsi/messages/error' + + recv_get_leg: + action: receive + summary: Get a single leg + channel: + $ref: '#/channels/vsi' + messages: + - $ref: '#/channels/vsi/messages/get_leg' + reply: + channel: + $ref: '#/channels/vsi' + messages: + - $ref: '#/channels/vsi/messages/get_leg.result' + - $ref: '#/channels/vsi/messages/error' + +components: + messages: + listLegs: + name: ListLegs + correlationId: + location: '$message.payload#/request_id' + payload: + type: object + required: [request, request_id] + properties: + request: + const: list_legs + request_id: + type: string + listLegsResult: + name: ListLegsResult + correlationId: + location: '$message.payload#/request_id' + payload: + type: object + properties: + request_id: + type: string + legs: + type: array + items: + type: string + getLeg: + name: GetLeg + correlationId: + location: '$message.payload#/request_id' + payload: + type: object + required: [request, request_id, leg_id] + properties: + request: + const: get_leg + request_id: + type: string + leg_id: + type: string + getLegResult: + name: GetLegResult + payload: + type: object + properties: + request_id: + type: string + leg: + type: [object, "null"] + error: + name: Error + payload: + type: object + required: [error] + properties: + request_id: + type: string + error: + type: object + properties: + code: + type: integer + message: + type: string