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..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 @@ -1,11 +1,15 @@ 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; +import java.util.Objects; /** * An entry under {@code channels:}: one addressable place on the broker (a Kafka topic, an AMQP @@ -14,10 +18,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 +46,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 +83,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,21 +119,74 @@ 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; + this.name = Objects.requireNonNull(name, "name"); } public Builder address(String address) { this.address = address; 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 = Objects.requireNonNull(parameters, "parameters"); + return this; + } + + public Builder bindings(AsyncApiChannelBindings bindings) { + this.bindings = Objects.requireNonNull(bindings, "bindings"); return 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..eae91e84b3 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiChannelBindings.java @@ -0,0 +1,126 @@ +package com.webfuzzing.asyncapi.models; + +import com.fasterxml.jackson.databind.JsonNode; + +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. + * + * 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 = 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 2f6c35a522..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 @@ -15,7 +16,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 +47,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 +78,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 +96,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 +139,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 +178,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 +242,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 +285,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,13 +295,15 @@ 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(); 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) { @@ -252,27 +311,28 @@ public Builder defaultContentType(String defaultContentType) { return this; } - public Builder channels(Map channels) { - this.channels = channels; - return this; - } + public Builder servers(Map servers) { this.servers = Objects.requireNonNull(servers, "servers"); 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; + public Builder messages(Map messages) { this.messages = Objects.requireNonNull(messages, "messages"); return this; } + + public Builder componentSchemas(Map componentSchemas) { + this.componentSchemas = Objects.requireNonNull(componentSchemas, "componentSchemas"); return this; } - public Builder componentSchemas(Map componentSchemas) { - this.componentSchemas = componentSchemas; + public Builder securitySchemes(Map 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 c3073059b3..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 @@ -1,7 +1,11 @@ package com.webfuzzing.asyncapi.models; +import com.fasterxml.jackson.databind.JsonNode; + 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. @@ -37,6 +41,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 +65,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 +110,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,20 +145,28 @@ 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; 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 = Objects.requireNonNull(security, "security"); 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; } 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..85f730675c --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiSecurityScheme.java @@ -0,0 +1,81 @@ +package com.webfuzzing.asyncapi.models; + +import java.util.Objects; + +/** + * 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 = Objects.requireNonNull(name, "name"); + this.type = Objects.requireNonNull(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..50eee63b43 --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServer.java @@ -0,0 +1,140 @@ +package com.webfuzzing.asyncapi.models; + +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. + * + * 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 = Objects.requireNonNull(name, "name"); + this.host = Objects.requireNonNull(host, "host"); + this.protocol = Objects.requireNonNull(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 = Objects.requireNonNull(variables, "variables"); + 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 new file mode 100644 index 0000000000..2954bb2cfc --- /dev/null +++ b/core-extra/asyncapi-parser/src/main/java/com/webfuzzing/asyncapi/models/AsyncApiServerVariable.java @@ -0,0 +1,58 @@ +package com.webfuzzing.asyncapi.models; + +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. + */ +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 = Objects.requireNonNull(name, "name"); + this.defaultValue = defaultValue; + this.enumeration = Collections.unmodifiableList(Objects.requireNonNull(enumeration, "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..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 @@ -5,14 +5,19 @@ 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; +import com.webfuzzing.asyncapi.resolver.RefLocations; import java.util.ArrayDeque; import java.util.ArrayList; @@ -41,13 +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 SERVER_REF_PREFIX = POINTER_ROOT + Keyword.SERVERS + RefLocations.PATH_SEPARATOR; - private static final String MESSAGE_REF_PREFIX = "#/components/messages/"; + private static final String COMPONENT_REF_PREFIX = POINTER_ROOT + Keyword.COMPONENTS + RefLocations.PATH_SEPARATOR; - private static final String KAFKA = "kafka"; + 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 @@ -94,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( @@ -114,13 +190,27 @@ 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, 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 + 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()) { + 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); @@ -129,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) { @@ -137,28 +227,42 @@ public static AsyncApiDocument parse( } } + JsonNode channelsNode = root.get(Keyword.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(Keyword.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, warnings); + entry.getKey(), entry.getValue(), root, channels, messages, securitySchemes, warnings); if (operation != null) { operations.put(entry.getKey(), operation); } } + JsonNode serversNode = root.get(Keyword.SERVERS); + Map servers = new LinkedHashMap<>(); + for (Map.Entry entry : objectFieldsOf(serversNode).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(); } @@ -179,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 + "'", @@ -202,21 +306,21 @@ private static AsyncApiMessage parseMessage( headers = null; } - Map bindings = dereferencedFields(node.get("bindings"), root, warnings); - JsonNode kafka = bindings.get(KAFKA); + 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(); } @@ -249,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; @@ -258,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; } } @@ -358,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( @@ -368,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( @@ -380,6 +484,133 @@ 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(Keyword.HOST)); + String protocol = scalarOf(node.get(Keyword.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; + } + + 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(Keyword.DEFAULT)), + scalarsOf(variable.get(Keyword.ENUM)), + scalarOf(variable.get(Keyword.DESCRIPTION)))); + } + + return AsyncApiServer.builder(name, host, protocol) + .protocolVersion(scalarOf(node.get(Keyword.PROTOCOL_VERSION))) + .pathname(scalarOf(node.get(Keyword.PATHNAME))) + .variables(variables) + .security(parseSecurity( + node.get(Keyword.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(Keyword.TYPE)); + + if (type == null) { + return null; + } + + return new AsyncApiSecurityScheme( + name, + type.toLowerCase(Locale.ENGLISH), + scalarOf(node.get(Keyword.IN)), + scalarOf(node.get(Keyword.SCHEME)), + scalarOf(node.get(Keyword.BEARER_FORMAT)), + scalarOf(node.get(Keyword.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( @@ -396,7 +627,8 @@ private static AsyncApiChannel parseChannel( Map messageKeys = new LinkedHashMap<>(); - for (Map.Entry entry : objectFieldsOf(node.get("messages")).entrySet()) { + JsonNode messagesNode = node.get(Keyword.MESSAGES); + for (Map.Entry entry : objectFieldsOf(messagesNode).entrySet()) { String id = resolveChannelMessage( name, entry.getKey(), entry.getValue(), root, defaultContentType, componentSchemas, messages, warnings); @@ -405,9 +637,20 @@ private static AsyncApiChannel parseChannel( } } + List servers = new ArrayList<>(); + for (String ref : refsOf(node.get(Keyword.SERVERS))) { + String key = AsyncApiRefResolver.refKey(ref, SERVER_REF_PREFIX); + if (key != null) { + servers.add(key); + } + } + return AsyncApiChannel.builder(name) - .address(scalarOf(node.get("address"))) + .address(scalarOf(node.get(Keyword.ADDRESS))) + .servers(servers) .messageKeys(messageKeys) + .parameters(dereferencedFields(node.get(Keyword.PARAMETERS), root, warnings)) + .bindings(parseChannelBindings(dereferencedFields(node.get(Keyword.BINDINGS), root, warnings))) .build(); } @@ -498,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; } } @@ -506,6 +749,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(Keyword.AMQP); + JsonNode ws = raw.get(Keyword.WS); + + return AsyncApiChannelBindings.builder() + .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(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(); + } + // ------------------------------------------------------------------ operations private static AsyncApiOperation parseOperation( @@ -514,6 +774,7 @@ private static AsyncApiOperation parseOperation( JsonNode root, Map channels, Map messages, + Map securitySchemes, List warnings) { JsonNode declared = dereference(rawNode, root, warnings); @@ -522,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( @@ -533,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); @@ -548,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( @@ -558,10 +819,13 @@ private static AsyncApiOperation parseOperation( return AsyncApiOperation.builder(name, action, channelName) .messageIds(messageIds) - .reply(parseReply(node.get("reply"), root, channels, messages, name, warnings)) - .title(scalarOf(node.get("title"))) - .summary(scalarOf(node.get("summary"))) - .description(scalarOf(node.get("description"))) + .reply(parseReply(node.get(Keyword.REPLY), root, channels, messages, name, warnings)) + .security(parseSecurity( + 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(); } @@ -574,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; @@ -601,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); @@ -616,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))); } /** @@ -814,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; @@ -864,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()); } } @@ -901,13 +1165,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 +1180,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. */ @@ -988,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)); } 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()); + } +} 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