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..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