From 2ae3c7be6b7a8f57f2e0bdbb815b9d8086f6182d Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Mon, 24 Aug 2026 14:05:20 -0300 Subject: [PATCH 1/4] AsyncAPI 3.x: parse servers, bindings, channel parameters and security The last of the document a client needs: how to reach the service, as opposed to what to say to it. - AsyncApiServer, with host, protocol, protocolVersion, pathname and variables. The protocol is also how AsyncAPI distinguishes the two incompatible AMQPs -- "amqp" is 0-9-1, and 1.0 is the separate "amqp1". Many published documents declare no server at all, and that is not an error: they describe the contract and leave the broker to deployment. - AsyncApiChannelBindings. Only the few fields that change what a client has to do are lifted out; everything else stays in a raw map, so nothing is lost and a later transport can read it without the model growing first. The Kafka binding matters most: it carries its own topic, and when present that is the topic a client uses rather than the channel address. Hence effectiveAddress(protocol), which prefers it for Kafka only and leaves the declared address untouched so the document still reads as written. - Channel parameters, kept verbatim. Resolving a templated address to a concrete topic is a run-time concern, not a parsing one. - AsyncApiSecurityScheme. Its type is a free-form string rather than an enum on purpose: AsyncAPI's catalogue is broad and broker-specific, and parsing must not fail on a scheme that is valid but that no transport here can use yet. Schemes may also be written inline where they are used rather than in components, and those are registered under a name derived from where they appear. Bindings and parameters are dereferenced. Reading a binding without following its $ref would leave a channel on its declared address while the real topic sat in components, which is a wrong answer rather than a missing one. --- .../asyncapi/models/AsyncApiChannel.java | 86 +++++- .../models/AsyncApiChannelBindings.java | 125 ++++++++ .../asyncapi/models/AsyncApiDocument.java | 77 ++++- .../asyncapi/models/AsyncApiOperation.java | 40 +++ .../models/AsyncApiSecurityScheme.java | 79 +++++ .../asyncapi/models/AsyncApiServer.java | 139 +++++++++ .../models/AsyncApiServerVariable.java | 57 ++++ .../asyncapi/parser/AsyncApiParser.java | 239 ++++++++++++++- .../asyncapi/AsyncApiParserTest.java | 279 ++++++++++++++++++ .../asyncapi/artificial/bindings.yaml | 69 +++++ .../resources/asyncapi/artificial/traits.yaml | 99 +++++++ 11 files changed, 1270 insertions(+), 19 deletions(-) create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java create mode 100644 core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/bindings.yaml create mode 100644 core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/traits.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 index 232df4c21f..2d16a30485 100644 --- 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 @@ -1,10 +1,13 @@ package com.webfuzzing.asyncapi.models; +import com.fasterxml.jackson.databind.JsonNode; + import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -14,10 +17,21 @@ */ public class AsyncApiChannel { + /** + * The one protocol whose binding decides where a message actually goes. + */ + public static final String KAFKA = "kafka"; + private final String name; private final String address; + /** + * Names of the servers this channel is available on, from its {@code servers} array. + * Empty means every server, which is the specification's default. + */ + private final List servers; + /** * 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 @@ -31,10 +45,21 @@ public class AsyncApiChannel { */ private final List messageIds; + /** + * Key is the parameter name, as used by a {@code {placeholder}} in the address. + * Value is that parameter's declaration, kept as a raw node. + */ + private final Map parameters; + + private final AsyncApiChannelBindings bindings; + private AsyncApiChannel(Builder builder) { this.name = builder.name; this.address = builder.address; + this.servers = Collections.unmodifiableList(builder.servers); this.messageKeys = Collections.unmodifiableMap(new LinkedHashMap<>(builder.messageKeys)); + this.parameters = Collections.unmodifiableMap(builder.parameters); + this.bindings = builder.bindings; //distinct, as two local keys may well point at the same message definition this.messageIds = Collections.unmodifiableList( new ArrayList<>(new LinkedHashSet<>(this.messageKeys.values()))); @@ -57,12 +82,21 @@ public String getName() { * * 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. + * {@code {parameter}} placeholders, which are left in place here -- see + * {@link #getParameters()}. */ public String getAddress() { return address; } + /** + * Names of the servers this channel is available on, from the channel's {@code servers} + * array. Empty means "all of them", which is the specification's default. + */ + public List getServers() { + return servers; + } + /** * Channel-local message key -> the id of that message in * {@link AsyncApiDocument#getMessages()}. @@ -84,12 +118,53 @@ public List getMessageIds() { return messageIds; } + /** + * Parameter name -> its declaration node, from {@code parameters:}. These back the + * {@code {placeholders}} in {@link #getAddress()}. Kept as raw nodes: resolving an address + * to a concrete topic is a run-time concern, not a parsing one. + */ + public Map getParameters() { + return parameters; + } + + /** + * Protocol bindings declared on this channel. + */ + public AsyncApiChannelBindings getBindings() { + return bindings; + } + + /** + * The address to actually use on the wire for a given protocol. + * + * Normally this is just {@link #getAddress()}, but a binding may override it: the Kafka + * binding has its own {@code topic} field, and when present it is the topic that is used + * rather than the channel address. The address itself is left untouched so the declaration + * stays readable. + */ + public String effectiveAddress(String protocol) { + + String topic = bindings.getKafkaTopic(); + + if (protocol != null && KAFKA.equals(protocol.toLowerCase(Locale.ENGLISH)) + && topic != null && !topic.trim().isEmpty()) { + return topic; + } + + return address; + } + public static class Builder { private final String name; private String address; + /** @see AsyncApiChannel#servers */ + private List servers = Collections.emptyList(); /** @see AsyncApiChannel#messageKeys */ private Map messageKeys = Collections.emptyMap(); + /** @see AsyncApiChannel#parameters */ + private Map parameters = Collections.emptyMap(); + private AsyncApiChannelBindings bindings = AsyncApiChannelBindings.none(); private Builder(String name) { this.name = name; @@ -97,11 +172,20 @@ private Builder(String name) { public Builder address(String address) { this.address = address; return this; } + public Builder servers(List servers) { this.servers = servers; return this; } + public Builder messageKeys(Map messageKeys) { this.messageKeys = messageKeys; return this; } + public Builder parameters(Map parameters) { + this.parameters = parameters; + return this; + } + + public Builder bindings(AsyncApiChannelBindings bindings) { this.bindings = bindings; return this; } + public AsyncApiChannel build() { return new AsyncApiChannel(this); } diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java new file mode 100644 index 0000000000..1ae6311fb7 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java @@ -0,0 +1,125 @@ +package com.webfuzzing.asyncapi.models; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Collections; +import java.util.Map; + +/** + * The subset of the protocol bindings that a client acts on, plus the untouched originals. + * + * Only a handful of fields are lifted out, because only a handful change what a client has to + * do. Everything else stays in {@link #getRaw()} so nothing is lost and a later transport can + * read it without the model having to grow first. + */ +public class AsyncApiChannelBindings { + + private final String kafkaTopic; + + private final String amqpIs; + + private final String amqpQueue; + + private final String amqpExchange; + + private final String wsMethod; + + /** + * Key is the protocol name, e.g. "kafka" or "amqp". + * Value is the binding declared for that protocol, exactly as written. + */ + private final Map raw; + + private AsyncApiChannelBindings(Builder builder) { + this.kafkaTopic = builder.kafkaTopic; + this.amqpIs = builder.amqpIs; + this.amqpQueue = builder.amqpQueue; + this.amqpExchange = builder.amqpExchange; + this.wsMethod = builder.wsMethod; + this.raw = Collections.unmodifiableMap(builder.raw); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Nothing declared, for a channel that has no bindings at all. + */ + public static AsyncApiChannelBindings none() { + return builder().build(); + } + + /** + * {@code bindings.kafka.topic}. When set, it overrides the channel address for Kafka. + */ + public String getKafkaTopic() { + return kafkaTopic; + } + + /** + * {@code bindings.amqp.is}, either "queue" or "routingKey". Decides whether a publisher + * should address a queue directly or go through an exchange. + */ + public String getAmqpIs() { + return amqpIs; + } + + /** + * {@code bindings.amqp.queue.name}. + */ + public String getAmqpQueue() { + return amqpQueue; + } + + /** + * {@code bindings.amqp.exchange.name}. + */ + public String getAmqpExchange() { + return amqpExchange; + } + + /** + * {@code bindings.ws.method}, the HTTP method used for the opening handshake. + */ + public String getWsMethod() { + return wsMethod; + } + + /** + * Every binding as declared, keyed by protocol name. + */ + public Map getRaw() { + return raw; + } + + public static class Builder { + + private String kafkaTopic; + private String amqpIs; + private String amqpQueue; + private String amqpExchange; + private String wsMethod; + /** @see AsyncApiChannelBindings#raw */ + private Map raw = Collections.emptyMap(); + + private Builder() { + } + + public Builder kafkaTopic(String kafkaTopic) { this.kafkaTopic = kafkaTopic; return this; } + + public Builder amqpIs(String amqpIs) { this.amqpIs = amqpIs; return this; } + + public Builder amqpQueue(String amqpQueue) { this.amqpQueue = amqpQueue; return this; } + + public Builder amqpExchange(String amqpExchange) { this.amqpExchange = amqpExchange; return this; } + + public Builder wsMethod(String wsMethod) { this.wsMethod = wsMethod; return this; } + + public Builder raw(Map raw) { this.raw = raw; return this; } + + public AsyncApiChannelBindings build() { + return new AsyncApiChannelBindings(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 2f6c35a522..c7874f30ff 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 @@ -15,7 +15,7 @@ * *
    *
  1. 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;
  2. + * written inline inside a channel, which are promoted here under a synthetic id; *
  3. all {@code $ref} between AsyncAPI constructs (channels, operations, messages, correlation * ids, traits) are already followed, and are represented as plain keys;
  4. *
  5. all {@code $ref} inside a message payload / headers JSON Schema are instead left @@ -46,6 +46,12 @@ public class AsyncApiDocument { private final String defaultContentType; + /** + * Key is the server name, i.e. its key under {@code servers}. + * Value is the server declared under it. + */ + private final Map servers; + /** * 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. @@ -71,6 +77,13 @@ public class AsyncApiDocument { */ private final Map componentSchemas; + /** + * Key is the security scheme key, i.e. its key under {@code components.securitySchemes}, + * or a synthetic name for a scheme written inline where it is used. + * Value is the scheme declared under it. + */ + private final Map securitySchemes; + /** * 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. @@ -82,10 +95,12 @@ private AsyncApiDocument(Builder builder) { this.sourceLocation = builder.sourceLocation; this.version = builder.version; this.defaultContentType = builder.defaultContentType; + this.servers = Collections.unmodifiableMap(builder.servers); 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.securitySchemes = Collections.unmodifiableMap(builder.securitySchemes); this.warnings = Collections.unmodifiableList(builder.warnings); } @@ -123,6 +138,15 @@ public String getDefaultContentType() { return defaultContentType; } + /** + * Server name -> server. Empty when the document declares no {@code servers} block, which + * is common: many published specs describe only the message contract and leave the broker + * to be supplied at deployment. + */ + public Map getServers() { + return servers; + } + /** * Channel key -> channel. */ @@ -153,6 +177,13 @@ public Map getComponentSchemas() { return componentSchemas; } + /** + * Security scheme key -> scheme, from {@code components.securitySchemes}. + */ + public Map getSecuritySchemes() { + return securitySchemes; + } + /** * Everything that went wrong without being fatal: a reference that could not be resolved, a * payload in a format that cannot be read. Reported to the user, so that a surprising @@ -210,6 +241,29 @@ public AsyncApiChannel replyChannelOf(AsyncApiOperation operation) { return channels.get(operation.getReply().getChannelName()); } + /** + * The servers a channel is available on. A channel that names none is available on all of + * them, which is what the specification prescribes and what callers would otherwise all + * have to remember for themselves. + */ + public List serversOf(AsyncApiChannel channel) { + + if (channel.getServers().isEmpty()) { + return new ArrayList<>(servers.values()); + } + + List found = new ArrayList<>(); + + for (String name : channel.getServers()) { + AsyncApiServer server = servers.get(name); + if (server != null) { + found.add(server); + } + } + + return found; + } + private List resolveMessages(List ids) { List found = new ArrayList<>(); @@ -230,6 +284,8 @@ public static class Builder { private final DocumentLocation sourceLocation; private final String version; private String defaultContentType = DEFAULT_CONTENT_TYPE; + /** @see AsyncApiDocument#servers */ + private Map servers = Collections.emptyMap(); /** @see AsyncApiDocument#channels */ private Map channels = Collections.emptyMap(); /** @see AsyncApiDocument#operations */ @@ -238,6 +294,8 @@ public static class Builder { private Map messages = Collections.emptyMap(); /** @see AsyncApiDocument#componentSchemas */ private Map componentSchemas = Collections.emptyMap(); + /** @see AsyncApiDocument#securitySchemes */ + private Map securitySchemes = Collections.emptyMap(); /** @see AsyncApiDocument#warnings */ private List warnings = Collections.emptyList(); @@ -252,26 +310,27 @@ public Builder defaultContentType(String defaultContentType) { return this; } - public Builder channels(Map channels) { - this.channels = channels; - return this; - } + public Builder servers(Map servers) { this.servers = servers; 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; - } + public Builder messages(Map messages) { this.messages = messages; return this; } public Builder componentSchemas(Map componentSchemas) { this.componentSchemas = componentSchemas; return this; } + public Builder securitySchemes(Map securitySchemes) { + this.securitySchemes = securitySchemes; + return this; + } + public Builder warnings(List warnings) { this.warnings = warnings; return this; } public AsyncApiDocument build() { 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 index c3073059b3..8daa09d74a 100644 --- 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 @@ -1,7 +1,10 @@ package com.webfuzzing.asyncapi.models; +import com.fasterxml.jackson.databind.JsonNode; + import java.util.Collections; import java.util.List; +import java.util.Map; /** * An entry under {@code operations:}, i.e. something an application does on a channel. @@ -37,6 +40,18 @@ public enum Action { SEND, RECEIVE } private final AsyncApiReply reply; + /** + * Names of the security schemes this operation requires, as keys into + * {@link AsyncApiDocument#getSecuritySchemes()}. + */ + private final List security; + + /** + * Key is the protocol name, e.g. "kafka" or "amqp". + * Value is the binding this operation declares for that protocol, as a raw node. + */ + private final Map bindings; + private final String title; private final String summary; @@ -49,6 +64,8 @@ private AsyncApiOperation(Builder builder) { this.channelName = builder.channelName; this.messageIds = Collections.unmodifiableList(builder.messageIds); this.reply = builder.reply; + this.security = Collections.unmodifiableList(builder.security); + this.bindings = Collections.unmodifiableMap(builder.bindings); this.title = builder.title; this.summary = builder.summary; this.description = builder.description; @@ -92,6 +109,21 @@ public AsyncApiReply getReply() { return reply; } + /** + * Names of the security schemes this operation requires. + */ + public List getSecurity() { + return security; + } + + /** + * Protocol bindings as declared, keyed by protocol. Nothing is lifted out of these yet, as + * no operation-level binding field currently changes what a client does. + */ + public Map getBindings() { + return bindings; + } + public String getTitle() { return title; } @@ -112,6 +144,10 @@ public static class Builder { /** @see AsyncApiOperation#messageIds */ private List messageIds = Collections.emptyList(); private AsyncApiReply reply; + /** @see AsyncApiOperation#security */ + private List security = Collections.emptyList(); + /** @see AsyncApiOperation#bindings */ + private Map bindings = Collections.emptyMap(); private String title; private String summary; private String description; @@ -126,6 +162,10 @@ private Builder(String name, Action action, String channelName) { public Builder reply(AsyncApiReply reply) { this.reply = reply; return this; } + public Builder security(List security) { this.security = security; return this; } + + public Builder bindings(Map bindings) { this.bindings = bindings; return this; } + public Builder title(String title) { this.title = title; return this; } public Builder summary(String summary) { this.summary = summary; return this; } diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java new file mode 100644 index 0000000000..899456ef92 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java @@ -0,0 +1,79 @@ +package com.webfuzzing.asyncapi.models; + +/** + * An entry under {@code components.securitySchemes}, i.e. how a client authenticates to the + * broker. + * + * {@link #getType()} is a free-form string rather than an enum on purpose: AsyncAPI's catalogue + * is broad and broker-specific ({@code userPassword}, {@code scramSha512}, {@code gssapi}, + * {@code X509}, {@code oauth2}, ...), and parsing must not fail on a scheme that is perfectly + * valid but that no transport here can use yet. Whether a scheme can actually be honoured is + * the connecting client's question. + */ +public class AsyncApiSecurityScheme { + + private final String name; + + private final String type; + + private final String location; + + private final String scheme; + + private final String bearerFormat; + + private final String description; + + public AsyncApiSecurityScheme( + String name, + String type, + String location, + String scheme, + String bearerFormat, + String description) { + this.name = name; + this.type = type; + this.location = location; + this.scheme = scheme; + this.bearerFormat = bearerFormat; + this.description = description; + } + + /** + * The component key, or a synthetic name when the scheme was written inline where it is + * used. + */ + public String getName() { + return name; + } + + /** + * Lowercased, as real documents write both "X509" and "x509". + */ + public String getType() { + return type; + } + + /** + * For {@code apiKey} and {@code httpApiKey}: where the key travels ("header", "query", + * "user", "password"). This is the document's {@code in} field, which is a Java keyword. + */ + public String getLocation() { + return location; + } + + /** + * For {@code http}: "basic", "bearer", and so on. + */ + public String getScheme() { + return scheme; + } + + public String getBearerFormat() { + return bearerFormat; + } + + public String getDescription() { + return description; + } +} diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java new file mode 100644 index 0000000000..7ce8166564 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java @@ -0,0 +1,139 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * An entry under {@code servers:}, i.e. one broker the API is exposed on. + * + * A document is not required to declare any server, and many published ones do not: they + * describe the message contract and leave the address to deployment. + */ +public class AsyncApiServer { + + private final String name; + + private final String host; + + private final String protocol; + + private final String protocolVersion; + + private final String pathname; + + /** + * Key is the variable name, as used by a {@code {placeholder}} in the host or pathname. + * Value is that variable's declaration. + */ + private final Map variables; + + /** + * Names of the security schemes this server requires, as keys into + * {@link AsyncApiDocument#getSecuritySchemes()}. + */ + private final List security; + + private AsyncApiServer(Builder builder) { + this.name = builder.name; + this.host = builder.host; + this.protocol = builder.protocol; + this.protocolVersion = builder.protocolVersion; + this.pathname = builder.pathname; + this.variables = Collections.unmodifiableMap(builder.variables); + this.security = Collections.unmodifiableList(builder.security); + } + + public static Builder builder(String name, String host, String protocol) { + return new Builder(name, host, protocol); + } + + public String getName() { + return name; + } + + /** + * Host and optional port, e.g. "localhost:9092". + */ + public String getHost() { + return host; + } + + /** + * Wire protocol, e.g. "kafka", "amqp", "mqtt", "ws". + * + * This is also how AsyncAPI distinguishes the two incompatible AMQPs: "amqp" is 0-9-1, + * while 1.0 is the separate "amqp1" protocol. + */ + public String getProtocol() { + return protocol; + } + + /** + * Free-text and rarely set, so a client that needs to know the dialect has to fall back on + * a conservative default -- MQTT 3.1.1 rather than 5.0, for instance. + */ + public String getProtocolVersion() { + return protocolVersion; + } + + /** + * Path prefix, e.g. "everest_api/1/error_history_consumer/{module_id}", for the transports + * whose address space has paths. Kept verbatim: substituting the placeholders is a run-time + * concern. + */ + public String getPathname() { + return pathname; + } + + /** + * What the {@code {placeholders}} in {@link #getHost()} and {@link #getPathname()} refer to. + */ + public Map getVariables() { + return variables; + } + + /** + * Names of the security schemes this server requires. + */ + public List getSecurity() { + return security; + } + + public static class Builder { + + private final String name; + private final String host; + private final String protocol; + private String protocolVersion; + private String pathname; + /** @see AsyncApiServer#variables */ + private Map variables = Collections.emptyMap(); + /** @see AsyncApiServer#security */ + private List security = Collections.emptyList(); + + private Builder(String name, String host, String protocol) { + this.name = name; + this.host = host; + this.protocol = protocol; + } + + public Builder protocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + return this; + } + + public Builder pathname(String pathname) { this.pathname = pathname; return this; } + + public Builder variables(Map variables) { + this.variables = variables; + return this; + } + + public Builder security(List security) { this.security = security; return this; } + + public AsyncApiServer build() { + return new AsyncApiServer(this); + } + } +} diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java new file mode 100644 index 0000000000..ca49fe1081 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java @@ -0,0 +1,57 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.Collections; +import java.util.List; + +/** + * One entry under {@code servers..variables}. Same shape as an OpenAPI server variable. + */ +public class AsyncApiServerVariable { + + private final String name; + + private final String defaultValue; + + /** + * The closed set of values this variable may take, when one is declared, in declaration + * order. Empty when the document declares none. Named for the {@code enum} field, which is + * a Java keyword. + */ + private final List enumeration; + + private final String description; + + public AsyncApiServerVariable( + String name, + String defaultValue, + List enumeration, + String description) { + this.name = name; + this.defaultValue = defaultValue; + this.enumeration = Collections.unmodifiableList(enumeration); + this.description = description; + } + + public String getName() { + return name; + } + + /** + * The document's {@code default} field, under a name that is not a Java keyword. + */ + public String getDefaultValue() { + return defaultValue; + } + + /** + * The closed set of values the variable may take, when one is declared. Named for the + * document's {@code enum} field, which is a Java keyword. + */ + public List getEnumeration() { + return enumeration; + } + + public String getDescription() { + return description; + } +} 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 f17810c5ac..0bba71f31b 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 @@ -5,11 +5,15 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.webfuzzing.asyncapi.mapper.AsyncApiMapper; import com.webfuzzing.asyncapi.models.AsyncApiChannel; +import com.webfuzzing.asyncapi.models.AsyncApiChannelBindings; 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.AsyncApiSecurityScheme; +import com.webfuzzing.asyncapi.models.AsyncApiServer; +import com.webfuzzing.asyncapi.models.AsyncApiServerVariable; import com.webfuzzing.asyncapi.models.DocumentLocation; import com.webfuzzing.asyncapi.resolver.AsyncApiDocumentFetcher; import com.webfuzzing.asyncapi.resolver.AsyncApiRefResolver; @@ -45,7 +49,9 @@ public class AsyncApiParser { private static final String MESSAGE_REF_PREFIX = "#/components/messages/"; - private static final String KAFKA = "kafka"; + private static final String SECURITY_REF_PREFIX = "#/components/securitySchemes/"; + + private static final String SERVER_REF_PREFIX = "#/servers/"; private static final String REF = "$ref"; @@ -119,6 +125,20 @@ public static AsyncApiDocument parse( defaultContentType = AsyncApiDocument.DEFAULT_CONTENT_TYPE; } + //security schemes are a mutable map, as schemes can also be declared inline where used + Map securitySchemes = new LinkedHashMap<>(); + for (Map.Entry entry : componentsOf(root, "securitySchemes").entrySet()) { + AsyncApiSecurityScheme scheme = parseSecurityScheme(entry.getKey(), entry.getValue(), root); + if (scheme == null) { + //same as for one written inline: a scheme with no 'type' says nothing usable + warnings.add( + "The security scheme '" + entry.getKey() + "' declares no 'type'." + + " It is ignored."); + } else { + securitySchemes.put(entry.getKey(), scheme); + } + } + Map componentSchemas = new LinkedHashMap<>(); for (Map.Entry entry : componentsOf(root, "schemas").entrySet()) { JsonNode schema = schemaOf(entry.getValue(), "the component schema '" + entry.getKey() + "'", warnings); @@ -147,18 +167,29 @@ public static AsyncApiDocument parse( Map operations = new LinkedHashMap<>(); for (Map.Entry entry : objectFieldsOf(root.get("operations")).entrySet()) { AsyncApiOperation operation = parseOperation( - entry.getKey(), entry.getValue(), root, channels, messages, warnings); + entry.getKey(), entry.getValue(), root, channels, messages, securitySchemes, warnings); if (operation != null) { operations.put(entry.getKey(), operation); } } + Map servers = new LinkedHashMap<>(); + for (Map.Entry entry : objectFieldsOf(root.get("servers")).entrySet()) { + AsyncApiServer server = + parseServer(entry.getKey(), entry.getValue(), root, securitySchemes, warnings); + if (server != null) { + servers.put(entry.getKey(), server); + } + } + return AsyncApiDocument.builder(schemaText, location, version) .defaultContentType(defaultContentType) + .servers(servers) .channels(channels) .operations(operations) .messages(messages) .componentSchemas(componentSchemas) + .securitySchemes(securitySchemes) .warnings(warnings) .build(); } @@ -203,7 +234,7 @@ private static AsyncApiMessage parseMessage( } Map bindings = dereferencedFields(node.get("bindings"), root, warnings); - JsonNode kafka = bindings.get(KAFKA); + JsonNode kafka = bindings.get(AsyncApiChannel.KAFKA); return AsyncApiMessage.builder(id) .name(scalarOr(node.get("name"), id)) @@ -380,6 +411,132 @@ private static AsyncApiCorrelationId parseCorrelationId( return parsed; } + // ------------------------------------------------------------------ servers + + private static AsyncApiServer parseServer( + String name, + JsonNode rawNode, + JsonNode root, + Map securitySchemes, + List warnings) { + + JsonNode node = dereference(rawNode, root, warnings); + + if (node == null) { + return null; + } + + String host = scalarOf(node.get("host")); + String protocol = scalarOf(node.get("protocol")); + + if (host == null || protocol == null) { + String missing = host == null && protocol == null + ? "host and protocol" + : (host == null ? "host" : "protocol"); + warnings.add("Server '" + name + "' declares no " + missing + ", and is ignored"); + return null; + } + + Map variables = new LinkedHashMap<>(); + for (Map.Entry entry : objectFieldsOf(node.get("variables")).entrySet()) { + JsonNode variable = entry.getValue(); + variables.put(entry.getKey(), new AsyncApiServerVariable( + entry.getKey(), + scalarOf(variable.get("default")), + scalarsOf(variable.get("enum")), + scalarOf(variable.get("description")))); + } + + return AsyncApiServer.builder(name, host, protocol) + .protocolVersion(scalarOf(node.get("protocolVersion"))) + .pathname(scalarOf(node.get("pathname"))) + .variables(variables) + .security(parseSecurity( + node.get("security"), "server '" + name + "'", root, securitySchemes, warnings)) + .build(); + } + + private static AsyncApiSecurityScheme parseSecurityScheme(String name, JsonNode rawNode, JsonNode root) { + + JsonNode node = rawNode; + String ref = AsyncApiRefResolver.refOf(rawNode); + + if (ref != null) { + JsonNode resolved = AsyncApiRefResolver.resolveLocal(root, ref); + if (resolved != null) { + node = resolved; + } + } + + String type = scalarOf(node.get("type")); + + if (type == null) { + return null; + } + + return new AsyncApiSecurityScheme( + name, + type.toLowerCase(Locale.ENGLISH), + scalarOf(node.get("in")), + scalarOf(node.get("scheme")), + scalarOf(node.get("bearerFormat")), + scalarOf(node.get("description"))); + } + + /** + * Read a {@code security} array, which may hold either references to declared schemes or + * schemes written inline. Inline ones are registered under a name derived from where they + * appear, so that everything is reachable from one map. + */ + private static List parseSecurity( + JsonNode node, + String owner, + JsonNode root, + Map securitySchemes, + List warnings) { + + if (node == null || !node.isArray()) { + return new ArrayList<>(); + } + + List names = new ArrayList<>(); + + for (int index = 0; index < node.size(); index++) { + + JsonNode entry = node.get(index); + String ref = AsyncApiRefResolver.refOf(entry); + + if (ref != null) { + + String key = AsyncApiRefResolver.refKey(ref, SECURITY_REF_PREFIX); + + if (key != null && securitySchemes.containsKey(key)) { + names.add(key); + } else { + warnings.add( + "The security of " + owner + " refers to '" + ref + "', which is not a" + + " declared security scheme. It is ignored."); + } + + } else { + + String synthetic = owner + ".security." + index; + AsyncApiSecurityScheme scheme = parseSecurityScheme(synthetic, entry, root); + + if (scheme == null) { + warnings.add( + "The security of " + owner + " declares a scheme with no 'type'." + + " It is ignored."); + } else { + securitySchemes.put(synthetic, scheme); + names.add(scheme.getName()); + } + } + } + + return names; + } + // ------------------------------------------------------------------ channels private static AsyncApiChannel parseChannel( @@ -405,9 +562,20 @@ private static AsyncApiChannel parseChannel( } } + List servers = new ArrayList<>(); + for (String ref : refsOf(node.get("servers"))) { + String key = AsyncApiRefResolver.refKey(ref, SERVER_REF_PREFIX); + if (key != null) { + servers.add(key); + } + } + return AsyncApiChannel.builder(name) .address(scalarOf(node.get("address"))) + .servers(servers) .messageKeys(messageKeys) + .parameters(dereferencedFields(node.get("parameters"), root, warnings)) + .bindings(parseChannelBindings(dereferencedFields(node.get("bindings"), root, warnings))) .build(); } @@ -506,6 +674,23 @@ private static boolean hasFieldsBesidesRef(JsonNode node) { return false; } + private static AsyncApiChannelBindings parseChannelBindings(Map raw) { + + JsonNode kafka = raw.get(AsyncApiChannel.KAFKA); + JsonNode amqp = raw.get("amqp"); + JsonNode ws = raw.get("ws"); + + return AsyncApiChannelBindings.builder() + .kafkaTopic(kafka == null ? null : scalarOf(kafka.get("topic"))) + .amqpIs(amqp == null ? null : scalarOf(amqp.get("is"))) + //an AMQP name may legitimately be the empty string: that is the default exchange + .amqpQueue(amqp == null ? null : textOf(childOf(amqp.get("queue"), "name"))) + .amqpExchange(amqp == null ? null : textOf(childOf(amqp.get("exchange"), "name"))) + .wsMethod(ws == null ? null : scalarOf(ws.get("method"))) + .raw(raw) + .build(); + } + // ------------------------------------------------------------------ operations private static AsyncApiOperation parseOperation( @@ -514,6 +699,7 @@ private static AsyncApiOperation parseOperation( JsonNode root, Map channels, Map messages, + Map securitySchemes, List warnings) { JsonNode declared = dereference(rawNode, root, warnings); @@ -559,6 +745,9 @@ private static AsyncApiOperation parseOperation( return AsyncApiOperation.builder(name, action, channelName) .messageIds(messageIds) .reply(parseReply(node.get("reply"), root, channels, messages, name, warnings)) + .security(parseSecurity( + node.get("security"), "operation '" + name + "'", root, securitySchemes, warnings)) + .bindings(dereferencedFields(node.get("bindings"), root, warnings)) .title(scalarOf(node.get("title"))) .summary(scalarOf(node.get("summary"))) .description(scalarOf(node.get("description"))) @@ -901,13 +1090,9 @@ private static String describe(JsonNode node) { */ private static String scalarOf(JsonNode node) { - if (node == null || node.isNull() || node.isContainerNode()) { - return null; - } + String text = textOf(node); - String text = node.asText(); - - return text.trim().isEmpty() ? null : text; + return text == null || text.trim().isEmpty() ? null : text; } /** @@ -920,6 +1105,42 @@ private static String scalarOr(JsonNode node, String fallback) { return value == null ? fallback : value; } + /** + * As {@link #scalarOf}, but keeping a value that is there and empty. Only a few fields can + * mean something by an empty string -- AMQP's default exchange is named "" -- so this is the + * exception rather than the rule. + */ + private static String textOf(JsonNode node) { + + if (node == null || node.isNull() || node.isContainerNode()) { + return null; + } + + return node.asText(); + } + + private static JsonNode childOf(JsonNode node, String field) { + return node == null ? null : node.get(field); + } + + private static List scalarsOf(JsonNode node) { + + List values = new ArrayList<>(); + + if (node == null || !node.isArray()) { + return values; + } + + for (JsonNode entry : node) { + String value = scalarOf(entry); + if (value != null) { + values.add(value); + } + } + + return values; + } + /** * The {@code $ref} of every entry of an array. */ 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 069cacc981..cb3a8220e2 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 @@ -2,11 +2,15 @@ import com.webfuzzing.asyncapi.access.AsyncApiAccess; import com.webfuzzing.asyncapi.models.AsyncApiChannel; +import com.webfuzzing.asyncapi.models.AsyncApiChannelBindings; 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.AsyncApiSecurityScheme; +import com.webfuzzing.asyncapi.models.AsyncApiServer; +import com.webfuzzing.asyncapi.models.AsyncApiServerVariable; import com.webfuzzing.asyncapi.parser.AsyncApiParsingException; import org.junit.jupiter.api.Test; @@ -986,4 +990,279 @@ public void testTextOnlyDocumentReportsUnresolvableExternalReferences() { "expected a warning about references that cannot be resolved: " + document.getWarnings()); } + // ------------------------------------------------------------------ servers and bindings + + @Test + public void testServers() { + + AsyncApiDocument document = load("/asyncapi/artificial/bindings.yaml"); + + assertEquals(setOf("kafka", "rabbit"), document.getServers().keySet()); + + AsyncApiServer rabbit = document.getServers().get("rabbit"); + assertEquals("localhost:5672", rabbit.getHost()); + assertEquals("amqp", rabbit.getProtocol()); + //the one field that separates AMQP 0-9-1 from the incompatible 1.0 + assertEquals("0.9.1", rabbit.getProtocolVersion()); + } + + @Test + public void testServerPathnameAndVariables() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: A server whose path is templated\n" + + " version: 1.0.0\n" + + "servers:\n" + + " default:\n" + + " host: localhost:1883\n" + + " protocol: mqtt\n" + + " pathname: 'api/1/{module_id}'\n" + + " variables:\n" + + " module_id:\n" + + " description: The id of the module\n" + + " default: main\n" + + " enum: [main, spare]\n"); + + AsyncApiServer server = document.getServers().get("default"); + assertEquals("api/1/{module_id}", server.getPathname()); + + //the placeholder is declared and left to be resolved at run time, not substituted here + AsyncApiServerVariable variable = server.getVariables().get("module_id"); + assertEquals("The id of the module", variable.getDescription()); + assertEquals("main", variable.getDefaultValue()); + assertEquals(Arrays.asList("main", "spare"), variable.getEnumeration()); + } + + @Test + public void testDocumentWithoutServers() { + + //describing only the contract, and leaving the broker to deployment, is common + AsyncApiDocument document = load("/asyncapi/artificial/inline-messages.yaml"); + + assertTrue(document.getServers().isEmpty()); + assertEquals(1, document.getOperations().size()); + } + + @Test + public void testKafkaTopicBindingWinsOverTheDeclaredAddress() { + + AsyncApiChannel channel = load("/asyncapi/artificial/bindings.yaml").getChannels().get("both"); + + //the declared address is kept as written... + assertEquals("events.declared", channel.getAddress()); + //...but on Kafka the binding's topic is what a client must actually use + assertEquals("events.from.binding", channel.effectiveAddress("kafka")); + assertEquals("events.from.binding", channel.effectiveAddress("KAFKA")); + + //on any other transport the topic means nothing + assertEquals("events.declared", channel.effectiveAddress("amqp")); + assertEquals("events.declared", channel.effectiveAddress(null)); + + assertEquals("routingKey", channel.getBindings().getAmqpIs()); + assertEquals("events.exchange", channel.getBindings().getAmqpExchange()); + } + + @Test + public void testKafkaBindingWithoutATopicLeavesTheAddressAlone() { + + AsyncApiDocument document = load("/asyncapi/artificial/bindings.yaml"); + + AsyncApiChannel emptyTopic = document.getChannels().get("emptyTopic"); + assertNull(emptyTopic.getBindings().getKafkaTopic()); + assertEquals("events.declared", emptyTopic.effectiveAddress("kafka")); + + AsyncApiChannel plain = document.getChannels().get("plain"); + assertTrue(plain.getBindings().getRaw().isEmpty()); + assertEquals("events.declared", plain.effectiveAddress("kafka")); + } + + @Test + public void testAmqpAndWebSocketChannelBindings() { + + AsyncApiChannelBindings amqp = + load("/asyncapi/artificial/traits.yaml").getChannels().get("tasks").getBindings(); + assertEquals("queue", amqp.getAmqpIs()); + assertEquals("tasks.request", amqp.getAmqpQueue()); + assertTrue(amqp.getRaw().containsKey("amqp")); + + AsyncApiChannelBindings ws = + load("/asyncapi/artificial/websocket-reply.yaml").getChannels().get("vsi").getBindings(); + assertEquals("GET", ws.getWsMethod()); + } + + @Test + public void testBindingsBehindAReferenceAreFollowed() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Bindings shared through components\n" + + " version: 1.0.0\n" + + "channels:\n" + + " c:\n" + + " address: declared\n" + + " bindings:\n" + + " $ref: '#/components/channelBindings/kafkaTopic'\n" + + " messages:\n" + + " m:\n" + + " payload:\n" + + " type: object\n" + + "operations:\n" + + " o:\n" + + " action: receive\n" + + " channel:\n" + + " $ref: '#/channels/c'\n" + + "components:\n" + + " channelBindings:\n" + + " kafkaTopic:\n" + + " kafka:\n" + + " topic: the.real.topic\n"); + + /* + Reading a binding without following the reference would leave the channel on its + declared address, and a client would publish to the wrong topic. That is a wrong + answer rather than a missing one, so it is worth a test of its own. + */ + AsyncApiChannel channel = document.getChannels().get("c"); + assertEquals("the.real.topic", channel.getBindings().getKafkaTopic()); + assertEquals("the.real.topic", channel.effectiveAddress("kafka")); + } + + @Test + public void testChannelParametersAreKeptAsDeclared() { + + AsyncApiChannel channel = + load("/asyncapi/artificial/inline-messages.yaml").getChannels().get("signup"); + + assertEquals(setOf("tenantId"), channel.getParameters().keySet()); + assertEquals("acme", channel.getParameters().get("tenantId").get("default").asText()); + } + + @Test + public void testTheServersAChannelIsOn() { + + AsyncApiDocument document = load("/asyncapi/artificial/bindings.yaml"); + + List names = new ArrayList<>(); + for (AsyncApiServer server : document.serversOf(document.getChannels().get("both"))) { + names.add(server.getName()); + } + + //no 'servers' on the channel means every server, as the specification has it + assertEquals(Arrays.asList("kafka", "rabbit"), names); + } + + // ------------------------------------------------------------------ security + + @Test + public void testSecuritySchemesAreRead() { + + AsyncApiDocument document = load("/asyncapi/artificial/traits.yaml"); + AsyncApiSecurityScheme scheme = document.getSecuritySchemes().get("userPassword"); + + assertEquals("userpassword", scheme.getType()); + assertEquals("SASL PLAIN over the broker connection", scheme.getDescription()); + + //and an operation says which of them it needs, through a trait in this document + assertEquals( + Arrays.asList("userPassword"), + document.getOperations().get("submitTask").getSecurity()); + } + + @Test + public void testSecuritySchemeWrittenInlineWhereItIsUsed() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Security stated on the server itself\n" + + " version: 1.0.0\n" + + "servers:\n" + + " dev:\n" + + " host: dev:5672\n" + + " protocol: amqp\n" + + " security:\n" + + " - type: userPassword\n" + + " description: An authentication method for the server\n"); + + AsyncApiServer server = document.getServers().get("dev"); + assertEquals(1, server.getSecurity().size()); + + //an inline scheme is registered under a name derived from where it appears + AsyncApiSecurityScheme scheme = document.getSecuritySchemes().get(server.getSecurity().get(0)); + assertEquals("userpassword", scheme.getType()); + assertEquals("An authentication method for the server", scheme.getDescription()); + } + + @Test + public void testSecurityThatCannotBeUsedIsReported() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Security pointing nowhere\n" + + " version: 1.0.0\n" + + "servers:\n" + + " dev:\n" + + " host: dev:5672\n" + + " protocol: amqp\n" + + " security:\n" + + " - $ref: '#/components/securitySchemes/absent'\n" + + " - description: no type at all\n"); + + assertTrue(document.getServers().get("dev").getSecurity().isEmpty()); + assertTrue(warns(document, "not a declared"), document.getWarnings().toString()); + assertTrue(warns(document, "no 'type'"), document.getWarnings().toString()); + } + + @Test + public void testDeclaredSecuritySchemeWithNoTypeIsReported() { + + /* + A scheme written inline with no 'type' was already reported; one declared under + components was dropped in silence. A server referring to it then failed for what + looked like a second, unrelated reason. + */ + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Scheme with no type\n" + + " version: 1.0.0\n" + + "servers:\n" + + " dev:\n" + + " host: dev:5672\n" + + " protocol: amqp\n" + + " security:\n" + + " - $ref: '#/components/securitySchemes/halfWritten'\n" + + "components:\n" + + " securitySchemes:\n" + + " halfWritten:\n" + + " description: someone meant to finish this\n"); + + assertFalse(document.getSecuritySchemes().containsKey("halfWritten")); + assertTrue(warns(document, "halfWritten", "no 'type'"), document.getWarnings().toString()); + } + + @Test + public void testServerMissingWhatItNeedsIsReported() { + + AsyncApiDocument document = parse( + "asyncapi: 3.0.0\n" + + "info:\n" + + " title: Incomplete servers\n" + + " version: 1.0.0\n" + + "servers:\n" + + " noProtocol:\n" + + " host: localhost:9092\n" + + " nothing:\n" + + " description: neither host nor protocol\n"); + + assertTrue(document.getServers().isEmpty()); + assertTrue(warns(document, "noProtocol", "protocol"), document.getWarnings().toString()); + //both missing fields are named, not just the first + assertTrue(warns(document, "nothing", "host and protocol"), document.getWarnings().toString()); + } + } diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/bindings.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/bindings.yaml new file mode 100644 index 0000000000..d2b5104312 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/bindings.yaml @@ -0,0 +1,69 @@ +asyncapi: 3.0.0 +info: + title: Channels whose wire address depends on the transport + version: 1.0.0 + +servers: + kafka: + host: localhost:9092 + protocol: kafka + rabbit: + host: localhost:5672 + protocol: amqp + protocolVersion: 0.9.1 + +channels: + # both an address and a Kafka topic: on Kafka the topic must win + both: + address: events.declared + messages: + event: + $ref: '#/components/messages/event' + bindings: + kafka: + topic: events.from.binding + amqp: + is: routingKey + exchange: + name: events.exchange + + # a Kafka binding that declares no topic: the address stands + emptyTopic: + address: events.declared + messages: + event: + $ref: '#/components/messages/event' + bindings: + kafka: + partitions: 3 + + # no binding at all + plain: + address: events.declared + messages: + event: + $ref: '#/components/messages/event' + +operations: + onBoth: + action: receive + channel: + $ref: '#/channels/both' + onEmptyTopic: + action: receive + channel: + $ref: '#/channels/emptyTopic' + emitPlain: + action: send + channel: + $ref: '#/channels/plain' + +components: + messages: + event: + name: Event + payload: + type: object + properties: + id: + type: string diff --git a/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/traits.yaml b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/traits.yaml new file mode 100644 index 0000000000..fcfc260cba --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/resources/asyncapi/artificial/traits.yaml @@ -0,0 +1,99 @@ +asyncapi: 3.0.0 +info: + title: Boilerplate factored out into traits + version: 1.0.0 + +servers: + broker: + host: localhost:5672 + protocol: amqp + protocolVersion: 0.9.1 + +channels: + tasks: + address: tasks.request + messages: + submit: + $ref: '#/components/messages/submit' + bindings: + amqp: + is: queue + queue: + name: tasks.request + durable: true + +operations: + submitTask: + action: receive + channel: + $ref: '#/channels/tasks' + traits: + - $ref: '#/components/operationTraits/authenticated' + # what the operation states itself must win over the trait + summary: Submit a task + + # two traits: the later one must win where they overlap + submitUrgentTask: + action: receive + channel: + $ref: '#/channels/tasks' + traits: + - $ref: '#/components/operationTraits/authenticated' + - $ref: '#/components/operationTraits/rateLimited' + + # a trait that is not an object, and one that points nowhere + brokenTraits: + action: receive + channel: + $ref: '#/channels/tasks' + traits: + - 'not an object at all' + - $ref: '#/components/operationTraits/absent' + +components: + operationTraits: + authenticated: + summary: Overridden by the operation + title: From the first trait + description: Requires a valid token + security: + - $ref: '#/components/securitySchemes/userPassword' + rateLimited: + title: From the second trait + description: Also rate limited + + messageTraits: + correlated: + contentType: application/json + correlationId: + location: '$message.header#/correlationId' + headers: + type: object + properties: + correlationId: + type: string + + securitySchemes: + userPassword: + type: userPassword + description: SASL PLAIN over the broker connection + + messages: + submit: + name: SubmitTask + traits: + - $ref: '#/components/messageTraits/correlated' + payload: + $ref: '#/components/schemas/Task' + + schemas: + Task: + type: object + required: [command] + properties: + command: + type: string + priority: + type: integer + minimum: 0 + maximum: 9 From bd3a056e499edea33431182a79c2995a17305be2 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Fri, 4 Sep 2026 18:01:38 -0300 Subject: [PATCH 2/4] AsyncAPI 3.x: name the node each section loop reads Review feedback: reading root.get(...) inline in a for-header is dense. Each of the five section loops now takes the node it iterates into a local first. Two were pointed out; all five are done, so they read alike. --- .../asyncapi/parser/AsyncApiParser.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 0bba71f31b..b1d59e575a 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 @@ -157,15 +157,17 @@ public static AsyncApiDocument parse( } } + JsonNode channelsNode = root.get("channels"); Map channels = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(root.get("channels")).entrySet()) { + for (Map.Entry entry : objectFieldsOf(channelsNode).entrySet()) { channels.put(entry.getKey(), parseChannel( entry.getKey(), entry.getValue(), root, defaultContentType, componentSchemas, messages, warnings)); } + JsonNode operationsNode = root.get("operations"); Map operations = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(root.get("operations")).entrySet()) { + for (Map.Entry entry : objectFieldsOf(operationsNode).entrySet()) { AsyncApiOperation operation = parseOperation( entry.getKey(), entry.getValue(), root, channels, messages, securitySchemes, warnings); if (operation != null) { @@ -173,8 +175,9 @@ public static AsyncApiDocument parse( } } + JsonNode serversNode = root.get("servers"); Map servers = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(root.get("servers")).entrySet()) { + for (Map.Entry entry : objectFieldsOf(serversNode).entrySet()) { AsyncApiServer server = parseServer(entry.getKey(), entry.getValue(), root, securitySchemes, warnings); if (server != null) { @@ -437,8 +440,9 @@ private static AsyncApiServer parseServer( return null; } + JsonNode variablesNode = node.get("variables"); Map variables = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(node.get("variables")).entrySet()) { + for (Map.Entry entry : objectFieldsOf(variablesNode).entrySet()) { JsonNode variable = entry.getValue(); variables.put(entry.getKey(), new AsyncApiServerVariable( entry.getKey(), @@ -553,7 +557,8 @@ private static AsyncApiChannel parseChannel( Map messageKeys = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(node.get("messages")).entrySet()) { + JsonNode messagesNode = node.get("messages"); + for (Map.Entry entry : objectFieldsOf(messagesNode).entrySet()) { String id = resolveChannelMessage( name, entry.getKey(), entry.getValue(), root, defaultContentType, componentSchemas, messages, warnings); From a4eb3c5eeacc08c771c273f0dcb864387ae4700d Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Fri, 4 Sep 2026 18:03:43 -0300 Subject: [PATCH 3/4] AsyncAPI 3.x: name every keyword the parser reads Review feedback: the field names the parser reads were literals at each of the 69 places they are read. They are now constants, gathered in one nested Keyword holder so the field name is written once and what the parser understands can be seen in one place. "default", "enum" and the rest were pointed out; all 53 are done, since leaving any as literals would keep the smell being fixed. The holder is nested rather than flat so that the names read as what they are at the use site -- node.get(Keyword.PAYLOAD) -- and so that the keyword "defaultContentType" cannot be confused with AsyncApiDocument's fallback value of the same name, which sits two lines away from where it is read. The $ref pointer prefixes are composed from the keywords and from the resolver's separator constants, so the shape of a pointer into channels, servers or components is written down once rather than spelled out per prefix. No behaviour changes. --- .../asyncapi/parser/AsyncApiParser.java | 222 ++++++++++++------ 1 file changed, 146 insertions(+), 76 deletions(-) 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 b1d59e575a..1607dd94da 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 @@ -17,6 +17,7 @@ import com.webfuzzing.asyncapi.models.DocumentLocation; import com.webfuzzing.asyncapi.resolver.AsyncApiDocumentFetcher; import com.webfuzzing.asyncapi.resolver.AsyncApiRefResolver; +import com.webfuzzing.asyncapi.resolver.RefLocations; import java.util.ArrayDeque; import java.util.ArrayList; @@ -45,15 +46,84 @@ */ public class AsyncApiParser { - private static final String CHANNEL_REF_PREFIX = "#/channels/"; + /** + * The AsyncAPI keywords this parser reads, spelled as the specification spells them. Kept + * together so that a field name is written once, and so that what the parser understands + * can be seen in one place. + */ + private static final class Keyword { + + static final String REF = "$ref"; + + static final String ACTION = "action"; + static final String ADDRESS = "address"; + static final String AMQP = "amqp"; + static final String ASYNCAPI = "asyncapi"; + static final String BEARER_FORMAT = "bearerFormat"; + static final String BINDINGS = "bindings"; + static final String CHANNEL = "channel"; + static final String CHANNELS = "channels"; + static final String COMPONENTS = "components"; + static final String CONTENT_TYPE = "contentType"; + static final String CORRELATION_ID = "correlationId"; + static final String DEFAULT = "default"; + static final String DEFAULT_CONTENT_TYPE = "defaultContentType"; + static final String DESCRIPTION = "description"; + static final String ENUM = "enum"; + static final String EXAMPLES = "examples"; + static final String EXCHANGE = "exchange"; + static final String HEADERS = "headers"; + static final String HOST = "host"; + static final String IN = "in"; + static final String IS = "is"; + static final String KEY = "key"; + static final String LOCATION = "location"; + static final String MESSAGES = "messages"; + static final String MESSAGE_TRAITS = "messageTraits"; + static final String METHOD = "method"; + static final String NAME = "name"; + static final String OPERATIONS = "operations"; + static final String OPERATION_TRAITS = "operationTraits"; + static final String PARAMETERS = "parameters"; + static final String PATHNAME = "pathname"; + static final String PAYLOAD = "payload"; + static final String PROTOCOL = "protocol"; + static final String PROTOCOL_VERSION = "protocolVersion"; + static final String QUEUE = "queue"; + static final String RECEIVE = "receive"; + static final String REPLY = "reply"; + static final String SCHEMA = "schema"; + static final String SCHEMA_FORMAT = "schemaFormat"; + static final String SCHEMAS = "schemas"; + static final String SCHEME = "scheme"; + static final String SECURITY = "security"; + static final String SECURITY_SCHEMES = "securitySchemes"; + static final String SEND = "send"; + static final String SERVERS = "servers"; + static final String SUMMARY = "summary"; + static final String TITLE = "title"; + static final String TOPIC = "topic"; + static final String TRAITS = "traits"; + static final String TYPE = "type"; + static final String VARIABLES = "variables"; + static final String WS = "ws"; + + private Keyword() { + } + } + + private static final String POINTER_ROOT = RefLocations.FRAGMENT_SEPARATOR + RefLocations.PATH_SEPARATOR; + + private static final String CHANNEL_REF_PREFIX = POINTER_ROOT + Keyword.CHANNELS + RefLocations.PATH_SEPARATOR; - private static final String MESSAGE_REF_PREFIX = "#/components/messages/"; + private static final String SERVER_REF_PREFIX = POINTER_ROOT + Keyword.SERVERS + RefLocations.PATH_SEPARATOR; - private static final String SECURITY_REF_PREFIX = "#/components/securitySchemes/"; + private static final String COMPONENT_REF_PREFIX = POINTER_ROOT + Keyword.COMPONENTS + RefLocations.PATH_SEPARATOR; - private static final String SERVER_REF_PREFIX = "#/servers/"; + private static final String MESSAGE_REF_PREFIX = COMPONENT_REF_PREFIX + Keyword.MESSAGES + RefLocations.PATH_SEPARATOR; - private static final String REF = "$ref"; + private static final String SECURITY_REF_PREFIX = + COMPONENT_REF_PREFIX + Keyword.SECURITY_SCHEMES + RefLocations.PATH_SEPARATOR; /** * Schema formats that are JSON Schema by another name, and so can be read here. Compared as @@ -100,7 +170,7 @@ public static AsyncApiDocument parse( throw new AsyncApiParsingException("The AsyncAPI document is not a JSON/YAML object"); } - String version = scalarOf(root.get("asyncapi")); + String version = scalarOf(root.get(Keyword.ASYNCAPI)); if (version == null) { throw new AsyncApiParsingException( @@ -120,14 +190,14 @@ public static AsyncApiDocument parse( AsyncApiRefResolver.inlineExternalDocuments( (ObjectNode) root, location, warnings, fetch, AsyncApiMapper::readTree); - String defaultContentType = scalarOf(root.get("defaultContentType")); + String defaultContentType = scalarOf(root.get(Keyword.DEFAULT_CONTENT_TYPE)); if (defaultContentType == null) { defaultContentType = AsyncApiDocument.DEFAULT_CONTENT_TYPE; } //security schemes are a mutable map, as schemes can also be declared inline where used Map securitySchemes = new LinkedHashMap<>(); - for (Map.Entry entry : componentsOf(root, "securitySchemes").entrySet()) { + for (Map.Entry entry : componentsOf(root, Keyword.SECURITY_SCHEMES).entrySet()) { AsyncApiSecurityScheme scheme = parseSecurityScheme(entry.getKey(), entry.getValue(), root); if (scheme == null) { //same as for one written inline: a scheme with no 'type' says nothing usable @@ -140,7 +210,7 @@ public static AsyncApiDocument parse( } Map componentSchemas = new LinkedHashMap<>(); - for (Map.Entry entry : componentsOf(root, "schemas").entrySet()) { + for (Map.Entry entry : componentsOf(root, Keyword.SCHEMAS).entrySet()) { JsonNode schema = schemaOf(entry.getValue(), "the component schema '" + entry.getKey() + "'", warnings); if (schema != null) { componentSchemas.put(entry.getKey(), schema); @@ -149,7 +219,7 @@ public static AsyncApiDocument parse( //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()) { + for (Map.Entry entry : componentsOf(root, Keyword.MESSAGES).entrySet()) { AsyncApiMessage message = parseMessage( entry.getKey(), entry.getValue(), root, defaultContentType, componentSchemas, warnings); if (message != null) { @@ -157,7 +227,7 @@ public static AsyncApiDocument parse( } } - JsonNode channelsNode = root.get("channels"); + JsonNode channelsNode = root.get(Keyword.CHANNELS); Map channels = new LinkedHashMap<>(); for (Map.Entry entry : objectFieldsOf(channelsNode).entrySet()) { channels.put(entry.getKey(), parseChannel( @@ -165,7 +235,7 @@ public static AsyncApiDocument parse( componentSchemas, messages, warnings)); } - JsonNode operationsNode = root.get("operations"); + JsonNode operationsNode = root.get(Keyword.OPERATIONS); Map operations = new LinkedHashMap<>(); for (Map.Entry entry : objectFieldsOf(operationsNode).entrySet()) { AsyncApiOperation operation = parseOperation( @@ -175,7 +245,7 @@ public static AsyncApiDocument parse( } } - JsonNode serversNode = root.get("servers"); + JsonNode serversNode = root.get(Keyword.SERVERS); Map servers = new LinkedHashMap<>(); for (Map.Entry entry : objectFieldsOf(serversNode).entrySet()) { AsyncApiServer server = @@ -213,22 +283,22 @@ private static AsyncApiMessage parseMessage( return null; } - JsonNode node = applyTraits(declared, root, "messageTraits", warnings); + JsonNode node = applyTraits(declared, root, Keyword.MESSAGE_TRAITS, warnings); - JsonNode payload = schemaOf(node.get("payload"), "message '" + id + "'", warnings); + JsonNode payload = schemaOf(node.get(Keyword.PAYLOAD), "message '" + id + "'", warnings); if (payload != null && reportUnfollowable( payload, componentSchemas, "message '" + id + "'", "The message is ignored.", warnings)) { payload = null; } - if (payload == null && node.has("payload")) { + if (payload == null && node.has(Keyword.PAYLOAD)) { //nothing can be built from a payload that cannot be read, so the message goes too return null; } //broken headers cost only the headers: the message itself is still usable - JsonNode headers = schemaOf(node.get("headers"), "the headers of message '" + id + "'", warnings); + JsonNode headers = schemaOf(node.get(Keyword.HEADERS), "the headers of message '" + id + "'", warnings); if (headers != null && reportUnfollowable( headers, componentSchemas, "the headers of message '" + id + "'", @@ -236,21 +306,21 @@ private static AsyncApiMessage parseMessage( headers = null; } - Map bindings = dereferencedFields(node.get("bindings"), root, warnings); + Map bindings = dereferencedFields(node.get(Keyword.BINDINGS), root, warnings); JsonNode kafka = bindings.get(AsyncApiChannel.KAFKA); return AsyncApiMessage.builder(id) - .name(scalarOr(node.get("name"), id)) - .contentType(scalarOr(node.get("contentType"), defaultContentType)) + .name(scalarOr(node.get(Keyword.NAME), id)) + .contentType(scalarOr(node.get(Keyword.CONTENT_TYPE), defaultContentType)) .payload(payload) .headers(headers) - .correlationId(parseCorrelationId(node.get("correlationId"), root, id, warnings)) - .kafkaKey(kafka == null ? null : kafka.get("key")) + .correlationId(parseCorrelationId(node.get(Keyword.CORRELATION_ID), root, id, warnings)) + .kafkaKey(kafka == null ? null : kafka.get(Keyword.KEY)) .bindings(bindings) - .examples(objectsOf(node.get("examples"))) - .title(scalarOf(node.get("title"))) - .summary(scalarOf(node.get("summary"))) - .description(scalarOf(node.get("description"))) + .examples(objectsOf(node.get(Keyword.EXAMPLES))) + .title(scalarOf(node.get(Keyword.TITLE))) + .summary(scalarOf(node.get(Keyword.SUMMARY))) + .description(scalarOf(node.get(Keyword.DESCRIPTION))) .build(); } @@ -283,7 +353,7 @@ private static JsonNode schemaOf(JsonNode node, String owner, List warni return null; } - String format = scalarOf(node.get("schemaFormat")); + String format = scalarOf(node.get(Keyword.SCHEMA_FORMAT)); if (format == null) { return node; @@ -292,7 +362,7 @@ private static JsonNode schemaOf(JsonNode node, String owner, List warni for (String known : JSON_SCHEMA_FORMATS) { if (format.startsWith(known)) { //a multi-format declaration keeps the schema itself one level down - JsonNode schema = node.get("schema"); + JsonNode schema = node.get(Keyword.SCHEMA); return schema == null ? node : schema; } } @@ -392,7 +462,7 @@ private static AsyncApiCorrelationId parseCorrelationId( return null; } - String location = scalarOf(node.get("location")); + String location = scalarOf(node.get(Keyword.LOCATION)); if (location == null) { warnings.add( @@ -402,7 +472,7 @@ private static AsyncApiCorrelationId parseCorrelationId( } AsyncApiCorrelationId parsed = - AsyncApiCorrelationId.parse(location, scalarOf(node.get("description"))); + AsyncApiCorrelationId.parse(location, scalarOf(node.get(Keyword.DESCRIPTION))); if (parsed == null) { warnings.add( @@ -429,8 +499,8 @@ private static AsyncApiServer parseServer( return null; } - String host = scalarOf(node.get("host")); - String protocol = scalarOf(node.get("protocol")); + String host = scalarOf(node.get(Keyword.HOST)); + String protocol = scalarOf(node.get(Keyword.PROTOCOL)); if (host == null || protocol == null) { String missing = host == null && protocol == null @@ -440,23 +510,23 @@ private static AsyncApiServer parseServer( return null; } - JsonNode variablesNode = node.get("variables"); + JsonNode variablesNode = node.get(Keyword.VARIABLES); Map variables = new LinkedHashMap<>(); for (Map.Entry entry : objectFieldsOf(variablesNode).entrySet()) { JsonNode variable = entry.getValue(); variables.put(entry.getKey(), new AsyncApiServerVariable( entry.getKey(), - scalarOf(variable.get("default")), - scalarsOf(variable.get("enum")), - scalarOf(variable.get("description")))); + scalarOf(variable.get(Keyword.DEFAULT)), + scalarsOf(variable.get(Keyword.ENUM)), + scalarOf(variable.get(Keyword.DESCRIPTION)))); } return AsyncApiServer.builder(name, host, protocol) - .protocolVersion(scalarOf(node.get("protocolVersion"))) - .pathname(scalarOf(node.get("pathname"))) + .protocolVersion(scalarOf(node.get(Keyword.PROTOCOL_VERSION))) + .pathname(scalarOf(node.get(Keyword.PATHNAME))) .variables(variables) .security(parseSecurity( - node.get("security"), "server '" + name + "'", root, securitySchemes, warnings)) + node.get(Keyword.SECURITY), "server '" + name + "'", root, securitySchemes, warnings)) .build(); } @@ -472,7 +542,7 @@ private static AsyncApiSecurityScheme parseSecurityScheme(String name, JsonNode } } - String type = scalarOf(node.get("type")); + String type = scalarOf(node.get(Keyword.TYPE)); if (type == null) { return null; @@ -481,10 +551,10 @@ private static AsyncApiSecurityScheme parseSecurityScheme(String name, JsonNode return new AsyncApiSecurityScheme( name, type.toLowerCase(Locale.ENGLISH), - scalarOf(node.get("in")), - scalarOf(node.get("scheme")), - scalarOf(node.get("bearerFormat")), - scalarOf(node.get("description"))); + scalarOf(node.get(Keyword.IN)), + scalarOf(node.get(Keyword.SCHEME)), + scalarOf(node.get(Keyword.BEARER_FORMAT)), + scalarOf(node.get(Keyword.DESCRIPTION))); } /** @@ -557,7 +627,7 @@ private static AsyncApiChannel parseChannel( Map messageKeys = new LinkedHashMap<>(); - JsonNode messagesNode = node.get("messages"); + JsonNode messagesNode = node.get(Keyword.MESSAGES); for (Map.Entry entry : objectFieldsOf(messagesNode).entrySet()) { String id = resolveChannelMessage( name, entry.getKey(), entry.getValue(), root, defaultContentType, @@ -568,7 +638,7 @@ private static AsyncApiChannel parseChannel( } List servers = new ArrayList<>(); - for (String ref : refsOf(node.get("servers"))) { + for (String ref : refsOf(node.get(Keyword.SERVERS))) { String key = AsyncApiRefResolver.refKey(ref, SERVER_REF_PREFIX); if (key != null) { servers.add(key); @@ -576,11 +646,11 @@ private static AsyncApiChannel parseChannel( } return AsyncApiChannel.builder(name) - .address(scalarOf(node.get("address"))) + .address(scalarOf(node.get(Keyword.ADDRESS))) .servers(servers) .messageKeys(messageKeys) - .parameters(dereferencedFields(node.get("parameters"), root, warnings)) - .bindings(parseChannelBindings(dereferencedFields(node.get("bindings"), root, warnings))) + .parameters(dereferencedFields(node.get(Keyword.PARAMETERS), root, warnings)) + .bindings(parseChannelBindings(dereferencedFields(node.get(Keyword.BINDINGS), root, warnings))) .build(); } @@ -671,7 +741,7 @@ private static boolean hasFieldsBesidesRef(JsonNode node) { Iterator names = node.fieldNames(); while (names.hasNext()) { - if (!REF.equals(names.next())) { + if (!Keyword.REF.equals(names.next())) { return true; } } @@ -682,16 +752,16 @@ private static boolean hasFieldsBesidesRef(JsonNode node) { private static AsyncApiChannelBindings parseChannelBindings(Map raw) { JsonNode kafka = raw.get(AsyncApiChannel.KAFKA); - JsonNode amqp = raw.get("amqp"); - JsonNode ws = raw.get("ws"); + JsonNode amqp = raw.get(Keyword.AMQP); + JsonNode ws = raw.get(Keyword.WS); return AsyncApiChannelBindings.builder() - .kafkaTopic(kafka == null ? null : scalarOf(kafka.get("topic"))) - .amqpIs(amqp == null ? null : scalarOf(amqp.get("is"))) + .kafkaTopic(kafka == null ? null : scalarOf(kafka.get(Keyword.TOPIC))) + .amqpIs(amqp == null ? null : scalarOf(amqp.get(Keyword.IS))) //an AMQP name may legitimately be the empty string: that is the default exchange - .amqpQueue(amqp == null ? null : textOf(childOf(amqp.get("queue"), "name"))) - .amqpExchange(amqp == null ? null : textOf(childOf(amqp.get("exchange"), "name"))) - .wsMethod(ws == null ? null : scalarOf(ws.get("method"))) + .amqpQueue(amqp == null ? null : textOf(childOf(amqp.get(Keyword.QUEUE), "name"))) + .amqpExchange(amqp == null ? null : textOf(childOf(amqp.get(Keyword.EXCHANGE), "name"))) + .wsMethod(ws == null ? null : scalarOf(ws.get(Keyword.METHOD))) .raw(raw) .build(); } @@ -713,9 +783,9 @@ private static AsyncApiOperation parseOperation( return null; } - JsonNode node = applyTraits(declared, root, "operationTraits", warnings); + JsonNode node = applyTraits(declared, root, Keyword.OPERATION_TRAITS, warnings); - AsyncApiOperation.Action action = actionOf(node.get("action")); + AsyncApiOperation.Action action = actionOf(node.get(Keyword.ACTION)); if (action == null) { warnings.add( @@ -724,7 +794,7 @@ private static AsyncApiOperation parseOperation( return null; } - String channelRef = AsyncApiRefResolver.refOf(node.get("channel")); + String channelRef = AsyncApiRefResolver.refOf(node.get(Keyword.CHANNEL)); String channelName = channelRef == null ? null : AsyncApiRefResolver.refKey(channelRef, CHANNEL_REF_PREFIX); @@ -739,7 +809,7 @@ private static AsyncApiOperation parseOperation( AsyncApiChannel channel = channels.get(channelName); List messageIds = - selectMessages(node.get("messages"), channel, channels, messages, name, warnings); + selectMessages(node.get(Keyword.MESSAGES), channel, channels, messages, name, warnings); if (messageIds.isEmpty()) { warnings.add( @@ -749,13 +819,13 @@ private static AsyncApiOperation parseOperation( return AsyncApiOperation.builder(name, action, channelName) .messageIds(messageIds) - .reply(parseReply(node.get("reply"), root, channels, messages, name, warnings)) + .reply(parseReply(node.get(Keyword.REPLY), root, channels, messages, name, warnings)) .security(parseSecurity( - node.get("security"), "operation '" + name + "'", root, securitySchemes, warnings)) - .bindings(dereferencedFields(node.get("bindings"), root, warnings)) - .title(scalarOf(node.get("title"))) - .summary(scalarOf(node.get("summary"))) - .description(scalarOf(node.get("description"))) + node.get(Keyword.SECURITY), "operation '" + name + "'", root, securitySchemes, warnings)) + .bindings(dereferencedFields(node.get(Keyword.BINDINGS), root, warnings)) + .title(scalarOf(node.get(Keyword.TITLE))) + .summary(scalarOf(node.get(Keyword.SUMMARY))) + .description(scalarOf(node.get(Keyword.DESCRIPTION))) .build(); } @@ -768,9 +838,9 @@ private static AsyncApiOperation.Action actionOf(JsonNode node) { } switch (action.toLowerCase(Locale.ENGLISH)) { - case "send": + case Keyword.SEND: return AsyncApiOperation.Action.SEND; - case "receive": + case Keyword.RECEIVE: return AsyncApiOperation.Action.RECEIVE; default: return null; @@ -795,7 +865,7 @@ private static AsyncApiReply parseReply( return null; } - String channelRef = AsyncApiRefResolver.refOf(node.get("channel")); + String channelRef = AsyncApiRefResolver.refOf(node.get(Keyword.CHANNEL)); String channelName = channelRef == null ? null : AsyncApiRefResolver.refKey(channelRef, CHANNEL_REF_PREFIX); @@ -810,16 +880,16 @@ private static AsyncApiReply parseReply( List messageIds = replyChannel == null ? new ArrayList() - : selectMessages(node.get("messages"), replyChannel, channels, messages, operationName, warnings); + : selectMessages(node.get(Keyword.MESSAGES), replyChannel, channels, messages, operationName, warnings); //the address may be declared here or shared through components.replyAddresses - JsonNode rawAddress = node.get("address"); + JsonNode rawAddress = node.get(Keyword.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"))); + address == null ? null : scalarOf(address.get(Keyword.LOCATION))); } /** @@ -1008,7 +1078,7 @@ private static JsonNode applyTraits( String componentKind, List warnings) { - JsonNode traits = node.get("traits"); + JsonNode traits = node.get(Keyword.TRAITS); if (traits == null || !traits.isArray() || traits.size() == 0) { return node; @@ -1058,7 +1128,7 @@ private static JsonNode shallowMerge(JsonNode... sources) { Iterator> fields = source.fields(); while (fields.hasNext()) { Map.Entry field = fields.next(); - if (!REF.equals(field.getKey())) { + if (!Keyword.REF.equals(field.getKey())) { merged.set(field.getKey(), field.getValue()); } } @@ -1214,7 +1284,7 @@ private static Map objectFieldsOf(JsonNode node) { */ private static Map componentsOf(JsonNode root, String kind) { - JsonNode components = root.get("components"); + JsonNode components = root.get(Keyword.COMPONENTS); return objectFieldsOf(components == null ? null : components.get(kind)); } From d0959f8758705b0b48d4c25c4c9dc8f8bc08a635 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio Date: Fri, 4 Sep 2026 18:23:06 -0300 Subject: [PATCH 4/4] AsyncAPI 3.x: reject a null where the model requires a value Review feedback asked whether the collections handed to the builders can be null. From a document they cannot: the specification makes every one of them optional, and the parser folds an absent field into an empty collection before the model sees it -- empty *is* the specification's "absent", as with a channel that names no servers being available on all of them. The parser is also the only caller, and every value it passes is a fresh collection. So the guard is a contract on a public API, not a fix for a live bug. The builders are the surface of a module meant to become a library, and a null handed to one of them used to fail later, inside Collections.unmodifiableList in the constructor, with a stack trace pointing at the model rather than at the caller. Objects.requireNonNull in the setter fails at the call site and names the field. Applied to everything the model requires: every collection-typed setter and the bindings object, and the arguments a model object is meaningless without -- a channel's name, an operation's name, action and channel, a message's id, name and content type, a server's name, host and protocol, a document's text, location and version, a security scheme's name and type, a correlation id's expression, source and pointer. Fields the documentation already describes as nullable, such as a channel's address or a message's payload, are left as they are. AsyncApiChannelTest pins the contract on the class the review pointed at. --- .../asyncapi/models/AsyncApiChannel.java | 14 +++-- .../models/AsyncApiChannelBindings.java | 3 +- .../models/AsyncApiCorrelationId.java | 8 ++- .../asyncapi/models/AsyncApiDocument.java | 21 +++---- .../asyncapi/models/AsyncApiMessage.java | 14 +++-- .../asyncapi/models/AsyncApiOperation.java | 13 +++-- .../models/AsyncApiSecurityScheme.java | 6 +- .../asyncapi/models/AsyncApiServer.java | 11 ++-- .../models/AsyncApiServerVariable.java | 5 +- .../asyncapi/AsyncApiChannelTest.java | 55 +++++++++++++++++++ 10 files changed, 111 insertions(+), 39 deletions(-) create mode 100644 core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiChannelTest.java 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 index 2d16a30485..e26a699d61 100644 --- 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 @@ -9,6 +9,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; /** * An entry under {@code channels:}: one addressable place on the broker (a Kafka topic, an AMQP @@ -167,24 +168,27 @@ public static class Builder { private AsyncApiChannelBindings bindings = AsyncApiChannelBindings.none(); private Builder(String name) { - this.name = name; + this.name = Objects.requireNonNull(name, "name"); } public Builder address(String address) { this.address = address; return this; } - public Builder servers(List servers) { this.servers = servers; return this; } + public Builder servers(List servers) { this.servers = Objects.requireNonNull(servers, "servers"); return this; } public Builder messageKeys(Map messageKeys) { - this.messageKeys = messageKeys; + this.messageKeys = Objects.requireNonNull(messageKeys, "messageKeys"); return this; } public Builder parameters(Map parameters) { - this.parameters = parameters; + this.parameters = Objects.requireNonNull(parameters, "parameters"); return this; } - public Builder bindings(AsyncApiChannelBindings bindings) { this.bindings = bindings; return this; } + public Builder bindings(AsyncApiChannelBindings bindings) { + this.bindings = Objects.requireNonNull(bindings, "bindings"); + return this; + } public AsyncApiChannel build() { return new AsyncApiChannel(this); diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java index 1ae6311fb7..eae91e84b3 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java @@ -4,6 +4,7 @@ import java.util.Collections; import java.util.Map; +import java.util.Objects; /** * The subset of the protocol bindings that a client acts on, plus the untouched originals. @@ -116,7 +117,7 @@ private Builder() { public Builder wsMethod(String wsMethod) { this.wsMethod = wsMethod; return this; } - public Builder raw(Map raw) { this.raw = raw; return this; } + public Builder raw(Map raw) { this.raw = Objects.requireNonNull(raw, "raw"); return this; } public AsyncApiChannelBindings build() { return new AsyncApiChannelBindings(this); diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiCorrelationId.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiCorrelationId.java index 1f38337594..66575dfd51 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiCorrelationId.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiCorrelationId.java @@ -1,5 +1,7 @@ package com.webfuzzing.asyncapi.models; +import java.util.Objects; + /** * A parsed {@code correlationId.location}, i.e. where in a message the value that pairs a * request with its reply is to be written and read. @@ -35,9 +37,9 @@ public enum Source { HEADER, PAYLOAD } private final String description; public AsyncApiCorrelationId(String raw, Source source, String pointer, String description) { - this.raw = raw; - this.source = source; - this.pointer = pointer; + this.raw = Objects.requireNonNull(raw, "raw"); + this.source = Objects.requireNonNull(source, "source"); + this.pointer = Objects.requireNonNull(pointer, "pointer"); this.description = description; this.fieldName = fieldNameOf(pointer); } 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 c7874f30ff..4ae570d2c0 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 @@ -6,6 +6,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; /** * A parsed AsyncAPI 3.x document, normalised so that a caller never has to walk the raw @@ -300,9 +301,9 @@ public static class Builder { private List warnings = Collections.emptyList(); private Builder(String rawText, DocumentLocation sourceLocation, String version) { - this.rawText = rawText; - this.sourceLocation = sourceLocation; - this.version = version; + this.rawText = Objects.requireNonNull(rawText, "rawText"); + this.sourceLocation = Objects.requireNonNull(sourceLocation, "sourceLocation"); + this.version = Objects.requireNonNull(version, "version"); } public Builder defaultContentType(String defaultContentType) { @@ -310,28 +311,28 @@ public Builder defaultContentType(String defaultContentType) { return this; } - public Builder servers(Map servers) { this.servers = servers; return this; } + public Builder servers(Map servers) { this.servers = Objects.requireNonNull(servers, "servers"); return this; } - public Builder channels(Map channels) { this.channels = channels; return this; } + public Builder channels(Map channels) { this.channels = Objects.requireNonNull(channels, "channels"); return this; } public Builder operations(Map operations) { - this.operations = operations; + this.operations = Objects.requireNonNull(operations, "operations"); return this; } - public Builder messages(Map messages) { this.messages = messages; return this; } + public Builder messages(Map messages) { this.messages = Objects.requireNonNull(messages, "messages"); return this; } public Builder componentSchemas(Map componentSchemas) { - this.componentSchemas = componentSchemas; + this.componentSchemas = Objects.requireNonNull(componentSchemas, "componentSchemas"); return this; } public Builder securitySchemes(Map securitySchemes) { - this.securitySchemes = securitySchemes; + this.securitySchemes = Objects.requireNonNull(securitySchemes, "securitySchemes"); return this; } - public Builder warnings(List warnings) { this.warnings = warnings; return this; } + public Builder warnings(List warnings) { this.warnings = Objects.requireNonNull(warnings, "warnings"); return this; } public AsyncApiDocument build() { return new AsyncApiDocument(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 1b853637aa..6032eb6b4f 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 @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; /** * One message definition, i.e. the shape of what travels on a channel. @@ -172,13 +173,16 @@ public static class Builder { private String description; private Builder(String id) { - this.id = id; + this.id = Objects.requireNonNull(id, "id"); this.name = id; } - public Builder name(String name) { this.name = name; return this; } + public Builder name(String name) { this.name = Objects.requireNonNull(name, "name"); return this; } - public Builder contentType(String contentType) { this.contentType = contentType; return this; } + public Builder contentType(String contentType) { + this.contentType = Objects.requireNonNull(contentType, "contentType"); + return this; + } public Builder payload(JsonNode payload) { this.payload = payload; return this; } @@ -191,9 +195,9 @@ public Builder correlationId(AsyncApiCorrelationId correlationId) { public Builder kafkaKey(JsonNode kafkaKey) { this.kafkaKey = kafkaKey; return this; } - public Builder bindings(Map bindings) { this.bindings = bindings; return this; } + public Builder bindings(Map bindings) { this.bindings = Objects.requireNonNull(bindings, "bindings"); return this; } - public Builder examples(List examples) { this.examples = examples; return this; } + public Builder examples(List examples) { this.examples = Objects.requireNonNull(examples, "examples"); return this; } public Builder title(String title) { this.title = title; return this; } 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 index 8daa09d74a..11b95305f3 100644 --- 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 @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; /** * An entry under {@code operations:}, i.e. something an application does on a channel. @@ -153,18 +154,18 @@ public static class Builder { private String description; private Builder(String name, Action action, String channelName) { - this.name = name; - this.action = action; - this.channelName = channelName; + this.name = Objects.requireNonNull(name, "name"); + this.action = Objects.requireNonNull(action, "action"); + this.channelName = Objects.requireNonNull(channelName, "channelName"); } - public Builder messageIds(List messageIds) { this.messageIds = messageIds; return this; } + public Builder messageIds(List messageIds) { this.messageIds = Objects.requireNonNull(messageIds, "messageIds"); return this; } public Builder reply(AsyncApiReply reply) { this.reply = reply; return this; } - public Builder security(List security) { this.security = security; return this; } + public Builder security(List security) { this.security = Objects.requireNonNull(security, "security"); return this; } - public Builder bindings(Map bindings) { this.bindings = bindings; return this; } + public Builder bindings(Map bindings) { this.bindings = Objects.requireNonNull(bindings, "bindings"); return this; } public Builder title(String title) { this.title = title; return this; } diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java index 899456ef92..85f730675c 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java @@ -1,5 +1,7 @@ package com.webfuzzing.asyncapi.models; +import java.util.Objects; + /** * An entry under {@code components.securitySchemes}, i.e. how a client authenticates to the * broker. @@ -31,8 +33,8 @@ public AsyncApiSecurityScheme( String scheme, String bearerFormat, String description) { - this.name = name; - this.type = type; + this.name = Objects.requireNonNull(name, "name"); + this.type = Objects.requireNonNull(type, "type"); this.location = location; this.scheme = scheme; this.bearerFormat = bearerFormat; diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java index 7ce8166564..50eee63b43 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; /** * An entry under {@code servers:}, i.e. one broker the API is exposed on. @@ -113,9 +114,9 @@ public static class Builder { private List security = Collections.emptyList(); private Builder(String name, String host, String protocol) { - this.name = name; - this.host = host; - this.protocol = protocol; + this.name = Objects.requireNonNull(name, "name"); + this.host = Objects.requireNonNull(host, "host"); + this.protocol = Objects.requireNonNull(protocol, "protocol"); } public Builder protocolVersion(String protocolVersion) { @@ -126,11 +127,11 @@ public Builder protocolVersion(String protocolVersion) { public Builder pathname(String pathname) { this.pathname = pathname; return this; } public Builder variables(Map variables) { - this.variables = variables; + this.variables = Objects.requireNonNull(variables, "variables"); return this; } - public Builder security(List security) { this.security = security; return this; } + public Builder security(List security) { this.security = Objects.requireNonNull(security, "security"); return this; } public AsyncApiServer build() { return new AsyncApiServer(this); diff --git a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java index ca49fe1081..2954bb2cfc 100644 --- a/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java @@ -2,6 +2,7 @@ import java.util.Collections; import java.util.List; +import java.util.Objects; /** * One entry under {@code servers..variables}. Same shape as an OpenAPI server variable. @@ -26,9 +27,9 @@ public AsyncApiServerVariable( String defaultValue, List enumeration, String description) { - this.name = name; + this.name = Objects.requireNonNull(name, "name"); this.defaultValue = defaultValue; - this.enumeration = Collections.unmodifiableList(enumeration); + this.enumeration = Collections.unmodifiableList(Objects.requireNonNull(enumeration, "enumeration")); this.description = description; } diff --git a/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiChannelTest.java b/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiChannelTest.java new file mode 100644 index 0000000000..7e581a6bc4 --- /dev/null +++ b/core-extra/asyncapi-parser/src/test/java/com/webfuzzing/asyncapi/AsyncApiChannelTest.java @@ -0,0 +1,55 @@ +package com.webfuzzing.asyncapi; + +import com.webfuzzing.asyncapi.models.AsyncApiChannel; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AsyncApiChannelTest { + + @Test + public void testACollectionThatIsRequiredCannotBeNull() { + + /* + A document may leave these out, and the parser then passes an empty collection: for + servers, empty means every server. Null is never a document state, only a caller's + mistake, and it fails here naming the field rather than later inside the constructor. + */ + NullPointerException e = assertThrows(NullPointerException.class, + () -> AsyncApiChannel.builder("c").servers(null)); + assertEquals("servers", e.getMessage()); + + e = assertThrows(NullPointerException.class, + () -> AsyncApiChannel.builder("c").messageKeys(null)); + assertEquals("messageKeys", e.getMessage()); + + e = assertThrows(NullPointerException.class, + () -> AsyncApiChannel.builder("c").bindings(null)); + assertEquals("bindings", e.getMessage()); + } + + @Test + public void testTheNameIsRequired() { + + NullPointerException e = assertThrows(NullPointerException.class, + () -> AsyncApiChannel.builder(null)); + assertEquals("name", e.getMessage()); + } + + @Test + public void testEmptyIsNotNull() { + + //what the parser passes for a channel that declares no servers and no messages + AsyncApiChannel channel = AsyncApiChannel.builder("c") + .servers(Collections.emptyList()) + .messageKeys(Collections.emptyMap()) + .build(); + + assertTrue(channel.getServers().isEmpty()); + assertTrue(channel.getMessageIds().isEmpty()); + } +}