feat: add plugin graph validation and compilation (#8008) Implement the graph DTO model and the validation/compilation core for the optional DAG plugin orchestration defined by the contract in #7994. New DTOs (shenyu-common): - GraphData / GraphNodeData / GraphBranchData - GraphNodeTypeEnum (PLUGIN / CHOICE / END) New graph package (org.apache.shenyu.common.graph): - GraphLimits: structural limits (nodes, out-degree, conditions, path length) - GraphValidationException: fail-closed, carries the violation list - GraphPlan: immutable compilation artifact (nodesById + successors) - GraphTopology: shared successor/type helpers - GraphValidator: cycle / reachability / reference / CHOICE determinism / limits / missing-plugin (fail closed) / duplicate-path checks - GraphCompiler: validateOrThrow then build the immutable GraphPlan - LegacyChainImporter: import a linear plugin chain into an equivalent graph 56 unit tests (JUnit5 + Hamcrest) cover all of the above; checkstyle clean (including test sources) and Apache RAT passes. This branch is stacked on the #7994 contract branch; the compiler matches that contract exactly. Fixes #7995 Refs #7993 Co-authored-by: Luke Haochao Zhuang <LukeHaochao.Zhuang@starbucks.cn>
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java new file mode 100644 index 0000000..f11488e --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java
@@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import java.util.List; +import java.util.Objects; + +/** + * A single ordered branch of a {@code CHOICE} graph node. + * + * <p>A branch reuses the existing {@link ConditionData} model so operators author conditions exactly + * as they do for selectors/rules. Selection is deterministic: branches are evaluated in declaration + * order and the first branch whose conditions match (under {@code matchMode}) wins; when no branch + * matches the owning node's {@code defaultTarget} is used. See {@code docs/plugin-graph-orchestration.md} + * (issue #7994) sections 4 and 6.</p> + * + * @since 2.7.2 + */ +public class GraphBranchData { + + /** + * the branch conditions, evaluated under {@code matchMode}. + */ + private List<ConditionData> conditions; + + /** + * match mode of {@code conditions}. + * {@linkplain org.apache.shenyu.common.enums.MatchModeEnum} (0 and, 1 or). + */ + private Integer matchMode; + + /** + * the target node id selected when this branch matches. + */ + private String target; + + /** + * no args constructor. + */ + public GraphBranchData() { + } + + /** + * all args constructor. + * + * @param conditions the branch conditions + * @param matchMode the condition match mode + * @param target the target node id + */ + public GraphBranchData(final List<ConditionData> conditions, final Integer matchMode, final String target) { + this.conditions = conditions; + this.matchMode = matchMode; + this.target = target; + } + + /** + * get conditions. + * + * @return conditions + */ + public List<ConditionData> getConditions() { + return conditions; + } + + /** + * set conditions. + * + * @param conditions conditions + */ + public void setConditions(final List<ConditionData> conditions) { + this.conditions = conditions; + } + + /** + * get matchMode. + * + * @return matchMode + */ + public Integer getMatchMode() { + return matchMode; + } + + /** + * set matchMode. + * + * @param matchMode matchMode + */ + public void setMatchMode(final Integer matchMode) { + this.matchMode = matchMode; + } + + /** + * get target. + * + * @return target + */ + public String getTarget() { + return target; + } + + /** + * set target. + * + * @param target target + */ + public void setTarget(final String target) { + this.target = target; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (Objects.isNull(o) || getClass() != o.getClass()) { + return false; + } + GraphBranchData that = (GraphBranchData) o; + return Objects.equals(conditions, that.conditions) && Objects.equals(matchMode, that.matchMode) + && Objects.equals(target, that.target); + } + + @Override + public int hashCode() { + return Objects.hash(conditions, matchMode, target); + } + + @Override + public String toString() { + return "GraphBranchData{" + + "conditions=" + + conditions + + ", matchMode=" + + matchMode + + ", target='" + + target + + '\'' + + '}'; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java new file mode 100644 index 0000000..624509f --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java
@@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import java.util.List; +import java.util.Objects; + +/** + * A versioned plugin orchestration graph definition. + * + * <p>This is the editable, syncable source-of-truth for a graph; a published {@code revision} is + * immutable and is compiled into a request-path-free plan by the graph compiler (issue #7995). The + * field semantics are fixed by {@code docs/plugin-graph-orchestration.md} (issue #7994) section 4.</p> + * + * @since 2.7.2 + */ +public class GraphData { + + /** + * contract version of this schema; validators reject unknown majors. + */ + private int schemaVersion; + + /** + * the namespace this graph is bound to. + */ + private String namespaceId; + + /** + * optional explicit deployment/group id; else namespace-wide. + */ + private String deploymentBinding; + + /** + * stable identity across revisions. + */ + private String graphId; + + /** + * monotonic per {@code graphId}; activation ordering key. + */ + private long revision; + + /** + * hash of the normalized node/edge content; change detection and idempotent sync. + */ + private String contentHash; + + /** + * the single entry node id; must reference a node in {@code nodes}. + */ + private String entryNodeId; + + /** + * the graph vertices; non-empty with unique node ids. + */ + private List<GraphNodeData> nodes; + + /** + * no args constructor. + */ + public GraphData() { + } + + /** + * get schemaVersion. + * + * @return schemaVersion + */ + public int getSchemaVersion() { + return schemaVersion; + } + + /** + * set schemaVersion. + * + * @param schemaVersion schemaVersion + */ + public void setSchemaVersion(final int schemaVersion) { + this.schemaVersion = schemaVersion; + } + + /** + * get namespaceId. + * + * @return namespaceId + */ + public String getNamespaceId() { + return namespaceId; + } + + /** + * set namespaceId. + * + * @param namespaceId namespaceId + */ + public void setNamespaceId(final String namespaceId) { + this.namespaceId = namespaceId; + } + + /** + * get deploymentBinding. + * + * @return deploymentBinding + */ + public String getDeploymentBinding() { + return deploymentBinding; + } + + /** + * set deploymentBinding. + * + * @param deploymentBinding deploymentBinding + */ + public void setDeploymentBinding(final String deploymentBinding) { + this.deploymentBinding = deploymentBinding; + } + + /** + * get graphId. + * + * @return graphId + */ + public String getGraphId() { + return graphId; + } + + /** + * set graphId. + * + * @param graphId graphId + */ + public void setGraphId(final String graphId) { + this.graphId = graphId; + } + + /** + * get revision. + * + * @return revision + */ + public long getRevision() { + return revision; + } + + /** + * set revision. + * + * @param revision revision + */ + public void setRevision(final long revision) { + this.revision = revision; + } + + /** + * get contentHash. + * + * @return contentHash + */ + public String getContentHash() { + return contentHash; + } + + /** + * set contentHash. + * + * @param contentHash contentHash + */ + public void setContentHash(final String contentHash) { + this.contentHash = contentHash; + } + + /** + * get entryNodeId. + * + * @return entryNodeId + */ + public String getEntryNodeId() { + return entryNodeId; + } + + /** + * set entryNodeId. + * + * @param entryNodeId entryNodeId + */ + public void setEntryNodeId(final String entryNodeId) { + this.entryNodeId = entryNodeId; + } + + /** + * get nodes. + * + * @return nodes + */ + public List<GraphNodeData> getNodes() { + return nodes; + } + + /** + * set nodes. + * + * @param nodes nodes + */ + public void setNodes(final List<GraphNodeData> nodes) { + this.nodes = nodes; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (Objects.isNull(o) || getClass() != o.getClass()) { + return false; + } + GraphData that = (GraphData) o; + return schemaVersion == that.schemaVersion && revision == that.revision + && Objects.equals(namespaceId, that.namespaceId) && Objects.equals(deploymentBinding, that.deploymentBinding) + && Objects.equals(graphId, that.graphId) && Objects.equals(contentHash, that.contentHash) + && Objects.equals(entryNodeId, that.entryNodeId) && Objects.equals(nodes, that.nodes); + } + + @Override + public int hashCode() { + return Objects.hash(schemaVersion, namespaceId, deploymentBinding, graphId, revision, contentHash, entryNodeId, nodes); + } + + @Override + public String toString() { + return "GraphData{" + + "schemaVersion=" + + schemaVersion + + ", namespaceId='" + + namespaceId + + '\'' + + ", deploymentBinding='" + + deploymentBinding + + '\'' + + ", graphId='" + + graphId + + '\'' + + ", revision=" + + revision + + ", contentHash='" + + contentHash + + '\'' + + ", entryNodeId='" + + entryNodeId + + '\'' + + ", nodes=" + + nodes + + '}'; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java new file mode 100644 index 0000000..935b701 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java
@@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import java.util.List; +import java.util.Objects; + +/** + * A vertex of a plugin orchestration graph. + * + * <p>The {@code type} field is the canonical name of + * {@linkplain org.apache.shenyu.common.enums.GraphNodeTypeEnum} ({@code PLUGIN}, {@code CHOICE} or + * {@code END}). Field applicability per type mirrors {@code docs/plugin-graph-orchestration.md} + * (issue #7994) section 4:</p> + * <ul> + * <li>{@code PLUGIN}: binds {@code pluginName} and has exactly one successor {@code next}; + * {@code required} marks a mandatory dependency that fails closed.</li> + * <li>{@code CHOICE}: ordered {@code conditions} branches (first match wins) plus a mandatory + * {@code defaultTarget}.</li> + * <li>{@code END}: terminal; no successor.</li> + * </ul> + * + * @since 2.7.2 + */ +public class GraphNodeData { + + /** + * unique node id within the graph. + */ + private String nodeId; + + /** + * node type. + * {@linkplain org.apache.shenyu.common.enums.GraphNodeTypeEnum}. + */ + private String type; + + /** + * the bound plugin name (PLUGIN nodes only). + * {@linkplain org.apache.shenyu.common.enums.PluginEnum#getName()}. + */ + private String pluginName; + + /** + * the single successor node id (PLUGIN nodes only). + */ + private String next; + + /** + * ordered branches, first match wins (CHOICE nodes only). + */ + private List<GraphBranchData> conditions; + + /** + * mandatory fallback target when no branch matches (CHOICE nodes only). + */ + private String defaultTarget; + + /** + * whether the bound plugin is a mandatory dependency that fails closed (PLUGIN nodes only). + */ + private boolean required; + + /** + * no args constructor. + */ + public GraphNodeData() { + } + + /** + * get nodeId. + * + * @return nodeId + */ + public String getNodeId() { + return nodeId; + } + + /** + * set nodeId. + * + * @param nodeId nodeId + */ + public void setNodeId(final String nodeId) { + this.nodeId = nodeId; + } + + /** + * get type. + * + * @return type + */ + public String getType() { + return type; + } + + /** + * set type. + * + * @param type type + */ + public void setType(final String type) { + this.type = type; + } + + /** + * get pluginName. + * + * @return pluginName + */ + public String getPluginName() { + return pluginName; + } + + /** + * set pluginName. + * + * @param pluginName pluginName + */ + public void setPluginName(final String pluginName) { + this.pluginName = pluginName; + } + + /** + * get next. + * + * @return next + */ + public String getNext() { + return next; + } + + /** + * set next. + * + * @param next next + */ + public void setNext(final String next) { + this.next = next; + } + + /** + * get conditions. + * + * @return conditions + */ + public List<GraphBranchData> getConditions() { + return conditions; + } + + /** + * set conditions. + * + * @param conditions conditions + */ + public void setConditions(final List<GraphBranchData> conditions) { + this.conditions = conditions; + } + + /** + * get defaultTarget. + * + * @return defaultTarget + */ + public String getDefaultTarget() { + return defaultTarget; + } + + /** + * set defaultTarget. + * + * @param defaultTarget defaultTarget + */ + public void setDefaultTarget(final String defaultTarget) { + this.defaultTarget = defaultTarget; + } + + /** + * is required. + * + * @return required + */ + public boolean isRequired() { + return required; + } + + /** + * set required. + * + * @param required required + */ + public void setRequired(final boolean required) { + this.required = required; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (Objects.isNull(o) || getClass() != o.getClass()) { + return false; + } + GraphNodeData that = (GraphNodeData) o; + return required == that.required && Objects.equals(nodeId, that.nodeId) && Objects.equals(type, that.type) + && Objects.equals(pluginName, that.pluginName) && Objects.equals(next, that.next) + && Objects.equals(conditions, that.conditions) && Objects.equals(defaultTarget, that.defaultTarget); + } + + @Override + public int hashCode() { + return Objects.hash(nodeId, type, pluginName, next, conditions, defaultTarget, required); + } + + @Override + public String toString() { + return "GraphNodeData{" + + "nodeId='" + + nodeId + + '\'' + + ", type='" + + type + + '\'' + + ", pluginName='" + + pluginName + + '\'' + + ", next='" + + next + + '\'' + + ", conditions=" + + conditions + + ", defaultTarget='" + + defaultTarget + + '\'' + + ", required=" + + required + + '}'; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java new file mode 100644 index 0000000..5be41f8 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java
@@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.enums; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** + * The type of a node in a plugin orchestration graph. + * + * <p>See {@code docs/plugin-graph-orchestration.md} (issue #7994) for the node semantics and + * {@code docs/plugin-graph-compatibility-audit.md} for the placement rules derived per plugin.</p> + * + * @since 2.7.2 + */ +public enum GraphNodeTypeEnum { + + /** + * Binds one installed plugin by name and has exactly one successor. + */ + PLUGIN("PLUGIN"), + + /** + * Evaluates ordered branches, first match wins, with a mandatory default target. + */ + CHOICE("CHOICE"), + + /** + * Terminal node; completes the traversal. + */ + END("END"); + + private final String name; + + /** + * all args constructor. + * + * @param name the canonical name persisted in {@code GraphNodeData#type} + */ + GraphNodeTypeEnum(final String name) { + this.name = name; + } + + /** + * get name. + * + * @return name + */ + public String getName() { + return name; + } + + /** + * Resolve a graph node type from its canonical name. + * + * @param name the persisted type name + * @return the matching enum, or empty when the name is null or unknown + */ + public static Optional<GraphNodeTypeEnum> fromName(final String name) { + if (Objects.isNull(name)) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(type -> type.name.equals(name)) + .findFirst(); + } + + /** + * Whether the given name is a canonical graph node type name. + * + * @param name the persisted type name + * @return true when the name maps to a known type + */ + public static boolean isValidName(final String name) { + return fromName(name).isPresent(); + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java new file mode 100644 index 0000000..1207263 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java
@@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Compiles a validated {@link GraphData} revision into an immutable {@link GraphPlan}. + * + * <p>Compilation is fail-closed: the graph is validated first and a {@link GraphValidationException} + * aborts the compile, so an invalid graph never produces a plan (contract section 13 forbids any + * fallback). On success every node's successors are pre-resolved once, so the GRAPH executor + * (issue #7996) traverses the plan without re-deriving topology per request.</p> + * + * @since 2.7.2 + */ +public final class GraphCompiler { + + private final GraphValidator validator; + + /** + * Instantiates a new GraphCompiler. + * + * @param validator the validator used to fail closed on invalid graphs + */ + public GraphCompiler(final GraphValidator validator) { + this.validator = Objects.requireNonNull(validator, "validator"); + } + + /** + * Compile a graph definition into an immutable plan. + * + * @param graph the graph definition to compile + * @return the compiled, immutable {@link GraphPlan} + * @throws GraphValidationException when the graph fails validation + */ + public GraphPlan compile(final GraphData graph) { + validator.validateOrThrow(graph); + final Map<String, GraphNodeData> nodesById = new LinkedHashMap<>(); + final Map<String, List<String>> successors = new LinkedHashMap<>(); + for (GraphNodeData node : graph.getNodes()) { + nodesById.put(node.getNodeId(), node); + successors.put(node.getNodeId(), GraphTopology.successorsOf(node)); + } + return new GraphPlan(graph, nodesById, successors); + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java new file mode 100644 index 0000000..1a488ce --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java
@@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +/** + * The resource limits applied when validating and compiling a plugin orchestration graph. + * + * <p>Defaults are conservative and configurable; they bound compile cost, plan memory, + * retained-revision memory and internal-dispatch recursion. The values mirror the limit table in + * {@code docs/plugin-graph-orchestration.md} (issue #7994) section 8.</p> + * + * @since 2.7.2 + */ +public final class GraphLimits { + + /** + * default maximum number of nodes per graph. + */ + public static final int DEFAULT_MAX_NODES = 128; + + /** + * default maximum out-degree (CHOICE branches). + */ + public static final int DEFAULT_MAX_OUT_DEGREE = 16; + + /** + * default maximum conditions per branch. + */ + public static final int DEFAULT_MAX_CONDITIONS_PER_BRANCH = 32; + + /** + * default maximum path length (prefix + branch + suffix). + */ + public static final int DEFAULT_MAX_PATH_LENGTH = 64; + + /** + * default maximum fork / internal-dispatch recursion depth. + */ + public static final int DEFAULT_MAX_FORK_RECURSION_DEPTH = 8; + + /** + * default maximum retained revisions per graph. + */ + public static final int DEFAULT_MAX_RETAINED_REVISIONS = 8; + + /** + * default maximum graphs per namespace. + */ + public static final int DEFAULT_MAX_GRAPHS_PER_NAMESPACE = 32; + + private static final GraphLimits DEFAULT = new GraphLimits(DEFAULT_MAX_NODES, DEFAULT_MAX_OUT_DEGREE, + DEFAULT_MAX_CONDITIONS_PER_BRANCH, DEFAULT_MAX_PATH_LENGTH, DEFAULT_MAX_FORK_RECURSION_DEPTH, + DEFAULT_MAX_RETAINED_REVISIONS, DEFAULT_MAX_GRAPHS_PER_NAMESPACE); + + private final int maxNodes; + + private final int maxOutDegree; + + private final int maxConditionsPerBranch; + + private final int maxPathLength; + + private final int maxForkRecursionDepth; + + private final int maxRetainedRevisions; + + private final int maxGraphsPerNamespace; + + /** + * all args constructor. + * + * @param maxNodes maximum number of nodes per graph + * @param maxOutDegree maximum CHOICE branch fan-out + * @param maxConditionsPerBranch maximum conditions per branch + * @param maxPathLength maximum root-to-END path length + * @param maxForkRecursionDepth maximum fork / internal-dispatch recursion depth + * @param maxRetainedRevisions maximum retained revisions per graph + * @param maxGraphsPerNamespace maximum graphs per namespace + */ + public GraphLimits(final int maxNodes, final int maxOutDegree, final int maxConditionsPerBranch, + final int maxPathLength, final int maxForkRecursionDepth, final int maxRetainedRevisions, + final int maxGraphsPerNamespace) { + this.maxNodes = maxNodes; + this.maxOutDegree = maxOutDegree; + this.maxConditionsPerBranch = maxConditionsPerBranch; + this.maxPathLength = maxPathLength; + this.maxForkRecursionDepth = maxForkRecursionDepth; + this.maxRetainedRevisions = maxRetainedRevisions; + this.maxGraphsPerNamespace = maxGraphsPerNamespace; + } + + /** + * The shared default limits. + * + * @return the default {@link GraphLimits} + */ + public static GraphLimits defaults() { + return DEFAULT; + } + + /** + * get maxNodes. + * + * @return maxNodes + */ + public int getMaxNodes() { + return maxNodes; + } + + /** + * get maxOutDegree. + * + * @return maxOutDegree + */ + public int getMaxOutDegree() { + return maxOutDegree; + } + + /** + * get maxConditionsPerBranch. + * + * @return maxConditionsPerBranch + */ + public int getMaxConditionsPerBranch() { + return maxConditionsPerBranch; + } + + /** + * get maxPathLength. + * + * @return maxPathLength + */ + public int getMaxPathLength() { + return maxPathLength; + } + + /** + * get maxForkRecursionDepth. + * + * @return maxForkRecursionDepth + */ + public int getMaxForkRecursionDepth() { + return maxForkRecursionDepth; + } + + /** + * get maxRetainedRevisions. + * + * @return maxRetainedRevisions + */ + public int getMaxRetainedRevisions() { + return maxRetainedRevisions; + } + + /** + * get maxGraphsPerNamespace. + * + * @return maxGraphsPerNamespace + */ + public int getMaxGraphsPerNamespace() { + return maxGraphsPerNamespace; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java new file mode 100644 index 0000000..2c829b7 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java
@@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The compiled, immutable, request-path-free form of a validated {@link GraphData} revision. + * + * <p>A plan freezes the graph topology: every node is indexed by id and every node's successors are + * pre-resolved (a {@code PLUGIN} node has its single {@code next}; a {@code CHOICE} node has its + * ordered branch targets followed by its {@code defaultTarget}; an {@code END} node has none). The + * GRAPH executor (issue #7996) traverses a plan snapshot without mutating it, so all collections + * exposed here are unmodifiable. See {@code docs/plugin-graph-orchestration.md} (issue #7994) + * sections 3 and 10.</p> + * + * @since 2.7.2 + */ +public final class GraphPlan { + + private final String graphId; + + private final long revision; + + private final String contentHash; + + private final String namespaceId; + + private final String entryNodeId; + + private final Map<String, GraphNodeData> nodesById; + + private final Map<String, List<String>> successors; + + /** + * Instantiates a new GraphPlan. + * + * @param graph the validated graph definition this plan was compiled from + * @param nodesById node id to node index (copied, exposed unmodifiable) + * @param successors node id to resolved successor node ids (copied, exposed unmodifiable) + */ + public GraphPlan(final GraphData graph, final Map<String, GraphNodeData> nodesById, + final Map<String, List<String>> successors) { + Objects.requireNonNull(graph, "graph"); + Objects.requireNonNull(nodesById, "nodesById"); + Objects.requireNonNull(successors, "successors"); + this.graphId = graph.getGraphId(); + this.revision = graph.getRevision(); + this.contentHash = graph.getContentHash(); + this.namespaceId = graph.getNamespaceId(); + this.entryNodeId = graph.getEntryNodeId(); + this.nodesById = Collections.unmodifiableMap(new LinkedHashMap<>(nodesById)); + this.successors = freezeSuccessors(successors); + } + + private static Map<String, List<String>> freezeSuccessors(final Map<String, List<String>> raw) { + Map<String, List<String>> frozen = new LinkedHashMap<>(raw.size()); + for (Map.Entry<String, List<String>> entry : raw.entrySet()) { + frozen.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue()))); + } + return Collections.unmodifiableMap(frozen); + } + + /** + * get graphId. + * + * @return graphId + */ + public String getGraphId() { + return graphId; + } + + /** + * get revision. + * + * @return revision + */ + public long getRevision() { + return revision; + } + + /** + * get contentHash. + * + * @return contentHash + */ + public String getContentHash() { + return contentHash; + } + + /** + * get namespaceId. + * + * @return namespaceId + */ + public String getNamespaceId() { + return namespaceId; + } + + /** + * get entryNodeId. + * + * @return entryNodeId + */ + public String getEntryNodeId() { + return entryNodeId; + } + + /** + * The node bound to the given id. + * + * @param nodeId the node id + * @return the node, or {@code null} when the id is not part of this plan + */ + public GraphNodeData getNode(final String nodeId) { + return nodesById.get(nodeId); + } + + /** + * The resolved successor node ids of the given node, in evaluation order. + * + * @param nodeId the node id + * @return an unmodifiable successor list; empty for an unknown id or an {@code END} node + */ + public List<String> getSuccessors(final String nodeId) { + return successors.getOrDefault(nodeId, Collections.emptyList()); + } + + /** + * The unmodifiable set of all node ids in this plan. + * + * @return the node ids + */ + public Set<String> nodeIds() { + return nodesById.keySet(); + } + + /** + * The number of nodes in this plan. + * + * @return the node count + */ + public int size() { + return nodesById.size(); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (Objects.isNull(o) || getClass() != o.getClass()) { + return false; + } + GraphPlan that = (GraphPlan) o; + return revision == that.revision && Objects.equals(graphId, that.graphId) + && Objects.equals(contentHash, that.contentHash) && Objects.equals(namespaceId, that.namespaceId) + && Objects.equals(entryNodeId, that.entryNodeId) && Objects.equals(nodesById, that.nodesById) + && Objects.equals(successors, that.successors); + } + + @Override + public int hashCode() { + return Objects.hash(graphId, revision, contentHash, namespaceId, entryNodeId, nodesById, successors); + } + + @Override + public String toString() { + return "GraphPlan{" + + "graphId='" + + graphId + + '\'' + + ", revision=" + + revision + + ", contentHash='" + + contentHash + + '\'' + + ", namespaceId='" + + namespaceId + + '\'' + + ", entryNodeId='" + + entryNodeId + + '\'' + + ", nodes=" + + nodesById.size() + + '}'; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java new file mode 100644 index 0000000..54b58a6 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java
@@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Static helpers describing the topology of a plugin orchestration graph node. + * + * <p>Shared by the validator and the compiler so successor resolution and node-type tests are defined + * exactly once. Successor resolution is structural only: it never evaluates branch conditions (that is + * a request-time concern of the GRAPH executor, issue #7996).</p> + * + * @since 2.7.2 + */ +public final class GraphTopology { + + private GraphTopology() { + } + + /** + * Whether the value is null or blank. + * + * @param value the string to test + * @return true when null or blank + */ + public static boolean isBlank(final String value) { + return Objects.isNull(value) || value.isBlank(); + } + + /** + * Whether the node is a {@code PLUGIN} node. + * + * @param node the node + * @return true for a PLUGIN node + */ + public static boolean isPlugin(final GraphNodeData node) { + return Objects.nonNull(node) && GraphNodeTypeEnum.PLUGIN.getName().equals(node.getType()); + } + + /** + * Whether the node is a {@code CHOICE} node. + * + * @param node the node + * @return true for a CHOICE node + */ + public static boolean isChoice(final GraphNodeData node) { + return Objects.nonNull(node) && GraphNodeTypeEnum.CHOICE.getName().equals(node.getType()); + } + + /** + * Whether the node is an {@code END} node. + * + * @param node the node + * @return true for an END node + */ + public static boolean isEnd(final GraphNodeData node) { + return Objects.nonNull(node) && GraphNodeTypeEnum.END.getName().equals(node.getType()); + } + + /** + * The structural successor node ids of a node, in evaluation order. + * + * <p>A {@code PLUGIN} node yields its single {@code next}; a {@code CHOICE} node yields its + * distinct branch targets in declaration order followed by its {@code defaultTarget}; an + * {@code END} node yields none.</p> + * + * @param node the node + * @return the successor node ids (never null) + */ + public static List<String> successorsOf(final GraphNodeData node) { + if (isPlugin(node)) { + return isBlank(node.getNext()) ? Collections.emptyList() : Collections.singletonList(node.getNext()); + } + if (isChoice(node)) { + return choiceSuccessors(node); + } + return Collections.emptyList(); + } + + private static List<String> choiceSuccessors(final GraphNodeData node) { + final List<String> targets = new ArrayList<>(); + if (Objects.nonNull(node.getConditions())) { + for (GraphBranchData branch : node.getConditions()) { + if (Objects.nonNull(branch) && !isBlank(branch.getTarget()) && !targets.contains(branch.getTarget())) { + targets.add(branch.getTarget()); + } + } + } + if (!isBlank(node.getDefaultTarget()) && !targets.contains(node.getDefaultTarget())) { + targets.add(node.getDefaultTarget()); + } + return targets; + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.java new file mode 100644 index 0000000..94f8d56 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.java
@@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.exception.ShenyuException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Thrown when a plugin orchestration graph fails validation. + * + * <p>Carries the full, ordered list of violations so callers can surface every actionable error at + * once (naming the offending node id / plugin name) rather than only the first. Validation is + * fail-closed: a graph that cannot be validated is never compiled or activated.</p> + * + * @since 2.7.2 + */ +public class GraphValidationException extends ShenyuException { + + private static final long serialVersionUID = -6871045473042167831L; + + private final List<String> violations; + + /** + * Instantiates a new GraphValidationException. + * + * @param violations the ordered, non-empty list of violation messages + */ + public GraphValidationException(final List<String> violations) { + super(buildMessage(violations)); + this.violations = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(violations, "violations"))); + } + + /** + * The ordered, unmodifiable list of violation messages. + * + * @return the violations + */ + public List<String> getViolations() { + return violations; + } + + private static String buildMessage(final List<String> violations) { + if (Objects.isNull(violations) || violations.isEmpty()) { + return "plugin graph validation failed"; + } + return "plugin graph validation failed with " + violations.size() + " violation(s): " + String.join("; ", violations); + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java new file mode 100644 index 0000000..b7f5230 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java
@@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Validates a {@link GraphData} against the Phase-1 contract before it may be compiled. + * + * <p>Validation is fail-closed and reports every violation at once (each naming the offending node id + * or plugin name). It enforces the rules derived in {@code docs/plugin-graph-compatibility-audit.md} + * (issue #7994) section 5: header/schema integrity, unique node ids, resource limits, per-type node + * shape, installed-plugin catalog resolution, CHOICE determinism, reference integrity, acyclicity, + * reachability, and path-wise checks (bounded length, no plugin executed twice on a path, termination + * at an {@code END} node).</p> + * + * @since 2.7.2 + */ +public final class GraphValidator { + + /** + * the only schema major version accepted in Phase 1. + */ + public static final int SUPPORTED_SCHEMA_VERSION = 1; + + private static final int MAX_ENUMERATED_PATHS = 100000; + + private final GraphLimits limits; + + private final Set<String> installedPlugins; + + /** + * Instantiates a new GraphValidator with the default limits. + * + * @param installedPlugins the names of plugins installed in the runtime catalog + */ + public GraphValidator(final Set<String> installedPlugins) { + this(GraphLimits.defaults(), installedPlugins); + } + + /** + * Instantiates a new GraphValidator. + * + * @param limits the resource limits to enforce + * @param installedPlugins the names of plugins installed in the runtime catalog + */ + public GraphValidator(final GraphLimits limits, final Set<String> installedPlugins) { + this.limits = Objects.requireNonNull(limits, "limits"); + this.installedPlugins = Objects.requireNonNull(installedPlugins, "installedPlugins"); + } + + /** + * Validate a graph, returning all violations. + * + * @param graph the graph to validate + * @return the ordered, de-duplicated list of violations; empty when the graph is valid + */ + public List<String> validate(final GraphData graph) { + final List<String> violations = new ArrayList<>(); + if (Objects.isNull(graph)) { + violations.add("graph must not be null"); + return violations; + } + validateHeader(graph, violations); + final Map<String, GraphNodeData> byId = indexNodes(graph, violations); + if (byId.isEmpty()) { + return dedupe(violations); + } + validateLimits(graph, byId, violations); + validateNodeShape(byId, violations); + validateChoiceDeterminism(byId, violations); + if (validateReferences(graph, byId, violations)) { + final boolean acyclic = validateAcyclic(graph, byId, violations); + validateReachability(graph, byId, violations); + if (acyclic) { + validatePaths(graph, byId, violations); + } + } + return dedupe(violations); + } + + /** + * Validate a graph or throw. + * + * @param graph the graph to validate + * @throws GraphValidationException when the graph has at least one violation + */ + public void validateOrThrow(final GraphData graph) { + final List<String> violations = validate(graph); + if (!violations.isEmpty()) { + throw new GraphValidationException(violations); + } + } + + private List<String> dedupe(final List<String> violations) { + return violations.stream().distinct().collect(Collectors.toList()); + } + + private void validateHeader(final GraphData graph, final List<String> violations) { + if (graph.getSchemaVersion() != SUPPORTED_SCHEMA_VERSION) { + violations.add("unsupported schemaVersion " + graph.getSchemaVersion() + ", expected " + SUPPORTED_SCHEMA_VERSION); + } + if (GraphTopology.isBlank(graph.getGraphId())) { + violations.add("graphId must not be blank"); + } + if (GraphTopology.isBlank(graph.getNamespaceId())) { + violations.add("namespaceId must not be blank"); + } + if (Objects.isNull(graph.getNodes()) || graph.getNodes().isEmpty()) { + violations.add("nodes must not be empty"); + } + if (GraphTopology.isBlank(graph.getEntryNodeId())) { + violations.add("entryNodeId must not be blank"); + } + } + + private Map<String, GraphNodeData> indexNodes(final GraphData graph, final List<String> violations) { + final Map<String, GraphNodeData> byId = new LinkedHashMap<>(); + if (Objects.isNull(graph.getNodes())) { + return byId; + } + for (GraphNodeData node : graph.getNodes()) { + if (Objects.isNull(node) || GraphTopology.isBlank(node.getNodeId())) { + violations.add("every node must have a non-blank nodeId"); + continue; + } + if (byId.containsKey(node.getNodeId())) { + violations.add("duplicate nodeId '" + node.getNodeId() + "'"); + continue; + } + if (!GraphNodeTypeEnum.isValidName(node.getType())) { + violations.add("node '" + node.getNodeId() + "' has unknown type '" + node.getType() + "'"); + } + byId.put(node.getNodeId(), node); + } + return byId; + } + + private void validateLimits(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) { + if (byId.size() > limits.getMaxNodes()) { + violations.add("graph '" + graph.getGraphId() + "' has " + byId.size() + " nodes, exceeding max " + limits.getMaxNodes()); + } + for (GraphNodeData node : byId.values()) { + if (!GraphTopology.isChoice(node)) { + continue; + } + final int branches = Objects.isNull(node.getConditions()) ? 0 : node.getConditions().size(); + if (branches > limits.getMaxOutDegree()) { + violations.add("CHOICE node '" + node.getNodeId() + "' has " + branches + " branches, exceeding max out-degree " + limits.getMaxOutDegree()); + } + validateBranchConditionCount(node, violations); + } + } + + private void validateBranchConditionCount(final GraphNodeData node, final List<String> violations) { + if (Objects.isNull(node.getConditions())) { + return; + } + for (GraphBranchData branch : node.getConditions()) { + if (Objects.isNull(branch)) { + continue; + } + final int conditions = Objects.isNull(branch.getConditions()) ? 0 : branch.getConditions().size(); + if (conditions > limits.getMaxConditionsPerBranch()) { + violations.add("branch to '" + branch.getTarget() + "' on CHOICE node '" + node.getNodeId() + "' has " + + conditions + " conditions, exceeding max " + limits.getMaxConditionsPerBranch()); + } + } + } + + private void validateNodeShape(final Map<String, GraphNodeData> byId, final List<String> violations) { + for (GraphNodeData node : byId.values()) { + if (GraphTopology.isPlugin(node)) { + validatePluginNode(node, violations); + } else if (GraphTopology.isChoice(node)) { + validateChoiceNode(node, violations); + } else if (GraphTopology.isEnd(node)) { + validateEndNode(node, violations); + } + } + } + + private void validatePluginNode(final GraphNodeData node, final List<String> violations) { + if (GraphTopology.isBlank(node.getPluginName())) { + violations.add("PLUGIN node '" + node.getNodeId() + "' must bind a pluginName"); + } else if (!installedPlugins.contains(node.getPluginName())) { + violations.add((node.isRequired() ? "required " : "") + "PLUGIN node '" + node.getNodeId() + + "' binds plugin '" + node.getPluginName() + "' which is not installed (fail closed)"); + } + if (GraphTopology.isBlank(node.getNext())) { + violations.add("PLUGIN node '" + node.getNodeId() + "' must have exactly one successor 'next'"); + } + } + + private void validateChoiceNode(final GraphNodeData node, final List<String> violations) { + if (Objects.isNull(node.getConditions()) || node.getConditions().isEmpty()) { + violations.add("CHOICE node '" + node.getNodeId() + "' must declare at least one branch"); + } + if (GraphTopology.isBlank(node.getDefaultTarget())) { + violations.add("CHOICE node '" + node.getNodeId() + "' must declare a defaultTarget (selection must be total)"); + } + } + + private void validateEndNode(final GraphNodeData node, final List<String> violations) { + if (!GraphTopology.isBlank(node.getNext())) { + violations.add("END node '" + node.getNodeId() + "' must not declare a successor 'next'"); + } + if (Objects.nonNull(node.getConditions()) && !node.getConditions().isEmpty()) { + violations.add("END node '" + node.getNodeId() + "' must not declare branches"); + } + } + + private void validateChoiceDeterminism(final Map<String, GraphNodeData> byId, final List<String> violations) { + for (GraphNodeData node : byId.values()) { + if (!GraphTopology.isChoice(node) || Objects.isNull(node.getConditions())) { + continue; + } + final Set<GraphBranchData> seen = new HashSet<>(); + for (GraphBranchData branch : node.getConditions()) { + if (Objects.nonNull(branch) && !seen.add(branch)) { + violations.add("CHOICE node '" + node.getNodeId() + "' declares a duplicate branch to '" + + branch.getTarget() + "' (ambiguous authoring)"); + } + } + } + } + + private boolean validateReferences(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) { + boolean ok = true; + if (!GraphTopology.isBlank(graph.getEntryNodeId()) && !byId.containsKey(graph.getEntryNodeId())) { + violations.add("entryNodeId '" + graph.getEntryNodeId() + "' does not reference a node"); + ok = false; + } + for (GraphNodeData node : byId.values()) { + if (GraphTopology.isPlugin(node) && !GraphTopology.isBlank(node.getNext()) && !byId.containsKey(node.getNext())) { + violations.add("PLUGIN node '" + node.getNodeId() + "' successor 'next' -> unknown node '" + node.getNext() + "'"); + ok = false; + } + if (GraphTopology.isChoice(node)) { + ok = validateChoiceReferences(node, byId, violations) && ok; + } + } + return ok; + } + + private boolean validateChoiceReferences(final GraphNodeData node, final Map<String, GraphNodeData> byId, final List<String> violations) { + boolean ok = true; + if (Objects.nonNull(node.getConditions())) { + for (GraphBranchData branch : node.getConditions()) { + if (Objects.isNull(branch) || GraphTopology.isBlank(branch.getTarget()) || !byId.containsKey(branch.getTarget())) { + final String target = Objects.isNull(branch) ? null : branch.getTarget(); + violations.add("CHOICE node '" + node.getNodeId() + "' branch target '" + target + "' -> unknown node"); + ok = false; + } + } + } + if (!GraphTopology.isBlank(node.getDefaultTarget()) && !byId.containsKey(node.getDefaultTarget())) { + violations.add("CHOICE node '" + node.getNodeId() + "' defaultTarget '" + node.getDefaultTarget() + "' -> unknown node"); + ok = false; + } + return ok; + } + + private boolean validateAcyclic(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) { + final Set<String> visited = new HashSet<>(); + final Set<String> onStack = new LinkedHashSet<>(); + boolean acyclic = true; + for (String start : byId.keySet()) { + if (!visited.contains(start) && hasCycle(start, byId, visited, onStack)) { + violations.add("graph '" + graph.getGraphId() + "' contains a cycle reachable from node '" + start + "'"); + acyclic = false; + } + } + return acyclic; + } + + private boolean hasCycle(final String nodeId, final Map<String, GraphNodeData> byId, final Set<String> visited, final Set<String> onStack) { + visited.add(nodeId); + onStack.add(nodeId); + final GraphNodeData node = byId.get(nodeId); + if (Objects.nonNull(node)) { + for (String next : GraphTopology.successorsOf(node)) { + if (onStack.contains(next)) { + return true; + } + if (!visited.contains(next) && byId.containsKey(next) && hasCycle(next, byId, visited, onStack)) { + return true; + } + } + } + onStack.remove(nodeId); + return false; + } + + private void validateReachability(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) { + final Set<String> reachable = new HashSet<>(); + final Deque<String> queue = new ArrayDeque<>(); + queue.add(graph.getEntryNodeId()); + while (!queue.isEmpty()) { + final String current = queue.poll(); + if (!reachable.add(current)) { + continue; + } + final GraphNodeData node = byId.get(current); + if (Objects.isNull(node)) { + continue; + } + for (String next : GraphTopology.successorsOf(node)) { + if (byId.containsKey(next)) { + queue.add(next); + } + } + } + for (String nodeId : byId.keySet()) { + if (!reachable.contains(nodeId)) { + violations.add("node '" + nodeId + "' is not reachable from entryNodeId '" + graph.getEntryNodeId() + "'"); + } + } + } + + private void validatePaths(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) { + final int[] pathCount = {0}; + final boolean[] overflow = {false}; + walkPaths(graph, graph.getEntryNodeId(), byId, new ArrayList<>(), new ArrayList<>(), pathCount, overflow, violations); + if (pathCount[0] == 0) { + violations.add("graph '" + graph.getGraphId() + "' has no path terminating at an END node"); + } + if (overflow[0]) { + violations.add("graph '" + graph.getGraphId() + "' exceeds the maximum enumerable path count " + MAX_ENUMERATED_PATHS); + } + } + + private void walkPaths(final GraphData graph, final String nodeId, final Map<String, GraphNodeData> byId, + final List<String> path, final List<String> plugins, final int[] pathCount, + final boolean[] overflow, final List<String> violations) { + if (overflow[0] || pathCount[0] > MAX_ENUMERATED_PATHS) { + overflow[0] = true; + return; + } + final GraphNodeData node = byId.get(nodeId); + if (Objects.isNull(node)) { + return; + } + path.add(nodeId); + final boolean tracksPlugin = GraphTopology.isPlugin(node) && !GraphTopology.isBlank(node.getPluginName()); + if (tracksPlugin) { + plugins.add(node.getPluginName()); + } + if (GraphTopology.isEnd(node)) { + checkPath(graph, path, plugins, pathCount, violations); + } else { + for (String next : GraphTopology.successorsOf(node)) { + walkPaths(graph, next, byId, path, plugins, pathCount, overflow, violations); + } + } + path.remove(path.size() - 1); + if (tracksPlugin) { + plugins.remove(plugins.size() - 1); + } + } + + private void checkPath(final GraphData graph, final List<String> path, final List<String> plugins, + final int[] pathCount, final List<String> violations) { + pathCount[0] = pathCount[0] + 1; + if (path.size() > limits.getMaxPathLength()) { + violations.add("graph '" + graph.getGraphId() + "' has a path of length " + path.size() + + " exceeding max " + limits.getMaxPathLength() + ": " + path); + } + final Set<String> seen = new HashSet<>(); + for (String pluginName : plugins) { + if (!seen.add(pluginName)) { + violations.add("graph '" + graph.getGraphId() + "' path " + path + " executes plugin '" + + pluginName + "' more than once"); + } + } + } +}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java new file mode 100644 index 0000000..d8c14c3 --- /dev/null +++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java
@@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.dto.PluginData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; +import org.apache.shenyu.common.enums.PluginEnum; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Imports the legacy sorted plugin chain into an equivalent linear {@link GraphData}. + * + * <p>This gives operators a migration path from the {@code PluginData.sort}-ordered chain to an + * explicit graph: the produced graph is a single common prefix of {@code PLUGIN} nodes followed by one + * {@code END} node, reproducing legacy execution order exactly. Plugins whose name matches the + * mandatory global/auth class (see {@code docs/plugin-graph-compatibility-audit.md}, issue #7994) are + * marked {@code required} so the fail-closed semantics of the legacy chain are preserved.</p> + * + * @since 2.7.2 + */ +public final class LegacyChainImporter { + + /** + * the node id of the terminal END node produced by the importer. + */ + public static final String END_NODE_ID = "end"; + + private static final Set<String> MANDATORY_PLUGINS = Set.of( + PluginEnum.GLOBAL.getName(), + PluginEnum.SIGN.getName(), + PluginEnum.JWT.getName(), + PluginEnum.OAUTH2.getName(), + PluginEnum.CASDOOR.getName(), + PluginEnum.WAF.getName(), + PluginEnum.KEY_AUTH.getName(), + PluginEnum.BASIC_AUTH.getName()); + + private LegacyChainImporter() { + } + + /** + * Import an ordered legacy plugin chain into a linear graph definition. + * + * <p>Disabled plugins and entries without a name are dropped; the remainder are ordered by + * {@link PluginData#getSort()} ascending (nulls last), mirroring the legacy chain ordering.</p> + * + * @param plugins the legacy plugins (need not be pre-sorted) + * @param namespaceId the namespace to bind the graph to + * @param graphId the stable graph identity + * @param revision the revision number to publish + * @return a linear {@link GraphData} equivalent to the legacy chain + */ + public static GraphData importLinearChain(final List<PluginData> plugins, final String namespaceId, + final String graphId, final long revision) { + Objects.requireNonNull(plugins, "plugins"); + final List<PluginData> ordered = orderEnabled(plugins); + final List<GraphNodeData> nodes = new ArrayList<>(); + for (int i = 0; i < ordered.size(); i++) { + nodes.add(pluginNode(ordered.get(i), i, ordered.size())); + } + nodes.add(endNode()); + final GraphData graph = new GraphData(); + graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION); + graph.setNamespaceId(namespaceId); + graph.setGraphId(graphId); + graph.setRevision(revision); + graph.setEntryNodeId(ordered.isEmpty() ? END_NODE_ID : nodeId(0)); + graph.setNodes(nodes); + graph.setContentHash(contentHash(graph)); + return graph; + } + + /** + * Compute a deterministic content fingerprint of a graph's normalized node/edge content. + * + * <p>Phase 1 uses a stable hash of the canonicalized node sequence for change detection and + * idempotent sync; a stronger digest may replace it without a schema change.</p> + * + * @param graph the graph whose content to fingerprint + * @return the hex content hash + */ + public static String contentHash(final GraphData graph) { + final StringBuilder canonical = new StringBuilder(); + if (Objects.isNull(graph) || Objects.isNull(graph.getNodes())) { + return Integer.toHexString(canonical.toString().hashCode()); + } + for (GraphNodeData node : graph.getNodes()) { + appendNode(canonical, node); + } + return Integer.toHexString(canonical.toString().hashCode()); + } + + private static void appendNode(final StringBuilder canonical, final GraphNodeData node) { + canonical.append(node.getNodeId()).append('|') + .append(node.getType()).append('|') + .append(node.getPluginName()).append('|') + .append(node.getNext()).append('|') + .append(node.getDefaultTarget()).append('|') + .append(node.isRequired()).append(';'); + if (Objects.isNull(node.getConditions())) { + return; + } + for (GraphBranchData branch : node.getConditions()) { + canonical.append(branch.getTarget()).append('=').append(branch.getMatchMode()) + .append('=').append(branch.getConditions()).append(','); + } + } + + private static List<PluginData> orderEnabled(final List<PluginData> plugins) { + return plugins.stream() + .filter(Objects::nonNull) + .filter(plugin -> !Boolean.FALSE.equals(plugin.getEnabled())) + .filter(plugin -> !GraphTopology.isBlank(plugin.getName())) + .sorted(Comparator.comparingInt(plugin -> Objects.isNull(plugin.getSort()) ? Integer.MAX_VALUE : plugin.getSort())) + .collect(Collectors.toList()); + } + + private static GraphNodeData pluginNode(final PluginData plugin, final int index, final int total) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(nodeId(index)); + node.setType(GraphNodeTypeEnum.PLUGIN.getName()); + node.setPluginName(plugin.getName()); + node.setRequired(MANDATORY_PLUGINS.contains(plugin.getName())); + node.setNext(index == total - 1 ? END_NODE_ID : nodeId(index + 1)); + return node; + } + + private static GraphNodeData endNode() { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(END_NODE_ID); + node.setType(GraphNodeTypeEnum.END.getName()); + return node; + } + + private static String nodeId(final int index) { + return "n" + index; + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java new file mode 100644 index 0000000..06821d0 --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java
@@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +/** + * Test case for {@link GraphBranchData}. + */ +public class GraphBranchDataTest { + + @Test + public void testGetterSetter() { + final GraphBranchData branch = new GraphBranchData(); + branch.setConditions(Collections.singletonList(new ConditionData())); + branch.setMatchMode(1); + branch.setTarget("n1"); + + assertThat(branch.getConditions(), hasSize(1)); + assertThat(branch.getMatchMode(), is(1)); + assertThat(branch.getTarget(), is("n1")); + } + + @Test + public void testAllArgsConstructor() { + final GraphBranchData branch = new GraphBranchData(Collections.emptyList(), 0, "n2"); + assertThat(branch.getTarget(), is("n2")); + assertThat(branch.getMatchMode(), is(0)); + } + + @Test + public void testEqualsAndHashCode() { + final GraphBranchData one = new GraphBranchData(Collections.emptyList(), 0, "n1"); + final GraphBranchData two = new GraphBranchData(Collections.emptyList(), 0, "n1"); + final Set<GraphBranchData> set = new HashSet<>(); + set.add(one); + set.add(two); + assertThat(set, hasSize(1)); + assertThat(one, is(two)); + assertThat(one.hashCode(), is(two.hashCode())); + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java new file mode 100644 index 0000000..b246022 --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java
@@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +/** + * Test case for {@link GraphData}. + */ +public class GraphDataTest { + + @Test + public void testGetterSetter() { + final GraphData graph = new GraphData(); + graph.setSchemaVersion(1); + graph.setNamespaceId("ns"); + graph.setDeploymentBinding("deploy"); + graph.setGraphId("g"); + graph.setRevision(9L); + graph.setContentHash("hash"); + graph.setEntryNodeId("n0"); + graph.setNodes(Collections.singletonList(new GraphNodeData())); + + assertThat(graph.getSchemaVersion(), is(1)); + assertThat(graph.getNamespaceId(), is("ns")); + assertThat(graph.getDeploymentBinding(), is("deploy")); + assertThat(graph.getGraphId(), is("g")); + assertThat(graph.getRevision(), is(9L)); + assertThat(graph.getContentHash(), is("hash")); + assertThat(graph.getEntryNodeId(), is("n0")); + assertThat(graph.getNodes(), hasSize(1)); + } + + @Test + public void testEqualsAndHashCode() { + final GraphData one = sample(); + final GraphData two = sample(); + final Set<GraphData> set = new HashSet<>(); + set.add(one); + set.add(two); + assertThat(set, hasSize(1)); + assertThat(one, is(two)); + assertThat(one.hashCode(), is(two.hashCode())); + } + + @Test + public void testRevisionAffectsEquality() { + final GraphData one = sample(); + final GraphData two = sample(); + two.setRevision(2L); + assertThat(one.equals(two), is(false)); + } + + private GraphData sample() { + final GraphData graph = new GraphData(); + graph.setSchemaVersion(1); + graph.setNamespaceId("ns"); + graph.setGraphId("g"); + graph.setRevision(1L); + graph.setContentHash("hash"); + graph.setEntryNodeId("n0"); + graph.setNodes(Collections.emptyList()); + return graph; + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java new file mode 100644 index 0000000..43435f9 --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java
@@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.dto; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +/** + * Test case for {@link GraphNodeData}. + */ +public class GraphNodeDataTest { + + @Test + public void testGetterSetter() { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId("n0"); + node.setType("PLUGIN"); + node.setPluginName("global"); + node.setNext("n1"); + node.setConditions(Collections.singletonList(new GraphBranchData())); + node.setDefaultTarget("n2"); + node.setRequired(true); + + assertThat(node.getNodeId(), is("n0")); + assertThat(node.getType(), is("PLUGIN")); + assertThat(node.getPluginName(), is("global")); + assertThat(node.getNext(), is("n1")); + assertThat(node.getConditions(), hasSize(1)); + assertThat(node.getDefaultTarget(), is("n2")); + assertThat(node.isRequired(), is(true)); + } + + @Test + public void testEqualsAndHashCode() { + final GraphNodeData one = sample(); + final GraphNodeData two = sample(); + final Set<GraphNodeData> set = new HashSet<>(); + set.add(one); + set.add(two); + assertThat(set, hasSize(1)); + assertThat(one, is(two)); + assertThat(one.hashCode(), is(two.hashCode())); + } + + @Test + public void testRequiredAffectsEquality() { + final GraphNodeData one = sample(); + final GraphNodeData two = sample(); + two.setRequired(true); + assertThat(one.equals(two), is(false)); + } + + private GraphNodeData sample() { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId("n0"); + node.setType("PLUGIN"); + node.setPluginName("global"); + node.setNext("n1"); + return node; + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java new file mode 100644 index 0000000..488981e --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java
@@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.enums; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * Test case for {@link GraphNodeTypeEnum}. + */ +public class GraphNodeTypeEnumTest { + + @Test + public void testGetName() { + assertThat(GraphNodeTypeEnum.PLUGIN.getName(), is("PLUGIN")); + assertThat(GraphNodeTypeEnum.CHOICE.getName(), is("CHOICE")); + assertThat(GraphNodeTypeEnum.END.getName(), is("END")); + } + + @Test + public void testFromName() { + assertThat(GraphNodeTypeEnum.fromName("PLUGIN").orElse(null), is(GraphNodeTypeEnum.PLUGIN)); + assertThat(GraphNodeTypeEnum.fromName("CHOICE").orElse(null), is(GraphNodeTypeEnum.CHOICE)); + assertThat(GraphNodeTypeEnum.fromName("END").orElse(null), is(GraphNodeTypeEnum.END)); + assertThat(GraphNodeTypeEnum.fromName("SWITCH").isPresent(), is(false)); + assertThat(GraphNodeTypeEnum.fromName(null).isPresent(), is(false)); + } + + @Test + public void testIsValidName() { + assertThat(GraphNodeTypeEnum.isValidName("PLUGIN"), is(true)); + assertThat(GraphNodeTypeEnum.isValidName("plugin"), is(false)); + assertThat(GraphNodeTypeEnum.isValidName(null), is(false)); + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java new file mode 100644 index 0000000..a59788e --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java
@@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.ConditionData; +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Test case for {@link GraphCompiler} and the immutability of {@link GraphPlan}. + */ +public class GraphCompilerTest { + + private static final Set<String> PLUGINS = Set.of("global", "divide", "grpc", "response", "mock"); + + private final GraphCompiler compiler = new GraphCompiler(new GraphValidator(PLUGINS)); + + @Test + public void testCompileLinearPlanResolvesSuccessors() { + final GraphPlan plan = compiler.compile(linearGraph()); + assertThat(plan.getGraphId(), is("g-test")); + assertThat(plan.getNamespaceId(), is("ns-test")); + assertThat(plan.getRevision(), is(1L)); + assertThat(plan.getEntryNodeId(), is("n0")); + assertThat(plan.size(), is(4)); + assertThat(plan.getSuccessors("n0"), contains("n1")); + assertThat(plan.getSuccessors("n2"), contains("end")); + assertThat(plan.getSuccessors("end").isEmpty(), is(true)); + assertThat(plan.getNode("n0").getPluginName(), is("global")); + assertThat(plan.getNode("ghost"), is(nullValue())); + } + + @Test + public void testCompileChoicePlanResolvesBranchThenDefault() { + final GraphPlan plan = compiler.compile(choiceGraph()); + assertThat(plan.getSuccessors("c"), contains("n1", "n2")); + } + + @Test + public void testCompileInvalidGraphThrows() { + final GraphData graph = linearGraph(); + graph.setEntryNodeId("ghost"); + assertThrows(GraphValidationException.class, () -> compiler.compile(graph)); + } + + @Test + public void testPlanNodeIdsAreImmutable() { + final GraphPlan plan = compiler.compile(linearGraph()); + assertThrows(UnsupportedOperationException.class, () -> plan.nodeIds().add("x")); + } + + @Test + public void testPlanSuccessorsAreImmutable() { + final GraphPlan plan = compiler.compile(linearGraph()); + assertThrows(UnsupportedOperationException.class, () -> plan.getSuccessors("n0").add("x")); + } + + @Test + public void testEqualGraphsCompileToEqualPlans() { + assertThat(compiler.compile(linearGraph()), is(compiler.compile(linearGraph()))); + } + + private GraphData linearGraph() { + return graph("n0", + plugin("n0", "global", "n1"), + plugin("n1", "divide", "n2"), + plugin("n2", "response", "end"), + end("end")); + } + + private GraphData choiceGraph() { + final GraphNodeData choice = choice("c", "n2", branch("n1", condition("req_method", "grpc"))); + return graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "grpc", "n2"), + plugin("n2", "response", "end"), + end("end")); + } + + private GraphData graph(final String entry, final GraphNodeData... nodes) { + final GraphData graph = new GraphData(); + graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION); + graph.setNamespaceId("ns-test"); + graph.setGraphId("g-test"); + graph.setRevision(1L); + graph.setContentHash("hash"); + graph.setEntryNodeId(entry); + graph.setNodes(new ArrayList<>(Arrays.asList(nodes))); + return graph; + } + + private GraphNodeData plugin(final String id, final String pluginName, final String next) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.PLUGIN.getName()); + node.setPluginName(pluginName); + node.setNext(next); + return node; + } + + private GraphNodeData end(final String id) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.END.getName()); + return node; + } + + private GraphNodeData choice(final String id, final String defaultTarget, final GraphBranchData... branches) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.CHOICE.getName()); + node.setDefaultTarget(defaultTarget); + node.setConditions(new ArrayList<>(Arrays.asList(branches))); + return node; + } + + private GraphBranchData branch(final String target, final ConditionData... conditions) { + return new GraphBranchData(new ArrayList<>(Arrays.asList(conditions)), 0, target); + } + + private ConditionData condition(final String paramType, final String paramValue) { + final ConditionData data = new ConditionData(); + data.setParamType(paramType); + data.setOperator("match"); + data.setParamValue(paramValue); + return data; + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java new file mode 100644 index 0000000..7a3564d --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java
@@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; + +/** + * Test case for {@link GraphTopology}. + */ +public class GraphTopologyTest { + + @Test + public void testIsBlank() { + assertThat(GraphTopology.isBlank(null), is(true)); + assertThat(GraphTopology.isBlank(" "), is(true)); + assertThat(GraphTopology.isBlank("x"), is(false)); + } + + @Test + public void testNodeTypePredicates() { + assertThat(GraphTopology.isPlugin(node(GraphNodeTypeEnum.PLUGIN)), is(true)); + assertThat(GraphTopology.isChoice(node(GraphNodeTypeEnum.CHOICE)), is(true)); + assertThat(GraphTopology.isEnd(node(GraphNodeTypeEnum.END)), is(true)); + assertThat(GraphTopology.isPlugin(null), is(false)); + assertThat(GraphTopology.isPlugin(node(GraphNodeTypeEnum.END)), is(false)); + } + + @Test + public void testSuccessorsOfPlugin() { + final GraphNodeData plugin = node(GraphNodeTypeEnum.PLUGIN); + plugin.setNext("n1"); + assertThat(GraphTopology.successorsOf(plugin), contains("n1")); + + final GraphNodeData noNext = node(GraphNodeTypeEnum.PLUGIN); + assertThat(GraphTopology.successorsOf(noNext), is(empty())); + } + + @Test + public void testSuccessorsOfEnd() { + assertThat(GraphTopology.successorsOf(node(GraphNodeTypeEnum.END)), is(empty())); + } + + @Test + public void testSuccessorsOfChoiceAppendsDistinctDefault() { + final GraphNodeData choice = node(GraphNodeTypeEnum.CHOICE); + choice.setConditions(Arrays.asList(branch("n1"), branch("n2"))); + choice.setDefaultTarget("n3"); + assertThat(GraphTopology.successorsOf(choice), contains("n1", "n2", "n3")); + } + + @Test + public void testSuccessorsOfChoiceDoesNotDuplicateDefault() { + final GraphNodeData choice = node(GraphNodeTypeEnum.CHOICE); + choice.setConditions(Collections.singletonList(branch("n1"))); + choice.setDefaultTarget("n1"); + assertThat(GraphTopology.successorsOf(choice), contains("n1")); + } + + private GraphNodeData node(final GraphNodeTypeEnum type) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId("id"); + node.setType(type.getName()); + return node; + } + + private GraphBranchData branch(final String target) { + return new GraphBranchData(Collections.emptyList(), 0, target); + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java new file mode 100644 index 0000000..1728b39 --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java
@@ -0,0 +1,366 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.ConditionData; +import org.apache.shenyu.common.dto.GraphBranchData; +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Test case for {@link GraphValidator}. + */ +public class GraphValidatorTest { + + private static final Set<String> PLUGINS = Set.of( + "global", "sign", "divide", "response", "grpc", "mock", "rewrite", "uri"); + + private final GraphValidator validator = new GraphValidator(PLUGINS); + + @Test + public void testNullGraphRejected() { + assertThat(validator.validate(null), hasItem(containsString("must not be null"))); + } + + @Test + public void testValidLinearGraph() { + final GraphData graph = linearGraph(); + assertThat(validator.validate(graph), is(empty())); + } + + @Test + public void testValidChoiceGraph() { + final GraphData graph = choiceGraph(); + assertThat(validator.validate(graph), is(empty())); + } + + @Test + public void testUnsupportedSchemaVersion() { + final GraphData graph = linearGraph(); + graph.setSchemaVersion(2); + assertThat(validator.validate(graph), hasItem(containsString("unsupported schemaVersion"))); + } + + @Test + public void testBlankIdentityRejected() { + final GraphData graph = linearGraph(); + graph.setGraphId(" "); + graph.setNamespaceId(null); + graph.setEntryNodeId(""); + final List<String> violations = validator.validate(graph); + assertThat(violations, hasItem(containsString("graphId must not be blank"))); + assertThat(violations, hasItem(containsString("namespaceId must not be blank"))); + assertThat(violations, hasItem(containsString("entryNodeId must not be blank"))); + } + + @Test + public void testEmptyNodesRejected() { + final GraphData graph = linearGraph(); + graph.setNodes(Collections.emptyList()); + assertThat(validator.validate(graph), hasItem(containsString("nodes must not be empty"))); + } + + @Test + public void testDuplicateNodeIdRejected() { + final GraphData graph = graph("n0", + plugin("n0", "global", "n1"), + plugin("n0", "sign", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("duplicate nodeId 'n0'"))); + } + + @Test + public void testUnknownNodeTypeRejected() { + final GraphNodeData bad = new GraphNodeData(); + bad.setNodeId("n1"); + bad.setType("SWITCH"); + final GraphData graph = graph("n0", plugin("n0", "global", "n1"), bad, end("end")); + assertThat(validator.validate(graph), hasItem(containsString("unknown type 'SWITCH'"))); + } + + @Test + public void testEntryReferencesMissingNode() { + final GraphData graph = linearGraph(); + graph.setEntryNodeId("ghost"); + assertThat(validator.validate(graph), hasItem(containsString("entryNodeId 'ghost' does not reference a node"))); + } + + @Test + public void testDanglingPluginNextRejected() { + final GraphData graph = graph("n0", + plugin("n0", "global", "ghost"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("successor 'next' -> unknown node 'ghost'"))); + } + + @Test + public void testChoiceWithoutDefaultTargetRejected() { + final GraphNodeData choice = choice("c", null, branch("n1", condition("req_method", "GET"))); + final GraphData graph = graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "divide", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("must declare a defaultTarget"))); + } + + @Test + public void testChoiceBranchUnknownTargetRejected() { + final GraphNodeData choice = choice("c", "n1", branch("ghost", condition("req_method", "GET"))); + final GraphData graph = graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "divide", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("branch target 'ghost' -> unknown node"))); + } + + @Test + public void testDuplicateBranchesRejected() { + final GraphNodeData choice = choice("c", "n1", + branch("n1", condition("req_method", "GET")), + branch("n1", condition("req_method", "GET"))); + final GraphData graph = graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "divide", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("duplicate branch"))); + } + + @Test + public void testCycleRejected() { + final GraphData graph = graph("n0", + plugin("n0", "global", "n1"), + plugin("n1", "divide", "n0"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("contains a cycle"))); + } + + @Test + public void testUnreachableNodeRejected() { + final GraphData graph = graph("n0", + plugin("n0", "global", "end"), + plugin("orphan", "mock", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("node 'orphan' is not reachable"))); + } + + @Test + public void testNoPathToEndRejected() { + final GraphData graph = graph("n0", + plugin("n0", "global", "n1"), + plugin("n1", "divide", null)); + final List<String> violations = validator.validate(graph); + assertThat(violations, hasItem(containsString("must have exactly one successor"))); + } + + @Test + public void testPluginWithoutNameRejected() { + final GraphData graph = graph("n0", + plugin("n0", null, "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("must bind a pluginName"))); + } + + @Test + public void testEndNodeWithNextRejected() { + final GraphNodeData badEnd = end("end"); + badEnd.setNext("n0"); + final GraphData graph = graph("n0", plugin("n0", "global", "end"), badEnd); + assertThat(validator.validate(graph), hasItem(containsString("END node 'end' must not declare a successor"))); + } + + @Test + public void testRequiredPluginMissingFromCatalogFailsClosed() { + final GraphNodeData required = plugin("n0", "global", "end"); + required.setRequired(true); + final GraphData graph = graph("n0", required, end("end")); + final GraphValidator emptyCatalog = new GraphValidator(Set.of()); + assertThat(emptyCatalog.validate(graph), hasItem(containsString("required PLUGIN node 'n0' binds plugin 'global' which is not installed"))); + } + + @Test + public void testOptionalPluginMissingFromCatalogRejected() { + final GraphData graph = graph("n0", plugin("n0", "unknownPlugin", "end"), end("end")); + assertThat(validator.validate(graph), hasItem(containsString("which is not installed"))); + } + + @Test + public void testDuplicatePluginOnPathRejected() { + final GraphNodeData choice = choice("c", "n2", + branch("n1", condition("req_method", "GET"))); + final GraphData graph = graph("n0", + plugin("n0", "divide", "c"), + choice, + plugin("n1", "divide", "end"), + plugin("n2", "mock", "end"), + end("end")); + assertThat(validator.validate(graph), hasItem(containsString("executes plugin 'divide' more than once"))); + } + + @Test + public void testMaxNodesLimit() { + final GraphValidator small = new GraphValidator(limits(2, 16, 32, 64), PLUGINS); + assertThat(small.validate(linearGraph()), hasItem(containsString("exceeding max 2"))); + } + + @Test + public void testMaxOutDegreeLimit() { + final GraphValidator small = new GraphValidator(limits(128, 1, 32, 64), PLUGINS); + final GraphNodeData choice = choice("c", "n3", + branch("n1", condition("req_method", "GET")), + branch("n2", condition("req_method", "POST"))); + final GraphData graph = graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "divide", "end"), + plugin("n2", "mock", "end"), + plugin("n3", "uri", "end"), + end("end")); + assertThat(small.validate(graph), hasItem(containsString("exceeding max out-degree 1"))); + } + + @Test + public void testMaxConditionsPerBranchLimit() { + final GraphValidator small = new GraphValidator(limits(128, 16, 1, 64), PLUGINS); + final GraphNodeData choice = choice("c", "n1", + branch("n1", condition("req_method", "GET"), condition("uri", "/x"))); + final GraphData graph = graph("n0", + plugin("n0", "global", "c"), + choice, + plugin("n1", "divide", "end"), + end("end")); + assertThat(small.validate(graph), hasItem(containsString("exceeding max 1"))); + } + + @Test + public void testMaxPathLengthLimit() { + final GraphValidator small = new GraphValidator(limits(128, 16, 32, 2), PLUGINS); + assertThat(small.validate(linearGraph()), hasItem(containsString("exceeding max 2"))); + } + + @Test + public void testValidateOrThrowRaises() { + final GraphData graph = linearGraph(); + graph.setEntryNodeId("ghost"); + final GraphValidationException ex = assertThrows(GraphValidationException.class, () -> validator.validateOrThrow(graph)); + assertThat(ex.getViolations(), hasItem(containsString("does not reference a node"))); + } + + @Test + public void testValidateOrThrowPassesForValidGraph() { + validator.validateOrThrow(linearGraph()); + validator.validateOrThrow(choiceGraph()); + } + + private GraphData linearGraph() { + return graph("n0", + plugin("n0", "global", "n1", true), + plugin("n1", "sign", "n2", true), + plugin("n2", "divide", "n3"), + plugin("n3", "response", "end"), + end("end")); + } + + private GraphData choiceGraph() { + final GraphNodeData choice = choice("c", "n2", branch("n1", condition("req_method", "grpc"))); + return graph("n0", + plugin("n0", "global", "c", true), + choice, + plugin("n1", "grpc", "n3"), + plugin("n2", "divide", "n3"), + plugin("n3", "response", "end"), + end("end")); + } + + private GraphData graph(final String entry, final GraphNodeData... nodes) { + final GraphData graph = new GraphData(); + graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION); + graph.setNamespaceId("ns-test"); + graph.setGraphId("g-test"); + graph.setRevision(1L); + graph.setContentHash("hash"); + graph.setEntryNodeId(entry); + graph.setNodes(new ArrayList<>(Arrays.asList(nodes))); + return graph; + } + + private GraphNodeData plugin(final String id, final String pluginName, final String next) { + return plugin(id, pluginName, next, false); + } + + private GraphNodeData plugin(final String id, final String pluginName, final String next, final boolean required) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.PLUGIN.getName()); + node.setPluginName(pluginName); + node.setNext(next); + node.setRequired(required); + return node; + } + + private GraphNodeData end(final String id) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.END.getName()); + return node; + } + + private GraphNodeData choice(final String id, final String defaultTarget, final GraphBranchData... branches) { + final GraphNodeData node = new GraphNodeData(); + node.setNodeId(id); + node.setType(GraphNodeTypeEnum.CHOICE.getName()); + node.setDefaultTarget(defaultTarget); + node.setConditions(new ArrayList<>(Arrays.asList(branches))); + return node; + } + + private GraphBranchData branch(final String target, final ConditionData... conditions) { + return new GraphBranchData(new ArrayList<>(Arrays.asList(conditions)), 0, target); + } + + private ConditionData condition(final String paramType, final String paramValue) { + final ConditionData data = new ConditionData(); + data.setParamType(paramType); + data.setOperator("match"); + data.setParamValue(paramValue); + return data; + } + + private GraphLimits limits(final int maxNodes, final int maxOutDegree, final int maxConditions, final int maxPathLength) { + return new GraphLimits(maxNodes, maxOutDegree, maxConditions, maxPathLength, + GraphLimits.DEFAULT_MAX_FORK_RECURSION_DEPTH, GraphLimits.DEFAULT_MAX_RETAINED_REVISIONS, + GraphLimits.DEFAULT_MAX_GRAPHS_PER_NAMESPACE); + } +}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java new file mode 100644 index 0000000..bdb79c8 --- /dev/null +++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java
@@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.common.graph; + +import org.apache.shenyu.common.dto.GraphData; +import org.apache.shenyu.common.dto.GraphNodeData; +import org.apache.shenyu.common.dto.PluginData; +import org.apache.shenyu.common.enums.GraphNodeTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +/** + * Test case for {@link LegacyChainImporter}. + */ +public class LegacyChainImporterTest { + + private static final Set<String> PLUGINS = Set.of("global", "sign", "divide", "response"); + + private final GraphValidator validator = new GraphValidator(PLUGINS); + + @Test + public void testImportReproducesLegacySortOrder() { + final List<PluginData> plugins = Arrays.asList( + plugin("divide", 200, true), + plugin("global", -1, true), + plugin("response", 420, true), + plugin("sign", 20, true)); + final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 7L); + + assertThat(graph.getSchemaVersion(), is(GraphValidator.SUPPORTED_SCHEMA_VERSION)); + assertThat(graph.getNamespaceId(), is("ns-test")); + assertThat(graph.getGraphId(), is("g-import")); + assertThat(graph.getRevision(), is(7L)); + assertThat(graph.getEntryNodeId(), is("n0")); + + final List<GraphNodeData> nodes = graph.getNodes(); + assertThat(nodes.size(), is(5)); + assertThat(nodes.get(0).getPluginName(), is("global")); + assertThat(nodes.get(0).isRequired(), is(true)); + assertThat(nodes.get(0).getNext(), is("n1")); + assertThat(nodes.get(1).getPluginName(), is("sign")); + assertThat(nodes.get(1).isRequired(), is(true)); + assertThat(nodes.get(2).getPluginName(), is("divide")); + assertThat(nodes.get(2).isRequired(), is(false)); + assertThat(nodes.get(3).getPluginName(), is("response")); + assertThat(nodes.get(3).getNext(), is(LegacyChainImporter.END_NODE_ID)); + assertThat(nodes.get(4).getType(), is(GraphNodeTypeEnum.END.getName())); + } + + @Test + public void testImportSkipsDisabledPlugins() { + final List<PluginData> plugins = Arrays.asList( + plugin("global", -1, true), + plugin("sign", 20, false), + plugin("divide", 200, true)); + final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L); + assertThat(graph.getNodes().size(), is(3)); + assertThat(graph.getNodes().get(1).getPluginName(), is("divide")); + } + + @Test + public void testImportEmptyChainYieldsSingleEnd() { + final GraphData graph = LegacyChainImporter.importLinearChain(Collections.emptyList(), "ns-test", "g-empty", 1L); + assertThat(graph.getEntryNodeId(), is(LegacyChainImporter.END_NODE_ID)); + assertThat(graph.getNodes().size(), is(1)); + assertThat(graph.getNodes().get(0).getType(), is(GraphNodeTypeEnum.END.getName())); + assertThat(validator.validate(graph), is(empty())); + } + + @Test + public void testImportedGraphPassesValidationAndCompiles() { + final List<PluginData> plugins = Arrays.asList( + plugin("global", -1, true), + plugin("sign", 20, true), + plugin("divide", 200, true), + plugin("response", 420, true)); + final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L); + assertThat(validator.validate(graph), is(empty())); + final GraphPlan plan = new GraphCompiler(validator).compile(graph); + assertThat(plan.getEntryNodeId(), is("n0")); + assertThat(plan.getSuccessors("n3"), is(Collections.singletonList(LegacyChainImporter.END_NODE_ID))); + } + + @Test + public void testContentHashIsDeterministicAndContentSensitive() { + final List<PluginData> plugins = Arrays.asList(plugin("global", -1, true), plugin("divide", 200, true)); + final GraphData first = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L); + final GraphData second = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L); + assertThat(first.getContentHash(), is(second.getContentHash())); + + final GraphData changed = LegacyChainImporter.importLinearChain( + Arrays.asList(plugin("global", -1, true), plugin("response", 420, true)), "ns-test", "g-import", 1L); + assertThat(changed.getContentHash(), is(not(first.getContentHash()))); + } + + private PluginData plugin(final String name, final int sort, final boolean enabled) { + return PluginData.builder().id(name).name(name).role("0").enabled(enabled).sort(sort).build(); + } +}