From fd02d44fac6c437642af06902ab59ec13fe6569f Mon Sep 17 00:00:00 2001 From: andrii0lomakin Date: Fri, 19 Dec 2025 13:44:54 +0100 Subject: [PATCH 1/3] New feature - the ability to include other YAML files using the "includes" property was added. Files including feature support nesting. So one YAML file can include several files that in turn include another file and so on. Circular references are detected during inclusion, and an exception is thrown. The following formats of file paths are supported: 1. The absolute file path. 2. Path relative to including file. 3. Files prefixed with `classpath:` are searched inside class path. Files loaded through class path also support relative path references that will be resolved inside class path scope. Properties of included files are overwritten in order of inclusion, after that including file will overwrite those properties. --- .../tinkerpop/gremlin/server/Settings.java | 335 ++++++++++++++++-- .../driver/remote/AbstractFeatureTest.java | 6 +- .../remote/AbstractRemoteGraphProvider.java | 4 +- .../AbstractGremlinServerIntegrationTest.java | 7 +- .../GremlinServerShutdownIntegrationTest.java | 4 +- .../gremlin/server/SettingsTest.java | 243 ++++++++++++- .../server/util/CheckedGraphManagerTest.java | 5 +- .../server/util/DefaultGraphManagerTest.java | 22 +- .../gremlin/server/settings/base1.yaml | 1 + .../gremlin/server/settings/config/base2.yaml | 1 + .../gremlin/server/settings/config/root.yaml | 2 + 11 files changed, 580 insertions(+), 50 deletions(-) create mode 100644 gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/base1.yaml create mode 100644 gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/base2.yaml create mode 100644 gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/root.yaml 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: + *

    + *
  1. Relative paths to other YAML files
  2. + *
  3. Absolute paths to other YAML files
  4. + *
  5. Classpath resources
  6. + *
+ *

+ * 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..31e05900108 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,228 @@ 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); + } + + 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..de2f6b442ba --- /dev/null +++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/base1.yaml @@ -0,0 +1 @@ +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..255d99821db --- /dev/null +++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/base2.yaml @@ -0,0 +1 @@ +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..6f22158c296 --- /dev/null +++ b/gremlin-server/src/test/resources/org/apache/tinkerpop/gremlin/server/settings/config/root.yaml @@ -0,0 +1,2 @@ +includes: [ '../base1.yaml', 'base2.yaml' ] +host: localhost1 \ No newline at end of file From f5195ea638c33a6845e95edca2811d0d0f3150a4 Mon Sep 17 00:00:00 2001 From: andrii0lomakin Date: Fri, 19 Dec 2025 18:22:21 +0100 Subject: [PATCH 2/3] YAML headers were updated to include license. --- .../gremlin/server/settings/base1.yaml | 17 +++++++++++++++++ .../gremlin/server/settings/config/base2.yaml | 17 +++++++++++++++++ .../gremlin/server/settings/config/root.yaml | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) 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 index de2f6b442ba..acf935eef3b 100644 --- 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 @@ -1 +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 index 255d99821db..8b65b167282 100644 --- 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 @@ -1 +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 index 6f22158c296..b28be103248 100644 --- 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 @@ -1,2 +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 From b50f0ee4073d21e4e05090a221781a959bf1901e Mon Sep 17 00:00:00 2001 From: andrii0lomakin Date: Wed, 14 Jan 2026 12:13:32 +0100 Subject: [PATCH 3/3] Add unit test for nested YAML file inclusion in Settings in case of duplication of included file in different branches. --- .../tinkerpop/gremlin/server/SettingsTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 31e05900108..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 @@ -286,6 +286,24 @@ public void testEmptyFile() throws IOException { 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());