8000 Add suggestions when features are incorrectly specified by mabartos · Pull Request #48963 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,16 @@
package org.keycloak.quarkus.runtime.configuration;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import picocli.CommandLine;

/**
* Custom CommandLine.UnmatchedArgumentException with amended suggestions
*/
public class KcUnmatchedArgumentException extends CommandLine.UnmatchedArgumentException {

private static final int MAX_OPTION_SUGGESTIONS = 7;
private static final int MAX_COMMAND_SUGGESTIONS = 3;

Expand All @@ -47,7 +45,7 @@ public KcUnmatchedArgumentException(CommandLine.UnmatchedArgumentException ex) {
*/
@Override
public List<String> getSuggestions() {
String unmatched = this.getUnmatched().get(0).toLowerCase();
String unmatched = this.getUnmatched().get(0);
List<String> candidates;
int maxSuggestions;
if (isUnknownOption()) {
Expand All @@ -62,31 +60,7 @@ public List<String> getSuggestions() {
}
maxSuggestions = MAX_COMMAND_SUGGESTIONS;
}

return candidates.stream().map(c -> Map.entry(cosineSimilarity(unmatched, c.toLowerCase()), c))
.sorted((e1, e2) -> e2.getKey().compareTo(e1.getKey())).map(Map.Entry::getValue).limit(maxSuggestions).toList();
}

static double cosineSimilarity(String a, String b) {
Map<String, Integer> aFreq = bigramFrequency(a);
Map<String, Integer> bFreq = bigramFrequency(b);
double dot = dotProduct(aFreq, bFreq);
double normA = dotProduct(aFreq, aFreq);
double normB = dotProduct(bFreq, bFreq);
double denominator = Math.sqrt(normA * normB);
return denominator == 0 ? 0 : dot / denominator;
}

private static Map<String, Integer> bigramFrequency(String s) {
Map<String, Integer> freq = new HashMap<>();
for (int i = 0; i < s.length() - 1; i++) {
freq.merge(s.substring(i, i + 2), 1, Integer::sum);
}
return freq;
}

private static double dotProduct(Map<String, Integer> m1, Map<String, Integer> m2) {
return m1.entrySet().stream()
.collect(Collectors.summingDouble(e -> e.getValue() * (m2.getOrDefault(e.getKey(), 0))));
return SimilarityUtil.findSimilar(unmatched, candidates, maxSuggestions, 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.keycloak.quarkus.runtime.configuration;

import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* Bigram-based cosine similarity for suggesting corrections to misspelled CLI values.
* Used by option-name matching ({@link KcUnmatchedArgumentException}) and
* feature-name validation ({@link org.keycloak.quarkus.runtime.configuration.mappers.FeaturePropertyMappers}).
*/
public final class SimilarityUtil {

public static final int DEFAULT_MAX_SUGGESTIONS = 5;
public static final double DEFAULT_MIN_SIMILARITY = 0.4;
Comment thread
mabartos marked this conversation as resolved.
Resolved

private SimilarityUtil() {
}

public static List<String> findSimilar(String input, List<String> candidates) {
return findSimilar(input, candidates, DEFAULT_MAX_SUGGESTIONS, DEFAULT_MIN_SIMILARITY);
}

public static double cosineSimilarity(String a, String b) {
Map<String, Integer> aFreq = bigramFrequency(a);
Map<String, Integer> bFreq = bigramFrequency(b);
double dot = dotProduct(aFreq, bFreq);
double normA = dotProduct(aFreq, aFreq);
double normB = dotProduct(bFreq, bFreq);
double denominator = Math.sqrt(normA * normB);
return denominator == 0 ? 0 : dot / denominator;
}

public static List<String> findSimilar(String input, List<String> candidates, int maxSuggestions, double minSimilarity) {
String lowerInput = input.toLowerCase();
return candidates.stream()
.map(candidate -> Map.entry(cosineSimilarity(lowerInput, candidate.toLowerCase()), candidate))
.filter(e -> e.getKey() >= minSimilarity)
.sorted(Comparator.<Map.Entry<Double, String>, Double>comparing(Map.Entry::getKey).reversed())
.limit(maxSuggestions)
.map(Map.Entry::getValue)
.toList();
}

private static Map<String, Integer> bigramFrequency(String s) {
Map<String, Integer> freq = new HashMap<>();
for (int i = 0; i < s.length() - 1; i++) {
freq.merge(s.substring(i, i + 2), 1, Integer::sum);
}
return freq;
}

private static double dotProduct(Map<String, Integer> m1, Map<String, Integer> m2) {
return m1.entrySet().stream()
.collect(Collectors.summingDouble(e -> e.getValue() * (m2.getOrDefault(e.getKey(), 0))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.keycloak.common.Profile.Feature;
import org.keycloak.config.FeatureOptions;
import org.keycloak.quarkus.runtime.cli.PropertyException;
import org.keycloak.quarkus.runtime.configuration.SimilarityUtil;

import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;

Expand All @@ -36,7 +37,7 @@ public List<PropertyMapper<?>> getPropertyMappers() {

public static void validateSingleFeature(String feature, String value) {
if (!Profile.getAllUnversionedFeatureNames().contains(feature)) {
throw new PropertyException("'%s' is an unrecognized feature, it should be one of %s".formatted(feature, FeatureOptions.getFeatureValues(false, false)));
throw new PropertyException(unrecognizedFeatureMessage(feature, FeatureOptions.getFeatureValues(false, false)));
}

Matcher matcher = VERSION_SUFFIX_PATTERN.matcher(value);
Expand All @@ -62,14 +63,21 @@ public static void validateEnabledFeature(String feature) {
"%s has an invalid format for enabling a feature, expected format is feature:v{version}, e.g. docker:v1",
feature));
}
throw new PropertyException(String.format("'%s' is an unrecognized feature, it should be one of %s", feature,
FeatureOptions.getFeatureValues(false)));
throw new PropertyException(unrecognizedFeatureMessage(feature, FeatureOptions.getFeatureValues(false)));
}
String unversionedFeature = matcher.group(1);
int version = Integer.parseInt(matcher.group(2));
validateFeatureVersions(unversionedFeature, version);
}

private static String unrecognizedFeatureMessage(String feature, List<String> validFeatures) {
List<String> suggestions = SimilarityUtil.findSimilar(feature, validFeatures);
if (!suggestions.isEmpty()) {
return "'%s' is an unrecognized feature. Did you mean: %s?".formatted(feature, String.join(", ", suggestions));
}
return "'%s' is an unrecognized feature, it should be one of %s".formatted(feature, validFeatures);
}

private static void validateFeatureVersions(String feature, int version) {
Set<Feature> featureVersions = Profile.getFeatureVersions(feature);
if (featureVersions.isEmpty() || featureVersions.stream().noneMatch(f -> f.getVersion() == version)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ public void failInvalidMultiOptionValue() {
assertEquals(CommandLine.ExitCode.USAGE, nonRunningPicocli.exitCode);
assertThat(nonRunningPicocli.getErrString(),
containsString("'xyz' is an unrecognized feature, it should be one of"));
onAfter();

// near-match should suggest similar features
nonRunningPicocli = pseudoLaunch("build", "--features", "tokn-exchang");
assertEquals(CommandLine.ExitCode.USAGE, nonRunningPicocli.exitCode);
assertThat(nonRunningPicocli.getErrString(),
containsString("'tokn-exchang' is an unrecognized feature. Did you mean:"));
assertThat(nonRunningPicocli.getErrString(), containsString("token-exchange"));
}
Comment thread
mabartos marked this conversation as resolved.
Resolved

@Test
Expand Down Expand Up @@ -1292,7 +1300,7 @@ public void singleFeatureFlag() {
assertThat(nonRunningPicocli.getErrString(), containsString("Missing value for feature 'spiffe'"));
onAfter();

// Non-existing
// Non-existing - no close match, falls back to full list
nonRunningPicocli = pseudoLaunch("start-dev", "--feature-not-here=enabled");
assertEquals(CommandLine.ExitCode.USAGE, nonRunningPicocli.exitCode);
assertThat(nonRunningPicocli.getErrString(), containsString("'not-here' is an unrecognized feature, it should be one of"));
Expand All @@ -1311,6 +1319,13 @@ public void singleFeatureFlag() {
assertThat(nonRunningPicocli.getErrString(), not(containsString("preview")));
onAfter();

// Near-match - should suggest similar features
nonRunningPicocli = pseudoLaunch("start-dev", "--feature-impersonaton=enabled");
assertEquals(CommandLine.ExitCode.USAGE, nonRunningPicocli.exitCode);
assertThat(nonRunningPicocli.getErrString(), containsString("'impersonaton' is an unrecognized feature. Did you mean:"));
assertThat(nonRunningPicocli.getErrString(), containsString("impersonation"));
onAfter();
Comment thread
mabartos marked this conversation as resolved.
Resolved

// wrong value
nonRunningPicocli = pseudoLaunch("start-dev", "--feature-impersonation=false");
assertEquals(CommandLine.ExitCode.USAGE, nonRunningPicocli.exitCode);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.keycloak.quarkus.runtime.configuration;

import java.util.List;

import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class SimilarityUtilTest {

@Test
public void exactMatch() {
assertEquals(1.0, SimilarityUtil.cosineSimilarity("token-exchange", "token-exchange"), 0.001);
}

@Test
public void similarStrings() {
double similarity = SimilarityUtil.cosineSimilarity("tokn-exchang", "token-exchange");
assertTrue("Expected high similarity for near-match, got " + similarity, similarity > 0.5);
}

@Test
public void dissimilarStrings() {
double similarity = SimilarityUtil.cosineSimilarity("xyz", "token-exchange");
assertTrue("Expected low similarity for unrelated strings, got " + similarity, similarity < 0.3);
}

@Test
public void emptyString() {
assertEquals(0.0, SimilarityUtil.cosineSimilarity("", "test"), 0.001);
}

@Test
public void singleChar() {
assertEquals(0.0, SimilarityUtil.cosineSimilarity("a", "b"), 0.001);
}

@Test
public void findSimilarReturnsBestMatches() {
List<String> candidates = List.of("token-exchange", "admin-api", "account-api",
"docker", "impersonation", "token-introspection", "passkeys");

List<String> suggestions = SimilarityUtil.findSimilar("tokn-exchang", candidates, 3, SimilarityUtil.DEFAULT_MIN_SIMILARITY);

assertTrue("Should suggest token-exchange", suggestions.contains("token-exchange"));
assertTrue("Should return at most 3 suggestions", suggestions.size() <= 3);
assertEquals("Best match should be first", "token-exchange", suggestions.get(0));
}

@Test
public void findSimilarFiltersLowSimilarity() {
List<String> candidates = List.of("token-exchange", "admin-api", "docker");

List<String> suggestions = SimilarityUtil.findSimilar("zzzzzzzzz", candidates, 5, SimilarityUtil.DEFAULT_MIN_SIMILARITY);

assertTrue("Should return no suggestions for unrelated input", suggestions.isEmpty());
}

@Test
public void findSimilarCaseInsensitive() {
List<String> candidates = List.of("Token-Exchange", "admin-api");

List<String> suggestions = SimilarityUtil.findSimilar("TOKEN-EXCHANGE", candidates, 3, SimilarityUtil.DEFAULT_MIN_SIMILARITY);

assertTrue("Should match case-insensitively", suggestions.contains("Token-Exchange"));
}
}
Loading
0