This is an automated email from the ASF dual-hosted git repository. chrisdutz pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/plc4x.git
commit 751b74634c3603f35cf8d03ef6896d9307bac15a Author: Christofer Dutz <[email protected]> AuthorDate: Sun Aug 16 18:03:20 2026 +0200 feat(plc4j/spi): Warn about connection-string parameters nothing reads The configuration factory ignores any parameter it has no field for, so a misspelt or misplaced one looked accepted while silently leaving the default in place. Writing 'remote-slot=2' instead of 'cotp.remote-slot=2' is the case that keeps costing people an afternoon - the connection comes up on the default slot and nothing says why it is talking to the wrong CPU (GH-2247). DriverBase now compares the supplied parameter names against what the driver, the transport, the audit log and the connection control options declare, and warns about the remainder. Where a known parameter is an obvious near-miss it is suggested: a matching name under a different prefix wins outright, since a missing prefix is the mistake that actually happens, and a short edit distance catches ordinary typos. Nothing close enough means no suggestion rather than an invented one. WARN Connection string parameter 'remote-slot' is not known to driver 's7' and is ignored - did you mean 'cotp.remote-slot'? mistake that actually happens, and a short edit distance catches ordinary typos. Nothing close enough means no suggestion rather than an invented one. WARN Connection string parameter 'remote-slot' is not known to driver 's7' and is ignored - did you mean 'cotp.remote-slot'? The full list of accepted parameters is logged at DEBUG, where someone debugging can ask for it, rather than in the warning. This is deliberately a warning and not an exception: a driver may read further prefixed configurations this code cannot see, and failing the someone debugging can ask for it, rather than in the warning. This is deliberately a warning and not an exception: a driver may read further prefixed configurations this code cannot see, and failing the connection over a false positive would break working setups. To check message - none of them trips it. ConfigurationFactory.parameterNames exposes the existing query parser so the check reads the connection string the same way the code that consumes it does. --- .../java/spi/config/ConfigurationFactory.java | 12 ++ .../apache/plc4x/java/spi/drivers/DriverBase.java | 162 +++++++++++++++++ .../drivers/DriverBaseUnknownParameterTest.java | 196 +++++++++++++++++++++ 3 files changed, 370 insertions(+) diff --git a/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/ConfigurationFactory.java b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/ConfigurationFactory.java index 06226daaa7..d0e267593a 100644 --- a/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/ConfigurationFactory.java +++ b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/ConfigurationFactory.java @@ -301,6 +301,18 @@ public class ConfigurationFactory { return Enum.valueOf((Class<Enum>) field.getType(), valueString); } + /** + * The parameter names a connection string carries, in the order they appear. Exposed so callers + * can compare what was supplied against what the configurations actually declare - the factory + * silently ignores anything it has no field for, which makes a typo look like it was accepted. + * + * @param paramString the parameter part of a connection string (may be null or blank). + * @return the parameter names, never null. + */ + public static Set<String> parameterNames(String paramString) { + return splitQuery(paramString).keySet(); + } + /** * <a href="https://stackoverflow.com/questions/13592236/parse-a-uri-string-into-name-value-collection/13592567#13592567">...</a> */ diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java index b9d21e2598..5c3039a3c8 100644 --- a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java @@ -29,6 +29,7 @@ import org.apache.plc4x.java.api.metadata.PlcDriverMetadata; import org.apache.plc4x.java.api.types.OptionType; import org.apache.plc4x.java.spi.config.Configuration; import org.apache.plc4x.java.spi.config.ConfigurationFactory; +import org.apache.plc4x.java.spi.config.annotations.ComplexConfigurationParameter; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.Required; @@ -68,6 +69,8 @@ import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static java.util.stream.Collectors.toList; + /** * Abstract base class for PLC4X drivers. * <p> @@ -214,6 +217,11 @@ public abstract class DriverBase implements PlcDriver { // Create and initialize the audit log. AuditLogConfiguration auditLogConfiguration = configurationFactory.createPrefixedConfiguration( AuditLogConfiguration.class, "log", paramString); + + // Point out parameters nothing will ever read. The configuration factory ignores what it has + // no field for, so a misspelt or misplaced parameter looks accepted and silently leaves the + // default in place - 'remote-slot=2' instead of 'cotp.remote-slot=2' being the classic case. + warnAboutUnknownParameters(paramString, transportCode, transportConfigType); this.auditLog = AuditLog.builder() .withSource(getProtocolCode()) .withConfiguration(auditLogConfiguration) @@ -309,6 +317,160 @@ public abstract class DriverBase implements PlcDriver { }; } + /** + * Logs a warning for every parameter in the connection string that none of the configurations + * involved declares, listing what was expected. + * <p> + * This is deliberately a warning and not an exception: the driver, the transport, the audit log + * and the connection control options are the configurations this class knows about, but a driver + * is free to read further prefixed configurations of its own, and those parameters would look + * unknown from here. Failing the connection over that would break working setups; saying + * something turns a silent misconfiguration into a visible one. + */ + private void warnAboutUnknownParameters(String paramString, String transportCode, + Class<? extends TransportConfiguration> transportConfigType) { + List<String> unknown = findUnknownParameters( + paramString, getConfigurationClass(), transportConfigType, transportCode); + if (unknown.isEmpty()) { + return; + } + Set<String> known = knownParameterNames(getConfigurationClass(), transportConfigType, transportCode); + for (String name : unknown) { + String suggestion = suggestionFor(name, known); + if (suggestion == null) { + log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored.", + name, getProtocolCode()); + } else { + log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored - " + + "did you mean '{}'?", name, getProtocolCode(), suggestion); + } + } + if (log.isDebugEnabled()) { + List<String> sorted = new ArrayList<>(known); + Collections.sort(sorted); + log.debug("Parameters driver '{}' accepts over transport '{}': {}", + getProtocolCode(), transportCode, sorted); + } + } + + /** + * The known parameter the given unknown one was most likely meant to be, or {@code null} when + * nothing is close enough to be worth suggesting. + * <p> + * A missing or wrong prefix is the mistake that actually happens - {@code remote-slot} for + * {@code cotp.remote-slot} - so a name that matches some known parameter's last segment wins + * outright. Otherwise a short edit distance catches ordinary typos. + */ + static String suggestionFor(String unknown, Set<String> known) { + String unknownLeaf = leafOf(unknown); + List<String> byLeaf = known.stream() + .filter(candidate -> !candidate.equals(unknown) && leafOf(candidate).equals(unknownLeaf)) + .sorted() + .collect(toList()); + if (!byLeaf.isEmpty()) { + return byLeaf.get(0); + } + + // Allow roughly one edit per four characters, so short names don't match everything. + int budget = Math.min(3, Math.max(1, unknown.length() / 4)); + String best = null; + int bestDistance = Integer.MAX_VALUE; + for (String candidate : known) { + int distance = editDistance(unknown, candidate); + if (distance <= budget && ((best == null) || (distance < bestDistance) + || ((distance == bestDistance) && (candidate.compareTo(best) < 0)))) { + best = candidate; + bestDistance = distance; + } + } + return best; + } + + /** The part after the last dot - the parameter name without any prefix. */ + private static String leafOf(String name) { + int lastDot = name.lastIndexOf('.'); + return (lastDot < 0) ? name : name.substring(lastDot + 1); + } + + /** + * Levenshtein distance. Hand-rolled to keep the SPI free of another dependency; the strings + * involved are parameter names, so the quadratic cost is irrelevant. + */ + private static int editDistance(String left, String right) { + int[] previous = new int[right.length() + 1]; + int[] current = new int[right.length() + 1]; + for (int j = 0; j <= right.length(); j++) { + previous[j] = j; + } + for (int i = 1; i <= left.length(); i++) { + current[0] = i; + for (int j = 1; j <= right.length(); j++) { + int substitution = previous[j - 1] + (left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1); + current[j] = Math.min(substitution, Math.min(previous[j] + 1, current[j - 1] + 1)); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[right.length()]; + } + + /** + * The parameters in {@code paramString} that none of the configurations involved declares, in the + * order they were supplied. Package-private so it can be tested without a registered transport. + */ + static List<String> findUnknownParameters(String paramString, Class<?> driverConfigClass, + Class<?> transportConfigClass, String transportCode) { + Set<String> supplied = ConfigurationFactory.parameterNames(paramString); + if (supplied.isEmpty()) { + return Collections.emptyList(); + } + Set<String> known = knownParameterNames(driverConfigClass, transportConfigClass, transportCode); + return supplied.stream().filter(name -> !known.contains(name)).collect(toList()); + } + + private static Set<String> knownParameterNames(Class<?> driverConfigClass, Class<?> transportConfigClass, + String transportCode) { + Set<String> known = new HashSet<>(); + collectParameterNames(driverConfigClass, "", known); + collectParameterNames(transportConfigClass, transportCode + ".", known); + collectParameterNames(AuditLogConfiguration.class, "log.", known); + collectParameterNames(ConnectionControlConfiguration.class, "", known); + return known; + } + + /** + * Adds every parameter name the given configuration class declares to {@code names}, prefixed + * with {@code prefix}. Complex parameters contribute their nested names under their own prefix, + * so an OPC UA {@code encoding.*} parameter is recognised rather than reported as unknown. + */ + private static void collectParameterNames(Class<?> configurationClass, String prefix, Set<String> names) { + if (configurationClass == null) { + return; + } + Class<?> current = configurationClass; + while (current != null && current != Object.class) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + ConfigurationParameter parameterAnnotation = field.getAnnotation(ConfigurationParameter.class); + if (parameterAnnotation != null && !parameterAnnotation.value().isEmpty()) { + names.add(prefix + parameterAnnotation.value()); + continue; + } + ComplexConfigurationParameter complexAnnotation = + field.getAnnotation(ComplexConfigurationParameter.class); + if (complexAnnotation != null) { + String nestedPrefix = complexAnnotation.prefix().isEmpty() + ? prefix : prefix + complexAnnotation.prefix() + "."; + collectParameterNames(field.getType(), nestedPrefix, names); + } + } + current = current.getSuperclass(); + } + } + /** * Walks the full class hierarchy of the given configuration class and returns one * {@link Option} for every field annotated with {@link ConfigurationParameter}. diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java new file mode 100644 index 0000000000..684e0a76ba --- /dev/null +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java @@ -0,0 +1,196 @@ +/* + * 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 + * + * https://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.plc4x.java.spi.drivers; + +import org.apache.plc4x.java.spi.config.Configuration; +import org.apache.plc4x.java.spi.config.annotations.ComplexConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The configuration factory ignores any parameter it has no field for, so a misspelt or misplaced + * parameter used to look accepted while silently leaving the default in place - writing + * {@code remote-slot=2} instead of {@code cotp.remote-slot=2} being the case that keeps costing + * people an afternoon (GH-2247). These parameters are now reported. + */ +class DriverBaseUnknownParameterTest { + + private static List<String> unknownIn(String paramString) { + return DriverBase.findUnknownParameters( + paramString, DriverConfig.class, TransportConfig.class, "cotp"); + } + + @Test + void acceptsParametersTheDriverDeclares() { + assertEquals(List.of(), unknownIn("controller-type=S7_300&read-timeout=5000")); + } + + @Test + void acceptsTransportParametersUnderTheirPrefix() { + assertEquals(List.of(), unknownIn("cotp.remote-slot=2&cotp.local-tsap=512")); + } + + /** + * The case from the issue: a transport parameter written without its prefix reaches nothing. + */ + @Test + void reportsATransportParameterWithoutItsPrefix() { + assertEquals(List.of("remote-slot"), unknownIn("controller-type=S7_200&remote-slot=2")); + } + + @Test + void reportsAMisspeltParameter() { + assertEquals(List.of("controler-type"), unknownIn("controler-type=S7_300")); + } + + @Test + void reportsSeveralInTheOrderTheyWereSupplied() { + assertEquals(List.of("transport-protocol", "remote-rack"), + unknownIn("transport-protocol=pniop&remote-rack=0&controller-type=S7_200")); + } + + /** + * Parameters inherited from a superclass count - transport configurations are usually a + * protocol-specific subclass of a shared base. + */ + @Test + void acceptsInheritedTransportParameters() { + assertEquals(List.of(), unknownIn("cotp.remote-tsap=512")); + } + + /** + * A complex parameter contributes its nested names under its own prefix. Warning about these + * would be a false positive, which is the failure mode that would make the whole check a + * nuisance - OPC UA has exactly this shape with its {@code encoding.*} options. + */ + @Test + void acceptsNestedComplexParameters() { + assertEquals(List.of(), unknownIn("encoding.charset=utf-8")); + } + + /** + * The options the SPI itself consumes are not the driver's, but they are still legitimate. + */ + @Test + void acceptsSpiOwnedParameters() { + assertEquals(List.of(), unknownIn("allow-unsupported-transport=true")); + } + + @Test + void saysNothingAboutAnEmptyParameterString() { + assertEquals(List.of(), unknownIn(null)); + assertEquals(List.of(), unknownIn("")); + } + + /** + * A driver that declares no configuration at all must not turn every parameter into a warning + * beyond the ones that are genuinely unknown. + */ + @Test + void handlesADriverWithoutAConfigurationClass() { + List<String> unknown = DriverBase.findUnknownParameters( + "allow-unsupported-transport=true&nonsense=1", null, TransportConfig.class, "cotp"); + assertEquals(List.of("nonsense"), unknown); + } + + @Test + void keepsTheValueOutOfTheReportedName() { + assertTrue(unknownIn("nonsense=secret-value").contains("nonsense"), + "the name is reported, not the value"); + assertEquals(List.of("nonsense"), unknownIn("nonsense=secret-value")); + } + + private static final Set<String> KNOWN = Set.of( + "controller-type", "read-timeout", "allow-unsupported-transport", + "cotp.remote-slot", "cotp.remote-rack", "cotp.local-tsap", "log.audit-log-file"); + + /** + * The mistake that actually happens: the right name, without its transport prefix. + */ + @Test + void suggestsThePrefixedNameForAnUnprefixedOne() { + assertEquals("cotp.remote-slot", DriverBase.suggestionFor("remote-slot", KNOWN)); + } + + @Test + void suggestsTheRightNameForATypo() { + assertEquals("controller-type", DriverBase.suggestionFor("controler-type", KNOWN)); + } + + /** + * Nothing is close to this, and inventing a suggestion would be worse than none. + */ + @Test + void suggestsNothingWhenNothingIsClose() { + assertNull(DriverBase.suggestionFor("transport-protocol", KNOWN)); + } + + /** + * A short name must not match half the parameter list just because everything is within a + * couple of edits of it. + */ + @Test + void doesNotStretchForShortNames() { + assertNull(DriverBase.suggestionFor("x", KNOWN)); + } + + @Test + void suggestsDeterministicallyWhenSeveralMatchEquallyWell() { + Set<String> ambiguous = Set.of("cotp.remote-slot", "tcp.remote-slot"); + assertEquals("cotp.remote-slot", DriverBase.suggestionFor("remote-slot", ambiguous)); + assertEquals("cotp.remote-slot", DriverBase.suggestionFor("remote-slot", ambiguous)); + } + + public static class EncodingConfig implements Configuration { + @ConfigurationParameter("charset") + protected String charset = "utf-8"; + } + + public static class DriverConfig implements Configuration { + @ConfigurationParameter("controller-type") + protected String controllerType = "ANY"; + + @ConfigurationParameter("read-timeout") + protected int readTimeout = 10000; + + @ComplexConfigurationParameter(prefix = "encoding", defaultOverrides = {}, requiredOverrides = {}) + protected EncodingConfig encoding; + } + + public static class BaseTransportConfig implements TransportConfiguration { + @ConfigurationParameter("remote-tsap") + protected int remoteTsap; + } + + public static class TransportConfig extends BaseTransportConfig { + @ConfigurationParameter("remote-slot") + protected int remoteSlot; + + @ConfigurationParameter("local-tsap") + protected int localTsap; + } +}
