Skip to content

Commit

Permalink
Verify command methods using Redis COMMAND metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
mp911de committed Oct 18, 2016
1 parent 08cf15d commit 2639e5c
Show file tree
Hide file tree
Showing 9 changed files with 419 additions and 34 deletions.
Expand Up @@ -10,7 +10,7 @@ public class CommandCreationException extends RedisException {
private final CommandMethod commandMethod;

public CommandCreationException(CommandMethod commandMethod, String msg) {
super(msg);
super(String.format("%s Offending method: %s", msg, commandMethod));
this.commandMethod = commandMethod;
}

Expand Down
Expand Up @@ -165,4 +165,9 @@ public int hashCode() {
result = 31 * result + (arguments != null ? arguments.hashCode() : 0);
return result;
}

@Override
public String toString() {
return method.toGenericString();
}
}
@@ -0,0 +1,11 @@
package com.lambdaworks.redis.dynamic;

/**
* @author Mark Paluch
*/
public class CommandMethodSyntaxException extends CommandCreationException {

public CommandMethodSyntaxException(CommandMethod commandMethod, String msg) {
super(commandMethod, msg);
}
}
191 changes: 191 additions & 0 deletions src/main/java/com/lambdaworks/redis/dynamic/CommandMethodVerifier.java
@@ -0,0 +1,191 @@
package com.lambdaworks.redis.dynamic;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import com.lambdaworks.redis.LettuceStrings;
import com.lambdaworks.redis.dynamic.parameter.Parameter;
import com.lambdaworks.redis.dynamic.segment.CommandSegments;
import com.lambdaworks.redis.internal.LettuceAssert;
import com.lambdaworks.redis.internal.LettuceLists;
import com.lambdaworks.redis.models.command.CommandDetail;

/**
* Verifies {@link CommandMethod} declarations by checking available Redis commands.
*
* @author Mark Paluch
*/
class CommandMethodVerifier {

/**
* Default maximum property distance: 2
*/
public static final int DEFAULT_MAX_DISTANCE = 2;

private List<CommandDetail> commandDetails;

/**
* Create a new {@link CommandMethodVerifier} given a {@link List} of {@link CommandDetail}
*
* @param commandDetails must not be {@literal null}.
*/
public CommandMethodVerifier(List<CommandDetail> commandDetails) {

LettuceAssert.notNull(commandDetails, "Command details must not be null");

this.commandDetails = LettuceLists.newList(commandDetails);
}

/**
*
* Verify a {@link CommandMethod} with its {@link CommandSegments}. This method verifies that the command exists and that
* the required number of arguments is declared.
*
* @param commandSegments
* @param commandMethod
*/
public void validate(CommandSegments commandSegments, CommandMethod commandMethod) throws CommandMethodSyntaxException {

LettuceAssert.notEmpty(commandSegments.getCommandType().name(), "Command name must not be empty");

CommandDetail commandDetail = findCommandDetail(commandSegments.getCommandType().name())
.orElseThrow(() -> syntaxException(commandSegments.getCommandType().name(), commandMethod));

validateParameters(commandDetail, commandSegments, commandMethod);
}

private void validateParameters(CommandDetail commandDetail, CommandSegments commandSegments, CommandMethod commandMethod) {

List<? extends Parameter> bindableParameters = commandMethod.getParameters().getBindableParameters();

int availableParameterCount = bindableParameters.size() + commandSegments.size();

// exact parameter count
if (commandDetail.getArity() - 1 == availableParameterCount) {
return;
}

// more or same parameter cound for dynamic arg count commands
if (0 > commandDetail.getArity() && availableParameterCount >= -(commandDetail.getArity() + 1)) {
return;
}

for (Parameter bindableParameter : bindableParameters) {

// Can't verify collection-like arguments as they may contain multiple elements.
if (bindableParameter.getTypeInformation().isCollectionLike()) {
return;
}
}

String message;
if (commandDetail.getArity() == 1) {
message = String.format("Command %s accepts no parameters.", commandDetail.getName().toUpperCase());
} else if (commandDetail.getArity() < -1) {
message = String.format("Command %s requires at least %d parameters but method declares %d parameter(s).",
commandDetail.getName().toUpperCase(), Math.abs(commandDetail.getArity()) - 1, availableParameterCount);
} else {
message = String.format("Command %s accepts %d parameters but method declares %d parameter(s).",
commandDetail.getName().toUpperCase(), commandDetail.getArity() - 1, availableParameterCount);
}

throw new CommandMethodSyntaxException(commandMethod, message);
}

private CommandMethodSyntaxException syntaxException(String commandName, CommandMethod commandMethod) {

CommandMatches commandMatches = CommandMatches.forCommand(commandName, commandDetails);

if (commandMatches.hasMatches()) {
return new CommandMethodSyntaxException(commandMethod,
String.format("Command %s does not exist. Did you mean: %s?", commandName, commandMatches));
}

return new CommandMethodSyntaxException(commandMethod, String.format("Command %s does not exist", commandName));

}

private Optional<CommandDetail> findCommandDetail(String commandName) {
return commandDetails.stream().filter(commandDetail -> commandDetail.getName().equalsIgnoreCase(commandName))
.findFirst();
}

static class CommandMatches {

private final List<String> matches = new ArrayList<>();

private CommandMatches(List<String> matches) {
this.matches.addAll(matches);
}

public static CommandMatches forCommand(String command, List<CommandDetail> commandDetails) {
return new CommandMatches(calculateMatches(command, commandDetails));
}

private static List<String> calculateMatches(String command, List<CommandDetail> commandDetails) {

return commandDetails.stream() //
.filter(commandDetail -> calculateStringDistance(commandDetail.getName().toLowerCase(),
command.toLowerCase()) <= DEFAULT_MAX_DISTANCE)
.map(CommandDetail::getName) //
.map(String::toUpperCase) //
.sorted((o1, o2) -> calculateStringDistance(o1, o2)).collect(Collectors.toList());
}

public boolean hasMatches() {
return !matches.isEmpty();
}

@Override
public String toString() {
return LettuceStrings.collectionToDelimitedString(matches, ", ", "", "");
}

/**
* Calculate the distance between the given two Strings according to the Levenshtein algorithm.
*
* @param s1 the first String
* @param s2 the second String
* @return the distance value
*/
private static int calculateStringDistance(String s1, String s2) {

if (s1.length() == 0) {
return s2.length();
}

if (s2.length() == 0) {
return s1.length();
}

int d[][] = new int[s1.length() + 1][s2.length() + 1];

for (int i = 0; i <= s1.length(); i++) {
d[i][0] = i;
}

for (int j = 0; j <= s2.length(); j++) {
d[0][j] = j;
}

for (int i = 1; i <= s1.length(); i++) {
char s_i = s1.charAt(i - 1);
for (int j = 1; j <= s2.length(); j++) {
int cost;
char t_j = s2.charAt(j - 1);
if (s_i == t_j) {
cost = 0;
} else {
cost = 1;
}
d[i][j] = Math.min(Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1), d[i - 1][j - 1] + cost);
}
}

return d[s1.length()][s2.length()];
}
}

}
Expand Up @@ -2,6 +2,7 @@

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
Expand All @@ -14,6 +15,7 @@
import com.lambdaworks.redis.api.StatefulRedisConnection;
import com.lambdaworks.redis.api.async.BaseRedisAsyncCommands;
import com.lambdaworks.redis.api.reactive.BaseRedisReactiveCommands;
import com.lambdaworks.redis.cluster.api.StatefulRedisClusterConnection;
import com.lambdaworks.redis.codec.ByteArrayCodec;
import com.lambdaworks.redis.codec.RedisCodec;
import com.lambdaworks.redis.codec.StringCodec;
Expand All @@ -28,8 +30,11 @@
import com.lambdaworks.redis.dynamic.output.OutputRegistryCommandOutputFactoryResolver;
import com.lambdaworks.redis.dynamic.parameter.ExecutionSpecificParameters;
import com.lambdaworks.redis.dynamic.segment.AnnotationCommandSegmentFactory;
import com.lambdaworks.redis.dynamic.segment.CommandSegments;
import com.lambdaworks.redis.internal.LettuceAssert;
import com.lambdaworks.redis.internal.LettuceLists;
import com.lambdaworks.redis.models.command.CommandDetail;
import com.lambdaworks.redis.models.command.CommandDetailParser;
import com.lambdaworks.redis.output.CommandOutput;
import com.lambdaworks.redis.protocol.CommandArgs;
import com.lambdaworks.redis.protocol.LettuceCharsets;
Expand Down Expand Up @@ -78,11 +83,14 @@ public interface MyRedisCommands {
public class RedisCommandFactory {

private final StatefulConnection<?, ?> connection;
private final CommandMethodVerifier commandMethodVerifier;
private final List<RedisCodec<?, ?>> redisCodecs = new ArrayList<>();

private CommandOutputFactoryResolver commandOutputFactoryResolver = new OutputRegistryCommandOutputFactoryResolver(
new OutputRegistry());

private boolean verifyCommandMethods = true;

/**
* Create a new {@link CommandFactory} given {@link StatefulConnection}.
*
Expand All @@ -105,6 +113,26 @@ public RedisCommandFactory(StatefulConnection<?, ?> connection, List<RedisCodec<

this.connection = connection;
this.redisCodecs.addAll(redisCodecs);

commandMethodVerifier = new CommandMethodVerifier(getCommands(connection));
}

private List<CommandDetail> getCommands(StatefulConnection<?, ?> connection) {

List<Object> commands = Collections.emptyList();
if (connection instanceof StatefulRedisConnection) {
commands = ((StatefulRedisConnection) connection).sync().command();
}

if (connection instanceof StatefulRedisClusterConnection) {
commands = ((StatefulRedisClusterConnection) connection).sync().command();
}

if (commands.isEmpty()) {
verifyCommandMethods = false;
}

return CommandDetailParser.parse(commands);
}

/**
Expand All @@ -119,6 +147,16 @@ public void setCommandOutputFactoryResolver(CommandOutputFactoryResolver command
this.commandOutputFactoryResolver = commandOutputFactoryResolver;
}

/**
* Enables/disables command verification which checks the command name against Redis {@code COMMAND} and the argument count.
*
* @param verifyCommandMethods {@literal true} to enable command verification (default) or {@literal false} to disable
* command verification.
*/
public void setVerifyCommandMethods(boolean verifyCommandMethods) {
this.verifyCommandMethods = verifyCommandMethods;
}

/**
* Returns a Redis Command instance for the given interface.
*
Expand Down Expand Up @@ -270,8 +308,7 @@ public Object invoke(MethodInvocation invocation) throws Throwable {

if (commandFactory.isStreamingExecution()) {

return redisReactiveCommands
.createDissolvingFlux(() -> commandFactory.createCommand(arguments));
return redisReactiveCommands.createDissolvingFlux(() -> commandFactory.createCommand(arguments));
}

return redisReactiveCommands.createFlux(() -> commandFactory.createCommand(arguments));
Expand Down Expand Up @@ -303,19 +340,25 @@ public CommandFactory resolveRedisCommandFactory(Method method, RedisCommandsMet
RedisCodec<?, ?> codec = codecResolver.resolve(commandMethod);

if (codec == null) {
throw new IllegalStateException(String.format("Cannot resolve codec for command method %s", method));
throw new CommandCreationException(commandMethod, "Cannot resolve codec.");
}

CodecAwareOutputFactoryResolver outputFactoryResolver = new CodecAwareOutputFactoryResolver(
commandOutputFactoryResolver, codec);
CommandSegments commandSegments = commandSegmentFactory.createCommandSegments(commandMethod);

if (verifyCommandMethods) {
commandMethodVerifier.validate(commandSegments, commandMethod);
}

if (commandMethod.isReactiveExecution()) {

return new ReactiveCommandSegmentCommandFactory(commandSegmentFactory.createCommandSegments(commandMethod),
commandMethod, (RedisCodec) codec, outputFactoryResolver);
return new ReactiveCommandSegmentCommandFactory(commandSegments, commandMethod, (RedisCodec) codec,
outputFactoryResolver);
}

return new CommandSegmentCommandFactory<>(commandSegmentFactory.createCommandSegments(commandMethod), commandMethod,
(RedisCodec) codec, outputFactoryResolver);
return new CommandSegmentCommandFactory<>(commandSegments, commandMethod, (RedisCodec) codec,
outputFactoryResolver);
}
}

Expand All @@ -337,14 +380,20 @@ public ReactiveCommandSegmentCommandFactory resolveRedisCommandFactory(Method me
RedisCodec<?, ?> codec = codecResolver.resolve(commandMethod);

if (codec == null) {
throw new IllegalStateException(String.format("Cannot resolve codec for command method %s", method));
throw new CommandCreationException(commandMethod, "Cannot resolve codec.");
}

CommandSegments commandSegments = commandSegmentFactory.createCommandSegments(commandMethod);

if (verifyCommandMethods) {
commandMethodVerifier.validate(commandSegments, commandMethod);
}

CodecAwareOutputFactoryResolver outputFactoryResolver = new CodecAwareOutputFactoryResolver(
commandOutputFactoryResolver, codec);

return new ReactiveCommandSegmentCommandFactory(commandSegmentFactory.createCommandSegments(commandMethod),
commandMethod, (RedisCodec) codec, outputFactoryResolver);
return new ReactiveCommandSegmentCommandFactory(commandSegments, commandMethod, (RedisCodec) codec,
outputFactoryResolver);
}
}

Expand Down
Expand Up @@ -41,6 +41,10 @@ public ProtocolKeyword getCommandType() {
return commandType;
}

public int size() {
return segments.size();
}

private static class StringCommandType implements ProtocolKeyword {

private final byte[] commandTypeBytes;
Expand Down

0 comments on commit 2639e5c

Please sign in to comment.