Skip to content
Merged
4 changes: 2 additions & 2 deletions docker/configs/server1-conf/rest-server.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# bind url
restserver.url=http://127.0.0.1:8081
restserver.url=127.0.0.1:8081
# gremlin server url, need to be consistent with host and port in gremlin-server.yaml
gremlinserver.url=http://127.0.0.1:8181
gremlinserver.url=127.0.0.1:8181

graphs=./conf/graphs

Expand Down
4 changes: 2 additions & 2 deletions docker/configs/server2-conf/rest-server.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# bind url
restserver.url=http://127.0.0.1:8082
restserver.url=127.0.0.1:8082
# gremlin server url, need to be consistent with host and port in gremlin-server.yaml
gremlinserver.url=http://127.0.0.1:8182
gremlinserver.url=127.0.0.1:8182

graphs=./conf/graphs

Expand Down
4 changes: 2 additions & 2 deletions docker/configs/server3-conf/rest-server.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# bind url
restserver.url=http://127.0.0.1:8083
restserver.url=127.0.0.1:8083
# gremlin server url, need to be consistent with host and port in gremlin-server.yaml
gremlinserver.url=http://127.0.0.1:8183
gremlinserver.url=127.0.0.1:8183

graphs=./conf/graphs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
import com.google.common.base.Predicate;

public class ConfigOption<T> extends TypedOption<T, T> {
private boolean urlNormalize = false;
private String defaultScheme = null;

public ConfigOption<T> withUrlNormalization(String scheme) {
this.urlNormalize = true;
this.defaultScheme = scheme;
return this;
}

public boolean needsUrlNormalization() {
return this.urlNormalize;
}

public String getDefaultScheme() {
return this.defaultScheme;
}

public ConfigOption(String name, String desc, T value) {
this(name, desc, null, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public class HugeConfig extends PropertiesConfiguration {

private static final Logger LOG = Log.logger(HugeConfig.class);

// Cache for URL normalization metadata (populated lazily per key)
private static final Map<String, String> URL_NORMALIZATIONS = new HashMap<>();

private String configPath;

public HugeConfig(Configuration config) {
Expand Down Expand Up @@ -87,9 +90,17 @@ private void setLayoutIfNeeded(Configuration conf) {
@SuppressWarnings("unchecked")
public <T, R> R get(TypedOption<T, R> option) {
Object value = this.getProperty(option.name());
boolean fromDefault = false;

if (value == null) {
return option.defaultValue();
value = option.defaultValue();
fromDefault = true;
}

if (!fromDefault) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code Quality: Inconsistent behavior between default and explicit values

Line 94-96 shows normalization only applies to explicitly set values (fromDefault = false), but not to default values from option.defaultValue().

This creates inconsistent behavior:

  • Default value http://127.0.0.1:8080 → used as-is
  • User sets 127.0.0.1:8080 → normalized to http://127.0.0.1:8080

Question: Is this intentional? If defaults already have schemes, why do we need .withUrlNormalization() on the options?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is intentional.
The default values already have http://, so they are already correct. There is nothing to fix, so normalization is skipped. User values may not have the scheme (for example: 127.0.0.1:8080), so only those need normalization.
.withUrlNormalization() is just metadata. It tells the system: “this option is a URL, and use this scheme when fixing user input.”

So:
Default → already correct → no change
User value → may be incomplete → normalize

value = normalizeUrlOptionIfNeeded(option.name(), value);
}

return (R) value;
}

Expand Down Expand Up @@ -213,4 +224,86 @@ private static Configuration loadConfigFile(File configFile) {
e, configFile);
}
}

private static Object normalizeUrlOptionIfNeeded(String key, Object value) {
if (value == null) {
return null;
}

String scheme = defaultSchemeFor(key);
if (scheme == null) {
return value;
}

// Normalize URL options if configured with .withUrlNormalization()
if (value instanceof String) {
String original = (String) value;
String normalized = prefixSchemeIfMissing(original, scheme);

if (!original.equals(normalized)) {
LOG.warn("Config '{}' is missing scheme, auto-corrected to '{}'",
key, normalized);
}

return normalized;
}

// If it ever hits here, it means config storage returned a non-string type;
// leave it unchanged (safer than forcing toString()).
return value;
}

private static String defaultSchemeFor(String key) {
// Check if we already cached this key's scheme
if (URL_NORMALIZATIONS.containsKey(key)) {
return URL_NORMALIZATIONS.get(key);
}

// We don't know yet - look it up NOW from OptionSpace
synchronized (URL_NORMALIZATIONS) {
// Double-check after acquiring lock
if (URL_NORMALIZATIONS.containsKey(key)) {
return URL_NORMALIZATIONS.get(key);
}

// Look up the option from OptionSpace
TypedOption<?, ?> option = OptionSpace.get(key);
String scheme = null;

if (option instanceof ConfigOption) {
ConfigOption<?> configOption = (ConfigOption<?>) option;
if (configOption.needsUrlNormalization()) {
scheme = configOption.getDefaultScheme();
}
}

// Cache it for next time (even if null)
URL_NORMALIZATIONS.put(key, scheme);
return scheme;
}
}

private static String prefixSchemeIfMissing(String raw, String scheme) {
if (raw == null) {
return null;
}
String s = raw.trim();
if (s.isEmpty()) {
return s;
}

int scIdx = s.indexOf("://");
if (scIdx > 0) {
// Normalize existing scheme to lowercase while preserving the rest
String existingScheme = s.substring(0, scIdx).toLowerCase();
String rest = s.substring(scIdx + 3); // skip the "://" delimiter
return existingScheme + "://" + rest;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Important: Missing Edge Case Handling

The current implementation doesn't handle URLs with credentials or userinfo:

// These would incorrectly get prefixed:
"user:password@127.0.0.1:8080" → "http://user:password@127.0.0.1:8080"
"admin@localhost:8080""http://admin@localhost:8080"

While valid according to RFC 3986 (scheme://[userinfo@]host[:port]), detecting these requires checking for @ before any /:

Suggested change
}
// Keep original string if scheme already exists
String lower = s.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return s;
}
// Don't add scheme if userinfo is present without scheme
// (e.g., "user:pass@host:port" - likely malformed or needs manual fixing)
int slashPos = s.indexOf('/');
int atPos = s.indexOf('@');
if (atPos != -1 && (slashPos == -1 || atPos < slashPos)) {
// Has userinfo component - preserve as-is to avoid masking config errors
return s;
}
return scheme + s;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefixSchemeIfMissing() method currently just normalizes URLs and doesn’t try to validate things like userinfo (user@host). That seems reasonable since handling credentials in URLs is discouraged and @ can also appear in valid paths. Adding special checks here would blur the line between normalization and validation.It’s probably better to handle stricter validation at the client/usage layer if needed.I’m happy to add a separate check or open an additional PR if you think stricter handling belongs here. Please let me know your preference.


String defaultScheme = scheme == null ? "" : scheme;
if (!defaultScheme.isEmpty() && !defaultScheme.endsWith("://")) {
defaultScheme = defaultScheme + "://";
}
return defaultScheme + s;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class ServerOptions extends OptionHolder {
"The url for listening of graph server.",
disallowEmpty(),
"http://127.0.0.1:8080"
);
).withUrlNormalization("http://");

public static final ConfigOption<Integer> SERVER_EVENT_HUB_THREADS =
new ConfigOption<>(
Expand Down Expand Up @@ -118,7 +118,7 @@ public class ServerOptions extends OptionHolder {
"The url of gremlin server.",
disallowEmpty(),
"http://127.0.0.1:8182"
);
).withUrlNormalization("http://");

public static final ConfigOption<Integer> GREMLIN_SERVER_TIMEOUT =
new ConfigOption<>(
Expand Down Expand Up @@ -270,15 +270,15 @@ public class ServerOptions extends OptionHolder {
"to clients. only used when starting the server in k8s.",
disallowEmpty(),
"http://0.0.0.0:8080"
);
).withUrlNormalization("http://");

public static final ConfigOption<String> SERVER_K8S_URL =
new ConfigOption<>(
"server.k8s_url",
"The url of k8s.",
disallowEmpty(),
"https://127.0.0.1:8888"
);
).withUrlNormalization("https://");

public static final ConfigOption<Boolean> SERVER_K8S_USE_CA =
new ConfigOption<>(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# bind url
# could use '0.0.0.0' or specified (real)IP to expose external network access
restserver.url=http://127.0.0.1:8080
restserver.url=127.0.0.1:8080
#restserver.enable_graphspaces_filter=false
# gremlin server url, need to be consistent with host and port in gremlin-server.yaml
#gremlinserver.url=http://127.0.0.1:8182
#gremlinserver.url=127.0.0.1:8182

graphs=./conf/graphs

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with this
* work for additional information regarding copyright ownership. The ASF
* licenses this file to You under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package org.apache.hugegraph.unit.config;

import org.apache.commons.configuration2.PropertiesConfiguration;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.OptionSpace;
import org.apache.hugegraph.config.ServerOptions;
import org.apache.hugegraph.testutil.Assert;
import org.junit.BeforeClass;
import org.junit.Test;

public class ServerOptionsTest {

@BeforeClass
public static void init() {
OptionSpace.register("server",
ServerOptions.class.getName());
}

@Test
public void testUrlOptionNormalizeAddsDefaultScheme() {
PropertiesConfiguration conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "127.0.0.1:8080");
conf.setProperty("gremlinserver.url", "127.0.0.1:8182");
conf.setProperty("server.urls_to_pd", "0.0.0.0:8080");
conf.setProperty("server.k8s_url", "127.0.0.1:8888");

HugeConfig config = new HugeConfig(conf);

Assert.assertEquals("http://127.0.0.1:8080",
config.get(ServerOptions.REST_SERVER_URL));
Assert.assertEquals("http://127.0.0.1:8182",
config.get(ServerOptions.GREMLIN_SERVER_URL));
Assert.assertEquals("http://0.0.0.0:8080",
config.get(ServerOptions.SERVER_URLS_TO_PD));
Assert.assertEquals("https://127.0.0.1:8888",
config.get(ServerOptions.SERVER_K8S_URL));
}

@Test
public void testUrlNormalizationEdgeCases() {
// Whitespace trimming
PropertiesConfiguration conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", " 127.0.0.1:8080 ");
HugeConfig config = new HugeConfig(conf);
Assert.assertEquals("http://127.0.0.1:8080",
config.get(ServerOptions.REST_SERVER_URL));

// Case normalization
conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "HTTP://127.0.0.1:8080");
config = new HugeConfig(conf);
Assert.assertEquals("http://127.0.0.1:8080",
config.get(ServerOptions.REST_SERVER_URL));

// IPv6 without scheme
conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "[::1]:8080");
config = new HugeConfig(conf);
Assert.assertEquals("http://[::1]:8080",
config.get(ServerOptions.REST_SERVER_URL));

// IPv6 with existing scheme
conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "http://[::1]:8080");
config = new HugeConfig(conf);
Assert.assertEquals("http://[::1]:8080",
config.get(ServerOptions.REST_SERVER_URL));
}

@Test
public void testUrlNormalizationPreservesHostnameCase() {
// Uppercase scheme + mixed-case hostname
PropertiesConfiguration conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "HTTP://MyServer:8080");
HugeConfig config = new HugeConfig(conf);
// Should lowercase ONLY the scheme, preserve "MyServer"
Assert.assertEquals("http://MyServer:8080",
config.get(ServerOptions.REST_SERVER_URL));

// Use server.k8s_url for HTTPS test (it defaults to https://)
conf = new PropertiesConfiguration();
conf.setProperty("server.k8s_url", "HTTPS://MyHost:8888");
config = new HugeConfig(conf);
Assert.assertEquals("https://MyHost:8888",
config.get(ServerOptions.SERVER_K8S_URL));
}

@Test
public void testUrlNormalizationPreservesPathCase() {
PropertiesConfiguration conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "http://127.0.0.1:8080/SomePath/CaseSensitive");
HugeConfig config = new HugeConfig(conf);
Assert.assertEquals("http://127.0.0.1:8080/SomePath/CaseSensitive",
config.get(ServerOptions.REST_SERVER_URL));
}

@Test
public void testHttpsSchemeIsNotDowngraded() {
PropertiesConfiguration conf = new PropertiesConfiguration();
conf.setProperty("restserver.url", "https://127.0.0.1:8080");
HugeConfig config = new HugeConfig(conf);
Assert.assertEquals("https://127.0.0.1:8080",
config.get(ServerOptions.REST_SERVER_URL));
}
}
Loading