diff --git a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
index fabde5fbf02..bfaec1f14d3 100644
--- a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
+++ b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
@@ -41,20 +41,14 @@
import org.yaml.snakeyaml.TypeDescription;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
+import org.yaml.snakeyaml.nodes.*;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.ServiceLoader;
-import java.util.UUID;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
import javax.net.ssl.TrustManager;
@@ -64,6 +58,8 @@
* @author Stephen Mallette (http://stephen.genoprime.com)
*/
public class Settings {
+ private static final String CLASSPATH_PREFIX = "classpath:";
+ private static final String INCLUDES_KEY = "includes";
private static final Logger logger = LoggerFactory.getLogger(Settings.class);
@@ -192,7 +188,7 @@ public Settings() {
/**
* If set to {@code true} the Gremlin Server will close the session when a GraphOp (commit or rollback) is
* successfully completed on that session.
- *
+ *
* NOTE: Defaults to false in 3.7.x/3.8.x to prevent breaking change.
*/
public boolean closeSessionPostGraphOp = false;
@@ -340,13 +336,270 @@ public long getEvaluationTimeout() {
/**
* Read configuration from a file into a new {@link Settings} object.
+ *
+ * This method supports recursive includes of other YAML files over the "includes" property that contains
+ * a list of strings that can be:
+ *
+ * - Relative paths to other YAML files
+ * - Absolute paths to other YAML files
+ * - Classpath resources
+ *
+ *
+ * If properties names of the included files or root file (file that contains "includes" property) are the same,
+ * they are overwritten forming a single property set. In any other cases just appended to the root file.
+ * "includes" can be nested and included files can also contain "includes" property.
+ * Included files can override properties of other included files, the root file in turn overriding properties of included files.
+ *
+ * This is quite permissive strategy that works because we then map resulting YAML to Settings object
+ * preventing any configuration inconsistencies.
+ *
+ * Abstract example:
+ * base.yaml
+ *
+ * server:
+ * connector: { port: 8080, protocol: 'http' }
+ * logging: { level: 'INFO' }
+ *
+ * root.yaml
+ *
+ * includes: ['base.yaml']
+ * server:
+ * connector: { port: 9090 } # Overwrite the port only
+ * logging: { file: '/var/log/app.log' } # Add the file, keep level
+ *
+ *
+ * Resulting configuration:
+ *
+ * server:
+ * connector: { port: 9090, protocol: 'http' }
+ * logging: { level: 'INFO', file: '/var/log/app.log' }
+ *
*
* @param file the location of a Gremlin Server YAML configuration file
* @return a new {@link Optional} object wrapping the created {@link Settings}
*/
- public static Settings read(final String file) throws Exception {
- final InputStream input = new FileInputStream(new File(file));
- return read(input);
+ public static Settings read(final String file) {
+ final NodeMapper constructor = createDefaultYamlConstructor();
+ final Yaml yaml = new Yaml();
+
+ HashSet loadStack = new HashSet<>();
+
+ // Normalize the initial path
+ String normalizedPath = normalizeInitialPath(file);
+ Node finalNode = loadNodeRecursive(yaml, normalizedPath, loadStack);
+ if (finalNode == null) {
+ return new Settings();
+ }
+
+ finalNode.setTag(new Tag(Settings.class));
+ return (Settings) constructor.map(finalNode);
+ }
+
+
+ private static Node loadNodeRecursive(Yaml yaml, String currentPath, HashSet loadStack) {
+ try {
+ if (loadStack.contains(currentPath)) {
+ throw new IllegalStateException("Circular dependency detected: " + currentPath);
+ }
+
+ loadStack.add(currentPath);
+ try (InputStream inputStream = getInputStream(currentPath)) {
+ Node rootNode = yaml.compose(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ if (!(rootNode instanceof MappingNode)) {
+ return rootNode;
+ }
+ MappingNode rootMappingNode = (MappingNode) rootNode;
+ //Extract and remove "includes"
+ List includes = extractAndRemoveIncludes(rootMappingNode);
+ //Base Accumulator
+ MappingNode accumulatedNode = new MappingNode(Tag.MAP, new ArrayList<>(), rootMappingNode.getFlowStyle());
+ //Process Includes
+ if (!includes.isEmpty()) {
+ for (String includeRaw : includes) {
+ //Resolve the include path relative to the current file (or absolute)
+ String resolvedIncludePath = resolvePath(currentPath, includeRaw);
+ Node includedNode = loadNodeRecursive(yaml, resolvedIncludePath, loadStack);
+
+ if (includedNode instanceof MappingNode) {
+ mergeMappingNodes(accumulatedNode, (MappingNode) includedNode);
+ } else {
+ // Non-map include replaces everything
+ return includedNode;
+ }
+ }
+ }
+
+ //Merge Current Content Over Accumulator
+ mergeMappingNodes(accumulatedNode, rootMappingNode);
+ return accumulatedNode;
+
+ } finally {
+ loadStack.remove(currentPath);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Error loading YAML from: " + currentPath, e);
+ }
+ }
+
+ /**
+ * Determines how to open the stream based on the "classpath:" prefix.
+ */
+ private static InputStream getInputStream(String path) throws IOException {
+ if (path.startsWith(CLASSPATH_PREFIX)) {
+ String resourcePath = path.substring(CLASSPATH_PREFIX.length());
+ // Ensure resource path doesn't start with slash for ClassLoader
+ if (resourcePath.startsWith("/")) {
+ resourcePath = resourcePath.substring(1);
+ }
+
+ InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath);
+ if (is == null) {
+ // Fallback to class's classloader
+ is = Settings.class.getClassLoader().getResourceAsStream(resourcePath);
+ }
+ if (is == null) {
+ throw new FileNotFoundException("Classpath resource not found: " + resourcePath);
+ }
+ return is;
+ } else {
+ Path fsPath = Paths.get(path);
+ if (!Files.exists(fsPath)) {
+ throw new FileNotFoundException("File not found: " + path);
+ }
+ return new FileInputStream(fsPath.toFile());
+ }
+ }
+
+ /**
+ * Handles relative path resolution for both FileSystem and Classpath contexts.
+ */
+ private static String resolvePath(String contextPath, String includePath) {
+ // If include is absolute (File system absolute or explicit classpath), return it.
+ if (includePath.startsWith(CLASSPATH_PREFIX) || Paths.get(includePath).isAbsolute()) {
+ return normalizeInitialPath(includePath);
+ }
+
+ if (contextPath.startsWith(CLASSPATH_PREFIX)) {
+ // --- Context is Classpath ---
+ String contextResource = contextPath.substring(CLASSPATH_PREFIX.length());
+
+ // Treat the resource string as a Path to utilize 'getParent' and 'resolve' logic easily
+ // We use Paths.get() purely for string manipulation here.
+ Path contextAsPath = Paths.get(contextResource);
+ Path parent = contextAsPath.getParent();
+
+ Path resolved;
+ if (parent == null) {
+ // e.g. "contextPath" was "classpath:app.yaml", parent is null. Include is relative to root.
+ resolved = Paths.get(includePath);
+ } else {
+ resolved = parent.resolve(includePath);
+ }
+
+ // Normalize to remove ".." segments
+ Path normalized = resolved.normalize();
+
+ // Convert back to forward slashes for Classpath consistency (Windows fix)
+ String resourceString = normalized.toString().replace(File.separatorChar, '/');
+
+ return CLASSPATH_PREFIX + resourceString;
+
+ } else {
+ // --- Context is File System ---
+ Path contextFile = Paths.get(contextPath);
+ Path parent = contextFile.getParent();
+
+ if (parent == null) {
+ // e.g. "contextPath" was just "app.yaml"
+ return Paths.get(includePath).toAbsolutePath().normalize().toString();
+ }
+
+ return parent.resolve(includePath).toAbsolutePath().normalize().toString();
+ }
+ }
+
+ private static String normalizeInitialPath(String path) {
+ if (path.startsWith(CLASSPATH_PREFIX)) {
+ // For classpath, we just ensure consistent slashes
+ return path.replace('\\', '/');
+ } else {
+ // For files, we want the absolute path for cycle detection uniqueness
+ return Paths.get(path).toAbsolutePath().normalize().toString();
+ }
+ }
+
+ private static List extractAndRemoveIncludes(MappingNode node) {
+ List includes = new ArrayList<>();
+ List tuples = node.getValue();
+ Iterator iterator = tuples.iterator();
+
+ while (iterator.hasNext()) {
+ NodeTuple tuple = iterator.next();
+ Node keyNode = tuple.getKeyNode();
+
+ if (keyNode instanceof ScalarNode && INCLUDES_KEY.equals(((ScalarNode) keyNode).getValue())) {
+ Node valueNode = tuple.getValueNode();
+
+ if (valueNode instanceof SequenceNode) {
+ SequenceNode seq = (SequenceNode) valueNode;
+
+ for (Node item : seq.getValue()) {
+ if (item instanceof ScalarNode) {
+ includes.add(((ScalarNode) item).getValue());
+ }
+ }
+ } else {
+ throw new IllegalArgumentException("'includes' must be a list of strings");
+ }
+
+ iterator.remove();
+ break;
+ }
+ }
+
+ return includes;
+ }
+
+ private static void mergeMappingNodes(MappingNode baseNode, MappingNode overrideNode) {
+ List baseTuples = baseNode.getValue();
+ List overrideTuples = overrideNode.getValue();
+
+ for (NodeTuple overrideTuple : overrideTuples) {
+ Node keyNode = overrideTuple.getKeyNode();
+ Node valueNode = overrideTuple.getValueNode();
+
+ //key is not a property name we append it
+ if (!(keyNode instanceof ScalarNode)) {
+ baseTuples.add(overrideTuple);
+ continue;
+ }
+
+ String keyName = ((ScalarNode) keyNode).getValue();
+ NodeTuple existingTuple = findTupleByKey(baseTuples, keyName);
+
+ if (existingTuple != null) {
+ Node existingValue = existingTuple.getValueNode();
+ if (existingValue instanceof MappingNode && valueNode instanceof MappingNode) {
+ mergeMappingNodes((MappingNode) existingValue, (MappingNode) valueNode);
+ } else {
+ int index = baseTuples.indexOf(existingTuple);
+ baseTuples.set(index, overrideTuple);
+ }
+ } else {
+ baseTuples.add(overrideTuple);
+ }
+ }
+ }
+
+ private static NodeTuple findTupleByKey(List tuples, String keyName) {
+ for (NodeTuple tuple : tuples) {
+ Node keyNode = tuple.getKeyNode();
+ if (keyNode instanceof ScalarNode && keyName.equals(((ScalarNode) keyNode).getValue())) {
+ return tuple;
+ }
+ }
+
+ return null;
}
/**
@@ -355,9 +608,9 @@ public static Settings read(final String file) throws Exception {
*
* @return a {@link Constructor} to parse a Gremlin Server YAML
*/
- protected static Constructor createDefaultYamlConstructor() {
+ protected static NodeMapper createDefaultYamlConstructor() {
final LoaderOptions options = new LoaderOptions();
- final Constructor constructor = new Constructor(Settings.class, options);
+ final NodeMapper constructor = new NodeMapper(Settings.class, options);
final TypeDescription settingsDescription = new TypeDescription(Settings.class);
settingsDescription.addPropertyParameters("graphs", String.class, String.class);
settingsDescription.addPropertyParameters("scriptEngines", String.class, ScriptEngineSettings.class);
@@ -411,7 +664,10 @@ protected static Constructor createDefaultYamlConstructor() {
*
* @param stream an input stream containing a Gremlin Server YAML configuration
* @return a new {@link Optional} object wrapping the created {@link Settings}
+ * @deprecated as does not handle inclusion of another YAML files.
+ * Please use {@link Settings#read(String)} instead.
*/
+ @Deprecated
public static Settings read(final InputStream stream) {
Objects.requireNonNull(stream);
@@ -470,7 +726,7 @@ public static class ScriptEngineSettings {
* A set of configurations for {@link GremlinPlugin} instances to apply to this {@link GremlinScriptEngine}.
* Plugins will be applied in the order they are listed.
*/
- public Map> plugins = new LinkedHashMap<>();
+ public Map> plugins = new LinkedHashMap<>();
}
/**
@@ -478,7 +734,8 @@ public static class ScriptEngineSettings {
*/
public static class SerializerSettings {
- public SerializerSettings() {}
+ public SerializerSettings() {
+ }
SerializerSettings(final String className, final Map config) {
this.className = className;
@@ -582,15 +839,15 @@ public static class SslSettings {
/**
* A list of SSL protocols to enable. @see JSSE
- * Protocols
+ * "https://docs.oracle.com/javase/8/docs/technotes/guides/security/SunProviders.html#SunJSSE_Protocols">JSSE
+ * Protocols
*/
public List sslEnabledProtocols = new ArrayList<>();
/**
* A list of cipher suites to enable. @see Cipher
- * Suites
+ * "https://docs.oracle.com/javase/8/docs/technotes/guides/security/SunProviders.html#SupportedCipherSuites">Cipher
+ * Suites
*/
public List sslCipherSuites = new ArrayList<>();
@@ -726,4 +983,32 @@ public static abstract class IntervalMetrics extends BaseMetrics {
public static abstract class BaseMetrics {
public boolean enabled = false;
}
+
+ private static final class NodeMapper extends Constructor {
+ public NodeMapper(LoaderOptions loadingConfig) {
+ super(loadingConfig);
+ }
+
+ public NodeMapper(Class> theRoot, LoaderOptions loadingConfig) {
+ super(theRoot, loadingConfig);
+ }
+
+ public NodeMapper(TypeDescription theRoot, LoaderOptions loadingConfig) {
+ super(theRoot, loadingConfig);
+ }
+
+ public NodeMapper(TypeDescription theRoot, Collection moreTDs, LoaderOptions loadingConfig) {
+ super(theRoot, moreTDs, loadingConfig);
+ }
+
+ public NodeMapper(String theRoot, LoaderOptions loadingConfig) throws ClassNotFoundException {
+ super(theRoot, loadingConfig);
+ }
+
+ public Object map(Node node) {
+ // constructDocument is preferred over constructObject as it handles
+ // recursive references and cleanup of internal collections
+ return super.constructDocument(node);
+ }
+ }
}
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractFeatureTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractFeatureTest.java
index 387ae280ec3..339e264973e 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractFeatureTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractFeatureTest.java
@@ -35,10 +35,10 @@ public abstract class AbstractFeatureTest {
@BeforeClass
public static void setUp() throws Exception {
- final InputStream stream = GremlinServer.class.getResourceAsStream("gremlin-server-integration.yaml");
- final Settings settings = Settings.read(stream);
- ServerTestHelper.rewritePathsInGremlinServerSettings(settings);
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
+ ServerTestHelper.rewritePathsInGremlinServerSettings(settings);
server = new GremlinServer(settings);
server.start().get(100, TimeUnit.SECONDS);
}
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractRemoteGraphProvider.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractRemoteGraphProvider.java
index 0f97f1338d6..98c2a4c14f8 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractRemoteGraphProvider.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/driver/remote/AbstractRemoteGraphProvider.java
@@ -209,8 +209,8 @@ public static Cluster.Builder createClusterBuilder(final Serializers serializer)
}
public static void startServer() throws Exception {
- final InputStream stream = GremlinServer.class.getResourceAsStream("gremlin-server-integration.yaml");
- final Settings settings = Settings.read(stream);
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
ServerTestHelper.rewritePathsInGremlinServerSettings(settings);
settings.maxContentLength = 1024000;
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/AbstractGremlinServerIntegrationTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/AbstractGremlinServerIntegrationTest.java
index 147014c138a..8be76416db5 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/AbstractGremlinServerIntegrationTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/AbstractGremlinServerIntegrationTest.java
@@ -82,9 +82,6 @@ protected void overrideEvaluationTimeout(final long timeoutInMillis) {
overriddenSettings.evaluationTimeout = timeoutInMillis;
}
- public InputStream getSettingsInputStream() {
- return AbstractGremlinServerIntegrationTest.class.getResourceAsStream("gremlin-server-integration.yaml");
- }
@Before
public void setUp() throws Exception {
@@ -123,8 +120,8 @@ public void startServer() throws Exception {
}
public CompletableFuture startServerAsync() throws Exception {
- final InputStream stream = getSettingsInputStream();
- final Settings settings = Settings.read(stream);
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
overriddenSettings = overrideSettings(settings);
ServerTestHelper.rewritePathsInGremlinServerSettings(overriddenSettings);
if (GREMLIN_SERVER_EPOLL) {
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerShutdownIntegrationTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerShutdownIntegrationTest.java
index 25eaab20394..cd9ccb4fd2e 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerShutdownIntegrationTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerShutdownIntegrationTest.java
@@ -51,8 +51,8 @@ public InputStream getSettingsInputStream() {
}
public Settings getBaseSettings() {
- final InputStream stream = getSettingsInputStream();
- return Settings.read(stream);
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ return Settings.read(filePath);
}
public CompletableFuture startServer(final Settings settings) throws Exception {
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/SettingsTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/SettingsTest.java
index 148e8d163e7..717d62a9420 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/SettingsTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/SettingsTest.java
@@ -18,15 +18,33 @@
*/
package org.apache.tinkerpop.gremlin.server;
-import org.junit.Test;
+import org.apache.commons.io.FileUtils;
+import org.junit.*;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
+import java.io.IOException;
import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
-import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.*;
public class SettingsTest {
+ private Path tempDir;
+
+ @Before
+ public void before() throws Exception {
+ tempDir = Files.createTempDirectory(SettingsTest.class.getSimpleName());
+ }
+
+ @After
+ public void afterClass() throws Exception {
+ FileUtils.deleteDirectory(tempDir.toFile());
+ }
private static class CustomSettings extends Settings {
public String customValue = "localhost";
@@ -39,7 +57,7 @@ public static CustomSettings read(final InputStream stream) {
}
@Test
- public void constructorCanBeExtendToParseCustomYamlAndSettingsValues() throws Exception {
+ public void constructorCanBeExtendToParseCustomYamlAndSettingsValues() {
final InputStream stream = SettingsTest.class.getResourceAsStream("custom-gremlin-server.yaml");
final CustomSettings settings = CustomSettings.read(stream);
@@ -49,11 +67,246 @@ public void constructorCanBeExtendToParseCustomYamlAndSettingsValues() throws Ex
}
@Test
- public void defaultCustomValuesAreHandledCorrectly() throws Exception {
+ public void defaultCustomValuesAreHandledCorrectly() {
final InputStream stream = SettingsTest.class.getResourceAsStream("gremlin-server-integration.yaml");
final CustomSettings settings = CustomSettings.read(stream);
assertEquals("localhost", settings.customValue);
}
+
+ @Test
+ public void testSimpleIncludeAndMerge() throws IOException {
+ createFile("base.yaml", "graphs: { graph1: 'base1', graph2: 'base2' }");
+ createFile("root.yaml",
+ "includes: ['base.yaml']\n" +
+ "graphs:\n" +
+ " graph1: 'root'"
+
+ );
+
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+
+ assertEquals("root", result.graphs.get("graph1"));
+ assertEquals("base2", result.graphs.get("graph2"));
+ }
+
+ @Test
+ public void testDeepMerge() throws IOException {
+ createFile("base.yaml",
+ "scriptEngines:\n" +
+ " engine1:\n" +
+ " config: \n" +
+ " connector: { port: 8080, protocol: 'http' }\n" +
+ " logging: { level: 'INFO' }"
+ );
+ createFile("root.yaml",
+ "includes: ['base.yaml']\n" +
+ "scriptEngines:\n" +
+ " engine1:\n" +
+ " config: \n" +
+ " connector: { port: 9090 }\n" +
+ " logging: { file: '/var/log/app.log' }"
+ );
+
+
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+ @SuppressWarnings("unchecked")
+ Map connector = (Map) result.scriptEngines.get("engine1").config.get("connector");
+ @SuppressWarnings("unchecked")
+ Map logging = (Map) result.scriptEngines.get("engine1").config.get("logging");
+
+ assertEquals(9090, connector.get("port"));
+ assertEquals("http", connector.get("protocol"));
+ assertEquals("INFO", logging.get("level"));
+ assertEquals("/var/log/app.log", logging.get("file"));
+ }
+
+ @Test
+ public void testMultipleIncludesOrder() throws IOException {
+ // Arrange
+ createFile("one.yaml", "host: localhost1");
+ createFile("two.yaml", "host: localhost2");
+ createFile("root.yaml",
+ "includes: ['one.yaml', 'two.yaml']\n" // two should overwrite one
+ );
+
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+
+ assertEquals("localhost2", result.host);
+ }
+
+ @Test
+ public void testListReplacement() throws IOException {
+ createFile("base.yaml", "serializers: \n" +
+ " [ {className: 'a'} , {className: 'b' }]");
+ createFile("root.yaml",
+ "includes: ['base.yaml']\n" +
+ "serializers: \n" +
+ " [ {className: 'c'}]");
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+
+ List serializers = result.serializers;
+ assertEquals(1, serializers.size());
+ assertEquals("c", serializers.get(0).className);
+ }
+
+ @Test
+ public void testRelativePathResolution() throws IOException {
+ // Structure:
+ // root.yaml
+ // subdir/
+ // child.yaml
+ // nested/
+ // grandchild.yaml
+
+ createFile("subdir/nested/grandchild.yaml", "host: 0.0.0.0");
+ createFile("subdir/child.yaml",
+ "includes: ['nested/grandchild.yaml']\n" +
+ "port: 9090"
+ );
+ createFile("root.yaml",
+ "includes: ['subdir/child.yaml']\n" +
+ "threadPoolWorker: 1000"
+ );
+
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+ assertEquals(9090, result.port);
+ assertEquals(1000, result.threadPoolWorker);
+ assertEquals("0.0.0.0", result.host);
+ }
+
+ @Test
+ public void testParentPathResolution() throws IOException {
+ // root.yaml -> includes 'config/sub.yaml'
+ // config/sub.yaml -> includes '../shared.yaml'
+
+ createFile("shared.yaml", "host: 0.0.0.0");
+ createFile("config/sub.yaml", "includes: ['../shared.yaml']");
+ createFile("root.yaml", "includes: ['config/sub.yaml']");
+
+ Settings result = Settings.read(tempDir.resolve("root.yaml").toString());
+
+ assertEquals("0.0.0.0", result.host);
+ }
+
+ @Test
+ public void testCircularDependency() throws IOException {
+ createFile("a.yaml", "includes: ['b.yaml']");
+ createFile("b.yaml", "includes: ['a.yaml']");
+
+ try {
+ Settings.read(tempDir.resolve("a.yaml").toString());
+ Assert.fail("Expected exception");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().contains("Circular dependency detected"));
+ }
+ }
+
+ @Test
+ public void testDiamondDependency() throws IOException {
+ // A -> B, A -> C
+ // B -> D
+ // C -> D
+ // This is valid. D should be loaded twice (or handled gracefully) but not cause a cycle error.
+
+ createFile("d.yaml", "host: '0.0.0.0'");
+ createFile("b.yaml", "includes: ['d.yaml']\nport: 9090");
+ createFile("c.yaml", "includes: ['d.yaml']\nthreadPoolWorker: 1000");
+ createFile("a.yaml", "includes: ['b.yaml', 'c.yaml']");
+
+ Settings result = Settings.read(tempDir.resolve("a.yaml").toString());
+ assertEquals(9090, result.port);
+ assertEquals(1000, result.threadPoolWorker);
+ assertEquals("0.0.0.0", result.host);
+ }
+
+ @Test
+ public void testClasspathResolution() {
+ Settings result = Settings.read("classpath:org/apache/tinkerpop/gremlin/server/settings/config/root.yaml");
+
+ assertEquals(9090, result.port);
+ assertEquals(1000, result.threadPoolWorker);
+ assertEquals("localhost1", result.host);
+ }
+
+ @Test
+ public void testFileSystemIncludingClasspath() throws IOException {
+ createFile("disk.yaml", "includes: ['classpath:org/apache/tinkerpop/gremlin/server/settings/config/root.yaml']\ngremlinPool: 2000");
+ Settings result = Settings.read(tempDir.resolve("disk.yaml").toString());
+
+ assertEquals(9090, result.port);
+ assertEquals(1000, result.threadPoolWorker);
+ assertEquals("localhost1", result.host);
+ assertEquals(2000, result.gremlinPool);
+ }
+
+ @Test
+ public void testMissingFile() {
+ try {
+ Settings.read(tempDir.resolve("root.yaml").toString());
+ Assert.fail("Expected exception");
+ } catch (Exception e) {
+ String msg = e.getMessage();
+ assertTrue(msg.contains("root.yaml"));
+ assertTrue(msg.contains("Error loading YAML from"));
+ }
+ }
+
+ @Test
+ public void testMissingIncludedFile() throws IOException {
+ createFile("root.yaml", "includes: ['non_existent.yaml']");
+ try {
+ Settings.read(tempDir.resolve("root.yaml").toString());
+ Assert.fail("Expected exception");
+ } catch (Exception e) {
+ String msg = e.getMessage();
+ assertTrue(msg.contains("non_existent.yaml"));
+ assertTrue(msg.contains("Error loading YAML from"));
+ }
+ }
+
+ @Test
+ public void testMalformedIncludes() throws IOException {
+ createFile("root.yaml", "includes: 'just_a_string.yaml'");
+
+ try {
+ Settings.read(tempDir.resolve("root.yaml").toString());
+ Assert.fail("Expected exception");
+ } catch (Exception e) {
+ String msg = e.getMessage();
+ assertTrue(msg.contains("'includes' must be a list of strings"));
+ }
+ }
+
+ @Test
+ public void testEmptyFile() throws IOException {
+ createFile("empty.yaml", "");
+ Settings settings = Settings.read(tempDir.resolve("empty.yaml").toString());
+ assertNotNull(settings);
+ }
+
+
+ @Test
+ public void testIncludeBranches() throws IOException {
+ //root.yaml ____ branch1.yaml ____ base1.yaml
+ // \___ branch2.yaml ____ base1.yaml
+ // \___ base2.yaml
+
+ createFile("root.yaml", "includes:\n - branch1.yaml\n - branch2.yaml");
+ createFile("branch1.yaml", "includes:\n - base1.yaml");
+ createFile("branch2.yaml", "includes:\n - base1.yaml\n - base2.yaml");
+ createFile("base1.yaml", "");
+ createFile("base2.yaml", "");
+
+ Settings settings = Settings.read(tempDir.resolve("root.yaml").toString());
+ assertNotNull(settings);
+ }
+
+
+ private void createFile(String fileName, String content) throws IOException {
+ Path file = tempDir.resolve(fileName);
+ Files.createDirectories(file.getParent());
+ Files.write(file, content.getBytes(StandardCharsets.UTF_8));
+ }
}
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/CheckedGraphManagerTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/CheckedGraphManagerTest.java
index 5cd49c8b048..fe152e6c894 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/CheckedGraphManagerTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/CheckedGraphManagerTest.java
@@ -25,6 +25,7 @@
import java.util.function.Function;
import org.apache.tinkerpop.gremlin.server.GraphManager;
+import org.apache.tinkerpop.gremlin.server.GremlinServer;
import org.apache.tinkerpop.gremlin.server.Settings;
import org.junit.Before;
@@ -36,8 +37,8 @@ public class CheckedGraphManagerTest {
@Before
public void before() throws Exception {
- settings = Settings
- .read(CheckedGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ settings = Settings.read(filePath);
}
/**
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/DefaultGraphManagerTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/DefaultGraphManagerTest.java
index daf04bc1589..66d3b1f58fc 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/DefaultGraphManagerTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/DefaultGraphManagerTest.java
@@ -19,6 +19,7 @@
package org.apache.tinkerpop.gremlin.server.util;
import org.apache.tinkerpop.gremlin.server.GraphManager;
+import org.apache.tinkerpop.gremlin.server.GremlinServer;
import org.apache.tinkerpop.gremlin.server.Settings;
import org.apache.tinkerpop.gremlin.structure.Graph;
import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph;
@@ -43,7 +44,8 @@ public class DefaultGraphManagerTest {
@Test
public void shouldReturnGraphs() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Set graphNames = graphManager.getGraphNames();
@@ -63,7 +65,8 @@ public void shouldReturnGraphs() {
@Test
public void shouldGetAsBindings() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Bindings bindings = graphManager.getAsBindings();
@@ -82,7 +85,8 @@ public void shouldGetAsBindings() {
@Test
public void shouldGetGraph() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Graph graph = graphManager.getGraph("graph");
@@ -92,7 +96,8 @@ public void shouldGetGraph() {
@Test
public void shouldGetDynamicallyAddedGraph() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Graph graph = graphManager.getGraph("graph"); //fake out a graph instance
graphManager.putGraph("newGraph", graph);
@@ -114,7 +119,8 @@ public void shouldGetDynamicallyAddedGraph() {
@Test
public void shouldNotGetRemovedGraph() throws Exception {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Graph graph = graphManager.getGraph("graph"); //fake out a graph instance
graphManager.putGraph("newGraph", graph);
@@ -133,7 +139,8 @@ public void shouldNotGetRemovedGraph() throws Exception {
@Test
public void openGraphShouldReturnExistingGraph() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Graph graph = graphManager.openGraph("graph", null);
@@ -143,7 +150,8 @@ public void openGraphShouldReturnExistingGraph() {
@Test
public void openGraphShouldReturnNewGraphUsingThunk() {
- final Settings settings = Settings.read(DefaultGraphManagerTest.class.getResourceAsStream("../gremlin-server-integration.yaml"));
+ var filePath = "classpath:" + GremlinServer.class.getPackageName().replace(".", "/") + "/gremlin-server-integration.yaml";
+ final Settings settings = Settings.read(filePath);
final GraphManager graphManager = new DefaultGraphManager(settings);
final Graph graph = graphManager.getGraph("graph"); //fake out graph instance
diff --git a/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/base1.yaml b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/base1.yaml
new file mode 100644
index 00000000000..acf935eef3b
--- /dev/null
+++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/base1.yaml
@@ -0,0 +1,18 @@
+# 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.
+
+port: 9090
\ No newline at end of file
diff --git a/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/base2.yaml b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/base2.yaml
new file mode 100644
index 00000000000..8b65b167282
--- /dev/null
+++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/base2.yaml
@@ -0,0 +1,18 @@
+# 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.
+
+threadPoolWorker: 1000
\ No newline at end of file
diff --git a/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/root.yaml b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/root.yaml
new file mode 100644
index 00000000000..b28be103248
--- /dev/null
+++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/root.yaml
@@ -0,0 +1,19 @@
+# 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.
+
+includes: [ '../base1.yaml', 'base2.yaml' ]
+host: localhost1
\ No newline at end of file